diff --git a/.dockerignore b/.dockerignore index 211a015825..7ee8b09816 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,19 +1,20 @@ * ### Includes ### +!packages/**/src/** +!packages/**/public/** +!packages/**/assets/** +!packages/**/scripts/** !pnpm-*.yaml !package.json +!turbo.json +!supervisord.*.conf !patches/** -!packages/**/src/** -!packages/**/assets/** + !**/package.json -!**/nodemon.json !**/tsconfig.json -!**/build.mjs -!next.config.mjs -!sentry.*.config.ts -!public/** -!src/** -!tests/** -!start.*.sh -!scripts/** +!**/nest-cli.json +!**/tsup.config.ts +!**/vite.config.ts +!**/index.html + diff --git a/.env.example b/.env.example deleted file mode 100644 index 3d6733a667..0000000000 --- a/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -APPS_REPO_ID=7a92c8307e0a8074763c80be1fcfa4f87da6641daea9211aea6743b0116aba3b -APPS_REPO_URL=https://github.com/runtipi/runtipi-appstore -TZ=Etc/GMT -INTERNAL_IP=localhost -DNS_IP=9.9.9.9 -ARCHITECTURE=arm64 -TIPI_VERSION=1.5.2 -JWT_SECRET=secret -ROOT_FOLDER_HOST=/path/to/runtipi -RUNTIPI_APP_DATA_PATH=/path/to/runtipi -NGINX_PORT=7000 -NGINX_PORT_SSL=443 -DOMAIN=tipi.localhost -POSTGRES_HOST=runtipi-db -POSTGRES_DBNAME=tipi -POSTGRES_USERNAME=tipi -POSTGRES_PASSWORD=postgres -POSTGRES_PORT=5432 -REDIS_HOST=runtipi-redis -REDIS_PASSWORD=redis -DEMO_MODE=false -LOCAL_DOMAIN=tipi.lan diff --git a/.env.test b/.env.test deleted file mode 100644 index 8b9505a7ef..0000000000 --- a/.env.test +++ /dev/null @@ -1,14 +0,0 @@ -POSTGRES_HOST=localhost -POSTGRES_DBNAME=postgres -POSTGRES_USERNAME=postgres -POSTGRES_PASSWORD=postgres -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 -DOMAIN=tipi.localhost -LOCAL_DOMAIN=tipi.lan diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index a3d8be6cc2..b1d3616dcd 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -22,10 +22,6 @@ jobs: VERSION=$(npm run version --silent) echo "tagname=v${VERSION}-alpha.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - - uses: rickstaa/action-create-tag@v1 - with: - tag: ${{ steps.get_tag.outputs.tagname }} - build-images: runs-on: ubuntu-latest needs: create-tag @@ -33,9 +29,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Log in to Docker Hub uses: docker/login-action@v3 with: @@ -89,7 +82,7 @@ jobs: repo: cli owner: runtipi run_id: ${{ steps.return_dispatch.outputs.run_id }} - run_timeout_seconds: 300 + run_timeout_seconds: 1200 poll_interval_ms: 5000 - name: Create bin folder @@ -119,9 +112,11 @@ jobs: name: cli path: bin - create-release: + publish-release: runs-on: ubuntu-latest needs: [build-images, build-cli, create-tag] + outputs: + id: ${{ steps.create_release.outputs.id }} steps: - name: Download CLI uses: actions/download-artifact@v4 @@ -129,9 +124,6 @@ jobs: name: cli path: cli - - name: List files - run: tree cli - - name: Create alpha release id: create_release uses: softprops/action-gh-release@v2 @@ -145,3 +137,10 @@ jobs: draft: false prerelease: true files: cli/runtipi-cli-* + + e2e-tests: + needs: [create-tag, publish-release] + uses: "./.github/workflows/e2e.yml" + secrets: inherit + with: + version: ${{ needs.create-tag.outputs.tagname }} diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 7b05d91622..e177109e07 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -8,6 +8,14 @@ on: required: true jobs: + integration-tests: + uses: "./.github/workflows/integration-tests.yml" + secrets: inherit + + unit-tests: + uses: "./.github/workflows/ci.yml" + secrets: inherit + create-tag: runs-on: ubuntu-latest outputs: @@ -22,10 +30,6 @@ jobs: VERSION=$(npm run version --silent) echo "tagname=v${VERSION}-beta.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - - uses: rickstaa/action-create-tag@v1 - with: - tag: ${{ steps.get_tag.outputs.tagname }} - build-images: needs: create-tag runs-on: ubuntu-latest @@ -33,9 +37,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Log in to Docker Hub uses: docker/login-action@v3 with: @@ -89,7 +90,7 @@ jobs: repo: cli owner: runtipi run_id: ${{ steps.return_dispatch.outputs.run_id }} - run_timeout_seconds: 300 + run_timeout_seconds: 1200 poll_interval_ms: 5000 - name: Create bin folder diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a687fb6cc8..d5d253a877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: Tipi CI on: + workflow_call: pull_request: + push: + branches: + - develop env: ROOT_FOLDER: /runtipi @@ -23,19 +27,6 @@ env: jobs: tests: runs-on: ubuntu-latest - services: - postgres: - image: postgres:14 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5433:5432 - # set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - name: Checkout uses: actions/checkout@v4 @@ -49,7 +40,7 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9.4.0 + version: 9.12.2 run_install: false - name: Get pnpm store directory @@ -69,17 +60,10 @@ jobs: run: pnpm install - name: Run biome tests - run: pnpm biome check - - - name: Get number of CPU cores - id: cpu-cores - uses: SimenB/github-actions-cpu-cores@v2 + run: pnpm lint:ci - name: Run tests - run: pnpm run test --max-workers ${{ steps.cpu-cores.outputs.count }} - - - name: Run packages tests - run: pnpm -r test + run: pnpm test build: runs-on: ubuntu-latest @@ -96,7 +80,7 @@ jobs: name: Install pnpm id: pnpm-install with: - version: 9.4.0 + version: 9.12.2 run_install: false - name: Get pnpm store directory @@ -116,10 +100,7 @@ jobs: run: pnpm install - name: Build client - run: npm run build + run: npm run bundle - name: Run tsc - run: pnpm run tsc - - - name: Run packages tsc run: pnpm -r run tsc diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e3a28de37d..7a5c4fd98a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,7 +5,7 @@ on: version: required: true type: string - description: 'Version to test (e.g. v1.6.0-beta.1)' + description: "Version to test (e.g. v1.6.0-beta.1)" workflow_dispatch: jobs: @@ -16,8 +16,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + version: "lab:latest" + driver: cloud + endpoint: "runtipi/runtipi-cloud-builder" - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -36,8 +46,6 @@ jobs: platforms: linux/amd64 push: true tags: ghcr.io/runtipi/runtipi:e2e - cache-from: type=registry,ref=ghcr.io/runtipi/runtipi:buildcache - cache-to: type=registry,ref=ghcr.io/runtipi/runtipi:buildcache,mode=max - name: Create cli folder run: mkdir -p bin @@ -79,24 +87,28 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Create .env.local + run: | + echo "LOG_LEVEL=debug" > .env.local + - name: Run install script if: ${{ inputs.version }} run: | curl -s https://raw.githubusercontent.com/runtipi/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh chmod +x install.sh - ./install.sh --version ${{ inputs.version }} --asset runtipi-cli-linux-x86_64.tar.gz + ./install.sh --version ${{ inputs.version }} --asset runtipi-cli-linux-x86_64.tar.gz --env-file ${{ github.workspace }}/.env.local - name: Run install script if: ${{ !inputs.version }} run: | - ./scripts/install.sh --version e2e + ./scripts/install.sh --version e2e --env-file ${{ github.workspace }}/.env.local cd .. - uses: pnpm/action-setup@v4.0.0 name: Install pnpm id: pnpm-install with: - version: 9.4.0 + version: 9.12.2 run_install: false - name: Get pnpm store directory @@ -112,7 +124,7 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- - - name: Create .env.e2e file with Droplet IP + - name: Create .env.e2e file run: | echo "SERVER_IP=$(hostname -I | awk '{print $1}')" > .env.e2e echo "POSTGRES_PASSWORD=$(grep POSTGRES_PASSWORD runtipi/.env | cut -d '=' -f2)" >> .env.e2e @@ -125,13 +137,31 @@ jobs: with: node-version: 20 + - name: Get installed playwright version + id: playwright-version + run: echo "version=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_OUTPUT + + - name: Cache Playwright binaries + id: cache-playwright-binaries + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + - name: Install Playwright Browsers + if: steps.cache-playwright-binaries.outputs.cache-hit != 'true' run: npx playwright install --with-deps - name: Run Playwright tests id: run-e2e run: npm run test:e2e + - name: Dump app logs in playwright-report folder + if: always() + run: | + mkdir -p playwright-report + cp ./runtipi/logs/* playwright-report/ + - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000000..c3ff187420 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,66 @@ +name: Integraton tests +on: + workflow_call: + pull_request: + push: + branches: + - develop + +jobs: + tests: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + rabbitmq: + image: rabbitmq:4 + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4.0.0 + name: Install pnpm + id: pnpm-install + with: + version: 9.12.2 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Run integration tests + run: pnpm test:integration diff --git a/.github/workflows/issue-auto-close.yml b/.github/workflows/issue-auto-close.yml deleted file mode 100644 index 58a6e1867d..0000000000 --- a/.github/workflows/issue-auto-close.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Close inactive issues -on: - schedule: - - cron: "30 1 * * *" - -jobs: - close-issues: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/stale@v9 - with: - days-before-issue-stale: 30 - days-before-issue-close: 14 - stale-issue-label: "stale" - stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." - close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." - close-issue-reason: "completed" - any-of-issue-labels: "bug" - days-before-pr-stale: -1 - days-before-pr-close: -1 - repo-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 154f9e85c8..28c2201f9f 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -58,7 +58,7 @@ jobs: repo: cli owner: runtipi run_id: ${{ steps.return_dispatch.outputs.run_id }} - run_timeout_seconds: 600 + run_timeout_seconds: 1200 poll_interval_ms: 5000 - name: Create bin folder diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8ee7e32c6..d156c60d36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,14 @@ on: workflow_dispatch: jobs: + integration-tests: + uses: "./.github/workflows/integration-tests.yml" + secrets: inherit + + unit-tests: + uses: "./.github/workflows/ci.yml" + secrets: inherit + create-tag: runs-on: ubuntu-latest outputs: @@ -17,10 +25,6 @@ jobs: VERSION=$(npm run version --silent) echo "tagname=v${VERSION}" >> $GITHUB_OUTPUT - - uses: rickstaa/action-create-tag@v1 - with: - tag: ${{ steps.get_tag.outputs.tagname }} - build-images: if: github.repository == 'runtipi/runtipi' needs: create-tag @@ -29,9 +33,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Log in to Docker Hub uses: docker/login-action@v3 with: @@ -85,7 +86,7 @@ jobs: repo: cli owner: runtipi run_id: ${{ steps.return_dispatch.outputs.run_id }} - run_timeout_seconds: 600 + run_timeout_seconds: 1200 poll_interval_ms: 5000 - name: Create bin folder diff --git a/.gitignore b/.gitignore index fa00859d84..4a59f8a0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +/node_modules +**/dist/** +.turbo + *.swo *.swp @@ -74,3 +78,7 @@ temp public/js/* !public/js/.gitkeep + +/cache + +coverage diff --git a/Dockerfile b/Dockerfile index 228490bfdd..8bc9f88902 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,59 +12,29 @@ ARG LOCAL ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} ENV SENTRY_RELEASE=${TIPI_VERSION} -ENV LOCAL=${LOCAL} -RUN npm install pnpm@9.4.0 -g +RUN npm install pnpm@9.12.2 -g RUN apk add --no-cache curl python3 make g++ git WORKDIR /deps COPY ./pnpm-lock.yaml ./ +COPY ./patches ./patches RUN pnpm fetch # ---- RUNNER BASE ---- FROM node_base AS runner_base -RUN apk add --no-cache curl openssl git -RUN npm install pm2 -g +RUN apk add --no-cache curl openssl git rabbitmq-server supervisor -# ---- BUILD DASHBOARD ---- -FROM builder_base AS dashboard_builder - -WORKDIR /dashboard - -COPY ./pnpm-workspace.yaml ./ -COPY ./scripts ./scripts -COPY ./public ./public - -COPY ./package.json ./ -COPY ./packages/worker/package.json ./packages/worker/package.json -COPY ./packages/shared/package.json ./packages/shared/package.json -COPY ./packages/db/package.json ./packages/db/package.json -COPY ./packages/cache/package.json ./packages/cache/package.json - -RUN pnpm install -r --prefer-offline - -COPY ./packages ./packages - -COPY ./src ./src -COPY ./tsconfig.json ./tsconfig.json -COPY ./next.config.mjs ./next.config.mjs -COPY ./tests ./tests - -# Sentry -COPY ./sentry.client.config.ts ./sentry.client.config.ts - -RUN pnpm build - -# ---- BUILD WORKER ---- -FROM builder_base AS worker_builder - -WORKDIR /worker +# ---- BUILDER ---- +FROM builder_base AS builder ARG TARGETARCH +ARG DOCKER_COMPOSE_VERSION="v2.29.2" ENV TARGETARCH=${TARGETARCH} -ARG DOCKER_COMPOSE_VERSION="v2.29.7" + +WORKDIR /app RUN echo "Building for ${TARGETARCH}" @@ -72,51 +42,59 @@ RUN if [ "${TARGETARCH}" = "arm64" ]; then \ curl -L -o docker-binary "https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-linux-aarch64"; \ elif [ "${TARGETARCH}" = "amd64" ]; then \ curl -L -o docker-binary "https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-linux-x86_64"; \ - else \ - echo "Unsupported architecture"; \ fi RUN chmod +x docker-binary COPY ./pnpm-workspace.yaml ./ -COPY ./packages/worker/package.json ./packages/worker/package.json -COPY ./packages/shared/package.json ./packages/shared/package.json -COPY ./packages/db/package.json ./packages/db/package.json -COPY ./packages/cache/package.json ./packages/cache/package.json -COPY ./packages/shared/package.json ./packages/shared/package.json +COPY ./pnpm-lock.yaml ./ +COPY ./package.json ./ +COPY ./packages/backend/package.json ./packages/backend/package.json +COPY ./packages/frontend/package.json ./packages/frontend/package.json +COPY ./packages/frontend/scripts ./packages/frontend/scripts +COPY ./packages/frontend/public ./packages/frontend/public +COPY ./patches ./patches RUN pnpm install -r --prefer-offline +COPY ./turbo.json ./turbo.json COPY ./packages ./packages -# Print TIPI_VERSION to the console RUN echo "TIPI_VERSION: ${SENTRY_RELEASE}" +RUN echo "LOCAL: ${LOCAL}" + +RUN npm run bundle -RUN pnpm -r --filter @runtipi/worker build +RUN if [ "${LOCAL}" != "true" ]; then \ + pnpm -r sentry:sourcemaps; \ + fi # ---- RUNNER ---- -FROM runner_base AS app +FROM runner_base AS runner + +ENV NODE_ENV="production" -ENV NODE_ENV=production +WORKDIR /app -WORKDIR /worker +RUN npm install argon2 -COPY --from=worker_builder /worker/packages/worker/dist . -COPY --from=worker_builder /worker/packages/worker/assets ./assets -COPY --from=worker_builder /worker/packages/db/assets/migrations ./assets/migrations -COPY --from=worker_builder /worker/docker-binary /usr/local/bin/docker-compose +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/packages/backend/dist ./ +COPY --from=builder /app/docker-binary /usr/local/bin/docker-compose -WORKDIR /dashboard +# Swagger UI +COPY --from=builder /app/packages/backend/node_modules/swagger-ui-dist/swagger-ui.css ./swagger-ui.css +COPY --from=builder /app/packages/backend/node_modules/swagger-ui-dist/swagger-ui-bundle.js ./swagger-ui-bundle.js +COPY --from=builder /app/packages/backend/node_modules/swagger-ui-dist/swagger-ui-standalone-preset.js ./swagger-ui-standalone-preset.js -COPY --from=dashboard_builder /dashboard/next.config.mjs ./ -COPY --from=dashboard_builder /dashboard/public ./public -COPY --from=dashboard_builder /dashboard/package.json ./package.json -COPY --from=dashboard_builder /dashboard/.next/standalone ./ -COPY --from=dashboard_builder /dashboard/.next/static ./.next/static +# Assets +COPY --from=builder /app/packages/backend/assets ./assets +COPY --from=builder /app/packages/backend/src/core/database/drizzle ./assets/migrations +COPY --from=builder /app/packages/backend/src/modules/i18n/translations ./assets/translations +COPY --from=builder /app/packages/frontend/dist ./assets/frontend -WORKDIR / -COPY ./start.prod.sh ./start.sh +COPY ./supervisord.prod.conf /etc/supervisord.conf -EXPOSE 3000 5000 5001 +EXPOSE 3000 5001 -CMD ["sh", "start.sh"] +CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 1be7f7a8a7..c3b5cbb3a4 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -3,20 +3,16 @@ ARG ALPINE_VERSION="3.20" FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} -ENV LOCAL=true -ENV SENTRY_RELEASE=development +RUN apk add --no-cache curl git +RUN npm install pnpm@9.12.2 -g -RUN apk add --no-cache python3 make g++ -RUN apk add --no-cache curl openssl git - -RUN npm install pnpm@9.4.0 pm2 -g +RUN apk add --no-cache curl openssl git rabbitmq-server supervisor ARG TARGETARCH -ARG DOCKER_COMPOSE_VERSION="v2.29.7" +ARG DOCKER_COMPOSE_VERSION="v2.29.2" ENV TARGETARCH=${TARGETARCH} ENV NODE_ENV="development" -# Dashboard WORKDIR /app RUN echo "Building for ${TARGETARCH}" @@ -28,32 +24,28 @@ RUN if [ "${TARGETARCH}" = "arm64" ]; then \ fi RUN chmod +x docker-binary - RUN mv docker-binary /usr/local/bin/docker-compose +COPY ./patches ./patches +COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml COPY ./pnpm-lock.yaml ./ -RUN pnpm fetch --ignore-scripts +RUN pnpm fetch -COPY ./package*.json ./ -COPY ./packages/worker/package.json ./packages/worker/package.json -COPY ./packages/shared/package.json ./packages/shared/package.json -COPY ./packages/db/package.json ./packages/db/package.json -COPY ./packages/cache/package.json ./packages/cache/package.json +COPY ./packages/backend/package.json ./packages/backend/package.json +COPY ./packages/frontend/package.json ./packages/frontend/package.json +COPY ./packages/frontend/scripts ./packages/frontend/scripts +COPY ./packages/frontend/public ./packages/frontend/public +COPY ./package.json ./package.json -COPY ./scripts ./scripts -COPY ./public ./public - -RUN pnpm install -r --prefer-offline +RUN pnpm install +COPY ./turbo.json ./turbo.json COPY ./packages ./packages +COPY ./packages/backend/assets ./assets +COPY ./packages/backend/src/core/database/drizzle ./assets/migrations -COPY ./packages/db/assets/migrations ./packages/worker/assets/migrations -COPY ./tsconfig.json ./tsconfig.json -COPY ./next.config.mjs ./next.config.mjs - -# Sentry -COPY ./sentry.client.config.ts ./sentry.client.config.ts +COPY ./supervisord.dev.conf /etc/supervisord.conf -COPY ./start.dev.sh ./start.sh +EXPOSE 3000 5001 -CMD ["sh", "start.sh"] +CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/README.md b/README.md index fa58ec44cf..a537ec6e76 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Tipi — A personal homeserver for everyone +# Runtipi — A personal homeserver for everyone [![All Contributors](https://img.shields.io/badge/all_contributors-53-orange.svg?style=flat-square)](#contributors-) @@ -7,24 +7,24 @@ [![License](https://img.shields.io/github/license/runtipi/runtipi)](https://github.com/runtipi/runtipi/blob/master/LICENSE) [![Version](https://img.shields.io/github/v/release/runtipi/runtipi?color=%235351FB&label=version)](https://github.com/runtipi/runtipi/releases) ![Issues](https://img.shields.io/github/issues/runtipi/runtipi) -[![Docker Pulls](https://badgen.net/docker/pulls/meienberger/runtipi?icon=docker&label=pulls)](https://hub.docker.com/r/meienberger/runtipi/) -[![Docker Image Size](https://badgen.net/docker/size/meienberger/runtipi?icon=docker&label=image%20size)](https://hub.docker.com/r/meienberger/runtipi/) ![Build](https://github.com/runtipi/runtipi/workflows/Tipi%20CI/badge.svg) [![Crowdin](https://badges.crowdin.net/runtipi/localized.svg)](https://crowdin.com/project/runtipi) +[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Tipi%20Guru-006BFF)](https://gurubase.io/g/tipi) > [!NOTE] -> Tipi is built with TypeScript, Next.js app router and Drizzle ORM! If you want to collaborate on a cool project, join the discussion on Discord! +> Runtipi is built with TypeScript, NestJS and React! If you want to collaborate on a cool project, join the discussion in the forums or on Discord! #### Join the community +[![Forums](https://img.shields.io/discourse/users?server=https%3A%2F%2Fforums.runtipi.io)](https://forums.runtipi.io/) [![Discord](https://img.shields.io/discord/976934649643294750?label=discord&logo=discord)](https://discord.gg/Bu9qEPnHsc) ![Preview](https://raw.githubusercontent.com/runtipi/runtipi/develop/screenshots/appstore.png) > [!WARNING] -> Tipi is built and maintained by volunteers. There is no guarantee of support or security when you use Tipi. While the system is considered stable, it is still in active development and may contain bugs. +> Runtipi is built and maintained by volunteers. There is no guarantee of support or security when you use Runtipi. While the system is considered stable, it is still in active development and may contain bugs. -Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/runtipi/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below. +Runtipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Runtipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Runtipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/runtipi/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below. ## Sponsors @@ -41,7 +41,7 @@ Visit our website [runtipi.io](https://www.runtipi.io/docs/getting-started/insta ## Demo -You can try out a demo of Tipi at [demo.runtipi.io](https://demo.runtipi.io) using the following credentials: +You can try out a demo of Runtipi at [demo.runtipi.io](https://demo.runtipi.io) using the following credentials: username: user@runtipi.io password: password @@ -52,7 +52,7 @@ You can find more documentation and tutorials / FAQ on [runtipi.io](https://www. ## ❤ Contributing -Tipi is made to be very easy to plug in new apps. We welcome and appreciate new contributions. +Runtipi is made to be very easy to plug in new apps. We welcome and appreciate new contributions. If you want to add a new app or feature, you can follow the [Contribution guide](https://www.runtipi.io/docs/contributing/adding-a-new-app) for instructions on how to do so. @@ -62,11 +62,10 @@ We are looking for contributions of all kinds. If you know design, development, [![License](https://img.shields.io/github/license/runtipi/runtipi)](https://github.com/runtipi/runtipi/blob/master/LICENSE) -Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. +Runtipi is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. ## 🗣 Community -- [Twitter](https://twitter.com/runtipi) - [Discord](https://discord.gg/Bu9qEPnHsc) ## 🙏 Acknowledgements @@ -77,10 +76,6 @@ Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may co - [Crowdin](https://crowdin.com) - Thanks for providing localization management for the project - [CodeRabbit](https://coderabbit.ai/) - Thanks for providing free AI code reviews in our Pull Requests -## 🔀 Server Actions - Component Flow - -[![Server Actions - Component Flow](https://nextjs.apidiagram.com/github/runtipi/runtipi/diagram.svg)](https://nextjs.apidiagram.com/github/runtipi/runtipi) - ## ✨ Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -91,9 +86,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - + + + @@ -131,7 +126,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + @@ -146,7 +141,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + diff --git a/biome.json b/biome.json index 01d25e77c1..8c9e72654e 100644 --- a/biome.json +++ b/biome.json @@ -1,23 +1,13 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "defaultBranch": "origin/develop", + "useIgnoreFile": true + }, "files": { - "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.json"], - "ignore": [ - "node_modules/**", - "dist/**", - "coverage/**", - ".next/**", - "public/**", - "app-data/**", - "apps/*/**", - "logs/**", - "media/**", - "repos/**", - "state/**", - "traefik/**", - "user-config/**", - "playwright-report/**" - ] + "ignore": ["dist/**", "public/**", "legacy/**"] }, "formatter": { "enabled": true, @@ -30,7 +20,7 @@ "ignore": [] }, "organizeImports": { - "enabled": false + "enabled": true }, "linter": { "enabled": true, @@ -86,6 +76,22 @@ } } } + }, + { + "include": ["packages/backend/**"], + "linter": { + "rules": { + "style": { + "useImportType": "off" + }, + "correctness": { + "useHookAtTopLevel": { + "level": "off", + "options": {} + } + } + } + } } ], "javascript": { diff --git a/crowdin.yml b/crowdin.yml index 00e221207f..baa83311f1 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,3 @@ files: - - source: /src/client/messages/en.json - translation: /src/client/messages/%locale%.json + - source: /packages/backend/src/modules/i18n/translations/en.json + translation: /packages/backend/src/modules/i18n/translations/%locale%.json diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c4da13c1c4..7e4246b80e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,11 +2,12 @@ services: runtipi-reverse-proxy: container_name: runtipi-reverse-proxy depends_on: - - runtipi + runtipi: + condition: service_healthy image: traefik:v3.1.4 restart: unless-stopped ports: - - 3000:80 + - 80:80 - 443:443 - 8080:8080 command: --providers.docker @@ -27,7 +28,7 @@ services: ports: - 5432:5432 environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: postgres POSTGRES_USER: tipi POSTGRES_DB: tipi healthcheck: @@ -38,40 +39,28 @@ services: networks: - tipi_main_network - runtipi-redis: - container_name: runtipi-redis - image: redis:7.2.0 - restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD} --stop-writes-on-bgsave-error no - ports: - - 6379:6379 - volumes: - - redisdata:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 10s - retries: 120 - networks: - - tipi_main_network - runtipi: build: context: . dockerfile: Dockerfile.dev - container_name: runtipi - restart: unless-stopped depends_on: runtipi-db: condition: service_healthy - runtipi-redis: - condition: service_healthy + container_name: runtipi + restart: unless-stopped + healthcheck: + start_period: 10s + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 5s + timeout: 3s + retries: 20 + ports: + - 3000:8080 + - 5001:5001 volumes: # Hot reload - - ./src:/app/src - - ./packages/worker/src:/app/packages/worker/src - - ./packages/shared/src:/app/packages/shared/src - - ./packages/db/src:/app/packages/db/src + - ./packages/backend/src:/app/packages/backend/src + - ./packages/frontend/src:/app/packages/frontend/src # Data - ${RUNTIPI_MEDIA_PATH:-.}/media:/data/media - ${RUNTIPI_STATE_PATH:-.}/state:/data/state @@ -84,9 +73,10 @@ services: - ${RUNTIPI_BACKUPS_PATH:-.}/backups:/data/backups # Static - ./.env:/data/.env + - ./cache:/cache - ./docker-compose.dev.yml:/data/docker-compose.yml - /var/run/docker.sock:/var/run/docker.sock:ro - - /proc:/host/proc:ro + - /proc/meminfo:/host/proc/meminfo:ro - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro networks: @@ -94,86 +84,13 @@ services: environment: LOCAL: true NODE_ENV: development - WORKER_APP_DIR: /app/packages/worker - DASHBOARD_APP_DIR: /app + ROOT_FOLDER_HOST: ${PWD} + POSTGRES_PASSWORD: postgres + INTERNAL_IP: 127.0.0.1 + TIPI_VERSION: 0.0.0 + LOG_LEVEL: debug env_file: - .env - labels: - # ---- General ----- # - traefik.enable: true - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https - - # ---- Dashboard ---- # - traefik.http.services.dashboard.loadbalancer.server.port: 3000 - # Local ip - traefik.http.routers.dashboard.rule: PathPrefix("/") - traefik.http.routers.dashboard.service: dashboard - traefik.http.routers.dashboard.entrypoints: web - # Websecure - traefik.http.routers.dashboard-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`) - traefik.http.routers.dashboard-insecure.service: dashboard - traefik.http.routers.dashboard-insecure.entrypoints: web - traefik.http.routers.dashboard-insecure.middlewares: redirect-to-https - traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`) - traefik.http.routers.dashboard-secure.service: dashboard - traefik.http.routers.dashboard-secure.entrypoints: websecure - traefik.http.routers.dashboard-secure.tls.certresolver: myresolver - # Local domain - traefik.http.routers.dashboard-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) - traefik.http.routers.dashboard-local-insecure.entrypoints: web - traefik.http.routers.dashboard-local-insecure.service: dashboard - traefik.http.routers.dashboard-local-insecure.middlewares: redirect-to-https - # Secure - traefik.http.routers.dashboard-local.rule: Host(`${LOCAL_DOMAIN}`) - traefik.http.routers.dashboard-local.entrypoints: websecure - traefik.http.routers.dashboard-local.tls: true - traefik.http.routers.dashboard-local.service: dashboard - - # ---- Worker ---- # - traefik.http.services.worker.loadbalancer.server.port: 5001 - traefik.http.services.worker-api.loadbalancer.server.port: 5000 - # Local ip - traefik.http.routers.worker.rule: PathPrefix("/worker") - traefik.http.routers.worker.service: worker - traefik.http.routers.worker.entrypoints: web - traefik.http.routers.worker-api.rule: PathPrefix("/worker-api") - traefik.http.routers.worker-api.service: worker-api - traefik.http.routers.worker-api.entrypoints: web - # Websecure - traefik.http.routers.worker-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`) - traefik.http.routers.worker-insecure.service: worker - traefik.http.routers.worker-insecure.entrypoints: web - traefik.http.routers.worker-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`) - traefik.http.routers.worker-secure.service: worker - traefik.http.routers.worker-secure.entrypoints: websecure - traefik.http.routers.worker-secure.tls.certresolver: myresolver - traefik.http.routers.worker-api-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker-api`) - traefik.http.routers.worker-api-insecure.service: worker-api - traefik.http.routers.worker-api-insecure.entrypoints: web - traefik.http.routers.worker-api-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-api-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker-api`) - traefik.http.routers.worker-api-secure.service: worker-api - traefik.http.routers.worker-api-secure.entrypoints: websecure - traefik.http.routers.worker-api-secure.tls.certresolver: myresolver - # Local domain - traefik.http.routers.worker-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker") - traefik.http.routers.worker-local-insecure.entrypoints: web - traefik.http.routers.worker-local-insecure.service: worker - traefik.http.routers.worker-local-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-api-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api") - traefik.http.routers.worker-api-local-insecure.entrypoints: web - traefik.http.routers.worker-api-local-insecure.service: worker-api - traefik.http.routers.worker-api-local-insecure.middlewares: redirect-to-https - # Secure - traefik.http.routers.worker-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker") - traefik.http.routers.worker-local.entrypoints: websecure - traefik.http.routers.worker-local.tls: true - traefik.http.routers.worker-local.service: worker - traefik.http.routers.worker-api-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api") - traefik.http.routers.worker-api-local.entrypoints: websecure - traefik.http.routers.worker-api-local.service: worker-api - traefik.http.routers.worker-api-local.tls: true networks: tipi_main_network: @@ -182,4 +99,3 @@ networks: volumes: pgdata: - redisdata: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3b68e78372..85c3bca9ed 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,7 +28,7 @@ services: ports: - 5432:5432 environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: postgres POSTGRES_USER: tipi POSTGRES_DB: tipi healthcheck: @@ -39,45 +39,24 @@ services: networks: - tipi_main_network - runtipi-redis: - container_name: runtipi-redis - image: redis:7.2.0 - restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD} --stop-writes-on-bgsave-error no - ports: - - 6379:6379 - volumes: - - redisdata:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 10s - retries: 120 - networks: - - tipi_main_network - runtipi: build: context: . dockerfile: Dockerfile args: + TIPI_VERSION: 0.0.0 LOCAL: true - TIPI_VERSION: development + depends_on: + runtipi-db: + condition: service_healthy container_name: runtipi + restart: unless-stopped healthcheck: - test: - ["CMD", "curl", "-f", "http://localhost:5000/worker-api/healthcheck"] + start_period: 10s + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] interval: 5s timeout: 3s retries: 20 - restart: unless-stopped - depends_on: - runtipi-db: - condition: service_healthy - runtipi-redis: - condition: service_healthy - env_file: - - .env volumes: # Data - ${RUNTIPI_MEDIA_PATH:-.}/media:/data/media @@ -91,19 +70,24 @@ services: - ${RUNTIPI_BACKUPS_PATH:-.}/backups:/data/backups # Static - ./.env:/data/.env - - ./docker-compose.prod.yml:/data/docker-compose.yml + - ./cache:/cache - /var/run/docker.sock:/var/run/docker.sock:ro - - /proc:/host/proc:ro + - /proc/meminfo:/host/proc/meminfo:ro + - ./docker-compose.prod.yml:/data/docker-compose.yml - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro + networks: + - tipi_main_network environment: - LOG_LEVEL: debug LOCAL: true NODE_ENV: production - networks: - - tipi_main_network - ports: - - 3000:3000 + ROOT_FOLDER_HOST: ${PWD} + POSTGRES_PASSWORD: postgres + INTERNAL_IP: 127.0.0.1 + TIPI_VERSION: 0.0.0 + LOG_LEVEL: debug + env_file: + - .env labels: # ---- General ----- # traefik.enable: true @@ -135,51 +119,31 @@ services: traefik.http.routers.dashboard-local.tls: true traefik.http.routers.dashboard-local.service: dashboard - # ---- Worker ----- # - traefik.http.services.worker.loadbalancer.server.port: 5001 - traefik.http.services.worker-api.loadbalancer.server.port: 5000 + # ---- socket ----- # + traefik.http.services.socket.loadbalancer.server.port: 5001 # Local ip - traefik.http.routers.worker.rule: PathPrefix("/worker") - traefik.http.routers.worker.service: worker - traefik.http.routers.worker.entrypoints: web - traefik.http.routers.worker-api.rule: PathPrefix("/worker-api") - traefik.http.routers.worker-api.service: worker-api - traefik.http.routers.worker-api.entrypoints: web + traefik.http.routers.socket.rule: PathPrefix("/api/socket.io") + traefik.http.routers.socket.service: socket + traefik.http.routers.socket.entrypoints: web # Websecure - traefik.http.routers.worker-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`) - traefik.http.routers.worker-insecure.service: worker - traefik.http.routers.worker-insecure.entrypoints: web - traefik.http.routers.worker-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`) - traefik.http.routers.worker-secure.service: worker - traefik.http.routers.worker-secure.entrypoints: websecure - traefik.http.routers.worker-secure.tls.certresolver: myresolver - traefik.http.routers.worker-api-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker-api`) - traefik.http.routers.worker-api-insecure.service: worker-api - traefik.http.routers.worker-api-insecure.entrypoints: web - traefik.http.routers.worker-api-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-api-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker-api`) - traefik.http.routers.worker-api-secure.service: worker-api - traefik.http.routers.worker-api-secure.entrypoints: websecure - traefik.http.routers.worker-api-secure.tls.certresolver: myresolver + traefik.http.routers.socket-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/api/socket.io`) + traefik.http.routers.socket-insecure.service: socket + traefik.http.routers.socket-insecure.entrypoints: web + traefik.http.routers.socket-insecure.middlewares: redirect-to-https + traefik.http.routers.socket-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/api/socket.io`) + traefik.http.routers.socket-secure.service: socket + traefik.http.routers.socket-secure.entrypoints: websecure + traefik.http.routers.socket-secure.tls.certresolver: myresolver # Local domain - traefik.http.routers.worker-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker") - traefik.http.routers.worker-local-insecure.entrypoints: web - traefik.http.routers.worker-local-insecure.service: worker - traefik.http.routers.worker-local-insecure.middlewares: redirect-to-https - traefik.http.routers.worker-api-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api") - traefik.http.routers.worker-api-local-insecure.entrypoints: web - traefik.http.routers.worker-api-local-insecure.service: worker-api - traefik.http.routers.worker-api-local-insecure.middlewares: redirect-to-https + traefik.http.routers.socket-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/api/socket.io") + traefik.http.routers.socket-local-insecure.entrypoints: web + traefik.http.routers.socket-local-insecure.service: socket + traefik.http.routers.socket-local-insecure.middlewares: redirect-to-https # Secure - traefik.http.routers.worker-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker") - traefik.http.routers.worker-local.entrypoints: websecure - traefik.http.routers.worker-local.tls: true - traefik.http.routers.worker-local.service: worker - traefik.http.routers.worker-api-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api") - traefik.http.routers.worker-api-local.entrypoints: websecure - traefik.http.routers.worker-api-local.tls: true - traefik.http.routers.worker-api-local.service: worker-api + traefik.http.routers.socket-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/api/socket.io") + traefik.http.routers.socket-local.entrypoints: websecure + traefik.http.routers.socket-local.tls: true + traefik.http.routers.socket-local.service: socket networks: tipi_main_network: @@ -188,4 +152,3 @@ networks: volumes: pgdata: - redisdata: diff --git a/e2e/0005-guest-dashboard.spec.ts b/e2e/0005-guest-dashboard.spec.ts index 4738469cfa..f7d20fa32c 100644 --- a/e2e/0005-guest-dashboard.spec.ts +++ b/e2e/0005-guest-dashboard.spec.ts @@ -1,11 +1,9 @@ import { expect, test } from '@playwright/test'; -import { appTable } from '@runtipi/db'; -import { loginUser } from './fixtures/fixtures'; +import { installApp, loginUser } from './fixtures/fixtures'; import { clearDatabase, db } from './helpers/db'; import { setSettings } from './helpers/settings'; test.beforeEach(async () => { - test.fixme(true, 'This test is flaky due to incorrect revalidation of the guest dashboard'); await clearDatabase(); await setSettings({}); }); @@ -16,42 +14,37 @@ test('user can activate the guest dashboard and see it when logged out', async ( await page.getByRole('tab', { name: 'Settings' }).click(); await page.getByLabel('guestDashboard').setChecked(true); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Update settings' }).click(); await page.getByTestId('logout-button').click(); await expect(page.getByText('No apps to display')).toBeVisible(); }); -test('logged out users can see the apps on the guest dashboard', async ({ browser }) => { - await setSettings({ guestDashboard: true }); - await db.insert(appTable).values({ - config: {}, - isVisibleOnGuestDashboard: true, - id: 'hello-world', - exposed: true, - exposedLocal: true, - domain: 'duckduckgo.com', - status: 'running', - openPort: true, - }); - await db.insert(appTable).values({ - config: {}, - openPort: true, - isVisibleOnGuestDashboard: false, - id: 'actual-budget', - exposed: false, - exposedLocal: false, - status: 'running', - }); - - const context = await browser.newContext(); - const page = await context.newPage(); - await page.goto('/'); - await expect(page.getByText(/Hello World web server/)).toBeVisible(); - const locator = page.locator('text=Actual Budget'); +test('logged out users can see the apps on the guest dashboard', async ({ page, context }) => { + await loginUser(page, context); + + const store = await db.query.appStore.findFirst(); + + if (!store) { + throw new Error('No store found'); + } + + await installApp(page, store?.id, 'nginx', { visibleOnGuestDashboard: true, domain: 'duckduckgo.com' }); + await installApp(page, store?.id, '2fauth', { visibleOnGuestDashboard: false }); + + await page.goto('/settings'); + await page.getByRole('tab', { name: 'Settings' }).click(); + await page.getByLabel('guestDashboard').setChecked(true); + await page.getByRole('button', { name: 'Update settings' }).click(); + await page.getByTestId('logout-button').click(); + + await expect(page.getByText(/Open-source simple and fast web server/)).toBeVisible(); + const locator = page.locator('text=2Fauth'); await expect(locator).not.toBeVisible(); - const [newPage] = await Promise.all([context.waitForEvent('page'), await page.getByRole('link', { name: /Hello World/ }).click()]); + await page.getByRole('link', { name: /Nginx/ }).click(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), page.getByRole('menuitem', { name: 'duckduckgo.com' }).click()]); await newPage.waitForLoadState(); expect(newPage.url()).toBe('https://duckduckgo.com/'); @@ -66,11 +59,9 @@ test('user can deactivate the guest dashboard and not see it when logged out', a await page.getByRole('tab', { name: 'Settings' }).click(); await page.getByLabel('guestDashboard').setChecked(false); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Update settings' }).click(); await page.getByTestId('logout-button').click(); - await page.goto('/'); - // We should be redirected to the login page await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); }); diff --git a/e2e/fixtures/fixtures.ts b/e2e/fixtures/fixtures.ts index 34e648ad1e..799c406cc9 100644 --- a/e2e/fixtures/fixtures.ts +++ b/e2e/fixtures/fixtures.ts @@ -1,13 +1,13 @@ import { type BrowserContext, type Page, expect } from '@playwright/test'; -import { userTable } from '@runtipi/db'; import * as argon2 from 'argon2'; +import { user } from '../../packages/backend/src/core/database/drizzle/schema'; import { testUser } from '../helpers/constants'; import { db } from '../helpers/db'; export const createTestUser = async () => { // Create user in database const password = await argon2.hash(testUser.password); - await db.insert(userTable).values({ password, username: testUser.email, operator: true }); + await db.insert(user).values({ password, username: testUser.email, operator: true, hasSeenWelcome: true }); }; export const loginUser = async (page: Page, _: BrowserContext) => { @@ -27,3 +27,31 @@ export const loginUser = async (page: Page, _: BrowserContext) => { await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }; + +type InstallAppOpts = { + visibleOnGuestDashboard?: boolean; + domain?: string; +}; + +export const installApp = async (page: Page, storeId: number, appId: string, opts: InstallAppOpts = {}) => { + await page.goto(`/app-store/${storeId}/${appId}`); + + // Install app + await page.getByRole('button', { name: 'Install' }).click(); + + await expect(page.getByText('Display on guest dashboard')).toBeVisible(); + + if (opts.visibleOnGuestDashboard) { + await page.getByLabel('isVisibleOnGuestDashboard').setChecked(true); + } + + if (opts.domain) { + await page.getByLabel('exposed', { exact: true }).setChecked(true); + await page.getByPlaceholder('Domain name').fill(opts.domain); + } + + await page.getByRole('button', { name: 'Install' }).click(); + + await expect(page.getByText('Installing')).toBeVisible(); + await expect(page.getByText('Running')).toBeVisible({ timeout: 60000 }); +}; diff --git a/e2e/helpers/db.ts b/e2e/helpers/db.ts index 3fdc2dd567..f33f375e1a 100644 --- a/e2e/helpers/db.ts +++ b/e2e/helpers/db.ts @@ -1,17 +1,12 @@ -import * as schema from '@runtipi/db'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { Pool } from 'pg'; +import * as schema from '../../packages/backend/src/core/database/drizzle/schema'; const connectionString = `postgresql://tipi:${process.env.POSTGRES_PASSWORD}@${process.env.SERVER_IP}:5432/tipi?connect_timeout=300`; -const pool = new Pool({ - connectionString, -}); - -export const db = drizzle(pool, { schema }); +export const db = drizzle(connectionString, { schema }); export const clearDatabase = async () => { // delete all data in table user - await db.delete(schema.userTable); - await db.delete(schema.appTable); + await db.delete(schema.user); + await db.delete(schema.app); }; diff --git a/e2e/helpers/settings.ts b/e2e/helpers/settings.ts index c564b68f92..d420333378 100644 --- a/e2e/helpers/settings.ts +++ b/e2e/helpers/settings.ts @@ -1,11 +1,21 @@ import { promises } from 'node:fs'; import path from 'node:path'; -import type { settingsSchema } from '@runtipi/shared'; -import { pathExists } from '@runtipi/shared/node'; import type { z } from 'zod'; +import type { settingsSchema } from '../../packages/backend/src/app.dto'; +import { user } from '../../packages/backend/src/core/database/drizzle/schema'; import { BASE_PATH } from './constants'; +import { db } from './db'; -export const setSettings = async (settings: z.infer) => { +const pathExists = async (path: string) => { + try { + await promises.access(path); + return true; + } catch { + return false; + } +}; + +export const setSettings = async (settings: Partial>) => { await promises.mkdir(path.join(BASE_PATH, 'state'), { recursive: true }); await promises.writeFile(path.join(BASE_PATH, 'state', 'settings.json'), JSON.stringify(settings)); }; @@ -22,15 +32,6 @@ export const unsetPasswordChangeRequest = async () => { }; export const setWelcomeSeen = async (seen: boolean) => { - const seenPath = path.join(BASE_PATH, 'state', 'seen-welcome'); - - if (seen && !(await pathExists(seenPath))) { - return promises.writeFile(seenPath, ''); - } - - if (!seen && (await pathExists(seenPath))) { - return promises.unlink(seenPath); - } - + await db.update(user).set({ hasSeenWelcome: seen }); return Promise.resolve(); }; diff --git a/global.d.ts b/global.d.ts deleted file mode 100644 index 5c9ab59801..0000000000 --- a/global.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -type Messages = typeof import('./src/client/messages/en.json'); -type IntlMessages = Messages; diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index 4f11a03dc6..0000000000 --- a/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 645e84cd6f..0000000000 --- a/next.config.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { withSentryConfig } from '@sentry/nextjs'; -import withNextIntl from 'next-intl/plugin'; - -/** @type {import('next').NextConfig} */ -const nextConfig = { - swcMinify: true, - output: 'standalone', - reactStrictMode: true, - transpilePackages: ['@runtipi/shared'], - experimental: { - serverComponentsExternalPackages: ['bullmq'], - outputFileTracingIncludes: { - '/login': ['./node_modules/argon2/**'], - }, - }, - async rewrites() { - return [ - { - source: '/apps/:id', - destination: '/app-store/:id', - }, - ]; - }, -}; - -const sentryConfig = { - silent: false, - org: 'runtipi', - project: 'runtipi-dashboard', - widenClientFileUpload: true, - tunnelRoute: '/errors', - hideSourceMaps: false, - disableLogger: false, -}; - -const config = process.env.LOCAL !== 'true' ? withSentryConfig(nextConfig, sentryConfig) : nextConfig; - -export default withNextIntl()(config); diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts new file mode 100644 index 0000000000..420a5bb148 --- /dev/null +++ b/openapi-ts.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@hey-api/openapi-ts'; + +export default defineConfig({ + client: '@hey-api/client-fetch', + input: './packages/backend/src/swagger.json', + output: { + path: './packages/frontend/src/api-client', + format: 'biome', + }, + plugins: ['@tanstack/react-query'], +}); diff --git a/package.json b/package.json index 852623a191..e590e708db 100644 --- a/package.json +++ b/package.json @@ -1,142 +1,43 @@ { "name": "runtipi", - "version": "3.6.2", - "description": "A homeserver for everyone", - "packageManager": "pnpm@9.4.0", + "version": "3.7.5", + "description": "", + "packageManager": "pnpm@9.12.2", "scripts": { - "clean-containers": "docker rm -f $(docker ps -a -q)", - "knip": "knip", - "test": "dotenv -e .env.test -- vitest run --coverage", - "test:ui": "dotenv -e .env.test -- vitest --ui", + "dev": "turbo run dev", + "build": "turbo build", + "bundle": "turbo bundle", + "test": "turbo test", + "test:integration": "turbo run test:integration", + "start": "node ./index.js", + "start:dev": "docker compose --project-name runtipi -f docker-compose.dev.yml up --build", + "start:prod": "docker compose --project-name runtipi -f docker-compose.prod.yml up --build", "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", - "dev": "NODE_OPTIONS='--trace-warnings' next dev --turbo", - "build": "next build --experimental-build-mode compile", - "start": "NODE_ENV=production node server.js", - "start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev", - "start:dev": "docker compose --project-name runtipi -f docker-compose.dev.yml up --build", - "start:prod": "docker compose --env-file ./.env --project-name runtipi -f docker-compose.prod.yml up --build", - "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14", - "version": "echo $npm_package_version", - "tsc": "tsc --noEmit", - "gen:certs": "mkcert -key-file ./traefik/tls/key.pem -cert-file ./traefik/tls/cert.pem tipi.local \"*.tipi.local\"", - "postinstall": "./scripts/postinstall.sh" - }, - "dependencies": { - "@hookform/resolvers": "^3.9.0", - "@otplib/core": "^12.0.1", - "@otplib/plugin-crypto": "^12.0.1", - "@otplib/plugin-thirty-two": "^12.0.1", - "@radix-ui/react-context-menu": "^2.2.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-scroll-area": "^1.2.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@runtipi/cache": "workspace:^", - "@runtipi/db": "workspace:^", - "@runtipi/postgres-migrations": "^5.3.0", - "@runtipi/shared": "workspace:^", - "@sentry/nextjs": "^8.35.0", - "@tabler/core": "1.0.0-beta21", - "@tabler/icons-react": "^3.20.0", - "@tanstack/react-query": "^5.59.16", - "@uidotdev/usehooks": "^2.4.1", - "argon2": "^0.41.1", - "bullmq": "^5.21.2", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "dompurify": "^3.1.7", - "drizzle-orm": "^0.35.3", - "fs-extra": "^11.2.0", - "geist": "^1.3.1", - "inversify": "^6.0.3", - "ipaddr.js": "^2.2.0", - "jsonwebtoken": "^9.0.2", - "let-it-go": "^1.0.0", - "lodash.merge": "^4.6.2", - "minisearch": "^7.1.0", - "next": "14.2.15", - "next-client-cookies": "^1.1.1", - "next-intl": "^3.23.5", - "next-safe-action": "7.9.7", - "pg": "^8.13.1", - "qrcode.react": "^4.1.0", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-hook-form": "^7.53.1", - "react-hot-toast": "^2.4.1", - "react-markdown": "^9.0.1", - "react-timezone-select": "^3.2.8", - "react-tooltip": "^5.28.0", - "redaxios": "^0.5.1", - "reflect-metadata": "^0.2.2", - "rehype-raw": "^7.0.0", - "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.0", - "sass": "^1.80.4", - "semver": "^7.6.3", - "sharp": "0.33.5", - "socket.io-client": "^4.8.1", - "uuid": "^10.0.0", - "validator": "^13.12.0", - "winston": "^3.15.0", - "zod": "^3.23.8", - "zustand": "^4.5.5" + "gen:api-client": "openapi-ts", + "lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched", + "lint": "biome check", + "version": "echo $npm_package_version" }, + "keywords": [], + "author": "", + "license": "GNU General Public License v3.0", "devDependencies": { - "@babel/core": "^7.26.0", - "@biomejs/biome": "1.9.4", - "@faker-js/faker": "^9.0.3", - "@playwright/test": "^1.48.1", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.2", - "@testing-library/react": "^16.0.1", - "@testing-library/user-event": "^14.5.2", - "@total-typescript/shoehorn": "^0.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/dompurify": "^3.0.5", - "@types/fs-extra": "^11.0.4", - "@types/jsonwebtoken": "^9.0.7", - "@types/lodash.merge": "^4.6.9", - "@types/node": "22.8.0", + "@biomejs/biome": "^1.9.4", + "@hey-api/openapi-ts": "^0.60.1", + "@playwright/test": "^1.49.1", "@types/pg": "^8.11.10", - "@types/react": "18.3.12", - "@types/react-dom": "18.3.1", - "@types/semver": "^7.5.8", - "@types/uuid": "^10.0.0", - "@types/validator": "^13.12.2", - "@vitejs/plugin-react": "^4.3.3", - "@vitest/coverage-v8": "^2.1.3", - "@vitest/ui": "^2.1.3", - "dotenv-cli": "^7.4.1", - "jsdom": "^25.0.1", - "knip": "^5.34.0", - "memfs": "^4.14.0", - "msw": "^2.5.1", - "next-router-mock": "^0.9.13", - "typescript": "5.6.3", - "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.1.3", - "vitest-mock-extended": "^2.0.2", - "wait-for-expect": "^3.0.2" + "dotenv-cli": "^8.0.0", + "turbo": "^2.3.3" }, - "msw": { - "workerDirectory": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/runtipi/runtipi.git" - }, - "author": "", - "license": "GNU General Public License v3.0", - "bugs": { - "url": "https://github.com/runtipi/runtipi/issues" + "dependencies": { + "argon2": "^0.41.1", + "drizzle-orm": "^0.38.2", + "zod": "^3.24.1" }, - "homepage": "https://github.com/runtipi/runtipi#readme", "pnpm": { - "patchedDependencies": {} + "patchedDependencies": { + "@vercel/ncc": "patches/@vercel__ncc.patch" + } } } diff --git a/packages/worker/assets/traefik/dynamic/dynamic.yml b/packages/backend/assets/traefik/dynamic/dynamic.yml similarity index 100% rename from packages/worker/assets/traefik/dynamic/dynamic.yml rename to packages/backend/assets/traefik/dynamic/dynamic.yml diff --git a/packages/worker/assets/traefik/traefik.yml b/packages/backend/assets/traefik/traefik.yml similarity index 83% rename from packages/worker/assets/traefik/traefik.yml rename to packages/backend/assets/traefik/traefik.yml index daa9cf11f4..49ffb32cd0 100644 --- a/packages/worker/assets/traefik/traefik.yml +++ b/packages/backend/assets/traefik/traefik.yml @@ -4,7 +4,7 @@ api: providers: docker: - endpoint: 'unix:///var/run/docker.sock' + endpoint: "unix:///var/run/docker.sock" watch: true exposedByDefault: false file: @@ -13,9 +13,9 @@ providers: entryPoints: web: - address: ':80' + address: ":80" websecure: - address: ':443' + address: ":443" http: tls: certResolver: myresolver diff --git a/packages/backend/drizzle.config.ts b/packages/backend/drizzle.config.ts new file mode 100644 index 0000000000..74498941d9 --- /dev/null +++ b/packages/backend/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/core/database/drizzle/schema.ts', + out: './src/core/database/drizzle', + dbCredentials: { + url: 'postgresql://tipi:postgres@localhost:5432/tipi?connect_timeout=300', + }, +}); diff --git a/packages/backend/nest-cli.json b/packages/backend/nest-cli.json new file mode 100644 index 0000000000..a7506bc6fc --- /dev/null +++ b/packages/backend/nest-cli.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "monorepo": true, + "compilerOptions": { + "plugins": ["@nestjs/swagger"], + "deleteOutDir": true, + "typeCheck": true, + "builder": "swc", + "assets": [ + { + "include": "./src/core/database/migrations/*.sql", + "outDir": "./dist/assets/migrations", + "watchAssets": true + } + ] + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000000..ae79cc8254 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,112 @@ +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "main": "./src/exports/index.ts", + "scripts": { + "dev": "nest start --watch --preserveWatchOutput", + "build": "nest build", + "bundle": "rm -rf dist && ncc build src/main.ts -o dist --external argon2 --source-map", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "tsc": "tsc --noEmit", + "sentry:sourcemaps": "sentry-cli sourcemaps inject --org runtipi --project runtipi-backend ./dist && sentry-cli sourcemaps upload --org runtipi --project runtipi-backend ./dist", + "test": "vitest --coverage --watch=false", + "test:integration": "vitest --watch=false --config ./vitest.integration.config.mts", + "test:watch": "vitest", + "test:db": "docker compose -f ./src/tests/db.compose.yml up -d", + "studio": "drizzle-kit studio", + "gen:migrations": "drizzle-kit generate" + }, + "dependencies": { + "@keyv/sqlite": "^4.0.1", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/mapped-types": "*", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/serve-static": "^4.0.2", + "@nestjs/swagger": "^8.1.0", + "@nestjs/terminus": "^10.2.3", + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1", + "@sentry/cli": "^2.39.1", + "@sentry/nestjs": "^8.47.0", + "ansi-to-html": "^0.7.2", + "argon2": "^0.41.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "dotenv-cli": "^8.0.0", + "drizzle-orm": "^0.38.2", + "i18next": "^24.2.0", + "i18next-fs-backend": "^2.6.0", + "jsonwebtoken": "^9.0.2", + "keyv": "^5.2.2", + "lodash.clonedeep": "^4.5.0", + "minisearch": "^7.1.1", + "nestjs-spelunker": "^1.3.1", + "nestjs-zod": "^4.2.0", + "node-cron": "^3.0.3", + "pg": "^8.13.1", + "rabbitmq-client": "^5.0.2", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "semver": "^7.6.3", + "slugify": "^1.6.6", + "socket.io": "^4.8.1", + "sqlite3": "^5.1.7", + "swagger-ui-dist": "^5.18.2", + "systeminformation": "^5.23.14", + "validator": "^13.12.0", + "web-push": "^3.6.7", + "winston": "^3.17.0", + "yaml": "^2.6.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@faker-js/faker": "^9.3.0", + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.15", + "@sentry/types": "^8.47.0", + "@swc/cli": "0.5.2", + "@swc/core": "^1.10.1", + "@total-typescript/shoehorn": "^0.1.2", + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/lodash.clonedeep": "^4.5.9", + "@types/node": "^22.10.2", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.11.10", + "@types/semver": "^7.5.8", + "@types/supertest": "^6.0.0", + "@types/validator": "^13.12.2", + "@types/web-push": "^3.6.4", + "@vercel/ncc": "^0.38.3", + "@vitest/coverage-v8": "2.1.8", + "drizzle-kit": "^0.30.1", + "esbuild": "^0.24.0", + "memfs": "^4.15.0", + "msw": "^2.7.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.2", + "unplugin-swc": "^1.5.1", + "vite-tsconfig-paths": "5.1.4", + "vitest": "^2.1.8", + "vitest-mock-extended": "^2.0.2", + "wait-for-expect": "^3.0.2" + } +} diff --git a/packages/backend/src/@types/express/index.d.ts b/packages/backend/src/@types/express/index.d.ts new file mode 100644 index 0000000000..cfb5dfd804 --- /dev/null +++ b/packages/backend/src/@types/express/index.d.ts @@ -0,0 +1,9 @@ +import type { UserDto } from '@/modules/user/dto/user.dto'; + +declare global { + namespace Express { + interface Request { + user?: UserDto; + } + } +} diff --git a/packages/backend/src/__tests__/__snapshots__/app.service.test.ts.snap b/packages/backend/src/__tests__/__snapshots__/app.service.test.ts.snap new file mode 100644 index 0000000000..1f75c3d906 --- /dev/null +++ b/packages/backend/src/__tests__/__snapshots__/app.service.test.ts.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AppService > copyAssets > should create base folder structure 1`] = ` +"/ +├─ app-data/ +└─ data/ + ├─ .env + ├─ apps/ + ├─ backups/ + ├─ media/ + │ ├─ data/ + │ │ ├─ books/ + │ │ ├─ comics/ + │ │ ├─ images/ + │ │ ├─ movies/ + │ │ ├─ music/ + │ │ ├─ podcasts/ + │ │ ├─ roms/ + │ │ └─ tv/ + │ ├─ downloads/ + │ │ ├─ complete/ + │ │ ├─ incomplete/ + │ │ └─ watch/ + │ ├─ torrents/ + │ │ ├─ complete/ + │ │ ├─ incomplete/ + │ │ └─ watch/ + │ └─ usenet/ + │ ├─ complete/ + │ ├─ incomplete/ + │ └─ watch/ + ├─ repos/ + ├─ state/ + │ └─ seed + └─ traefik/ + ├─ dynamic/ + ├─ shared/ + └─ tls/" +`; diff --git a/packages/backend/src/__tests__/app.service.test.ts b/packages/backend/src/__tests__/app.service.test.ts new file mode 100644 index 0000000000..aaa33ff5fe --- /dev/null +++ b/packages/backend/src/__tests__/app.service.test.ts @@ -0,0 +1,157 @@ +import fs from 'node:fs'; +import { AppService } from '@/app.service'; +import { APP_DATA_DIR, APP_DIR, DATA_DIR, LATEST_RELEASE_URL } from '@/common/constants'; +import { CacheService } from '@/core/cache/cache.service'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import type { FsMock } from '@/tests/__mocks__/fs'; +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +const server = setupServer(); + +describe('AppService', () => { + let appService: AppService; + let configurationService = mock(); + let cacheService = mock(); + + beforeAll(() => { + server.listen(); + server.use( + http.get(LATEST_RELEASE_URL, () => { + return HttpResponse.json({ + tag_name: 'latest', + body: 'body', + }); + }), + ); + }); + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [AppService, FilesystemService], + }) + .useMocker(mock) + .compile(); + + appService = moduleRef.get(AppService); + configurationService = moduleRef.get(ConfigurationService); + cacheService = moduleRef.get(CacheService); + }); + + describe('getVersion', () => { + it('should return the version', async () => { + // arrange + const version = faker.system.semver(); + configurationService.getConfig.mockReturnValueOnce(fromPartial({ version })); + + // act + const result = await appService.getVersion(); + + // assert + expect(result.current).toBe(version); + }); + + it('shoult return version from cache if set', async () => { + // arrange + const version = faker.system.semver(); + const latest = faker.system.semver(); + configurationService.getConfig.mockReturnValueOnce(fromPartial({ version })); + cacheService.get.calledWith('latestVersion').mockResolvedValueOnce(latest); + cacheService.get.calledWith('latestVersionBody').mockResolvedValueOnce('body'); + + // act + const result = await appService.getVersion(); + + // assert + expect(result.current).toBe(version); + expect(result.latest).toBe(latest); + expect(result.body).toBe('body'); + }); + + it('should fetch latest version from github if not in cache', async () => { + // arrange + const version = faker.system.semver(); + const latest = faker.system.semver(); + const body = faker.lorem.paragraph(); + configurationService.getConfig.mockReturnValueOnce(fromPartial({ version })); + cacheService.get.calledWith('latestVersion').mockResolvedValueOnce(undefined); + cacheService.get.calledWith('latestVersionBody').mockResolvedValueOnce(undefined); + + server.use( + http.get(LATEST_RELEASE_URL, () => { + return HttpResponse.json({ + tag_name: latest, + body, + }); + }), + ); + + // act + const result = await appService.getVersion(); + + // assert + expect(result.current).toBe(version); + expect(result.latest).toBe(latest); + expect(result.body).toBe(body); + }); + + it('should return current version if cache fails', async () => { + // arrange + const version = faker.system.semver(); + configurationService.getConfig.mockReturnValueOnce(fromPartial({ version })); + cacheService.get.calledWith('latestVersion').mockRejectedValueOnce(new Error('error')); + + // act + const result = await appService.getVersion(); + + // assert + expect(result.current).toBe(version); + expect(result.latest).toBe(version); + expect(result.body).toBe(''); + }); + + it('should return current version if fetch fails', async () => { + // arrange + const version = faker.system.semver(); + configurationService.getConfig.mockReturnValueOnce(fromPartial({ version })); + cacheService.get.calledWith('latestVersion').mockResolvedValueOnce(undefined); + + server.use( + http.get(LATEST_RELEASE_URL, () => { + return new HttpResponse('error', { status: 500 }); + }), + ); + + // act + const result = await appService.getVersion(); + + // assert + expect(result.current).toBe(version); + expect(result.latest).toBe(version); + expect(result.body).toBe(''); + }); + }); + + describe('copyAssets', () => { + it('should create base folder structure', async () => { + // arrange + const appDir = APP_DIR; + const dataDir = DATA_DIR; + const appDataDir = APP_DATA_DIR; + const directories = { appDir, dataDir, appDataDir }; + configurationService.getConfig.mockReturnValueOnce(fromPartial({ directories, userSettings: { persistTraefikConfig: false } })); + + // act + await appService.copyAssets(); + + // assert + expect((fs as unknown as FsMock).tree()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/backend/src/app.controller.ts b/packages/backend/src/app.controller.ts new file mode 100644 index 0000000000..085477a70c --- /dev/null +++ b/packages/backend/src/app.controller.ts @@ -0,0 +1,81 @@ +import { ConfigurationService } from '@/core/config/configuration.service'; +import { UserRepository } from '@/modules/user/user.repository'; +import { Body, Controller, Get, Patch, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AcknowledgeWelcomeBody, AppContextDto, PartialUserSettingsDto, UserContextDto } from './app.dto'; +import { AppService } from './app.service'; +import { AppsService } from './modules/apps/apps.service'; +import { AuthGuard } from './modules/auth/auth.guard'; +import { MarketplaceService } from './modules/marketplace/marketplace.service'; +import type { UserDto } from './modules/user/dto/user.dto'; + +@Controller() +export class AppController { + constructor( + private readonly appService: AppService, + private readonly userRepository: UserRepository, + private readonly configuration: ConfigurationService, + private readonly appsService: AppsService, + private readonly marketplaceService: MarketplaceService, + ) {} + + @Get('/user-context') + @ZodSerializerDto(UserContextDto) + async userContext(@Req() req: Request): Promise { + const { guestDashboard, allowAutoThemes, allowErrorMonitoring } = this.configuration.get('userSettings'); + const version = await this.appService.getVersion(); + const operator = await this.userRepository.getFirstOperator(); + + return { + isLoggedIn: Boolean(req.user), + isConfigured: Boolean(operator), + isGuestDashboardEnabled: guestDashboard, + allowAutoThemes, + allowErrorMonitoring, + version, + }; + } + + @Get('/app-context') + @UseGuards(AuthGuard) + @ZodSerializerDto(AppContextDto) + async appContext(@Req() req: Request): Promise { + const version = await this.appService.getVersion(); + + const { userSettings } = this.configuration.getConfig(); + + const apps = await this.marketplaceService.getAvailableApps(); + + const installedApps = await this.appsService.getInstalledApps(); + const updatesAvailable = installedApps.filter(({ app, metadata }) => { + return Number(app.version) < Number(metadata?.latestVersion ?? 0) && app.status !== 'updating'; + }); + + return { version, userSettings, user: req.user as UserDto, apps, updatesAvailable: updatesAvailable.length }; + } + + @Patch('/user-settings') + @UseGuards(AuthGuard) + async updateUserSettings(@Body() body: PartialUserSettingsDto): Promise { + await this.configuration.setUserSettings(body); + } + + @Patch('/acknowledge-welcome') + @UseGuards(AuthGuard) + async acknowledgeWelcome(@Req() req: Request, @Body() body: AcknowledgeWelcomeBody): Promise { + if (!req.user) { + return; + } + + const version = await this.appService.getVersion(); + this.configuration.initSentry({ release: version.current, allowSentry: body.allowErrorMonitoring }); + await this.userRepository.updateUser(req.user.id, { hasSeenWelcome: true }); + + if (this.configuration.get('demoMode')) { + return; + } + + await this.configuration.setUserSettings({ allowErrorMonitoring: body.allowErrorMonitoring }); + } +} diff --git a/packages/backend/src/app.dto.ts b/packages/backend/src/app.dto.ts new file mode 100644 index 0000000000..cdd3912930 --- /dev/null +++ b/packages/backend/src/app.dto.ts @@ -0,0 +1,51 @@ +import { createZodDto } from 'nestjs-zod'; +import { UserDto } from './modules/user/dto/user.dto'; + +import { z } from 'zod'; +import { AppInfoSimpleDto } from './modules/marketplace/dto/marketplace.dto'; + +export const settingsSchema = z.object({ + dnsIp: z.string().ip().trim(), + internalIp: z.string().ip().trim(), + postgresPort: z.coerce.number(), + appsRepoUrl: z.string().url().trim(), + domain: z.string().trim(), + appDataPath: z.string().trim(), + localDomain: z.string().trim(), + demoMode: z.boolean(), + guestDashboard: z.boolean(), + allowAutoThemes: z.boolean(), + allowErrorMonitoring: z.boolean(), + persistTraefikConfig: z.boolean(), + port: z.coerce.number(), + sslPort: z.coerce.number(), + listenIp: z.string().ip().trim(), + timeZone: z.string().trim(), + eventsTimeout: z.coerce.number(), +}); + +export class UserSettingsDto extends createZodDto(settingsSchema) {} +export class PartialUserSettingsDto extends createZodDto(settingsSchema.partial()) {} + +export class AppContextDto extends createZodDto( + z.object({ + version: z.object({ current: z.string(), latest: z.string(), body: z.string() }), + userSettings: UserSettingsDto.schema, + user: UserDto.schema, + apps: AppInfoSimpleDto.schema.array(), + updatesAvailable: z.number(), + }), +) {} + +export class UserContextDto extends createZodDto( + z.object({ + version: z.object({ current: z.string(), latest: z.string(), body: z.string() }), + isLoggedIn: z.boolean().describe('Indicates if the user is logged in'), + isConfigured: z.boolean().describe('Indicates if the app is already configured'), + isGuestDashboardEnabled: z.boolean().describe('Indicates if the guest dashboard is enabled'), + allowAutoThemes: z.boolean().describe('Indicates if the app allows auto themes'), + allowErrorMonitoring: z.boolean().describe('Indicates if the app allows anonymous error monitoring'), + }), +) {} + +export class AcknowledgeWelcomeBody extends createZodDto(z.object({ allowErrorMonitoring: z.boolean() })) {} diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts new file mode 100644 index 0000000000..4c88dd101a --- /dev/null +++ b/packages/backend/src/app.module.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import { CacheModule } from '@/core/cache/cache.module'; +import { ConfigurationModule } from '@/core/config/configuration.module'; +import { DatabaseModule } from '@/core/database/database.module'; +import { AuthModule } from '@/modules/auth/auth.module'; +import { I18nModule } from '@/modules/i18n/i18n.module'; +import { type DynamicModule, type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { SentryModule } from '@sentry/nestjs/setup'; +import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { APP_DIR } from './common/constants'; +import { MainExceptionFilter } from './common/error/exception.filter'; +import { FilesystemModule } from './core/filesystem/filesystem.module'; +import { HealthModule } from './core/health/health.module'; +import { LoggerModule } from './core/logger/logger.module'; +import { LoggerService } from './core/logger/logger.service'; +import { SocketModule } from './core/socket/socket.module'; +import { AppLifecycleModule } from './modules/app-lifecycle/app-lifecycle.module'; +import { AppStoreModule } from './modules/app-stores/app-store.module'; +import { AppsModule } from './modules/apps/apps.module'; +import { AuthMiddleware } from './modules/auth/auth.middleware'; +import { BackupsModule } from './modules/backups/backups.module'; +import { LinksModule } from './modules/links/links.module'; +import { MarketplaceModule } from './modules/marketplace/marketplace.module'; +import { QueueModule } from './modules/queue/queue.module'; +import { SystemModule } from './modules/system/system.module'; +import { UserModule } from './modules/user/user.module'; + +const imports: (DynamicModule | typeof I18nModule)[] = [ + SentryModule.forRoot(), + SystemModule, + I18nModule, + AuthModule, + UserModule, + ConfigurationModule, + DatabaseModule, + CacheModule, + LoggerModule, + AppsModule, + FilesystemModule, + AppStoreModule, + QueueModule, + AppLifecycleModule, + SocketModule, + LinksModule, + BackupsModule, + HealthModule, + MarketplaceModule, +]; + +if (process.env.NODE_ENV === 'production') { + imports.push( + ServeStaticModule.forRoot({ + rootPath: path.join(APP_DIR, 'assets', 'frontend'), + exclude: ['/api*'], + }), + ); +} + +@Module({ + imports, + providers: [ + AppService, + { provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor }, + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, + { + provide: APP_FILTER, + useFactory: (logger: LoggerService) => new MainExceptionFilter(logger), + inject: [LoggerService], + }, + ], + controllers: [AppController], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(AuthMiddleware).forRoutes('*'); + } +} diff --git a/packages/backend/src/app.service.ts b/packages/backend/src/app.service.ts new file mode 100644 index 0000000000..641cf6e055 --- /dev/null +++ b/packages/backend/src/app.service.ts @@ -0,0 +1,215 @@ +import path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { z } from 'zod'; +import { LATEST_RELEASE_URL } from './common/constants'; +import { execAsync } from './common/helpers/exec-helpers'; +import { CacheService } from './core/cache/cache.service'; +import { ConfigurationService } from './core/config/configuration.service'; +import { DatabaseService } from './core/database/database.service'; +import { FilesystemService } from './core/filesystem/filesystem.service'; +import { LoggerService } from './core/logger/logger.service'; +import { SocketManager } from './core/socket/socket.service'; +import { AppStoreService } from './modules/app-stores/app-store.service'; +import { MarketplaceService } from './modules/marketplace/marketplace.service'; +import { RepoEventsQueue } from './modules/queue/entities/repo-events'; + +@Injectable() +export class AppService { + constructor( + private readonly cache: CacheService, + private readonly configuration: ConfigurationService, + private readonly logger: LoggerService, + private readonly repoQueue: RepoEventsQueue, + private readonly socketManager: SocketManager, + private readonly filesystem: FilesystemService, + private readonly appStoreService: AppStoreService, + private readonly marketplaceService: MarketplaceService, + private readonly databaseService: DatabaseService, + ) {} + + public async bootstrap() { + try { + await this.databaseService.migrate(); + + const { version, userSettings, __prod__ } = this.configuration.getConfig(); + + this.configuration.initSentry({ release: version, allowSentry: userSettings.allowErrorMonitoring }); + + await this.logger.flush(); + + this.logger.info(`Running version: ${process.env.TIPI_VERSION}`); + this.logger.info('Generating system env file...'); + + // Delete all repos for a clean start + if (__prod__) { + await this.appStoreService.deleteAllRepos(); + } + + await this.appStoreService.migrateLegacyRepo(); + + this.repoQueue.publish({ command: 'clone_all' }); + + await this.marketplaceService.initialize(); + + // Every 15 minutes, check for updates to the apps repo + this.repoQueue.publishRepeatable({ command: 'update_all' }, '*/15 * * * *'); + + this.socketManager.init(); + + await this.copyAssets(); + await this.generateTlsCertificates({ localDomain: userSettings.localDomain }); + } catch (e) { + this.logger.error(e); + Sentry.captureException(e, { tags: { source: 'bootstrap' } }); + } + } + + public async getVersion() { + const { version: currentVersion } = this.configuration.getConfig(); + + try { + let version = (await this.cache.get('latestVersion')) ?? ''; + let body = (await this.cache.get('latestVersionBody')) ?? ''; + + if (!version) { + const response = await fetch(LATEST_RELEASE_URL); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + + const res = z.object({ tag_name: z.string(), body: z.string() }).parse(data); + + version = res.tag_name; + body = res.body; + + await this.cache.set('latestVersion', version, 60 * 60); + await this.cache.set('latestVersionBody', body, 60 * 60); + } + + return { current: currentVersion, latest: version, body }; + } catch (e) { + this.logger.error(e); + return { + current: currentVersion, + latest: currentVersion, + body: '', + }; + } + } + + public async copyAssets() { + const { directories, userSettings } = this.configuration.getConfig(); + const { appDir, dataDir, appDataDir } = directories; + + const assetsFolder = path.join(appDir, 'assets'); + + this.logger.info('Creating traefik folders'); + + await this.filesystem.createDirectories([ + path.join(dataDir, 'traefik', 'dynamic'), + path.join(dataDir, 'traefik', 'shared'), + path.join(dataDir, 'traefik', 'tls'), + ]); + + if (userSettings.persistTraefikConfig) { + this.logger.warn('Skipping the copy of traefik files because persistTraefikConfig is set to true'); + } else { + this.logger.info('Copying traefik files'); + await this.filesystem.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(dataDir, 'traefik', 'traefik.yml')); + await this.filesystem.copyFile( + path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), + path.join(dataDir, 'traefik', 'dynamic', 'dynamic.yml'), + ); + } + + // Create base folders + this.logger.info('Creating base folders'); + await this.filesystem.createDirectories([ + path.join(dataDir, 'apps'), + path.join(dataDir, 'state'), + path.join(dataDir, 'repos'), + path.join(dataDir, 'backups'), + path.join(appDataDir), + ]); + + // Create media folders + this.logger.info('Creating media folders'); + await this.filesystem.createDirectories([ + path.join(dataDir, 'media', 'torrents', 'watch'), + path.join(dataDir, 'media', 'torrents', 'complete'), + path.join(dataDir, 'media', 'torrents', 'incomplete'), + path.join(dataDir, 'media', 'usenet', 'watch'), + path.join(dataDir, 'media', 'usenet', 'complete'), + path.join(dataDir, 'media', 'usenet', 'incomplete'), + path.join(dataDir, 'media', 'downloads', 'watch'), + path.join(dataDir, 'media', 'downloads', 'complete'), + path.join(dataDir, 'media', 'downloads', 'incomplete'), + path.join(dataDir, 'media', 'data', 'books'), + path.join(dataDir, 'media', 'data', 'comics'), + path.join(dataDir, 'media', 'data', 'movies'), + path.join(dataDir, 'media', 'data', 'music'), + path.join(dataDir, 'media', 'data', 'tv'), + path.join(dataDir, 'media', 'data', 'podcasts'), + path.join(dataDir, 'media', 'data', 'images'), + path.join(dataDir, 'media', 'data', 'roms'), + ]); + } + + /** + * Given a domain, generates the TLS certificates for it to be used with Traefik + * + * @param {string} data.domain The domain to generate the certificates for + */ + public generateTlsCertificates = async (data: { localDomain?: string }) => { + if (!data.localDomain) { + return; + } + + const { dataDir } = this.configuration.get('directories'); + + const tlsFolder = path.join(dataDir, 'traefik', 'tls'); + + // If the certificate already exists, don't generate it again + if ( + (await this.filesystem.pathExists(path.join(tlsFolder, `${data.localDomain}.txt`))) && + (await this.filesystem.pathExists(path.join(tlsFolder, 'cert.pem'))) && + (await this.filesystem.pathExists(path.join(tlsFolder, 'key.pem'))) + ) { + this.logger.info(`TLS certificate for ${data.localDomain} already exists`); + return; + } + + // Empty out the folder + const files = await this.filesystem.listFiles(tlsFolder); + await Promise.all( + files.map(async (file) => { + this.logger.info(`Removing file ${file}`); + await this.filesystem.removeFile(path.join(tlsFolder, file)); + }), + ); + + const subject = `/O=runtipi.io/OU=IT/CN=*.${data.localDomain}/emailAddress=webmaster@${data.localDomain}`; + const subjectAltName = `DNS:*.${data.localDomain},DNS:${data.localDomain}`; + + try { + this.logger.info(`Generating TLS certificate for ${data.localDomain}`); + const { stderr } = await execAsync( + `openssl req -x509 -newkey rsa:4096 -keyout ${dataDir}/traefik/tls/key.pem -out ${dataDir}/traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "subjectAltName = ${subjectAltName}" -nodes`, + ); + if ( + !(await this.filesystem.pathExists(path.join(tlsFolder, 'cert.pem'))) || + !(await this.filesystem.pathExists(path.join(tlsFolder, 'key.pem'))) + ) { + this.logger.error(`Failed to generate TLS certificate for ${data.localDomain}`); + this.logger.error(stderr); + } else { + this.logger.info(`Writing txt file for ${data.localDomain}`); + } + await this.filesystem.writeTextFile(path.join(tlsFolder, `${data.localDomain}.txt`), ''); + } catch (error) { + this.logger.error(error); + } + }; +} diff --git a/packages/backend/src/common/constants.ts b/packages/backend/src/common/constants.ts new file mode 100644 index 0000000000..ecbd444816 --- /dev/null +++ b/packages/backend/src/common/constants.ts @@ -0,0 +1,11 @@ +export const APP_DIR = '/app'; +export const DATA_DIR = '/data'; +export const APP_DATA_DIR = '/app-data'; + +export const SESSION_COOKIE_NAME = 'tipi.sid'; +export const SESSION_COOKIE_MAX_AGE = 1000 * 60 * 60 * 24; + +export const ARCHITECTURES = ['arm64', 'amd64'] as const; +export type Architecture = (typeof ARCHITECTURES)[number]; + +export const LATEST_RELEASE_URL = 'https://api.github.com/repos/runtipi/runtipi/releases/latest'; diff --git a/packages/backend/src/common/error/exception.filter.ts b/packages/backend/src/common/error/exception.filter.ts new file mode 100644 index 0000000000..06bf58ad51 --- /dev/null +++ b/packages/backend/src/common/error/exception.filter.ts @@ -0,0 +1,68 @@ +import type { LoggerService } from '@/core/logger/logger.service'; +import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import type { Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { TranslatableError } from './translatable-error'; + +@Catch() +export class MainExceptionFilter implements ExceptionFilter { + constructor(private readonly logger: LoggerService) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + let message = 'Internal server error'; + let cause: unknown; + + if (status === HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.error(`An error occured while calling: ${request.url}`, exception); + } + + // @ts-expect-error + const error = exception?.error; + if (error instanceof ZodError) { + this.logger.error('Schema validation failed: ', request.path, JSON.stringify(error.errors, null, 2)); + } + + if (exception instanceof Error && status !== HttpStatus.INTERNAL_SERVER_ERROR) { + message = exception.message; + } + + let intlParams: Record | undefined; + if (exception instanceof TranslatableError) { + const response = exception.getResponse(); + cause = exception.cause; + + if (typeof response === 'string') { + message = response; + } else { + // @ts-expect-error + message = response.message; + // @ts-expect-error + intlParams = response.intlParams; + } + } + + if (status >= 500 && !(exception instanceof TranslatableError)) { + Sentry.captureException(exception, { + tags: { + cause: String(cause), + url: request.url, + status, + }, + }); + } + + response.status(status).json({ + statusCode: status, + message, + path: request.url, + intlParams, + cause, + }); + } +} diff --git a/packages/backend/src/common/error/translatable-error.ts b/packages/backend/src/common/error/translatable-error.ts new file mode 100644 index 0000000000..96490f9106 --- /dev/null +++ b/packages/backend/src/common/error/translatable-error.ts @@ -0,0 +1,10 @@ +import { HttpException, type HttpExceptionOptions } from '@nestjs/common'; +import messages from '@/modules/i18n/translations/en.json'; + +type TranslationKey = keyof typeof messages; + +export class TranslatableError extends HttpException { + constructor(message: TranslationKey, intlParams?: Record, statusCode = 500, options?: HttpExceptionOptions) { + super({ message, intlParams }, statusCode, options); + } +} diff --git a/packages/backend/src/common/helpers/app-helpers.ts b/packages/backend/src/common/helpers/app-helpers.ts new file mode 100644 index 0000000000..cc7456a237 --- /dev/null +++ b/packages/backend/src/common/helpers/app-helpers.ts @@ -0,0 +1,30 @@ +import type { AppUrn } from '@/types/app/app.types'; + +export const extractAppUrn = (id: AppUrn) => { + const separatorIndex = id.indexOf(':'); + if (separatorIndex === -1) { + throw new Error(`Invalid App URN: ${id}`); + } + const appName = id.substring(0, separatorIndex); + const appStoreId = id.substring(separatorIndex + 1); + + if (!appStoreId || !appName) { + throw new Error(`Invalid App URN: ${id}`); + } + + return { appName, appStoreId }; +}; + +export const createAppUrn = (appName: string, appstore: string) => { + return `${appName}:${appstore}` as AppUrn; +}; + +export const castAppUrn = (id: string): AppUrn => { + // Validate app URN + const separatorIndex = id.indexOf(':'); + if (separatorIndex === -1) { + throw new Error(`Invalid namespaced app id: ${id}`); + } + + return id as AppUrn; +}; diff --git a/packages/backend/src/common/helpers/env-helpers.ts b/packages/backend/src/common/helpers/env-helpers.ts new file mode 100644 index 0000000000..4f9abefafc --- /dev/null +++ b/packages/backend/src/common/helpers/env-helpers.ts @@ -0,0 +1,137 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { settingsSchema } from '@/app.dto'; +import { EnvUtils } from '@/modules/env/env.utils'; +import dotenv from 'dotenv'; +import { DATA_DIR } from '../constants'; + +const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore'; +export const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore'; + +/** + * Generates a random seed if it does not exist yet + */ +const generateSeed = async () => { + const seedFilePath = path.join(DATA_DIR, 'state', 'seed'); + if (!fs.existsSync(seedFilePath)) { + const randomBytes = crypto.randomBytes(32); + const seed = randomBytes.toString('hex'); + await fs.promises.writeFile(seedFilePath, seed); + } +}; + +/** + * Returns the architecture of the current system + */ +const getArchitecture = () => { + const arch = os.arch(); + + if (arch === 'arm64') return 'arm64'; + if (arch === 'x64') return 'amd64'; + + throw new Error(`Unsupported architecture: ${arch}`); +}; + +export const generateSystemEnvFile = async (): Promise> => { + const envUtils = new EnvUtils(); + + await fs.promises.mkdir(path.join(DATA_DIR, 'state'), { recursive: true }); + + const settingsFilePath = path.join(DATA_DIR, 'state', 'settings.json'); + const envFilePath = path.join(DATA_DIR, '.env'); + + if (!fs.existsSync(envFilePath)) { + await fs.promises.writeFile(envFilePath, ''); + } + + const envFile = await fs.promises.readFile(envFilePath, 'utf-8'); + + const envMap: Map = envUtils.envStringToMap(envFile); + envMap.set('NODE_ENV', process.env.NODE_ENV || 'production'); + + if (!fs.existsSync(settingsFilePath)) { + await fs.promises.writeFile(settingsFilePath, JSON.stringify({})); + } + + const settingsFile = await fs.promises.readFile(settingsFilePath, 'utf-8'); + + const settings = settingsSchema.partial().safeParse(JSON.parse(settingsFile)); + + if (!settings.success) { + throw new Error(`Invalid settings.json file: ${settings.error.message}`); + } + + await generateSeed(); + + const { data } = settings; + + if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) { + data.appsRepoUrl = DEFAULT_REPO_URL; + } + + const jwtSecret = envMap.get('JWT_SECRET') || envUtils.deriveEntropy('jwt_secret'); + + const repoUrl = data.appsRepoUrl || envMap.get('APPS_REPO_URL') || DEFAULT_REPO_URL; + const hash = crypto.createHash('sha256'); + hash.update(repoUrl); + const repoId = hash.digest('hex'); + + const rootFolderHost = envMap.get('ROOT_FOLDER_HOST') ?? process.env.ROOT_FOLDER_HOST; + const internalIp = envMap.get('INTERNAL_IP') ?? '127.0.0.1'; + + if (!rootFolderHost) { + throw new Error( + 'Failed to determine root folder host. If you are not running via the CLI, please set the ROOT_FOLDER_HOST environment variable.', + ); + } + + // Ensure that the app data path does not contain the /app-data suffix + let appDataPath = data.appDataPath || envMap.get('RUNTIPI_APP_DATA_PATH'); + const appDataSegment = '/app-data'; + + while (appDataPath?.endsWith(appDataSegment)) { + appDataPath = appDataPath.slice(0, -appDataSegment.length); + } + + envMap.set('ROOT_FOLDER_HOST', rootFolderHost); + envMap.set('APPS_REPO_ID', repoId); + envMap.set('APPS_REPO_URL', data.appsRepoUrl || envMap.get('APPS_REPO_URL') || DEFAULT_REPO_URL); + envMap.set('TZ', data.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone); + envMap.set('INTERNAL_IP', data.listenIp || internalIp); + envMap.set('DNS_IP', data.dnsIp || envMap.get('DNS_IP') || '9.9.9.9'); + envMap.set('ARCHITECTURE', getArchitecture()); + envMap.set('JWT_SECRET', jwtSecret); + envMap.set('DOMAIN', data.domain || envMap.get('DOMAIN') || 'example.com'); + envMap.set('RUNTIPI_APP_DATA_PATH', appDataPath || rootFolderHost); + envMap.set('POSTGRES_HOST', 'runtipi-db'); + envMap.set('POSTGRES_DBNAME', 'tipi'); + envMap.set('POSTGRES_USERNAME', 'tipi'); + envMap.set('POSTGRES_PORT', String(5432)); + envMap.set('DEMO_MODE', typeof data.demoMode === 'boolean' ? String(data.demoMode) : envMap.get('DEMO_MODE') || 'false'); + envMap.set('GUEST_DASHBOARD', typeof data.guestDashboard === 'boolean' ? String(data.guestDashboard) : envMap.get('GUEST_DASHBOARD') || 'false'); + envMap.set('LOCAL_DOMAIN', data.localDomain || envMap.get('LOCAL_DOMAIN') || 'tipi.lan'); + envMap.set( + 'ALLOW_AUTO_THEMES', + typeof data.allowAutoThemes === 'boolean' ? String(data.allowAutoThemes) : envMap.get('ALLOW_AUTO_THEMES') || 'true', + ); + envMap.set( + 'ALLOW_ERROR_MONITORING', + typeof data.allowErrorMonitoring === 'boolean' ? String(data.allowErrorMonitoring) : envMap.get('ALLOW_ERROR_MONITORING') || 'false', + ); + envMap.set( + 'PERSIST_TRAEFIK_CONFIG', + typeof data.persistTraefikConfig === 'boolean' ? String(data.persistTraefikConfig) : envMap.get('PERSIST_TRAEFIK_CONFIG') || 'false', + ); + envMap.set( + 'QUEUE_TIMEOUT_IN_MINUTES', + typeof data.eventsTimeout === 'number' ? String(data.eventsTimeout) : envMap.get('QUEUE_TIMEOUT_IN_MINUTES') || '5', + ); + + await fs.promises.writeFile(envFilePath, envUtils.envMapToString(envMap)); + + dotenv.config({ path: envFilePath, override: true }); + + return envMap; +}; diff --git a/packages/shared/src/helpers/error-helpers.ts b/packages/backend/src/common/helpers/error-helpers.ts similarity index 98% rename from packages/shared/src/helpers/error-helpers.ts rename to packages/backend/src/common/helpers/error-helpers.ts index 0823d3a113..564ea04038 100644 --- a/packages/shared/src/helpers/error-helpers.ts +++ b/packages/backend/src/common/helpers/error-helpers.ts @@ -13,6 +13,7 @@ const IgnoreErrors = [ /port is already allocated/, /address already in use/, /Error with your custom app/, + /cannot assign requested address/, ]; const cleanseUrl = (url: string) => { diff --git a/packages/shared/src/node/helpers/exec-async.tsx b/packages/backend/src/common/helpers/exec-helpers.ts similarity index 100% rename from packages/shared/src/node/helpers/exec-async.tsx rename to packages/backend/src/common/helpers/exec-helpers.ts diff --git a/packages/shared/src/helpers/utils.ts b/packages/backend/src/common/helpers/file-helpers.ts similarity index 82% rename from packages/shared/src/helpers/utils.ts rename to packages/backend/src/common/helpers/file-helpers.ts index 6d0c709984..e4f3e16fac 100644 --- a/packages/shared/src/helpers/utils.ts +++ b/packages/backend/src/common/helpers/file-helpers.ts @@ -25,3 +25,5 @@ export const pLimit = (concurrency: number) => { next(); }); }; + +export const notEmpty = (value: TValue | null | undefined): value is TValue => value !== null && value !== undefined; diff --git a/packages/backend/src/core/archive/archive.module.ts b/packages/backend/src/core/archive/archive.module.ts new file mode 100644 index 0000000000..a02bea331a --- /dev/null +++ b/packages/backend/src/core/archive/archive.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ArchiveService } from './archive.service'; + +@Module({ + imports: [], + providers: [ArchiveService], + exports: [ArchiveService], +}) +export class ArchiveModule {} diff --git a/packages/backend/src/core/archive/archive.service.ts b/packages/backend/src/core/archive/archive.service.ts new file mode 100644 index 0000000000..59aa24d19f --- /dev/null +++ b/packages/backend/src/core/archive/archive.service.ts @@ -0,0 +1,23 @@ +import { execAsync } from '@/common/helpers/exec-helpers'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ArchiveService { + createTarGz = async (sourceDir: string, destinationFile: string) => { + return execAsync(`tar -czf ${destinationFile} -C ${sourceDir} .`); + }; + + extractTarGz = async (sourceFile: string, destinationDir: string) => { + const fileType = await execAsync(`file --brief --mime-type ${sourceFile}`); + const mimeType = fileType.stdout.trim(); + + let tarCommand = `tar -xzf ${sourceFile} -C ${destinationDir}`; + + // Support for legacy tarballs without the 'z' flag + if (mimeType === 'application/x-tar') { + tarCommand = `tar -xf ${sourceFile} -C ${destinationDir}`; + } + + return await execAsync(tarCommand); + }; +} diff --git a/packages/backend/src/core/cache/cache.module.ts b/packages/backend/src/core/cache/cache.module.ts new file mode 100644 index 0000000000..9874379176 --- /dev/null +++ b/packages/backend/src/core/cache/cache.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { CacheService } from './cache.service'; + +@Global() +@Module({ + imports: [], + controllers: [], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/packages/backend/src/core/cache/cache.service.ts b/packages/backend/src/core/cache/cache.service.ts new file mode 100644 index 0000000000..8cdbac3141 --- /dev/null +++ b/packages/backend/src/core/cache/cache.service.ts @@ -0,0 +1,55 @@ +import KeyvSqlite from '@keyv/sqlite'; +import { Injectable } from '@nestjs/common'; +import Keyv from 'keyv'; +import sqlite3 from 'sqlite3'; + +const ONE_DAY_IN_SECONDS = 60 * 60 * 24; + +@Injectable() +export class CacheService { + private client: Keyv; + private backend: KeyvSqlite; + + constructor() { + this.backend = new KeyvSqlite('sqlite:///cache/cache.sqlite'); + this.client = new Keyv({ + store: this.backend, + ttl: ONE_DAY_IN_SECONDS, + namespace: 'cache', + }); + } + + public getClient() { + return this.client; + } + + public set(key: string, value: string, expiration = ONE_DAY_IN_SECONDS) { + return this.client.set(key, value, expiration * 1000); + } + + public get(key: string) { + return this.client.get(key); + } + + public del(key: string) { + return this.client.delete(key); + } + + public async getByPrefix(prefix: string) { + const db = new sqlite3.Database('/cache/cache.sqlite'); + + return new Promise<{ key: string; val: string }[]>((resolve, reject) => { + db.all('SELECT * FROM keyv WHERE key LIKE ?', [`cache:${prefix}%`], (err, rows: { key: string; value: string }[]) => { + if (err) { + reject(err); + } else { + resolve(rows.map((row) => ({ key: row.key, val: JSON.parse(row.value).value }))); + } + }); + }); + } + + public clear() { + return this.client.clear(); + } +} diff --git a/packages/backend/src/core/config/configuration.module.ts b/packages/backend/src/core/config/configuration.module.ts new file mode 100644 index 0000000000..c32ce7bcf8 --- /dev/null +++ b/packages/backend/src/core/config/configuration.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigurationService } from '../config/configuration.service'; +import { EnvModule } from '@/modules/env/env.module'; + +@Global() +@Module({ + imports: [EnvModule], + controllers: [], + providers: [ConfigurationService], + exports: [ConfigurationService], +}) +export class ConfigurationModule {} diff --git a/packages/backend/src/core/config/configuration.service.ts b/packages/backend/src/core/config/configuration.service.ts new file mode 100644 index 0000000000..f27c5fab42 --- /dev/null +++ b/packages/backend/src/core/config/configuration.service.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { type PartialUserSettingsDto, settingsSchema } from '@/app.dto'; +import { APP_DATA_DIR, APP_DIR, ARCHITECTURES, DATA_DIR } from '@/common/constants'; +import { TranslatableError } from '@/common/error/translatable-error'; +import { cleanseErrorData } from '@/common/helpers/error-helpers'; +import { EnvUtils } from '@/modules/env/env.utils'; +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import dotenv from 'dotenv'; +import { z } from 'zod'; +import { FilesystemService } from '../filesystem/filesystem.service'; + +const envSchema = z.object({ + POSTGRES_HOST: z.string(), + POSTGRES_DBNAME: z.string(), + POSTGRES_USERNAME: z.string(), + POSTGRES_PASSWORD: z.string(), + POSTGRES_PORT: z.coerce.number().default(5432), + ARCHITECTURE: z.enum(ARCHITECTURES).default('amd64'), + INTERNAL_IP: z.string(), + TIPI_VERSION: z.string(), + JWT_SECRET: z.string(), + APPS_REPO_ID: z.string(), + APPS_REPO_URL: z.string(), + DOMAIN: z.string(), + LOCAL_DOMAIN: z.string(), + DNS_IP: z.string().default('9.9.9.9'), + RUNTIPI_APP_DATA_PATH: z.string(), + DEMO_MODE: z.string().transform((val) => val.toLowerCase() === 'true'), + GUEST_DASHBOARD: z.string().transform((val) => val.toLowerCase() === 'true'), + ALLOW_ERROR_MONITORING: z.string().transform((val) => val.toLowerCase() === 'true'), + ALLOW_AUTO_THEMES: z.string().transform((val) => val.toLowerCase() === 'true'), + PERSIST_TRAEFIK_CONFIG: z.string().transform((val) => val.toLowerCase() === 'true'), + QUEUE_TIMEOUT_IN_MINUTES: z.coerce.number().default(5), + LOG_LEVEL: z.string().default('info'), + TZ: z.string(), + ROOT_FOLDER_HOST: z.string(), + NGINX_PORT: z.coerce.number().default(80), + NGINX_PORT_SSL: z.coerce.number().default(443), +}); + +@Injectable() +export class ConfigurationService { + private config: ReturnType; + private envPath = path.join(DATA_DIR, '.env'); + + // Lowest level, cannot use any other service or module to avoid circular dependencies + constructor( + private readonly envUtils: EnvUtils, + private readonly filesystem: FilesystemService, + ) { + dotenv.config({ path: this.envPath, override: true }); + this.config = this.configure(); + } + + private configure() { + let envFile = ''; + try { + envFile = fs.readFileSync(this.envPath).toString(); + } catch (e) { + console.error('❌ .env file not found'); + } + + const envMap = this.envUtils.envStringToMap(envFile.toString()); + const conf = { ...Object.fromEntries(envMap), ...process.env } as Record; + + const env = envSchema.safeParse(conf); + + if (!env.success) { + console.error(env.error.errors); + throw new Error(`❌ Invalid environment variables ${JSON.stringify(env.error.flatten(), null, 2)}`); + } + + return { + database: { + host: env.data.POSTGRES_HOST, + port: env.data.POSTGRES_PORT, + username: env.data.POSTGRES_USERNAME, + password: env.data.POSTGRES_PASSWORD, + database: env.data.POSTGRES_DBNAME, + }, + directories: { + dataDir: DATA_DIR, + appDataDir: APP_DATA_DIR, + appDir: APP_DIR, + }, + logLevel: env.data.LOG_LEVEL, + version: env.data.TIPI_VERSION, + userSettings: { + allowAutoThemes: env.data.ALLOW_AUTO_THEMES, + allowErrorMonitoring: env.data.ALLOW_ERROR_MONITORING && process.env.NODE_ENV === 'production', + demoMode: env.data.DEMO_MODE, + guestDashboard: env.data.GUEST_DASHBOARD, + timeZone: env.data.TZ, + domain: env.data.DOMAIN, + localDomain: env.data.LOCAL_DOMAIN, + port: env.data.NGINX_PORT || 80, + sslPort: env.data.NGINX_PORT_SSL || 443, + listenIp: env.data.INTERNAL_IP, // TODO: Check if this is correct + internalIp: env.data.INTERNAL_IP, + appsRepoUrl: env.data.APPS_REPO_URL, + postgresPort: env.data.POSTGRES_PORT, + dnsIp: env.data.DNS_IP, + appDataPath: path.join(env.data.RUNTIPI_APP_DATA_PATH, 'app-data'), + persistTraefikConfig: env.data.PERSIST_TRAEFIK_CONFIG, + eventsTimeout: env.data.QUEUE_TIMEOUT_IN_MINUTES * 60 * 1000, + }, + deprecatedAppsRepoId: env.data.APPS_REPO_ID, // @deprecated + deprecatedAppsRepoUrl: env.data.APPS_REPO_URL, // @deprecated + architecture: env.data.ARCHITECTURE, + demoMode: env.data.DEMO_MODE, + rootFolderHost: env.data.ROOT_FOLDER_HOST, + envFilePath: this.envPath, + internalIp: env.data.INTERNAL_IP, + jwtSecret: env.data.JWT_SECRET, + __prod__: process.env.NODE_ENV === 'production', + }; + } + + public getConfig() { + return this.config; + } + + public get>(key: T) { + return this.config[key]; + } + + public async setUserSettings(settings: PartialUserSettingsDto) { + if (this.config.demoMode) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + if (settings.allowErrorMonitoring) { + this.initSentry({ release: this.config.version, allowSentry: settings.allowErrorMonitoring }); + } + + const settingsPath = path.join(DATA_DIR, 'state', 'settings.json'); + + const currentSettings = await this.filesystem.readJsonFile(settingsPath, settingsSchema.partial()); + + await this.filesystem.writeJsonFile(settingsPath, { + ...currentSettings, + ...settings, + }); + + this.config.userSettings = { + ...this.config.userSettings, + ...settings, + }; + } + + public async initSentry(params: { release: string; allowSentry: boolean }) { + const { release, allowSentry } = params; + + if (allowSentry) { + Sentry.init({ + release, + dsn: 'https://6cc88df40d1cdd0222ff30d996ca457c@o4504242900238336.ingest.us.sentry.io/4508264534835200', + environment: process.env.NODE_ENV, + beforeSend: cleanseErrorData, + includeLocalVariables: true, + integrations: [], + initialScope: { + tags: { version: release }, + }, + }); + } + } +} diff --git a/packages/backend/src/core/database/database.module.ts b/packages/backend/src/core/database/database.module.ts new file mode 100644 index 0000000000..3de1279c34 --- /dev/null +++ b/packages/backend/src/core/database/database.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { DatabaseService } from './database.service'; + +@Global() +@Module({ + imports: [], + controllers: [], + providers: [DatabaseService], + exports: [DatabaseService], +}) +export class DatabaseModule {} diff --git a/packages/backend/src/core/database/database.service.ts b/packages/backend/src/core/database/database.service.ts new file mode 100644 index 0000000000..e5ae8d06da --- /dev/null +++ b/packages/backend/src/core/database/database.service.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { type NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { ConfigurationService } from '../config/configuration.service'; +import { LoggerService } from '../logger/logger.service'; +import * as schema from './drizzle/schema'; + +@Injectable() +export class DatabaseService { + public db: NodePgDatabase; + + constructor( + private configurationService: ConfigurationService, + private logger: LoggerService, + ) { + const { username, port, database, host, password } = this.configurationService.get('database'); + const connectionString = `postgresql://${username}:${password}@${host}:${port}/${database}?connect_timeout=300`; + + this.db = drizzle(connectionString, { schema }); + } + + private getMigrationsPath(): string { + const { appDir } = this.configurationService.get('directories'); + return path.join(appDir, 'assets', 'migrations'); + } + + migrate = async () => { + try { + this.logger.debug('Starting database migration...'); + await migrate(this.db, { migrationsFolder: this.getMigrationsPath() }); + this.logger.debug('Database migration complete.'); + } catch (error) { + this.logger.error('Error migrating database:', error); + throw error; + } + }; +} diff --git a/packages/backend/src/core/database/drizzle/0000_sleepy_earthquake.sql b/packages/backend/src/core/database/drizzle/0000_sleepy_earthquake.sql new file mode 100644 index 0000000000..4972a38a54 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0000_sleepy_earthquake.sql @@ -0,0 +1,85 @@ +DO $$ +BEGIN + -- check if enum app_status_enum exists + IF NOT EXISTS ( + SELECT + 1 + FROM + pg_type + WHERE + typname = 'app_status_enum') THEN + -- create enum + CREATE TYPE "public"."app_status_enum" AS ENUM ( + 'running', + 'stopped', + 'installing', + 'uninstalling', + 'stopping', + 'starting', + 'missing', + 'updating', + 'resetting', + 'restarting', + 'backing_up', + 'restoring' +); +END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS "migrations" ( + "id" integer PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "hash" varchar(40) NOT NULL, + "executed_at" timestamp DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "migrations_name_key" UNIQUE("name") +); +-- > statement-breakpoint +CREATE TABLE IF NOT EXISTS "link" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar(20) NOT NULL, + "url" varchar NOT NULL, + "icon_url" varchar, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "user_id" integer NOT NULL, + "description" varchar(50) +); +-- > statement-breakpoint +CREATE TABLE IF NOT EXISTS "app" ( + "id" varchar PRIMARY KEY NOT NULL, + "status" "app_status_enum" DEFAULT 'stopped' NOT NULL, + "lastOpened" timestamp with time zone DEFAULT now(), + "numOpened" integer DEFAULT 0 NOT NULL, + "config" jsonb NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "exposed" boolean DEFAULT false NOT NULL, + "domain" varchar, + "is_visible_on_guest_dashboard" boolean DEFAULT false NOT NULL, + "open_port" boolean DEFAULT true NOT NULL, + "exposed_local" boolean DEFAULT true NOT NULL, + CONSTRAINT "UQ_9478629fc093d229df09e560aea" UNIQUE("id") +); +-- > statement-breakpoint +CREATE TABLE IF NOT EXISTS "user" ( + "id" serial PRIMARY KEY NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "operator" boolean DEFAULT false NOT NULL, + "totp_secret" text, + "totp_enabled" boolean DEFAULT false NOT NULL, + "salt" text, + "locale" varchar DEFAULT 'en' NOT NULL, + CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE("username") +); +-- > statement-breakpoint +DO $$ BEGIN + ALTER TABLE "link" ADD CONSTRAINT "FK_link_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + diff --git a/packages/backend/src/core/database/drizzle/0001_crazy_vanisher.sql b/packages/backend/src/core/database/drizzle/0001_crazy_vanisher.sql new file mode 100644 index 0000000000..f3a44f6003 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0001_crazy_vanisher.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "has_seen_welcome" boolean DEFAULT false NOT NULL; diff --git a/packages/db/assets/migrations/00002-add-app-version.sql b/packages/backend/src/core/database/drizzle/0002_add-app-version.sql similarity index 99% rename from packages/db/assets/migrations/00002-add-app-version.sql rename to packages/backend/src/core/database/drizzle/0002_add-app-version.sql index 4d287f21f3..7c64f8e27d 100644 --- a/packages/db/assets/migrations/00002-add-app-version.sql +++ b/packages/backend/src/core/database/drizzle/0002_add-app-version.sql @@ -30,3 +30,5 @@ BEGIN END IF; END $$; + + diff --git a/packages/db/assets/migrations/00003-add-status-updating.sql b/packages/backend/src/core/database/drizzle/0003_add-status-updating.sql similarity index 99% rename from packages/db/assets/migrations/00003-add-status-updating.sql rename to packages/backend/src/core/database/drizzle/0003_add-status-updating.sql index 02ed9cd817..83becdc12f 100644 --- a/packages/db/assets/migrations/00003-add-status-updating.sql +++ b/packages/backend/src/core/database/drizzle/0003_add-status-updating.sql @@ -13,3 +13,5 @@ BEGIN END IF; END $$; + + diff --git a/packages/db/assets/migrations/00004-add-exposed-domain-fields.sql b/packages/backend/src/core/database/drizzle/0004_add-exposed-domain-fields.sql similarity index 99% rename from packages/db/assets/migrations/00004-add-exposed-domain-fields.sql rename to packages/backend/src/core/database/drizzle/0004_add-exposed-domain-fields.sql index a56cd26e7d..cc1db5f557 100644 --- a/packages/db/assets/migrations/00004-add-exposed-domain-fields.sql +++ b/packages/backend/src/core/database/drizzle/0004_add-exposed-domain-fields.sql @@ -21,3 +21,5 @@ ALTER TABLE "app" -- Set default version to 1 ALTER TABLE "app" ALTER COLUMN "version" SET DEFAULT '1'; + + diff --git a/packages/db/assets/migrations/00005-add-user-operator.sql b/packages/backend/src/core/database/drizzle/0005_add-user-operator.sql similarity index 99% rename from packages/db/assets/migrations/00005-add-user-operator.sql rename to packages/backend/src/core/database/drizzle/0005_add-user-operator.sql index 0880343656..1ae95ef286 100644 --- a/packages/db/assets/migrations/00005-add-user-operator.sql +++ b/packages/backend/src/core/database/drizzle/0005_add-user-operator.sql @@ -16,3 +16,5 @@ ALTER TABLE "user" -- Set operator column to not null constraint ALTER TABLE "user" ALTER COLUMN "operator" SET NOT NULL; + + diff --git a/packages/db/assets/migrations/00006-add-totp-user-fields.sql b/packages/backend/src/core/database/drizzle/0006_add-totp-user-fields.sql similarity index 99% rename from packages/db/assets/migrations/00006-add-totp-user-fields.sql rename to packages/backend/src/core/database/drizzle/0006_add-totp-user-fields.sql index 4bca28f573..bbf7be97b4 100644 --- a/packages/db/assets/migrations/00006-add-totp-user-fields.sql +++ b/packages/backend/src/core/database/drizzle/0006_add-totp-user-fields.sql @@ -21,3 +21,5 @@ WHERE -- Set totp_enabled column to not null constraint ALTER TABLE "user" ALTER COLUMN "totp_enabled" SET NOT NULL; + + diff --git a/packages/db/assets/migrations/00008-merge-config-with-domain-and-exposed.sql b/packages/backend/src/core/database/drizzle/0007_merge-config-with-domain-and-exposed.sql similarity index 88% rename from packages/db/assets/migrations/00008-merge-config-with-domain-and-exposed.sql rename to packages/backend/src/core/database/drizzle/0007_merge-config-with-domain-and-exposed.sql index 997f55a72e..4e98597561 100644 --- a/packages/db/assets/migrations/00008-merge-config-with-domain-and-exposed.sql +++ b/packages/backend/src/core/database/drizzle/0007_merge-config-with-domain-and-exposed.sql @@ -1,4 +1,5 @@ --- For all apps that have a domain or exposed field set merge those values into the config jsonb +-- For all apps that have a domain or exposed field set merge those values into the +-- config jsonb UPDATE app SET @@ -12,3 +13,5 @@ SET config = jsonb_set(config, '{exposed}', to_jsonb (exposed)) WHERE exposed IS NOT NULL; + + diff --git a/packages/db/assets/migrations/00009-add-guest-dashboard.sql b/packages/backend/src/core/database/drizzle/0008_add-guest-dashboard.sql similarity index 99% rename from packages/db/assets/migrations/00009-add-guest-dashboard.sql rename to packages/backend/src/core/database/drizzle/0008_add-guest-dashboard.sql index 81b281b426..0c64be5d16 100644 --- a/packages/db/assets/migrations/00009-add-guest-dashboard.sql +++ b/packages/backend/src/core/database/drizzle/0008_add-guest-dashboard.sql @@ -13,3 +13,5 @@ WHERE -- Set is_visible_on_guest_dashboard column to not null constraint ALTER TABLE "app" ALTER COLUMN "is_visible_on_guest_dashboard" SET NOT NULL; + + diff --git a/packages/db/assets/migrations/00010-add-status-resetting.sql b/packages/backend/src/core/database/drizzle/0009_add-status-resetting.sql similarity index 99% rename from packages/db/assets/migrations/00010-add-status-resetting.sql rename to packages/backend/src/core/database/drizzle/0009_add-status-resetting.sql index 44f88673c8..a79c855407 100644 --- a/packages/db/assets/migrations/00010-add-status-resetting.sql +++ b/packages/backend/src/core/database/drizzle/0009_add-status-resetting.sql @@ -13,3 +13,5 @@ BEGIN END IF; END $$; + + diff --git a/packages/db/assets/migrations/00012-link-table-description.sql b/packages/backend/src/core/database/drizzle/0010_link-table-description.sql similarity index 96% rename from packages/db/assets/migrations/00012-link-table-description.sql rename to packages/backend/src/core/database/drizzle/0010_link-table-description.sql index 1fcd90d404..329121b3ba 100644 --- a/packages/db/assets/migrations/00012-link-table-description.sql +++ b/packages/backend/src/core/database/drizzle/0010_link-table-description.sql @@ -1,2 +1,4 @@ ALTER TABLE "link" - ADD COLUMN IF NOT EXISTS "description" character varying(50) \ No newline at end of file + ADD COLUMN IF NOT EXISTS "description" character varying(50) + + diff --git a/packages/db/assets/migrations/00013-app-local-exposed.sql b/packages/backend/src/core/database/drizzle/0011_app-local-exposed.sql similarity index 99% rename from packages/db/assets/migrations/00013-app-local-exposed.sql rename to packages/backend/src/core/database/drizzle/0011_app-local-exposed.sql index 370075173e..216a3bfd6b 100644 --- a/packages/db/assets/migrations/00013-app-local-exposed.sql +++ b/packages/backend/src/core/database/drizzle/0011_app-local-exposed.sql @@ -29,3 +29,5 @@ WHERE -- Set exposed_local column to not null constraint ALTER TABLE "app" ALTER COLUMN "exposed_local" SET NOT NULL; + + diff --git a/packages/db/assets/migrations/00014-restarting-state.sql b/packages/backend/src/core/database/drizzle/0012_restarting-state.sql similarity index 99% rename from packages/db/assets/migrations/00014-restarting-state.sql rename to packages/backend/src/core/database/drizzle/0012_restarting-state.sql index 949dc45a83..910e04cc5e 100644 --- a/packages/db/assets/migrations/00014-restarting-state.sql +++ b/packages/backend/src/core/database/drizzle/0012_restarting-state.sql @@ -13,3 +13,5 @@ BEGIN END IF; END $$; + + diff --git a/packages/db/assets/migrations/00015-backingup-state.sql b/packages/backend/src/core/database/drizzle/0013_backingup-state.sql similarity index 99% rename from packages/db/assets/migrations/00015-backingup-state.sql rename to packages/backend/src/core/database/drizzle/0013_backingup-state.sql index 1a6fc93360..b76d1033f6 100644 --- a/packages/db/assets/migrations/00015-backingup-state.sql +++ b/packages/backend/src/core/database/drizzle/0013_backingup-state.sql @@ -29,3 +29,5 @@ BEGIN END IF; END $$; + + diff --git a/packages/backend/src/core/database/drizzle/0014_first_gabe_jones.sql b/packages/backend/src/core/database/drizzle/0014_first_gabe_jones.sql new file mode 100644 index 0000000000..57d6ae8d01 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0014_first_gabe_jones.sql @@ -0,0 +1,164 @@ +CREATE TABLE IF NOT EXISTS "app_store" ( + "slug" varchar PRIMARY KEY NOT NULL, + "hash" varchar NOT NULL, + "name" varchar NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "url" varchar NOT NULL, + "branch" varchar DEFAULT 'main' NOT NULL, + "createdAt" timestamp DEFAULT now () NOT NULL, + "updatedAt" timestamp DEFAULT now () NOT NULL, + "deleted" boolean DEFAULT false NOT NULL, + CONSTRAINT "app_store_hash_unique" UNIQUE ("hash") +); + +--> statement-breakpoint +ALTER TABLE IF EXISTS "migrations" DISABLE ROW LEVEL SECURITY; + +--> statement-breakpoint +ALTER TABLE IF EXISTS "update" DISABLE ROW LEVEL SECURITY; + +--> statement-breakpoint +DROP TABLE IF EXISTS "migrations" CASCADE; + +--> statement-breakpoint +DROP TABLE IF EXISTS "update" CASCADE; + +--> statement-breakpoint +ALTER TABLE "app" +DROP CONSTRAINT IF EXISTS "UQ_9478629fc093d229df09e560aea"; + +--> statement-breakpoint +ALTER TABLE "user" +DROP CONSTRAINT IF EXISTS "UQ_78a916df40e02a9deb1c4b75edb"; + +--> statement-breakpoint +ALTER TABLE "link" +DROP CONSTRAINT IF EXISTS "FK_link_user_id"; + +-- CUSTOM +--> statement-breakpoint +ALTER TABLE "app" +ADD COLUMN IF NOT EXISTS "app_store_slug" varchar; + +--> statement-breakpoint +ALTER TABLE "app" +ADD COLUMN IF NOT EXISTS "app_name" varchar; + +--> statement-breakpoint +WITH + cte AS ( + SELECT + id + FROM + app + WHERE + app_name IS NULL + ) +UPDATE app +SET + app_name = cte.id +FROM + cte +WHERE + app.id = cte.id; + +--> statement-breakpoint +ALTER TABLE "app" +ADD COLUMN new_id INTEGER; + +--> statement-breakpoint +WITH + cte AS ( + SELECT + id, + ROW_NUMBER() OVER ( + ORDER BY + id + ) AS row_number + FROM + app + ) +UPDATE app +SET + new_id = cte.row_number +FROM + cte +WHERE + app.id = cte.id; + +--> statement-breakpoint +ALTER TABLE "app" +DROP COLUMN id; + +--> statement-breakpoint +ALTER TABLE "app" +RENAME COLUMN "new_id" TO "id"; + +--> statement-breakpoint +ALTER TABLE "app" ADD PRIMARY KEY ("id"); + +--> statement-breakpoint +ALTER TABLE "app" +ALTER COLUMN "app_name" +SET + NOT NULL; + +INSERT INTO + "app_store" ("slug", "hash", "name", "url", "branch") +VALUES + ( + 'migrated', + 'migrated', + 'migrated', + 'migrated', + 'main' + ); + +--> statement-breakpoint +UPDATE app +SET + app_store_slug = 'migrated'; + +--> statement-breakpoint +CREATE SEQUENCE app_id_seq; + +--> statement-breakpoint +ALTER TABLE app +ALTER COLUMN id +SET DEFAULT nextval ('app_id_seq'); + +--> statement-breakpoint +SELECT + setval ( + 'app_id_seq', + COALESCE( + ( + SELECT + MAX(id) + FROM + app + ), + 1 + ) + ); + +-- END CUSTOM +--> statement-breakpoint +ALTER TABLE "app" ADD CONSTRAINT "app_app_store_slug_app_store_slug_fk" FOREIGN KEY ("app_store_slug") REFERENCES "public"."app_store" ("slug") ON DELETE no action ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "link" ADD CONSTRAINT "link_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user" ("id") ON DELETE no action ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "app" +ALTER COLUMN "app_store_slug" +SET + NOT NULL; + +--> statement-breakpoint +ALTER TABLE "app" +DROP COLUMN "lastOpened"; + +--> statement-breakpoint +ALTER TABLE "app" +DROP COLUMN "numOpened"; diff --git a/packages/backend/src/core/database/drizzle/0015_unusual_newton_destine.sql b/packages/backend/src/core/database/drizzle/0015_unusual_newton_destine.sql new file mode 100644 index 0000000000..5c7a66c2ad --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0015_unusual_newton_destine.sql @@ -0,0 +1,2 @@ +ALTER TABLE "app_store" ALTER COLUMN "name" SET DATA TYPE varchar(16);--> statement-breakpoint +ALTER TABLE "app_store" DROP COLUMN "deleted"; \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql b/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql new file mode 100644 index 0000000000..5e4caafadd --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql @@ -0,0 +1 @@ +ALTER TABLE "app" ADD COLUMN "port" integer; \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0000_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..9d6df27296 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0000_snapshot.json @@ -0,0 +1,409 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "prevId": "", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "columns": ["name"], + "nullsNotDistinct": false, + "name": "migrations_name_key" + } + }, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "columns": ["name"], + "nullsNotDistinct": false, + "name": "UQ_6e7d7ecccdc972caa0ad33cb014" + } + }, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "tableTo": "user", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "columns": ["id"], + "nullsNotDistinct": false, + "name": "UQ_9478629fc093d229df09e560aea" + } + }, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "columns": ["username"], + "nullsNotDistinct": false, + "name": "UQ_78a916df40e02a9deb1c4b75edb" + } + }, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ], + "schema": "public" + }, + "public.update_status_enum": { + "name": "update_status_enum", + "values": ["FAILED", "SUCCESS"], + "schema": "public" + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {} + } +} + diff --git a/packages/backend/src/core/database/drizzle/meta/0001_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..e99656edd8 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0001_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "b0b799a5-85b5-4024-b7c9-7d74f2b425ba", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0002_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..c9d30bbaef --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "c75a4f0d-4fa8-4f04-832c-99e3bd3c7e44", + "prevId": "b0b799a5-85b5-4024-b7c9-7d74f2b425ba", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0003_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000000..3d05dd1235 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0003_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "f2d59623-bde8-4c99-aa25-29c76f6fd999", + "prevId": "c75a4f0d-4fa8-4f04-832c-99e3bd3c7e44", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0004_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000000..c373889d99 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0004_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "04f811f7-4f2e-49de-bbd4-f2817ab2ffbb", + "prevId": "f2d59623-bde8-4c99-aa25-29c76f6fd999", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0005_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..3f1f9e85ad --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0005_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "5ef41ff5-9f2b-471f-bd1a-575fc367dfe0", + "prevId": "04f811f7-4f2e-49de-bbd4-f2817ab2ffbb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0006_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..ff1ff5a142 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0006_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "1f507111-95dc-49f0-8bf4-b36e882a5596", + "prevId": "5ef41ff5-9f2b-471f-bd1a-575fc367dfe0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0007_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..4520ee5748 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0007_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "19e3848b-05cd-4484-b059-cadbd71ea8ab", + "prevId": "1f507111-95dc-49f0-8bf4-b36e882a5596", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0008_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000000..4e75bee6c2 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0008_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "04868c2c-0683-4865-bdcb-894f4b3f2db2", + "prevId": "19e3848b-05cd-4484-b059-cadbd71ea8ab", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0009_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000000..b2b4cccc00 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0009_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "6c5343a0-d2e9-4e92-a3bd-aee3dc08dfc6", + "prevId": "04868c2c-0683-4865-bdcb-894f4b3f2db2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0010_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000000..297c13a17b --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0010_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "4912a2c4-7bcd-4b3b-a346-b14e4478697a", + "prevId": "6c5343a0-d2e9-4e92-a3bd-aee3dc08dfc6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0011_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000000..9e7735daf5 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0011_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "133e82ce-0332-452b-993c-5c2253d481e3", + "prevId": "4912a2c4-7bcd-4b3b-a346-b14e4478697a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0012_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000000..e9b2ffe530 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0012_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "606a0a18-1aab-4999-a969-59f7e60d525d", + "prevId": "133e82ce-0332-452b-993c-5c2253d481e3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0013_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000000..6e6abbc8b7 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0013_snapshot.json @@ -0,0 +1,426 @@ +{ + "id": "2c7fea12-4f0a-4951-a7ab-8a529933fff8", + "prevId": "606a0a18-1aab-4999-a969-59f7e60d525d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "lastOpened": { + "name": "lastOpened", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "numOpened": { + "name": "numOpened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_9478629fc093d229df09e560aea": { + "name": "UQ_9478629fc093d229df09e560aea", + "columns": [ + "id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "FK_link_user_id": { + "name": "FK_link_user_id", + "tableFrom": "link", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migrations_name_key": { + "name": "migrations_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update": { + "name": "update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "update_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_6e7d7ecccdc972caa0ad33cb014": { + "name": "UQ_6e7d7ecccdc972caa0ad33cb014", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_78a916df40e02a9deb1c4b75edb": { + "name": "UQ_78a916df40e02a9deb1c4b75edb", + "columns": [ + "username" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0014_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000000..a9d5361ef4 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0014_snapshot.json @@ -0,0 +1,402 @@ +{ + "id": "a069576d-91a2-470f-8a07-cad90e823990", + "prevId": "2c7fea12-4f0a-4951-a7ab-8a529933fff8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "app_store_slug": { + "name": "app_store_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "app_name": { + "name": "app_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "app_app_store_slug_app_store_slug_fk": { + "name": "app_app_store_slug_app_store_slug_fk", + "tableFrom": "app", + "tableTo": "app_store", + "columnsFrom": [ + "app_store_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_store": { + "name": "app_store", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_store_hash_unique": { + "name": "app_store_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "link_user_id_user_id_fk": { + "name": "link_user_id_user_id_fk", + "tableFrom": "link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0015_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000000..e27db5344f --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0015_snapshot.json @@ -0,0 +1,395 @@ +{ + "id": "c98e7f88-641b-4653-9209-1032249c1f09", + "prevId": "a069576d-91a2-470f-8a07-cad90e823990", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "app_store_slug": { + "name": "app_store_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "app_name": { + "name": "app_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "app_app_store_slug_app_store_slug_fk": { + "name": "app_app_store_slug_app_store_slug_fk", + "tableFrom": "app", + "tableTo": "app_store", + "columnsFrom": [ + "app_store_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_store": { + "name": "app_store", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_store_hash_unique": { + "name": "app_store_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "link_user_id_user_id_fk": { + "name": "link_user_id_user_id_fk", + "tableFrom": "link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..0bc36dae6d --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json @@ -0,0 +1,401 @@ +{ + "id": "bc9468c8-4acb-4969-9873-9cf8562bc3fe", + "prevId": "c98e7f88-641b-4653-9209-1032249c1f09", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "app_store_slug": { + "name": "app_store_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "app_name": { + "name": "app_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "app_app_store_slug_app_store_slug_fk": { + "name": "app_app_store_slug_app_store_slug_fk", + "tableFrom": "app", + "tableTo": "app_store", + "columnsFrom": [ + "app_store_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_store": { + "name": "app_store", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_store_hash_unique": { + "name": "app_store_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "link_user_id_user_id_fk": { + "name": "link_user_id_user_id_fk", + "tableFrom": "link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/_journal.json b/packages/backend/src/core/database/drizzle/meta/_journal.json new file mode 100644 index 0000000000..8ba2d19dc2 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/_journal.json @@ -0,0 +1,125 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1731334056989, + "tag": "0000_sleepy_earthquake", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1731335935965, + "tag": "0001_crazy_vanisher", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1732529309886, + "tag": "0002_add-app-version", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1732529344918, + "tag": "0003_add-status-updating", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1732529372297, + "tag": "0004_add-exposed-domain-fields", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1732529416112, + "tag": "0005_add-user-operator", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1732529440403, + "tag": "0006_add-totp-user-fields", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1732529494219, + "tag": "0007_merge-config-with-domain-and-exposed", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1732529539686, + "tag": "0008_add-guest-dashboard", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1732529564105, + "tag": "0009_add-status-resetting", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1732529654682, + "tag": "0010_link-table-description", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1732529678975, + "tag": "0011_app-local-exposed", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1732529705065, + "tag": "0012_restarting-state", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1732529725697, + "tag": "0013_backingup-state", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1735050181151, + "tag": "0014_first_gabe_jones", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1735220946368, + "tag": "0015_unusual_newton_destine", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1735306634382, + "tag": "0016_cloudy_norman_osborn", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/relations.ts b/packages/backend/src/core/database/drizzle/relations.ts new file mode 100644 index 0000000000..c001b15919 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/relations.ts @@ -0,0 +1,13 @@ +import { relations } from "drizzle-orm/relations"; +import { user, link } from "./schema"; + +export const linkRelations = relations(link, ({one}) => ({ + user: one(user, { + fields: [link.userId], + references: [user.id] + }), +})); + +export const userRelations = relations(user, ({many}) => ({ + links: many(link), +})); \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/schema.ts b/packages/backend/src/core/database/drizzle/schema.ts new file mode 100644 index 0000000000..f820734622 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/schema.ts @@ -0,0 +1,91 @@ +import { relations } from 'drizzle-orm'; +import { boolean, customType, integer, pgEnum, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; + +export const appStatusEnum = pgEnum('app_status_enum', [ + 'running', + 'stopped', + 'installing', + 'uninstalling', + 'stopping', + 'starting', + 'missing', + 'updating', + 'resetting', + 'restarting', + 'backing_up', + 'restoring', +]); +export const updateStatusEnum = pgEnum('update_status_enum', ['FAILED', 'SUCCESS']); + +export const link = pgTable('link', { + id: serial().primaryKey().notNull(), + title: varchar({ length: 20 }).notNull(), + url: varchar().notNull(), + iconUrl: varchar('icon_url'), + createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + userId: integer('user_id') + .notNull() + .references(() => user.id), + description: varchar({ length: 50 }), +}); + +const appConfig = customType<{ data: Record; driverData: string }>({ + dataType() { + return 'jsonb'; + }, + toDriver(value: Record): string { + return JSON.stringify(value); + }, +}); + +export const app = pgTable('app', { + id: serial().primaryKey().notNull(), + status: appStatusEnum().default('stopped').notNull(), + config: appConfig('config').notNull(), + createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + version: integer().default(1).notNull(), + exposed: boolean().default(false).notNull(), + domain: varchar(), + isVisibleOnGuestDashboard: boolean('is_visible_on_guest_dashboard').default(false).notNull(), + openPort: boolean('open_port').default(true).notNull(), + port: integer(), + exposedLocal: boolean('exposed_local').default(true).notNull(), + appStoreSlug: varchar('app_store_slug') + .notNull() + .references(() => appStore.slug), + appName: varchar('app_name').notNull(), +}); + +export const appRelations = relations(app, ({ one }) => ({ + appStore: one(appStore, { + fields: [app.appStoreSlug], + references: [appStore.slug], + }), +})); + +export const user = pgTable('user', { + id: serial().primaryKey().notNull(), + username: varchar().notNull(), + password: varchar().notNull(), + createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + operator: boolean().default(false).notNull(), + totpSecret: text('totp_secret'), + totpEnabled: boolean('totp_enabled').default(false).notNull(), + salt: text(), + locale: varchar().default('en').notNull(), + hasSeenWelcome: boolean('has_seen_welcome').default(false).notNull(), +}); + +export const appStore = pgTable('app_store', { + slug: varchar().notNull().primaryKey(), + hash: varchar().notNull().unique(), + name: varchar({ length: 16 }).notNull(), + enabled: boolean().default(true).notNull(), + url: varchar().notNull(), + branch: varchar().default('main').notNull(), + createdAt: timestamp({ mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp({ mode: 'string' }).defaultNow().notNull(), +}); diff --git a/packages/backend/src/core/database/drizzle/types.ts b/packages/backend/src/core/database/drizzle/types.ts new file mode 100644 index 0000000000..5e54946a57 --- /dev/null +++ b/packages/backend/src/core/database/drizzle/types.ts @@ -0,0 +1,14 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { app, appStatusEnum, appStore, user } from './schema'; + +export const APP_STATUS = appStatusEnum.enumValues; +export type AppStatus = (typeof APP_STATUS)[number]; + +export type User = InferSelectModel; +export type NewUser = InferInsertModel; + +export type App = InferSelectModel; +export type NewApp = InferInsertModel; + +export type AppStore = InferSelectModel; +export type NewAppStore = InferInsertModel; diff --git a/packages/backend/src/core/encryption/encryption.module.ts b/packages/backend/src/core/encryption/encryption.module.ts new file mode 100644 index 0000000000..c221172767 --- /dev/null +++ b/packages/backend/src/core/encryption/encryption.module.ts @@ -0,0 +1,10 @@ +import { EnvUtils } from '@/modules/env/env.utils'; +import { Module } from '@nestjs/common'; +import { ConfigurationService } from '../config/configuration.service'; +import { EncryptionService } from './encryption.service'; + +@Module({ + providers: [ConfigurationService, EncryptionService, EnvUtils], + exports: [EncryptionService], +}) +export class EncryptionModule {} diff --git a/packages/backend/src/core/encryption/encryption.service.ts b/packages/backend/src/core/encryption/encryption.service.ts new file mode 100644 index 0000000000..7ec32d2a35 --- /dev/null +++ b/packages/backend/src/core/encryption/encryption.service.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { ConfigurationService } from '../config/configuration.service'; + +@Injectable() +export class EncryptionService { + algorithm = 'aes-256-gcm' as const; + keyLength = 32; + + constructor(private readonly config: ConfigurationService) {} + + /** + * Given a string, encrypts it using the provided salt + */ + encrypt = (data: string, salt: string) => { + const jwtSecret = this.config.get('jwtSecret'); + + const key = crypto.pbkdf2Sync(jwtSecret, salt, 100000, this.keyLength, 'sha256'); + const iv = crypto.randomBytes(12); + + const cipher = crypto.createCipheriv(this.algorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`; + }; + + /** + * Given an encrypted string, decrypts it using the provided salt + */ + decrypt = (encryptedData: string, salt: string) => { + const jwtSecret = this.config.get('jwtSecret'); + + const key = crypto.pbkdf2Sync(jwtSecret, salt, 100000, this.keyLength, 'sha256'); + const parts = encryptedData.split(':'); + const iv = Buffer.from(parts.shift() as string, 'hex'); + const encrypted = Buffer.from(parts.shift() as string, 'hex'); + const tag = Buffer.from(parts.shift() as string, 'hex'); + const decipher = crypto.createDecipheriv(this.algorithm, key, iv); + + decipher.setAuthTag(tag); + + let decrypted = decipher.update(encrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString(); + }; +} diff --git a/packages/backend/src/core/filesystem/filesystem.module.ts b/packages/backend/src/core/filesystem/filesystem.module.ts new file mode 100644 index 0000000000..c9901f56d5 --- /dev/null +++ b/packages/backend/src/core/filesystem/filesystem.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { FilesystemService } from './filesystem.service'; + +@Global() +@Module({ + imports: [], + providers: [FilesystemService], + exports: [FilesystemService], +}) +export class FilesystemModule {} diff --git a/packages/backend/src/core/filesystem/filesystem.service.ts b/packages/backend/src/core/filesystem/filesystem.service.ts new file mode 100644 index 0000000000..65e23a114f --- /dev/null +++ b/packages/backend/src/core/filesystem/filesystem.service.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs'; +import { EOL } from 'node:os'; +import path from 'node:path'; +import { APP_DATA_DIR, APP_DIR, DATA_DIR } from '@/common/constants'; +import { LoggerService } from '@/core/logger/logger.service'; +import { Injectable } from '@nestjs/common'; +import { ZodSchema } from 'zod'; + +@Injectable() +export class FilesystemService { + constructor(private readonly logger: LoggerService) {} + + private getSafeFilePath(filePath: string): string { + // Define allowed directories as absolute paths + const allowedDirs = [ + path.resolve(APP_DIR), + path.resolve(APP_DATA_DIR), + path.resolve(DATA_DIR), + path.resolve('/host/proc/'), + path.resolve('/tmp/'), + ]; + + // Resolve and normalize the file path to an absolute path + const resolvedPath = path.resolve(filePath); + + for (const dir of allowedDirs) { + if (path.relative(dir, resolvedPath).startsWith('..')) { + continue; // If relative path starts with '..', it's outside the allowed dir + } + + return resolvedPath; + } + + this.logger.error(`File path "${filePath}" is not allowed. Resolved: "${resolvedPath}"`); + throw new Error('File path is not allowed'); + } + + async readJsonFile(filePath: string, schema?: ZodSchema): Promise { + try { + const fileContent = await fs.promises.readFile(this.getSafeFilePath(filePath), 'utf8'); + const parsedContent = JSON.parse(fileContent); + + if (schema) { + const validatedContent = schema.safeParse(parsedContent); + if (!validatedContent.success) { + this.logger.debug(`File ${this.getSafeFilePath(filePath)} validation error:`, validatedContent.error); + return null; + } + return validatedContent.data; + } + + return parsedContent; + } catch (error) { + this.logger.error(`Error reading file ${this.getSafeFilePath(filePath)}: ${error}`); + return null; + } + } + + async readTextFile(filePath: string): Promise { + try { + return await fs.promises.readFile(this.getSafeFilePath(filePath), 'utf8'); + } catch (error) { + this.logger.error(`Error reading file ${this.getSafeFilePath(filePath)}: ${error}`); + return null; + } + } + + async readBinaryFile(filePath: string): Promise { + try { + return await fs.promises.readFile(this.getSafeFilePath(filePath)); + } catch (error) { + this.logger.error(`Error reading file ${this.getSafeFilePath(filePath)}: ${error}`); + return null; + } + } + + async writeJsonFile(filePath: string, data: T): Promise { + try { + await fs.promises.writeFile(this.getSafeFilePath(filePath), `${JSON.stringify(data, null, 2)}${EOL}`, 'utf8'); + return true; + } catch (error) { + this.logger.error(`Error writing file ${this.getSafeFilePath(filePath)}: ${error}`); + return false; + } + } + + async writeTextFile(filePath: string, content: string): Promise { + try { + await fs.promises.mkdir(this.getSafeFilePath(filePath.split('/').slice(0, -1).join('/')), { recursive: true }); + await fs.promises.writeFile(this.getSafeFilePath(filePath), `${content}${EOL}`, 'utf8'); + return true; + } catch (error) { + this.logger.error(`Error writing file ${this.getSafeFilePath(filePath)}: ${error}`); + return false; + } + } + + async pathExists(filePath: string): Promise { + return fs.promises + .access(this.getSafeFilePath(filePath)) + .then(() => true) + .catch(() => false); + } + + async copyFile(src: string, dest: string): Promise { + try { + await fs.promises.copyFile(this.getSafeFilePath(src), this.getSafeFilePath(dest)); + return true; + } catch (error) { + this.logger.error(`Error copying file from ${this.getSafeFilePath(src)} to ${this.getSafeFilePath(dest)}: ${error}`); + return false; + } + } + + async createDirectory(dirPath: string): Promise { + try { + await fs.promises.mkdir(this.getSafeFilePath(dirPath), { recursive: true }); + return true; + } catch (error) { + this.logger.error(`Error creating directory ${this.getSafeFilePath(dirPath)}: ${error}`); + return false; + } + } + + async createDirectories(dirPaths: string[]): Promise { + for (const dirPath of dirPaths) { + if (!(await this.createDirectory(this.getSafeFilePath(dirPath)))) { + return false; + } + } + return true; + } + + async copyDirectory(src: string, dest: string, options: fs.CopyOptions = {}): Promise { + try { + await fs.promises.cp(this.getSafeFilePath(src), this.getSafeFilePath(dest), { recursive: true, ...options }); + return true; + } catch (error) { + this.logger.error(`Error copying directory from ${this.getSafeFilePath(src)} to ${this.getSafeFilePath(dest)}: ${error}`); + return false; + } + } + + async removeDirectory(dirPath: string): Promise { + try { + await fs.promises.rm(this.getSafeFilePath(dirPath), { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + return true; + } catch (error) { + this.logger.error(`Error removing directory ${this.getSafeFilePath(dirPath)}: ${error}`); + return false; + } + } + + async removeFile(filePath: string): Promise { + try { + await fs.promises.unlink(this.getSafeFilePath(filePath)); + return true; + } catch (error) { + this.logger.error(`Error removing file ${this.getSafeFilePath(filePath)}: ${error}`); + return false; + } + } + + async listFiles(dirPath: string): Promise { + try { + return await fs.promises.readdir(this.getSafeFilePath(dirPath)); + } catch (error) { + this.logger.error(`Error listing files in ${this.getSafeFilePath(dirPath)}: ${error}`); + return []; + } + } + + async isDirectory(dirPath: string): Promise { + return (await fs.promises.lstat(this.getSafeFilePath(dirPath))).isDirectory(); + } + + async createTempDirectory(prefix: string): Promise { + return fs.promises.mkdtemp(prefix); + } + + async getStats(filePath: string) { + return await fs.promises.stat(this.getSafeFilePath(filePath)); + } +} diff --git a/packages/backend/src/core/health/health.controller.ts b/packages/backend/src/core/health/health.controller.ts new file mode 100644 index 0000000000..a68c73cf93 --- /dev/null +++ b/packages/backend/src/core/health/health.controller.ts @@ -0,0 +1,19 @@ +import { QueueHealthIndicator } from '@/modules/queue/queue.health'; +import { Controller, Get } from '@nestjs/common'; +import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; +import { SocketHealthIndicator } from '../socket/socket.health'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private queueHealthIndicator: QueueHealthIndicator, + private socketHealthIndicator: SocketHealthIndicator, + ) {} + + @Get() + @HealthCheck() + check() { + return this.health.check([() => this.queueHealthIndicator.isHealthy('queue'), () => this.socketHealthIndicator.isHealthy('socket')]); + } +} diff --git a/packages/backend/src/core/health/health.module.ts b/packages/backend/src/core/health/health.module.ts new file mode 100644 index 0000000000..107be7c7d7 --- /dev/null +++ b/packages/backend/src/core/health/health.module.ts @@ -0,0 +1,12 @@ +import { QueueModule } from '@/modules/queue/queue.module'; +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { SocketModule } from '../socket/socket.module'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], + imports: [TerminusModule, QueueModule, SocketModule], + providers: [], +}) +export class HealthModule {} diff --git a/packages/backend/src/core/logger/logger.module.ts b/packages/backend/src/core/logger/logger.module.ts new file mode 100644 index 0000000000..31238c1ff0 --- /dev/null +++ b/packages/backend/src/core/logger/logger.module.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { DATA_DIR } from '@/common/constants'; +import { Global, Module } from '@nestjs/common'; +import { LoggerService } from './logger.service'; + +@Global() +@Module({ + imports: [], + providers: [ + { + provide: LoggerService, + useFactory: () => new LoggerService('backend', path.join(DATA_DIR, 'logs')), + }, + ], + exports: [LoggerService], +}) +export class LoggerModule {} diff --git a/packages/backend/src/core/logger/logger.service.ts b/packages/backend/src/core/logger/logger.service.ts new file mode 100644 index 0000000000..d70658014f --- /dev/null +++ b/packages/backend/src/core/logger/logger.service.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import winston, { createLogger, format, transports } from 'winston'; + +const { printf, timestamp, combine, colorize, align, label } = format; + +type Transports = transports.ConsoleTransportInstance | transports.FileTransportInstance; + +/** + * Given an id and a logs folder, creates a new winston logger + * + * @param {string} id - The id of the logger, used to identify the logger in the logs + * @param {string} logsFolder - The folder where the logs will be stored + */ +export const newLogger = (id: string, logsFolder: string, logLevel = 'info') => { + const tr: Transports[] = []; + const exceptionHandlers: Transports[] = [new transports.Console()]; + + try { + tr.push( + new transports.File({ + filename: path.join(logsFolder, 'error.log'), + level: 'error', + }), + ); + tr.push( + new transports.File({ + filename: path.join(logsFolder, 'app.log'), + level: logLevel, + }), + ); + + tr.push(new transports.Console({ level: logLevel })); + } catch (error) { + // no-op + } + + return createLogger({ + level: logLevel, + format: combine( + label({ label: id }), + colorize(), + timestamp(), + align(), + printf((info) => `${id}: ${info.timestamp} - ${info.level} > ${info.message}`), + ), + transports: tr, + exceptionHandlers, + exitOnError: false, + }); +}; + +@Injectable() +export class LoggerService { + private winstonLogger: winston.Logger; + + private logsFolder: string; + + constructor(id: string, folder: string) { + this.winstonLogger = newLogger(id, folder, process.env.LOG_LEVEL); + this.logsFolder = folder; + } + + private streamLogToHistory(logFile: string) { + return new Promise((resolve, reject) => { + const appLogReadStream = fs.createReadStream(path.join(this.logsFolder, logFile), 'utf-8'); + const appLogHistoryWriteStream = fs.createWriteStream(path.join(this.logsFolder, `${logFile}.history`), { flags: 'a' }); + + appLogReadStream + .pipe(appLogHistoryWriteStream) + .on('finish', () => { + fs.writeFileSync(path.join(this.logsFolder, logFile), ''); + resolve(true); + }) + .on('error', (error) => { + reject(error); + }); + }); + } + + public flush = async () => { + try { + if (fs.existsSync(path.join(this.logsFolder, 'app.log'))) { + await this.streamLogToHistory('app.log'); + } + if (fs.existsSync(path.join(this.logsFolder, 'error.log'))) { + await this.streamLogToHistory('error.log'); + } + this.winstonLogger.info('Logs flushed'); + } catch (error) { + this.winstonLogger.error('Error flushing logs', error); + } + }; + + private log = (level: string, messages: unknown[]) => { + this.winstonLogger.log(level, messages.join(' ')); + }; + + public error = (...message: unknown[]) => { + this.log('error', message); + }; + + public info = (...message: unknown[]) => { + this.log('info', message); + }; + + public warn = (...message: unknown[]) => { + this.log('warn', message); + }; + + public debug = (...message: unknown[]) => { + this.log('debug', message); + }; +} diff --git a/packages/shared/src/schemas/socket-schemas.ts b/packages/backend/src/core/socket/socket-schemas.ts similarity index 91% rename from packages/shared/src/schemas/socket-schemas.ts rename to packages/backend/src/core/socket/socket-schemas.ts index 1c8308f98b..bf27bee202 100644 --- a/packages/shared/src/schemas/socket-schemas.ts +++ b/packages/backend/src/core/socket/socket-schemas.ts @@ -27,7 +27,7 @@ export const socketEventSchema = z.union([ z.literal('restore_error'), ]), data: z.object({ - appId: z.string(), + appUrn: z.string().refine((v) => v.split(':').length === 2), appStatus: z .enum([ 'running', @@ -51,7 +51,7 @@ export const socketEventSchema = z.union([ type: z.literal('app-logs-init'), event: z.literal('initLogs'), data: z.object({ - appId: z.string(), + appUrn: z.string().refine((v) => v.split(':').length === 2), maxLines: z.number().optional(), }), }), @@ -59,7 +59,7 @@ export const socketEventSchema = z.union([ type: z.literal('app-logs'), event: z.union([z.literal('newLogs'), z.literal('stopLogs')]), data: z.object({ - appId: z.string(), + appUrn: z.string().refine((v) => v.split(':').length === 2), lines: z.array(z.string()).optional(), }), }), diff --git a/packages/backend/src/core/socket/socket.health.ts b/packages/backend/src/core/socket/socket.health.ts new file mode 100644 index 0000000000..f304c331cc --- /dev/null +++ b/packages/backend/src/core/socket/socket.health.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; +import { SocketManager } from './socket.service'; + +@Injectable() +export class SocketHealthIndicator extends HealthIndicator { + constructor(private readonly socketManager: SocketManager) { + super(); + } + + async isHealthy(key: string): Promise { + const isConnected = await this.socketManager.isConnected(); + + if (isConnected) { + return Promise.resolve(this.getStatus(key, true)); + } + + throw new HealthCheckError( + 'Websocket check failed', + this.getStatus(key, false, { + message: 'Not connected to websocket server', + }), + ); + } +} diff --git a/packages/backend/src/core/socket/socket.module.ts b/packages/backend/src/core/socket/socket.module.ts new file mode 100644 index 0000000000..dc9264ff1a --- /dev/null +++ b/packages/backend/src/core/socket/socket.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SocketHealthIndicator } from './socket.health'; +import { SocketManager } from './socket.service'; + +@Module({ + imports: [], + providers: [SocketManager, SocketHealthIndicator], + exports: [SocketManager, SocketHealthIndicator], +}) +export class SocketModule {} diff --git a/packages/backend/src/core/socket/socket.service.ts b/packages/backend/src/core/socket/socket.service.ts new file mode 100644 index 0000000000..ae3aaa6170 --- /dev/null +++ b/packages/backend/src/core/socket/socket.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { Server } from 'socket.io'; +import { LoggerService } from '../logger/logger.service'; +import type { SocketEvent } from './socket-schemas'; + +@Injectable() +export class SocketManager { + public io: Server | null = null; + + constructor(private logger: LoggerService) {} + + init() { + if (this.io) { + return this.io; + } + + const io = new Server(5001, { cors: { origin: '*' }, path: '/api/socket.io' }); + this.logger.info('SocketManager initialized'); + + io.on('disconnect', (socket) => { + this.logger.debug('Client disconnected from socket', socket.id); + }); + + io.on('error', (error) => { + this.logger.error('SocketManager error:', error); + }); + + this.io = io; + + return io; + } + + async isConnected() { + if (!this.io) { + return false; + } + + return this.io.httpServer.listening; + } + + async emit(event: SocketEvent) { + if (!this.io) { + this.logger.error('SocketManager is not initialized'); + return; + } + + try { + const sockets = await this.io.fetchSockets(); + + for (const socket of sockets) { + try { + socket.emit(event.type, event); + } catch (error) { + this.logger.error('Error sending socket event:', error); + } + } + } catch (error) { + this.logger.error('Error emitting socket event:', error); + } + } +} diff --git a/packages/backend/src/exports/index.ts b/packages/backend/src/exports/index.ts new file mode 100644 index 0000000000..061e798502 --- /dev/null +++ b/packages/backend/src/exports/index.ts @@ -0,0 +1,3 @@ +import { socketEventSchema, type SocketEvent } from '../core/socket/socket-schemas'; + +export { socketEventSchema, type SocketEvent }; diff --git a/packages/backend/src/graph.mermaid b/packages/backend/src/graph.mermaid new file mode 100644 index 0000000000..b75d51df59 --- /dev/null +++ b/packages/backend/src/graph.mermaid @@ -0,0 +1,184 @@ +graph LR + AppModule-->SentryModule + SentryModule-->ConfigurationModule + ConfigurationModule-->EnvModule + EnvModule-->SentryModule + EnvModule-->ConfigurationModule + EnvModule-->DatabaseModule + DatabaseModule-->SentryModule + DatabaseModule-->ConfigurationModule + DatabaseModule-->CacheModule + CacheModule-->SentryModule + CacheModule-->ConfigurationModule + CacheModule-->DatabaseModule + CacheModule-->LoggerModule + LoggerModule-->SentryModule + LoggerModule-->ConfigurationModule + LoggerModule-->DatabaseModule + LoggerModule-->CacheModule + LoggerModule-->FilesystemModule + FilesystemModule-->SentryModule + FilesystemModule-->ConfigurationModule + FilesystemModule-->DatabaseModule + FilesystemModule-->CacheModule + FilesystemModule-->LoggerModule + CacheModule-->FilesystemModule + DatabaseModule-->LoggerModule + DatabaseModule-->FilesystemModule + EnvModule-->CacheModule + EnvModule-->LoggerModule + EnvModule-->FilesystemModule + ConfigurationModule-->SentryModule + ConfigurationModule-->DatabaseModule + ConfigurationModule-->CacheModule + ConfigurationModule-->LoggerModule + ConfigurationModule-->FilesystemModule + SentryModule-->DatabaseModule + SentryModule-->CacheModule + SentryModule-->LoggerModule + SentryModule-->FilesystemModule + AppModule-->SystemModule + SystemModule-->SentryModule + SystemModule-->ConfigurationModule + SystemModule-->DatabaseModule + SystemModule-->CacheModule + SystemModule-->LoggerModule + SystemModule-->FilesystemModule + AppModule-->I18nModule + I18nModule-->SentryModule + I18nModule-->ConfigurationModule + I18nModule-->DatabaseModule + I18nModule-->CacheModule + I18nModule-->LoggerModule + I18nModule-->FilesystemModule + AppModule-->AuthModule + AuthModule-->UserModule + UserModule-->SentryModule + UserModule-->ConfigurationModule + UserModule-->DatabaseModule + UserModule-->CacheModule + UserModule-->LoggerModule + UserModule-->FilesystemModule + AuthModule-->EncryptionModule + EncryptionModule-->SentryModule + EncryptionModule-->ConfigurationModule + EncryptionModule-->DatabaseModule + EncryptionModule-->CacheModule + EncryptionModule-->LoggerModule + EncryptionModule-->FilesystemModule + AuthModule-->SentryModule + AuthModule-->ConfigurationModule + AuthModule-->DatabaseModule + AuthModule-->CacheModule + AuthModule-->LoggerModule + AuthModule-->FilesystemModule + AppModule-->UserModule + AppModule-->ConfigurationModule + AppModule-->DatabaseModule + AppModule-->CacheModule + AppModule-->LoggerModule + AppModule-->AppsModule + AppsModule-->QueueModule + QueueModule-->SentryModule + QueueModule-->ConfigurationModule + QueueModule-->DatabaseModule + QueueModule-->CacheModule + QueueModule-->LoggerModule + QueueModule-->FilesystemModule + AppsModule-->EnvModule + AppsModule-->MarketplaceModule + MarketplaceModule-->AppStoreModule + AppStoreModule-->QueueModule + AppStoreModule-->SentryModule + AppStoreModule-->ConfigurationModule + AppStoreModule-->DatabaseModule + AppStoreModule-->CacheModule + AppStoreModule-->LoggerModule + AppStoreModule-->FilesystemModule + MarketplaceModule-->SentryModule + MarketplaceModule-->ConfigurationModule + MarketplaceModule-->DatabaseModule + MarketplaceModule-->CacheModule + MarketplaceModule-->LoggerModule + MarketplaceModule-->FilesystemModule + AppsModule-->SentryModule + AppsModule-->ConfigurationModule + AppsModule-->DatabaseModule + AppsModule-->CacheModule + AppsModule-->LoggerModule + AppsModule-->FilesystemModule + AppModule-->FilesystemModule + AppModule-->AppStoreModule + AppModule-->QueueModule + AppModule-->AppLifecycleModule + AppLifecycleModule-->QueueModule + AppLifecycleModule-->AppsModule + AppLifecycleModule-->EnvModule + AppLifecycleModule-->DockerModule + DockerModule-->AppsModule + DockerModule-->AppStoreModule + DockerModule-->SocketModule + SocketModule-->SentryModule + SocketModule-->ConfigurationModule + SocketModule-->DatabaseModule + SocketModule-->CacheModule + SocketModule-->LoggerModule + SocketModule-->FilesystemModule + DockerModule-->SentryModule + DockerModule-->ConfigurationModule + DockerModule-->DatabaseModule + DockerModule-->CacheModule + DockerModule-->LoggerModule + DockerModule-->FilesystemModule + AppLifecycleModule-->SocketModule + AppLifecycleModule-->MarketplaceModule + AppLifecycleModule-->BackupsModule + BackupsModule-->AppLifecycleModule + BackupsModule-->AppsModule + BackupsModule-->QueueModule + BackupsModule-->SocketModule + BackupsModule-->ArchiveModule + ArchiveModule-->SentryModule + ArchiveModule-->ConfigurationModule + ArchiveModule-->DatabaseModule + ArchiveModule-->CacheModule + ArchiveModule-->LoggerModule + ArchiveModule-->FilesystemModule + BackupsModule-->SentryModule + BackupsModule-->ConfigurationModule + BackupsModule-->DatabaseModule + BackupsModule-->CacheModule + BackupsModule-->LoggerModule + BackupsModule-->FilesystemModule + AppLifecycleModule-->SentryModule + AppLifecycleModule-->ConfigurationModule + AppLifecycleModule-->DatabaseModule + AppLifecycleModule-->CacheModule + AppLifecycleModule-->LoggerModule + AppLifecycleModule-->FilesystemModule + AppModule-->SocketModule + AppModule-->LinksModule + LinksModule-->SentryModule + LinksModule-->ConfigurationModule + LinksModule-->DatabaseModule + LinksModule-->CacheModule + LinksModule-->LoggerModule + LinksModule-->FilesystemModule + AppModule-->BackupsModule + AppModule-->HealthModule + HealthModule-->TerminusModule + TerminusModule-->SentryModule + TerminusModule-->ConfigurationModule + TerminusModule-->DatabaseModule + TerminusModule-->CacheModule + TerminusModule-->LoggerModule + TerminusModule-->FilesystemModule + HealthModule-->QueueModule + HealthModule-->SocketModule + HealthModule-->SentryModule + HealthModule-->ConfigurationModule + HealthModule-->DatabaseModule + HealthModule-->CacheModule + HealthModule-->LoggerModule + HealthModule-->FilesystemModule + AppModule-->MarketplaceModule \ No newline at end of file diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts new file mode 100644 index 0000000000..23d32944e7 --- /dev/null +++ b/packages/backend/src/main.ts @@ -0,0 +1,66 @@ +import { patchNestJsSwagger } from 'nestjs-zod'; + +import { SpelunkerModule } from 'nestjs-spelunker'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { type INestApplication, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import cookieParser from 'cookie-parser'; +import { AppModule } from './app.module'; +import { AppService } from './app.service'; +import { APP_DIR } from './common/constants'; +import { generateSystemEnvFile } from './common/helpers/env-helpers'; +import metadata from './metadata'; + +async function setupSwagger(app: INestApplication) { + const config = new DocumentBuilder().setTitle('Runtipi API').setDescription('API specs for Runtipi').setVersion('1.0').build(); + await SwaggerModule.loadPluginMetadata(metadata); + + const document = SwaggerModule.createDocument(app, config, { + operationIdFactory: (_: string, methodKey: string) => methodKey, + }); + SwaggerModule.setup('api/docs', app, document); + + // write the swagger.json file to the assets folder + if (process.env.NODE_ENV !== 'production') { + await fs.promises.writeFile(path.join(APP_DIR, 'packages', 'backend', 'src', 'swagger.json'), JSON.stringify(document, null, 2)); + } +} + +async function bootstrap() { + patchNestJsSwagger(); + + await generateSystemEnvFile(); + + const app = await NestFactory.create(AppModule, { + abortOnError: true, + logger: ['error', 'warn', 'fatal'], + }); + + if (process.env.NODE_ENV !== 'production') { + const tree = SpelunkerModule.explore(app); + const root = SpelunkerModule.graph(tree); + const edges = SpelunkerModule.findGraphEdges(root); + const mermaidEdges = edges.map(({ from, to }) => ` ${from.module.name}-->${to.module.name}`); + await fs.promises.writeFile(path.join(APP_DIR, 'packages', 'backend', 'src', 'graph.mermaid'), `graph LR\n${mermaidEdges.join('\n')}`); + } + + const appService = app.get(AppService); + await appService.bootstrap(); + + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + app.enableCors(); + app.use(cookieParser()); + + await setupSwagger(app); + + await app.listen(3000); +} + +bootstrap().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/backend/src/metadata.ts b/packages/backend/src/metadata.ts new file mode 100644 index 0000000000..95ce188520 --- /dev/null +++ b/packages/backend/src/metadata.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +export default async () => { + const t = { + ["./app.dto"]: await import("./app.dto"), + ["./modules/auth/dto/auth.dto"]: await import("./modules/auth/dto/auth.dto"), + ["./modules/marketplace/dto/marketplace.dto"]: await import("./modules/marketplace/dto/marketplace.dto"), + ["./modules/apps/dto/app.dto"]: await import("./modules/apps/dto/app.dto"), + ["./modules/backups/dto/backups.dto"]: await import("./modules/backups/dto/backups.dto"), + ["./modules/links/dto/links.dto"]: await import("./modules/links/dto/links.dto"), + ["./modules/system/dto/system.dto"]: await import("./modules/system/dto/system.dto") + }; + return { "@nestjs/swagger": { "models": [[import("./modules/user/dto/user.dto"), { "UserDto": {} }], [import("./modules/marketplace/dto/marketplace.dto"), { "AppInfoSimpleDto": {}, "AppInfoDto": {}, "MetadataDto": {}, "SearchAppsQueryDto": {}, "SearchAppsDto": {}, "AppDetailsDto": {}, "PullDto": {}, "AllAppStoresDto": {}, "UpdateAppStoreBodyDto": {}, "CreateAppStoreBodyDto": {} }], [import("./app.dto"), { "UserSettingsDto": {}, "PartialUserSettingsDto": {}, "AppContextDto": {}, "UserContextDto": {}, "AcknowledgeWelcomeBody": {} }], [import("./modules/queue/queue.entity"), { "Queue": {} }], [import("./modules/auth/dto/auth.dto"), { "LoginBody": {}, "VerifyTotpBody": {}, "LoginDto": {}, "RegisterBody": {}, "RegisterDto": {}, "ChangeUsernameBody": {}, "ChangePasswordBody": {}, "GetTotpUriBody": {}, "GetTotpUriDto": {}, "SetupTotpBody": {}, "DisableTotpBody": {}, "ResetPasswordBody": {}, "ResetPasswordDto": {}, "CheckResetPasswordRequestDto": {} }], [import("./modules/app-lifecycle/dto/app-lifecycle.dto"), { "AppFormBody": {}, "UninstallAppBody": {}, "UpdateAppBody": {} }], [import("./modules/apps/dto/app.dto"), { "AppDto": {}, "MyAppsDto": {}, "GuestAppsDto": {}, "GetAppDto": {} }], [import("./modules/backups/dto/backups.dto"), { "BackupDto": {}, "RestoreAppBackupDto": {}, "GetAppBackupsDto": {}, "GetAppBackupsQueryDto": {}, "DeleteAppBackupBodyDto": {} }], [import("./modules/links/dto/links.dto"), { "LinkBodyDto": {}, "EditLinkBodyDto": {}, "LinksDto": {} }], [import("./modules/system/dto/system.dto"), { "LoadDto": {} }]], "controllers": [[import("./app.controller"), { "AppController": { "userContext": { type: t["./app.dto"].UserContextDto }, "appContext": { type: t["./app.dto"].AppContextDto }, "updateUserSettings": {}, "acknowledgeWelcome": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "verifyTotp": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "register": { type: t["./modules/auth/dto/auth.dto"].RegisterDto }, "logout": {}, "changeUsername": {}, "changePassword": {}, "getTotpUri": { type: t["./modules/auth/dto/auth.dto"].GetTotpUriDto }, "setupTotp": {}, "disableTotp": {}, "resetPassword": { type: t["./modules/auth/dto/auth.dto"].ResetPasswordDto }, "cancelResetPassword": {}, "checkResetPasswordRequest": { type: t["./modules/auth/dto/auth.dto"].CheckResetPasswordRequestDto } } }], [import("./modules/i18n/i18n.controller"), { "I18nController": { "getTranslation": { type: Object } } }], [import("./core/health/health.controller"), { "HealthController": { "check": { type: Object } } }], [import("./modules/marketplace/marketplace.controller"), { "MarketplaceController": { "searchApps": { type: t["./modules/marketplace/dto/marketplace.dto"].SearchAppsDto }, "getImage": {}, "pullAppStore": { type: t["./modules/marketplace/dto/marketplace.dto"].PullDto }, "createAppStore": {}, "getAllAppStores": { type: t["./modules/marketplace/dto/marketplace.dto"].AllAppStoresDto }, "getEnabledAppStores": { type: t["./modules/marketplace/dto/marketplace.dto"].AllAppStoresDto }, "updateAppStore": {}, "deleteAppStore": {} } }], [import("./modules/apps/apps.controller"), { "AppsController": { "getInstalledApps": { type: t["./modules/apps/dto/app.dto"].MyAppsDto }, "getGuestApps": { type: t["./modules/apps/dto/app.dto"].GuestAppsDto }, "getApp": { type: t["./modules/apps/dto/app.dto"].GetAppDto } } }], [import("./modules/backups/backups.controller"), { "BackupsController": { "backupApp": {}, "restoreAppBackup": {}, "getAppBackups": { type: t["./modules/backups/dto/backups.dto"].GetAppBackupsDto }, "deleteAppBackup": {} } }], [import("./modules/app-lifecycle/app-lifecycle.controller"), { "AppLifecycleController": { "installApp": {}, "startApp": {}, "stopApp": {}, "restartApp": {}, "uninstallApp": {}, "resetApp": {}, "updateApp": {}, "updateAppConfig": {}, "updateAllApps": {} } }], [import("./modules/links/links.controller"), { "LinksController": { "getLinks": { type: t["./modules/links/dto/links.dto"].LinksDto }, "createLink": {}, "editLink": {}, "deleteLink": {} } }], [import("./modules/system/system.controller"), { "SystemController": { "systemLoad": { type: t["./modules/system/dto/system.dto"].LoadDto }, "downloadLocalCertificate": {} } }]] } }; +}; \ No newline at end of file diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle-command.factory.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle-command.factory.ts new file mode 100644 index 0000000000..a00aa0efd1 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle-command.factory.ts @@ -0,0 +1,77 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { Injectable } from '@nestjs/common'; +import type { z } from 'zod'; +import { AppFilesManager } from '../apps/app-files-manager'; +import { AppHelpers } from '../apps/app.helpers'; +import { BackupManager } from '../backups/backup.manager'; +import { DockerService } from '../docker/docker.service'; +import { EnvUtils } from '../env/env.utils'; +import { MarketplaceService } from '../marketplace/marketplace.service'; +import type { appEventSchema } from '../queue/entities/app-events'; +import { BackupAppCommand } from './commands/backup-app-command'; +import { GenerateAppEnvCommand } from './commands/generate-env-command'; +import { InstallAppCommand } from './commands/install-app-command'; +import { ResetAppCommand } from './commands/reset-app-command'; +import { RestartAppCommand } from './commands/restart-app-command'; +import { RestoreAppCommand } from './commands/restore-app-command'; +import { StartAppCommand } from './commands/start-app-command'; +import { StopAppCommand } from './commands/stop-app-command'; +import { UninstallAppCommand } from './commands/uninstall-app-command'; +import { UpdateAppCommand } from './commands/update-app-command'; + +@Injectable() +export class AppLifecycleCommandFactory { + constructor( + private readonly appFilesManager: AppFilesManager, + private readonly logger: LoggerService, + private readonly appHelpers: AppHelpers, + private readonly envUtils: EnvUtils, + private readonly dockerService: DockerService, + private readonly backupManager: BackupManager, + private readonly marketplaceService: MarketplaceService, + ) {} + + createCommand(eventData: z.infer) { + const command = eventData.command; + + switch (command) { + case 'install': + return new InstallAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers, this.envUtils); + case 'start': + return new StartAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers); + case 'stop': + return new StopAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers); + case 'restart': + return new RestartAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers); + case 'uninstall': + return new UninstallAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService); + case 'reset': + return new ResetAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers, this.envUtils); + case 'backup': + return new BackupAppCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.backupManager); + case 'restore': + return new RestoreAppCommand( + this.logger, + this.appFilesManager, + this.dockerService, + this.marketplaceService, + this.backupManager, + eventData.filename, + ); + case 'generate_env': + return new GenerateAppEnvCommand(this.logger, this.appFilesManager, this.dockerService, this.marketplaceService, this.appHelpers); + case 'update': + return new UpdateAppCommand( + this.logger, + this.appFilesManager, + this.dockerService, + this.marketplaceService, + this.appHelpers, + this.backupManager, + eventData.performBackup, + ); + default: + throw new Error(`Unknown command: ${command}`); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts new file mode 100644 index 0000000000..dd7a2f142d --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts @@ -0,0 +1,58 @@ +import { castAppUrn } from '@/common/helpers/app-helpers'; +import { Body, Controller, Delete, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard'; +import { AppLifecycleService } from './app-lifecycle.service'; +import { AppFormBody, UninstallAppBody, UpdateAppBody, appFormSchema } from './dto/app-lifecycle.dto'; + +@UseGuards(AuthGuard) +@Controller('app-lifecycle') +export class AppLifecycleController { + constructor(private readonly appLifecycleService: AppLifecycleService) {} + + @Post(':urn/install') + async installApp(@Param('urn') urn: string, @Body() body: AppFormBody) { + const form = appFormSchema.parse(body); + + return this.appLifecycleService.installApp({ appUrn: castAppUrn(urn), form }); + } + + @Post(':urn/start') + async startApp(@Param('urn') urn: string) { + return this.appLifecycleService.startApp({ appUrn: castAppUrn(urn) }); + } + + @Post(':urn/stop') + async stopApp(@Param('urn') urn: string) { + return this.appLifecycleService.stopApp({ appUrn: castAppUrn(urn) }); + } + + @Post(':urn/restart') + async restartApp(@Param('urn') urn: string) { + return this.appLifecycleService.restartApp({ appUrn: castAppUrn(urn) }); + } + + @Delete(':urn/uninstall') + async uninstallApp(@Param('urn') urn: string, @Body() body: UninstallAppBody) { + return this.appLifecycleService.uninstallApp({ appUrn: castAppUrn(urn), removeBackups: body.removeBackups }); + } + + @Post(':urn/reset') + async resetApp(@Param('urn') urn: string) { + return this.appLifecycleService.resetApp({ appUrn: castAppUrn(urn) }); + } + + @Patch(':urn/update') + async updateApp(@Param('urn') urn: string, @Body() body: UpdateAppBody) { + return this.appLifecycleService.updateApp({ appUrn: castAppUrn(urn), performBackup: body.performBackup }); + } + + @Patch(':urn/update-config') + async updateAppConfig(@Param('urn') urn: string, @Body() body: AppFormBody) { + return this.appLifecycleService.updateAppConfig({ appUrn: castAppUrn(urn), form: body }); + } + + @Patch('update-all') + async updateAllApps() { + return this.appLifecycleService.updateAllApps(); + } +} diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.module.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.module.ts new file mode 100644 index 0000000000..db0b3cc997 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.module.ts @@ -0,0 +1,19 @@ +import { SocketModule } from '@/core/socket/socket.module'; +import { Module, forwardRef } from '@nestjs/common'; +import { AppsModule } from '../apps/apps.module'; +import { BackupsModule } from '../backups/backups.module'; +import { DockerModule } from '../docker/docker.module'; +import { EnvModule } from '../env/env.module'; +import { MarketplaceModule } from '../marketplace/marketplace.module'; +import { QueueModule } from '../queue/queue.module'; +import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory'; +import { AppLifecycleController } from './app-lifecycle.controller'; +import { AppLifecycleService } from './app-lifecycle.service'; + +@Module({ + imports: [QueueModule, AppsModule, EnvModule, DockerModule, SocketModule, MarketplaceModule, forwardRef(() => BackupsModule)], + providers: [AppLifecycleService, AppLifecycleCommandFactory], + controllers: [AppLifecycleController], + exports: [AppLifecycleService], +}) +export class AppLifecycleModule {} diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts new file mode 100644 index 0000000000..b6256bded1 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts @@ -0,0 +1,401 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { createAppUrn, extractAppUrn } from '@/common/helpers/app-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { SocketManager } from '@/core/socket/socket.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { lt, valid } from 'semver'; +import semver from 'semver'; +import validator, { isFQDN } from 'validator'; +import type { z } from 'zod'; +import { AppFilesManager } from '../apps/app-files-manager'; +import { AppsRepository } from '../apps/apps.repository'; +import { AppsService } from '../apps/apps.service'; +import { BackupManager } from '../backups/backup.manager'; +import { MarketplaceService } from '../marketplace/marketplace.service'; +import { AppEventsQueue, appEventResultSchema, appEventSchema } from '../queue/entities/app-events'; +import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory'; +import { appFormSchema } from './dto/app-lifecycle.dto'; + +@Injectable() +export class AppLifecycleService { + constructor( + private readonly logger: LoggerService, + private readonly appEventsQueue: AppEventsQueue, + private readonly commandFactory: AppLifecycleCommandFactory, + private readonly appRepository: AppsRepository, + private readonly config: ConfigurationService, + private readonly marketplaceService: MarketplaceService, + private readonly appsService: AppsService, + private readonly appFilesManager: AppFilesManager, + private readonly socketManager: SocketManager, + private readonly backupManager: BackupManager, + ) { + this.logger.debug('Subscribing to app events...'); + this.appEventsQueue.onEvent((data, reply) => this.invokeCommand(data, reply)); + } + + async invokeCommand(data: z.infer, reply: (response: z.output) => Promise) { + try { + const command = this.commandFactory.createCommand(data); + const { success, message } = await command.execute(data.appUrn, data.form); + await reply({ success, message }); + } catch (err) { + this.logger.error(`Error invoking command: ${err}`); + await reply({ success: false, message: String(err) }); + } + } + + async startApp(params: { appUrn: AppUrn }) { + const { appUrn } = params; + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }, HttpStatus.NOT_FOUND); + } + + await this.appRepository.updateAppById(app.id, { status: 'starting' }); + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'starting' } }); + + this.appEventsQueue.publish({ appUrn, command: 'start', form: app.config }).then(async ({ success, message }) => { + if (success) { + this.logger.info(`App ${appUrn} started successfully`); + this.socketManager.emit({ type: 'app', event: 'start_success', data: { appUrn, appStatus: 'running' } }); + await this.appRepository.updateAppById(app.id, { status: 'running' }); + } else { + this.logger.error(`Failed to start app ${appUrn}: ${message}`); + this.socketManager.emit({ type: 'app', event: 'start_error', data: { appUrn, appStatus: 'stopped', error: message } }); + await this.appRepository.updateAppById(app.id, { status: 'stopped' }); + } + }); + } + + async installApp(params: { appUrn: AppUrn; form: unknown }): Promise { + const { appUrn, form } = params; + const { demoMode, version, architecture } = this.config.getConfig(); + + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'installing' } }); + + const app = await this.appRepository.getAppByUrn(appUrn); + + if (app) { + return this.startApp({ appUrn }); + } + + const parsedForm = appFormSchema.parse(form); + const { exposed, exposedLocal, openPort, domain, isVisibleOnGuestDashboard } = parsedForm; + const apps = await this.appRepository.getApps(); + + if (demoMode && apps.length >= 6) { + throw new TranslatableError('SYSTEM_ERROR_DEMO_MODE_LIMIT'); + } + + if (exposed && !domain) { + throw new TranslatableError('APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP'); + } + + if (domain && !isFQDN(domain)) { + throw new TranslatableError('APP_ERROR_DOMAIN_NOT_VALID', { domain }); + } + + const appInfo = await this.marketplaceService.getAppInfoFromAppStore(appUrn); + + if (!appInfo) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }, HttpStatus.NOT_FOUND); + } + + if (appInfo.supported_architectures?.length && !appInfo.supported_architectures.includes(architecture)) { + throw new TranslatableError('APP_ERROR_ARCHITECTURE_NOT_SUPPORTED', { id: appUrn, arch: architecture }); + } + + if (!appInfo.exposable && exposed) { + throw new TranslatableError('APP_ERROR_APP_NOT_EXPOSABLE', { id: appUrn }); + } + + if (appInfo.force_expose && !exposed) { + throw new TranslatableError('APP_ERROR_APP_FORCE_EXPOSED', { id: appUrn }); + } + + if (exposed && domain) { + const appsWithSameDomain = await this.appRepository.getAppsByDomain(domain); + + if (appsWithSameDomain.length > 0) { + throw new TranslatableError('APP_ERROR_DOMAIN_ALREADY_IN_USE', { domain, id: appsWithSameDomain[0]?.appName }); + } + } + + if (appInfo?.min_tipi_version && valid(version) && lt(version, appInfo.min_tipi_version)) { + throw new TranslatableError('APP_UPDATE_ERROR_MIN_TIPI_VERSION', { id: appUrn, minVersion: appInfo.min_tipi_version }); + } + + const { appName, appStoreId } = extractAppUrn(appUrn); + + const createdApp = await this.appRepository.createApp({ + appName, + status: 'installing', + config: parsedForm, + port: parsedForm.port ?? appInfo.port, + version: appInfo.tipi_version, + exposed: exposed || false, + domain: domain || null, + openPort: openPort || false, + exposedLocal: exposedLocal || false, + appStoreSlug: appStoreId, + isVisibleOnGuestDashboard, + }); + + // Send install command to the queue + this.appEventsQueue.publish({ appUrn, command: 'install', form: parsedForm }).then(async ({ success, message }) => { + if (success) { + this.logger.info(`App ${appUrn} installed successfully`); + await this.socketManager.emit({ type: 'app', event: 'install_success', data: { appUrn, appStatus: 'running' } }); + await this.appRepository.updateAppById(createdApp.id, { status: 'running' }); + } else { + this.socketManager.emit({ type: 'app', event: 'install_error', data: { appUrn, appStatus: 'missing', error: message } }); + this.logger.error(`Failed to install app ${appUrn}: ${message}`); + await this.appRepository.deleteAppById(createdApp.id); + } + }); + } + + /** + * Stop an app by its ID + */ + public async stopApp(params: { appUrn: AppUrn }) { + const { appUrn } = params; + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }, HttpStatus.NOT_FOUND); + } + + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'stopping' } }); + + await this.appRepository.updateAppById(app.id, { status: 'stopping' }); + + // Send stop command to the queue + this.appEventsQueue.publish({ command: 'stop', appUrn, form: app.config }).then(async ({ success, message }) => { + if (success) { + this.socketManager.emit({ type: 'app', event: 'stop_success', data: { appUrn, appStatus: 'stopped' } }); + this.logger.info(`App ${appUrn} stopped successfully`); + await this.appRepository.updateAppById(app.id, { status: 'stopped' }); + } else { + this.socketManager.emit({ type: 'app', event: 'stop_error', data: { appUrn, appStatus: 'running', error: message } }); + this.logger.error(`Failed to stop app ${appUrn}: ${message}`); + await this.appRepository.updateAppById(app.id, { status: 'running' }); + } + }); + } + + /** + * Restart an app by its ID + */ + public async restartApp(params: { appUrn: AppUrn }) { + const { appUrn } = params; + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND'); + } + + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'restarting' } }); + await this.appRepository.updateAppById(app.id, { status: 'restarting' }); + + this.appEventsQueue.publish({ command: 'restart', appUrn, form: app.config }).then(async ({ success, message }) => { + if (success) { + this.logger.info(`App ${appUrn} restarted successfully`); + this.socketManager.emit({ type: 'app', event: 'restart_success', data: { appUrn, appStatus: 'running' } }); + await this.appRepository.updateAppById(app.id, { status: 'running' }); + } else { + this.logger.error(`Failed to restart app ${appUrn}: ${message}`); + this.socketManager.emit({ type: 'app', event: 'restart_error', data: { appUrn, appStatus: 'running', error: message } }); + await this.appRepository.updateAppById(app.id, { status: 'stopped' }); + } + }); + } + + /** + * Uninstall an app by its ID + */ + public async uninstallApp(params: { appUrn: AppUrn; removeBackups: boolean }) { + const { appUrn, removeBackups } = params; + + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + if (removeBackups) { + await this.backupManager.deleteAppBackupsByUrn(appUrn); + } + + await this.appRepository.updateAppById(app.id, { status: 'uninstalling' }); + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'uninstalling' } }); + + this.appEventsQueue.publish({ command: 'uninstall', appUrn, form: app.config }).then(async ({ success, message }) => { + if (success) { + this.logger.info(`App ${appUrn} uninstalled successfully`); + await this.appRepository.deleteAppById(app.id); + await this.socketManager.emit({ type: 'app', event: 'uninstall_success', data: { appUrn, appStatus: 'missing' } }); + } else { + this.logger.error(`Failed to uninstall app ${appUrn}: ${message}`); + await this.appRepository.updateAppById(app.id, { status: 'stopped' }); + await this.socketManager.emit({ type: 'app', event: 'uninstall_error', data: { appUrn, appStatus: 'stopped', error: message } }); + } + }); + } + + /** + * Reset an app by its ID + */ + public async resetApp(params: { appUrn: AppUrn }) { + const { appUrn } = params; + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + const appStatusBeforeReset = app?.status; + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'resetting' } }); + await this.appRepository.updateAppById(app.id, { status: 'resetting' }); + + this.appEventsQueue.publish({ command: 'reset', appUrn, form: app.config }).then(async ({ success, message }) => { + if (success) { + this.logger.info(`App ${appUrn} reset successfully`); + await this.socketManager.emit({ type: 'app', event: 'reset_success', data: { appUrn, appStatus: 'stopped' } }); + if (appStatusBeforeReset === 'running') { + this.startApp({ appUrn }); + } else { + await this.appRepository.updateAppById(app.id, { status: appStatusBeforeReset }); + } + } else { + this.logger.error(`Failed to reset app ${appUrn}: ${message}`); + await this.socketManager.emit({ type: 'app', event: 'reset_error', data: { appUrn, appStatus: appStatusBeforeReset, error: message } }); + await this.appRepository.updateAppById(app.id, { status: 'running' }); + } + }); + } + + public async updateAppConfig(params: { appUrn: AppUrn; form: unknown }) { + const { appUrn, form } = params; + + const parsedForm = appFormSchema.parse(form); + + const { exposed, domain } = parsedForm; + + if (exposed && !domain) { + throw new TranslatableError('APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP'); + } + + if (domain && !validator.isFQDN(domain)) { + throw new TranslatableError('APP_ERROR_DOMAIN_NOT_VALID'); + } + + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + const appInfo = await this.appFilesManager.getInstalledAppInfo(appUrn); + + if (!appInfo) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + if (!appInfo.exposable && exposed) { + throw new TranslatableError('APP_ERROR_APP_NOT_EXPOSABLE', { id: appUrn }); + } + + if (appInfo.force_expose && !exposed) { + throw new TranslatableError('APP_ERROR_APP_FORCE_EXPOSED', { id: appUrn }); + } + + if (exposed && domain) { + const appsWithSameDomain = await this.appRepository.getAppsByDomain(domain, app.id); + + if (appsWithSameDomain.length > 0) { + throw new TranslatableError('APP_ERROR_DOMAIN_ALREADY_IN_USE', { domain, id: appsWithSameDomain[0]?.appName }); + } + } + + const { success, message } = await this.appEventsQueue.publish({ + command: 'generate_env', + appUrn, + form: parsedForm, + }); + + if (!success) { + this.logger.error(`Failed to update app ${appUrn}: ${message}`); + throw new TranslatableError('APP_ERROR_APP_FAILED_TO_UPDATE', { id: appUrn }, HttpStatus.INTERNAL_SERVER_ERROR, { cause: message }); + } + + await this.appRepository.updateAppById(app.id, { + exposed: exposed ?? false, + exposedLocal: parsedForm.exposedLocal ?? false, + openPort: parsedForm.openPort, + port: parsedForm.port ?? appInfo.port, + domain: domain || null, + config: parsedForm, + isVisibleOnGuestDashboard: parsedForm.isVisibleOnGuestDashboard ?? false, + }); + } + + public async updateApp(params: { appUrn: AppUrn; performBackup: boolean }) { + const { appUrn, performBackup } = params; + const app = await this.appRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + const version = this.config.get('version'); + + const { minTipiVersion } = await this.marketplaceService.getAppUpdateInfo(appUrn); + if (minTipiVersion && semver.valid(version) && semver.lt(version, minTipiVersion)) { + throw new TranslatableError('APP_UPDATE_ERROR_MIN_TIPI_VERSION', { id: appUrn, minVersion: minTipiVersion }); + } + + await this.appRepository.updateAppById(app.id, { status: 'updating' }); + + const appStatusBeforeUpdate = app.status; + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'updating' } }); + + this.appEventsQueue.publish({ command: 'update', appUrn, form: app.config, performBackup }).then(async ({ success, message }) => { + if (success) { + const appInfo = await this.appFilesManager.getInstalledAppInfo(appUrn); + + await this.appRepository.updateAppById(app.id, { status: appStatusBeforeUpdate, version: appInfo?.tipi_version }); + await this.updateAppConfig({ appUrn, form: app.config }); + await this.socketManager.emit({ type: 'app', event: 'update_success', data: { appUrn } }); + + if (appStatusBeforeUpdate === 'running') { + this.startApp({ appUrn }); + } + } else { + this.logger.error(`Failed to update app ${appUrn}: ${message}`); + await this.socketManager.emit({ type: 'app', event: 'update_error', data: { appUrn, appStatus: 'stopped', error: message } }); + await this.appRepository.updateAppById(app.id, { status: 'stopped' }); + } + }); + } + + async updateAllApps() { + const installedApps = await this.appsService.getInstalledApps(); + const availableUpdates = installedApps.filter(({ app, metadata }) => Number(app.version) < Number(metadata.latestVersion)); + + const updatePromises = availableUpdates.map(async ({ app }) => { + try { + const appUrn = createAppUrn(app.appName, app.appStoreSlug); + await this.updateApp({ appUrn, performBackup: true }); + } catch (e) { + this.logger.error(`Failed to update app ${app.id}`, e); + } + }); + + await Promise.all(updatePromises); + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/backup-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/backup-app-command.ts new file mode 100644 index 0000000000..ff1022a254 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/backup-app-command.ts @@ -0,0 +1,40 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { BackupManager } from '@/modules/backups/backup.manager'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class BackupAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly backupManager: BackupManager, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + + this.logger = logger; + this.appFilesManager = appFilesManager; + } + + public async execute(appUrn: AppUrn): Promise<{ success: boolean; message: string }> { + try { + this.logger.info(`Stopping app ${appUrn}`); + await this.dockerService.composeApp(appUrn, 'rm --force --stop').catch((err) => { + this.logger.error(`Failed to stop app ${appUrn}: ${err.message}`); + }); + + await this.backupManager.backupApp(appUrn); + + // Done + this.logger.info('Backup completed!'); + + return { success: true, message: `App ${appUrn} backed up successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'backup'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/command.ts b/packages/backend/src/modules/app-lifecycle/commands/command.ts new file mode 100644 index 0000000000..7548590599 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/command.ts @@ -0,0 +1,62 @@ +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { DockerComposeBuilder } from '@/modules/docker/builders/compose.builder'; +import { dynamicComposeSchema } from '@/modules/docker/builders/schemas'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import * as Sentry from '@sentry/nestjs'; + +export class AppLifecycleCommand { + constructor( + protected logger: LoggerService, + protected appFilesManager: AppFilesManager, + protected dockerService: DockerService, + protected marketplaceService: MarketplaceService, + ) {} + + protected async ensureAppDir(appUrn: AppUrn, form: AppEventFormInput): Promise { + const composeYaml = await this.appFilesManager.getDockerComposeYaml(appUrn); + + if (!composeYaml.content) { + await this.marketplaceService.copyAppFromRepoToInstalled(appUrn); + } + + const appInfo = await this.appFilesManager.getInstalledAppInfo(appUrn); + const composeJson = await this.appFilesManager.getDockerComposeJson(appUrn); + + if (composeJson.content && appInfo?.dynamic_config) { + try { + const { services } = dynamicComposeSchema.parse(composeJson.content); + const { appStoreId } = extractAppUrn(appUrn); + const dockerComposeBuilder = new DockerComposeBuilder(); + const composeFile = dockerComposeBuilder.getDockerCompose(services, form, appStoreId); + + await this.appFilesManager.writeDockerComposeYml(appUrn, composeFile); + } catch (err) { + this.logger.error(`Error generating docker-compose.yml file for app ${appUrn}. Falling back to default docker-compose.yml`); + this.logger.error(err); + Sentry.captureException(err, { + tags: { appId: appUrn, event: 'ensure_app_dir' }, + }); + } + } + + // Set permissions + await this.appFilesManager.setAppDataDirPermissions(appUrn); + } + + protected handleAppError = async (err: unknown, appId: string, event: string): Promise<{ success: false; message: string }> => { + Sentry.captureException(err, { + tags: { appId, event }, + }); + + if (err instanceof Error) { + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${String(err)}` }; + }; +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/generate-env-command.ts b/packages/backend/src/modules/app-lifecycle/commands/generate-env-command.ts new file mode 100644 index 0000000000..31334ec0e9 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/generate-env-command.ts @@ -0,0 +1,32 @@ +import type { LoggerService } from '@/core/logger/logger.service'; +import type { AppFilesManager } from '@/modules/apps/app-files-manager'; +import type { AppHelpers } from '@/modules/apps/app.helpers'; +import type { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class GenerateAppEnvCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput): Promise<{ success: boolean; message: string }> { + try { + this.logger.info(`Regenerating app.env file for app ${appUrn}`); + await this.ensureAppDir(appUrn, form); + await this.appHelpers.generateEnvFile(appUrn, form); + + return { success: true, message: `App ${appUrn} env file regenerated successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'generate_env_error'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/install-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/install-app-command.ts new file mode 100644 index 0000000000..720cc29176 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/install-app-command.ts @@ -0,0 +1,54 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { DockerService } from '@/modules/docker/docker.service'; +import { EnvUtils } from '@/modules/env/env.utils'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class InstallAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + private readonly envUtils: EnvUtils, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput): Promise<{ success: boolean; message: string }> { + try { + if (process.getuid && process.getgid) { + this.logger.info(`Installing app ${appUrn} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`); + } else { + this.logger.info(`Installing app ${appUrn}. No User ID or Group ID found.`); + } + + await this.marketplaceService.copyAppFromRepoToInstalled(appUrn); + + // Create app.env file + this.logger.info(`Creating app.env file for app ${appUrn}`); + await this.appHelpers.generateEnvFile(appUrn, form); + + // Copy data dir + const appEnv = await this.appFilesManager.getAppEnv(appUrn); + const envMap = this.envUtils.envStringToMap(appEnv.content); + + this.logger.info(`Copying data dir for app ${appUrn}`); + await this.marketplaceService.copyDataDir(appUrn, envMap); + + await this.ensureAppDir(appUrn, form); + + // run docker-compose up + await this.dockerService.composeApp(appUrn, 'up --detach --force-recreate --remove-orphans --pull always'); + + return { success: true, message: `App ${appUrn} installed successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'install'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/reset-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/reset-app-command.ts new file mode 100644 index 0000000000..7f3e3e4718 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/reset-app-command.ts @@ -0,0 +1,62 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { DockerService } from '@/modules/docker/docker.service'; +import type { EnvUtils } from '@/modules/env/env.utils'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class ResetAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + private readonly envUtils: EnvUtils, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput): Promise<{ success: boolean; message: string }> { + try { + this.logger.info(`Resetting app ${appUrn}`); + + await this.ensureAppDir(appUrn, form); + await this.appHelpers.generateEnvFile(appUrn, form); + + // Stop app + try { + await this.dockerService.composeApp(appUrn, 'down --remove-orphans --volumes'); + } catch (err) { + if (err instanceof Error && err.message.includes('conflict')) { + this.logger.warn(`Could not reset app ${appUrn}. Most likely there have been made changes to the compose file.`); + } else { + throw err; + } + } + + // Delete app data directory + await this.appFilesManager.deleteAppDataDir(appUrn); + await this.appFilesManager.createAppDataDir(appUrn); + + // Create app.env file + this.logger.info(`Creating app.env file for app ${appUrn}`); + await this.appHelpers.generateEnvFile(appUrn, form); + + // Copy data dir + this.logger.info(`Copying data dir for app ${appUrn}`); + const env = await this.appFilesManager.getAppEnv(appUrn); + const envMap = this.envUtils.envStringToMap(env.content); + + await this.marketplaceService.copyDataDir(appUrn, envMap); + await this.ensureAppDir(appUrn, form); + + return { success: true, message: `App ${appUrn} reset successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'reset'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/restart-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/restart-app-command.ts new file mode 100644 index 0000000000..fb19a11f9d --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/restart-app-command.ts @@ -0,0 +1,52 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class RestartAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput, skipEnvGeneration = false): Promise<{ success: boolean; message: string }> { + try { + const config = await this.appFilesManager.getInstalledAppInfo(appUrn); + + if (!config) { + return { success: true, message: 'App config not found. Skipping...' }; + } + + await this.ensureAppDir(appUrn, form); + + this.logger.info(`Stopping app ${appUrn}`); + + await this.dockerService.composeApp(appUrn, 'rm --force --stop').catch((err) => { + this.logger.error(`Failed to stop app ${appUrn}: ${err.message}`); + }); + await this.ensureAppDir(appUrn, form); + + if (!skipEnvGeneration) { + this.logger.info(`Regenerating app.env file for app ${appUrn}`); + await this.appHelpers.generateEnvFile(appUrn, form); + } + + await this.dockerService.composeApp(appUrn, 'up --detach --force-recreate --remove-orphans --pull always'); + + this.logger.info(`App ${appUrn} restarted`); + + return { success: true, message: `App ${appUrn} restarted successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'restart'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/restore-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/restore-app-command.ts new file mode 100644 index 0000000000..91e0eb0e12 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/restore-app-command.ts @@ -0,0 +1,38 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { BackupManager } from '@/modules/backups/backup.manager'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class RestoreAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly backupManager: BackupManager, + private readonly filename: string, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn): Promise<{ success: boolean; message: string }> { + try { + // Stop the app + this.logger.info(`Stopping app ${appUrn}`); + await this.dockerService.composeApp(appUrn, 'rm --force --stop').catch((err) => { + this.logger.error(`Failed to stop app ${appUrn}: ${err.message}`); + }); + + await this.backupManager.restoreApp(appUrn, this.filename); + + // Done + this.logger.info(`App ${appUrn} restored!`); + return { success: true, message: `App ${appUrn} restored successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'restore'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/start-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/start-app-command.ts new file mode 100644 index 0000000000..9cd35a9b2c --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/start-app-command.ts @@ -0,0 +1,44 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class StartAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + + this.logger = logger; + this.appFilesManager = appFilesManager; + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput, skipEnvGeneration = false) { + try { + this.logger.info(`Starting app ${appUrn}`); + + await this.ensureAppDir(appUrn, form); + + if (!skipEnvGeneration) { + this.logger.info(`Regenerating app.env file for app ${appUrn}`); + await this.appHelpers.generateEnvFile(appUrn, form); + } + + await this.dockerService.composeApp(appUrn, 'up --detach --force-recreate --remove-orphans --pull always'); + + this.logger.info(`App ${appUrn} started`); + + return { success: true, message: `App ${appUrn} started successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'start'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/stop-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/stop-app-command.ts new file mode 100644 index 0000000000..a131b2e9be --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/stop-app-command.ts @@ -0,0 +1,46 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class StopAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput, skipEnvGeneration = false) { + try { + const config = await this.appFilesManager.getInstalledAppInfo(appUrn); + + if (!config) { + return { success: true, message: 'App config not found. Skipping...' }; + } + + this.logger.info(`Stopping app ${appUrn}`); + + await this.ensureAppDir(appUrn, form); + + if (!skipEnvGeneration) { + this.logger.info(`Regenerating app.env file for app ${appUrn}`); + await this.appHelpers.generateEnvFile(appUrn, form); + } + + await this.dockerService.composeApp(appUrn, 'rm --force --stop'); + this.logger.info(`App ${appUrn} stopped`); + + return { success: true, message: `App ${appUrn} stopped successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'stop'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/uninstall-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/uninstall-app-command.ts new file mode 100644 index 0000000000..6e6f54d855 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/uninstall-app-command.ts @@ -0,0 +1,28 @@ +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class UninstallAppCommand extends AppLifecycleCommand { + public async execute(appUrn: AppUrn, form: AppEventFormInput) { + try { + this.logger.info(`Uninstalling app ${appUrn}`); + await this.ensureAppDir(appUrn, form); + + await this.dockerService.composeApp(appUrn, 'down --remove-orphans --volumes --rmi all').catch((err) => { + this.logger.warn( + `Could not fully uninstall app ${appUrn}. Some images may be in use by other apps or a folder has been deleted. Consider cleaning unused images docker system prune -a`, + err, + ); + }); + + await this.appFilesManager.deleteAppFolder(appUrn); + await this.appFilesManager.deleteAppDataDir(appUrn); + + this.logger.info(`App ${appUrn} uninstalled`); + + return { success: true, message: `App ${appUrn} uninstalled successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'uninstall'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/commands/update-app-command.ts b/packages/backend/src/modules/app-lifecycle/commands/update-app-command.ts new file mode 100644 index 0000000000..d6b138d241 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/commands/update-app-command.ts @@ -0,0 +1,53 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import type { AppHelpers } from '@/modules/apps/app.helpers'; +import type { BackupManager } from '@/modules/backups/backup.manager'; +import { DockerService } from '@/modules/docker/docker.service'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import type { AppUrn } from '@/types/app/app.types'; +import { AppLifecycleCommand } from './command'; + +export class UpdateAppCommand extends AppLifecycleCommand { + constructor( + logger: LoggerService, + appFilesManager: AppFilesManager, + dockerService: DockerService, + marketplaceService: MarketplaceService, + private readonly appHelpers: AppHelpers, + private readonly backupManager: BackupManager, + private readonly performBackup: boolean = true, + ) { + super(logger, appFilesManager, dockerService, marketplaceService); + } + + public async execute(appUrn: AppUrn, form: AppEventFormInput) { + try { + if (this.performBackup) { + await this.backupManager.backupApp(appUrn); + } + + this.logger.info(`Updating app ${appUrn}`); + await this.ensureAppDir(appUrn, form); + await this.appHelpers.generateEnvFile(appUrn, form); + + try { + await this.dockerService.composeApp(appUrn, 'up --detach --force-recreate --remove-orphans'); + await this.dockerService.composeApp(appUrn, 'down --rmi all --remove-orphans'); + } catch (err) { + this.logger.warn(`App ${appUrn} has likely a broken docker-compose.yml file. Continuing with update...`); + } + + await this.appFilesManager.deleteAppFolder(appUrn); + await this.marketplaceService.copyAppFromRepoToInstalled(appUrn); + + await this.ensureAppDir(appUrn, form); + + await this.dockerService.composeApp(appUrn, 'pull'); + + return { success: true, message: `App ${appUrn} updated successfully` }; + } catch (err) { + return this.handleAppError(err, appUrn, 'update_error'); + } + } +} diff --git a/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts new file mode 100644 index 0000000000..3621c16684 --- /dev/null +++ b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts @@ -0,0 +1,20 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const appFormSchema = z + .object({ + port: z.coerce.number().min(1024).max(65535).optional(), + exposed: z.boolean().optional(), + exposedLocal: z.boolean().optional(), + openPort: z.boolean().optional().default(true), + domain: z.string().optional(), + isVisibleOnGuestDashboard: z.boolean().optional(), + }) + .extend({}) + .catchall(z.unknown()); + +export class AppFormBody extends createZodDto(appFormSchema) {} + +export class UninstallAppBody extends createZodDto(z.object({ removeBackups: z.boolean() })) {} + +export class UpdateAppBody extends createZodDto(z.object({ performBackup: z.boolean() })) {} diff --git a/packages/backend/src/modules/app-stores/app-store-files-manager.ts b/packages/backend/src/modules/app-stores/app-store-files-manager.ts new file mode 100644 index 0000000000..c86abd9670 --- /dev/null +++ b/packages/backend/src/modules/app-stores/app-store-files-manager.ts @@ -0,0 +1,243 @@ +import path from 'node:path'; +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { execAsync } from '@/common/helpers/exec-helpers'; +import type { ConfigurationService } from '@/core/config/configuration.service'; +import type { FilesystemService } from '@/core/filesystem/filesystem.service'; +import type { LoggerService } from '@/core/logger/logger.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { appInfoSchema } from '../marketplace/dto/marketplace.dto'; + +export class AppStoreFilesManager { + constructor( + private readonly configuration: ConfigurationService, + private readonly filesystem: FilesystemService, + private readonly logger: LoggerService, + private storeId: string, + ) {} + + private getInstalledAppsFolder() { + const { directories } = this.configuration.getConfig(); + + return path.join(directories.dataDir, 'apps'); + } + + private getAppStoreFolder() { + const { directories } = this.configuration.getConfig(); + return path.join(directories.dataDir, 'repos', this.storeId, 'apps'); + } + + public getAppPaths(appUrn: AppUrn) { + const { appStoreId, appName } = extractAppUrn(appUrn); + + const { directories } = this.configuration.getConfig(); + + return { + appDataDir: path.join(directories.appDataDir, appStoreId, appName), + appRepoDir: path.join(this.getAppStoreFolder(), appName), + appInstalledDir: path.join(this.getInstalledAppsFolder(), appStoreId, appName), + }; + } + + /** + * Get the app info from the app store + * @param appUrn - The app id + */ + public async getAppInfoFromAppStore(appUrn: AppUrn) { + try { + const { appRepoDir } = this.getAppPaths(appUrn); + + if (await this.filesystem.pathExists(path.join(appRepoDir, 'config.json'))) { + const configFile = await this.filesystem.readTextFile(path.join(appRepoDir, 'config.json')); + + const config = JSON.parse(configFile ?? '{}'); + const parsedConfig = appInfoSchema.safeParse({ ...config, urn: appUrn }); + + if (!parsedConfig.success) { + this.logger.debug(`App ${appUrn} config error:`); + this.logger.debug(parsedConfig.error); + } + + if (parsedConfig.success && parsedConfig.data.available) { + const description = (await this.filesystem.readTextFile(path.join(appRepoDir, 'metadata', 'description.md'))) ?? ''; + return { ...parsedConfig.data, description }; + } + } + } catch (error) { + this.logger.error(`Error getting app info from app store for ${appUrn}: ${error}`); + } + } + + /** + * Copy the app from the repo to the installed apps folder + * @param appUrn - The app id + */ + public async copyAppFromRepoToInstalled(appUrn: AppUrn) { + const { appRepoDir, appDataDir, appInstalledDir } = this.getAppPaths(appUrn); + + if (!(await this.filesystem.pathExists(appRepoDir))) { + this.logger.error(`App ${appUrn} not found in repo ${this.storeId}`); + throw new Error(`App ${appUrn} not found in repo ${this.storeId}`); + } + + // delete eventual app folder if exists + this.logger.info(`Deleting app ${appUrn} folder if exists`); + await this.filesystem.removeDirectory(appInstalledDir); + + // Create app folder + this.logger.info(`Creating app ${appUrn} folder`); + await this.filesystem.createDirectory(appInstalledDir); + + // Create app data folder + this.logger.info(`Creating app ${appUrn} data folder`); + await this.filesystem.createDirectory(appDataDir); + + // Copy app folder from repo + this.logger.info(`Copying app ${appUrn} from repo ${this.storeId}`); + await this.filesystem.copyDirectory(appRepoDir, appInstalledDir); + } + + /** + * This function returns an object containing information about the updates available for the app with the provided id. + * It checks if the app is installed or not and looks for the config.json file in the appropriate directory. + * If the config.json file is invalid, it returns null. + * If the app is not found, it returns null. + * + * @param {string} appUrn - The app id. + */ + public async getAppUpdateInfo(appUrn: AppUrn) { + const config = await this.getAppInfoFromAppStore(appUrn); + + if (config) { + return { + latestVersion: config.tipi_version, + minTipiVersion: config.min_tipi_version, + latestDockerVersion: config.version, + }; + } + + return { latestVersion: 0, latestDockerVersion: '0.0.0' }; + } + + /** + * Get the list of available app ids + * @returns The list of app ids + */ + public async getAvailableAppUrns() { + const appsRepoFolder = this.getAppStoreFolder(); + + if (!(await this.filesystem.pathExists(appsRepoFolder))) { + this.logger.error(`Apps repo ${this.storeId} not found. Make sure your repo is configured correctly.`); + return []; + } + + const appsDir = await this.filesystem.listFiles(appsRepoFolder); + const skippedFiles = ['__tests__', 'docker-compose.common.yml', 'schema.json', '.DS_Store']; + + return appsDir.filter((app) => !skippedFiles.includes(app)).map((app) => `${app}:${this.storeId}` as AppUrn); + } + + /** + * Given a template and a map of variables, this function replaces all instances of the variables in the template with their values. + * + * @param {string} template - The template to be rendered. + * @param {Map} envMap - The map of variables and their values. + */ + private renderTemplate(template: string, envMap: Map) { + let renderedTemplate = template; + + envMap.forEach((value, key) => { + const safeKey = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + renderedTemplate = renderedTemplate.replace(new RegExp(`{{${safeKey}}}`, 'g'), value); + }); + + return renderedTemplate; + } + + public async copyDataDir(appUrn: AppUrn, envMap: Map) { + const { appInstalledDir, appDataDir } = this.getAppPaths(appUrn); + + // return if app does not have a data directory + if (!(await this.filesystem.pathExists(path.join(appInstalledDir, 'data')))) { + return; + } + + // Return if app has already a data directory + if (await this.filesystem.pathExists(path.join(appDataDir, 'data'))) { + return; + } + + // Create app-data folder + await this.filesystem.createDirectory(path.join(appDataDir, 'data')); + + const dataDir = await this.filesystem.listFiles(path.join(appInstalledDir, 'data')); + + const processFile = async (file: string) => { + if (file.endsWith('.template')) { + const template = await this.filesystem.readTextFile(path.join(appInstalledDir, 'data', file)); + if (template) { + const renderedTemplate = this.renderTemplate(template, envMap); + + await this.filesystem.writeTextFile(path.join(appDataDir, 'data', file.replace('.template', '')), renderedTemplate); + } + } else { + await this.filesystem.copyFile(path.join(appInstalledDir, 'data', file), path.join(appDataDir, 'data', file)); + } + }; + + const processDir = async (p: string) => { + await this.filesystem.createDirectory(path.join(appDataDir, 'data', p)); + + const files = await this.filesystem.listFiles(path.join(appInstalledDir, 'data', p)); + + await Promise.all( + files.map(async (file) => { + const fullPath = path.join(appInstalledDir, 'data', p, file); + + if (await this.filesystem.isDirectory(fullPath)) { + await processDir(path.join(p, file)); + } else { + await processFile(path.join(p, file)); + } + }), + ); + }; + + await Promise.all( + dataDir.map(async (file) => { + const fullPath = path.join(appInstalledDir, 'data', file); + + if (await this.filesystem.isDirectory(fullPath)) { + await processDir(file); + } else { + await processFile(file); + } + }), + ); + + // Remove any .gitkeep files from the app-data folder at any level + if (await this.filesystem.pathExists(path.join(appDataDir, 'data'))) { + await execAsync(`find ${appDataDir}/data -name .gitkeep -delete`).catch(() => { + this.logger.error(`Error removing .gitkeep files from ${appDataDir}/data`); + }); + } + } + + public async getAppImage(appUrn: AppUrn) { + const { appInstalledDir, appRepoDir } = this.getAppPaths(appUrn); + const { appDir } = this.configuration.get('directories'); + + const defaultFilePath = path.join(appInstalledDir, 'metadata', 'logo.jpg'); + const appRepoFilePath = path.join(appRepoDir, 'metadata', 'logo.jpg'); + + let filePath = path.join(appDir, 'public', 'app-not-found.jpg'); + + if (await this.filesystem.pathExists(defaultFilePath)) { + filePath = defaultFilePath; + } else if (await this.filesystem.pathExists(appRepoFilePath)) { + filePath = appRepoFilePath; + } + + const file = await this.filesystem.readBinaryFile(filePath); + return file; + } +} diff --git a/packages/backend/src/modules/app-stores/app-store.module.ts b/packages/backend/src/modules/app-stores/app-store.module.ts new file mode 100644 index 0000000000..32d17faeb6 --- /dev/null +++ b/packages/backend/src/modules/app-stores/app-store.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { QueueModule } from '../queue/queue.module'; +import { AppStoreRepository } from './app-store.repository'; +import { AppStoreService } from './app-store.service'; +import { ReposHelpers } from './repos.helpers'; + +@Module({ + imports: [QueueModule], + controllers: [], + providers: [AppStoreService, AppStoreRepository, ReposHelpers], + exports: [AppStoreService, ReposHelpers], +}) +export class AppStoreModule {} diff --git a/packages/backend/src/modules/app-stores/app-store.repository.ts b/packages/backend/src/modules/app-stores/app-store.repository.ts new file mode 100644 index 0000000000..62bf4feff3 --- /dev/null +++ b/packages/backend/src/modules/app-stores/app-store.repository.ts @@ -0,0 +1,95 @@ +import { DatabaseService } from '@/core/database/database.service'; +import { app, appStore } from '@/core/database/drizzle/schema'; +import type { NewAppStore } from '@/core/database/drizzle/types'; +import { Injectable } from '@nestjs/common'; +import { and, asc, count, eq } from 'drizzle-orm'; +import { ReposHelpers } from './repos.helpers'; + +@Injectable() +export class AppStoreRepository { + constructor( + private readonly databaseService: DatabaseService, + private readonly repoHelpers: ReposHelpers, + ) {} + + /** + * Given a hash, return the app store associated to it + */ + public async getAppStoreByHash(hash: string) { + return this.databaseService.db.query.appStore.findFirst({ where: eq(appStore.hash, hash) }); + } + + /** + * Given a slug, return the app store associated to it + */ + public async getAppStoreBySlug(slug: string) { + return this.databaseService.db.query.appStore.findFirst({ where: eq(appStore.slug, slug) }); + } + + /** + * Given appstore data, creates a appstore + */ + public async createAppStore(data: Omit) { + const hash = this.repoHelpers.getRepoHash(data.url); + + const newAppStore = await this.databaseService.db + .insert(appStore) + .values({ ...data, hash }) + .returning(); + + const insertedAppStore = newAppStore[0]; + if (!insertedAppStore) { + throw new Error('Failed to create new app store.'); + } + + return insertedAppStore; + } + + public async getEnabledAppStores() { + return this.databaseService.db + .select() + .from(appStore) + .where(and(eq(appStore.enabled, true))) + .orderBy(asc(appStore.hash)); + } + + public async getAllAppStores() { + return this.databaseService.db.select().from(appStore).orderBy(asc(appStore.hash)); + } + + public async removeAppStoreEntity(slug: string) { + return this.databaseService.db.delete(appStore).where(eq(appStore.slug, slug)); + } + + public async updateAppStoreHashAndUrl(slug: string, data: Pick) { + return this.databaseService.db.update(appStore).set({ hash: data.hash, url: data.url }).where(eq(appStore.slug, slug)); + } + + public async updateAppStore(slug: string, data: Omit) { + const update = await this.databaseService.db + .update(appStore) + .set({ name: data.name, enabled: data.enabled }) + .where(eq(appStore.slug, slug)) + .returning(); + const store = update[0]; + + if (!store) { + throw new Error('Failed to update app store.'); + } + + return store; + } + + public async enableAppStore(slug: string) { + return this.databaseService.db.update(appStore).set({ enabled: true }).where(eq(appStore.slug, slug)); + } + + public async disableAppStore(slug: string) { + return this.databaseService.db.update(appStore).set({ enabled: false }).where(eq(appStore.slug, slug)); + } + + public async getAppCountForStore(slug: string) { + const res = await this.databaseService.db.select({ count: count() }).from(app).where(eq(app.appStoreSlug, slug)); + return res[0]; + } +} diff --git a/packages/backend/src/modules/app-stores/app-store.service.ts b/packages/backend/src/modules/app-stores/app-store.service.ts new file mode 100644 index 0000000000..f7817959a1 --- /dev/null +++ b/packages/backend/src/modules/app-stores/app-store.service.ts @@ -0,0 +1,161 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import slugify from 'slugify'; +import type { UpdateAppStoreBodyDto } from '../marketplace/dto/marketplace.dto'; +import { RepoEventsQueue } from '../queue/entities/repo-events'; +import { AppStoreRepository } from './app-store.repository'; +import { ReposHelpers } from './repos.helpers'; + +@Injectable() +export class AppStoreService { + constructor( + private readonly logger: LoggerService, + private readonly repoQueue: RepoEventsQueue, + private readonly repoHelpers: ReposHelpers, + private readonly config: ConfigurationService, + private readonly appStoreRepository: AppStoreRepository, + ) { + this.repoQueue.onEvent(async (data, reply) => { + switch (data.command) { + case 'update_all': { + const stores = await this.appStoreRepository.getEnabledAppStores(); + for (const store of stores) { + await this.repoHelpers.pullRepo(store.url, store.slug); + } + await reply({ success: true, message: 'All repos updated' }); + break; + } + case 'clone_all': { + const stores = await this.appStoreRepository.getEnabledAppStores(); + for (const store of stores) { + await this.repoHelpers.cloneRepo(store.url, store.slug); + } + await reply({ success: true, message: 'All repos cloned' }); + break; + } + case 'clone': { + const { success, message } = await this.repoHelpers.cloneRepo(data.url, data.id); + await reply({ success, message }); + break; + } + case 'update': { + const { success, message } = await this.repoHelpers.pullRepo(data.url, data.id); + await reply({ success, message }); + break; + } + } + }); + } + + public async pullRepositories() { + const repositories = await this.appStoreRepository.getEnabledAppStores(); + + for (const repo of repositories) { + this.logger.debug(`Pulling repo ${repo.url}`); + await this.repoHelpers.pullRepo(repo.url, repo.slug); + } + + return { success: true }; + } + + /** + * Migrate the legacy repo to the new app store system + * + * @returns The ID of the migrated repo + */ + public async migrateLegacyRepo() { + const { deprecatedAppsRepoUrl, deprecatedAppsRepoId } = this.config.getConfig(); + + if (!deprecatedAppsRepoUrl) { + this.logger.debug('Skipping repo migration, no deprecated repo URL to migrate'); + return; + } + + const existing = await this.appStoreRepository.getAppStoreByHash('migrated'); + if (existing) { + this.logger.info('Migrating default repo'); + await this.appStoreRepository.updateAppStoreHashAndUrl(existing.slug, { url: deprecatedAppsRepoUrl, hash: deprecatedAppsRepoId }); + } + } + + public async getEnabledAppStores() { + return this.appStoreRepository.getEnabledAppStores(); + } + + public async getAllAppStores() { + return this.appStoreRepository.getAllAppStores(); + } + + /** + * Given an app store ID and the new data, update the app store in the database + * + * @param slug The ID of the app store to update + * @param body The new data to update the app store with + */ + public async updateAppStore(slug: string, body: UpdateAppStoreBodyDto) { + return this.appStoreRepository.updateAppStore(slug, body); + } + + /** + * Given an app store ID, delete it from the database and the filesystem + * + * @param slug The ID of the app store to delete + */ + public async deleteAppStore(slug: string) { + const stores = await this.appStoreRepository.getAllAppStores(); + + if (stores.length === 1) { + throw new TranslatableError('APP_STORE_DELETE_ERROR_LAST_STORE', {}, HttpStatus.BAD_REQUEST); + } + + const count = await this.appStoreRepository.getAppCountForStore(slug); + + if (count && count.count > 0) { + throw new TranslatableError('APP_STORE_DELETE_ERROR_APPS_EXIST', {}, HttpStatus.BAD_REQUEST); + } + + await this.appStoreRepository.removeAppStoreEntity(slug); + await this.repoHelpers.deleteRepo(slug); + + return { success: true }; + } + + public async createAppStore(body: { url: string; name: string }) { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const hash = this.repoHelpers.getRepoHash(body.url); + const existing = await this.appStoreRepository.getAppStoreByHash(hash); + if (existing) { + throw new TranslatableError('SERVER_ERROR_APP_STORE_ALREADY_EXISTS', {}, HttpStatus.CONFLICT); + } + + const slug = slugify(body.name, { lower: true, trim: true }); + + const existingSlug = await this.appStoreRepository.getAppStoreBySlug(slug); + if (existingSlug) { + throw new TranslatableError('SERVER_ERROR_DUPLICATE_APP_STORE_NAME', {}, HttpStatus.CONFLICT); + } + + const created = await this.appStoreRepository.createAppStore({ ...body, slug }); + const { success } = await this.repoHelpers.cloneRepo(body.url, created.slug); + + if (!success) { + await this.appStoreRepository.removeAppStoreEntity(created.slug); + throw new TranslatableError('APP_STORE_CLONE_ERROR', { url: body.url }, HttpStatus.BAD_REQUEST); + } + + return created; + } + + public async getAppCountForStore(slug: string) { + return this.appStoreRepository.getAppCountForStore(slug); + } + + public async deleteAllRepos() { + await this.repoHelpers.deleteAllRepos(); + } +} diff --git a/packages/backend/src/modules/app-stores/repos.helpers.ts b/packages/backend/src/modules/app-stores/repos.helpers.ts new file mode 100644 index 0000000000..6a82e0dfc5 --- /dev/null +++ b/packages/backend/src/modules/app-stores/repos.helpers.ts @@ -0,0 +1,182 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; +import { execAsync } from '@/common/helpers/exec-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ReposHelpers { + constructor( + private readonly logger: LoggerService, + private readonly configuration: ConfigurationService, + private readonly filesystem: FilesystemService, + ) {} + + /** + * Given a repo url, return a hash of it to be used as a folder name + * + * @param {string} repoUrl + */ + public getRepoHash = (repoUrl: string) => { + const hash = crypto.createHash('sha256'); + hash.update(repoUrl); + return hash.digest('hex'); + }; + + /** + * Extracts the base URL and branch from a repository URL. + * @param repoUrl The repository URL. + * @returns An array containing the base URL and branch, or just the base URL if no branch is found. + */ + private getRepoBaseUrlAndBranch = (repoUrl: string) => { + const treeIndex = repoUrl.indexOf('/tree/'); + + if (treeIndex !== -1) { + const baseUrl = repoUrl.substring(0, treeIndex); + const branch = repoUrl.substring(treeIndex + '/tree/'.length); + return [baseUrl, branch]; + } + + return [repoUrl, undefined]; + }; + + /** + * Error handler for repo operations + * @param {unknown} err + */ + private handleRepoError = (err: unknown) => { + Sentry.captureException(err); + + if (err instanceof Error) { + this.logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${String(err)}` }; + }; + + /** + * Given a repo url, clone it to the repos folder if it doesn't exist + * + * @param {string} url + */ + public cloneRepo = async (url: string, id: string) => { + try { + const { dataDir } = this.configuration.get('directories'); + + const repoPath = path.join(dataDir, 'repos', id); + + if (await this.filesystem.pathExists(repoPath)) { + this.logger.info(`Repo ${url} already exists`); + return { success: true, message: '' }; + } + + const [repoUrl, branch] = this.getRepoBaseUrlAndBranch(url); + + let cloneCommand: string; + if (branch) { + this.logger.info(`Cloning repo ${repoUrl} on branch ${branch} to ${repoPath}`); + cloneCommand = `git clone -b ${branch} --depth 1 ${repoUrl} ${repoPath}`; + } else { + this.logger.info(`Cloning repo ${repoUrl} to ${repoPath}`); + cloneCommand = `git clone --depth 1 ${repoUrl} ${repoPath}`; + } + const { stderr } = await execAsync(cloneCommand); + + // Chmod the repo folder to 777 + this.logger.info(`Executing: chmod -R 755 ${repoPath}`); + await execAsync(`chmod -R 755 ${repoPath}`); + + this.logger.info(`Cloned repo ${repoUrl} to ${repoPath}`); + return { success: !stderr.includes('fatal:'), message: '' }; + } catch (err) { + return this.handleRepoError(err); + } + }; + + /** + * Given a repo url, pull it to the repos folder if it exists + * + * @param {string} repoUrl + */ + public pullRepo = async (repoUrl: string, slug: string) => { + try { + const { dataDir } = this.configuration.get('directories'); + + const repoPath = path.join(dataDir, 'repos', slug); + + if (!(await this.filesystem.pathExists(repoPath))) { + this.logger.info(`Repo ${repoUrl} does not exist`); + return { success: false, message: `Repo ${repoUrl} does not exist` }; + } + + this.logger.info(`Pulling repo ${repoUrl} to ${repoPath}`); + + this.logger.info(`Executing: git config --global --add safe.directory ${repoPath}`); + await execAsync(`git config --global --add safe.directory ${repoPath}`).then(({ stderr }) => { + if (stderr) { + this.logger.error(`stderr: ${stderr}`); + } + }); + + // git config pull.rebase false + this.logger.info(`Executing: git -C ${repoPath} config pull.rebase false`); + await execAsync(`git -C ${repoPath} config pull.rebase false`).then(({ stderr }) => { + if (stderr) { + this.logger.error(`stderr: ${stderr}`); + } + }); + + this.logger.info(`Executing: git -C ${repoPath} rev-parse --abbrev-ref HEAD`); + const currentBranch = await execAsync(`git -C ${repoPath} rev-parse --abbrev-ref HEAD`).then(({ stdout }) => { + return stdout.trim(); + }); + + this.logger.info(`Executing: git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`); + await execAsync(`git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`); + + this.logger.info(`Pulled repo ${repoUrl} to ${repoPath}`); + return { success: true, message: '' }; + } catch (err) { + return this.handleRepoError(err); + } + }; + + /** + * Given a repo id, delete it from the repos folder + */ + public deleteRepo = async (id: string) => { + try { + const { dataDir } = this.configuration.get('directories'); + + const repoPath = path.join(dataDir, 'repos', id); + + if (!(await this.filesystem.pathExists(repoPath))) { + this.logger.info(`Repo ${id} does not exist`); + return { success: false, message: `Repo ${id} does not exist` }; + } + + this.logger.info(`Deleting repo ${id} from ${repoPath}`); + await this.filesystem.removeDirectory(repoPath); + + this.logger.info(`Deleted repo ${id} from ${repoPath}`); + return { success: true, message: '' }; + } catch (err) { + return this.handleRepoError(err); + } + }; + + public async deleteAllRepos() { + const { dataDir } = this.configuration.get('directories'); + const repos = await this.filesystem.listFiles(path.join(dataDir, 'repos')); + + for (const repo of repos) { + await this.deleteRepo(repo); + } + + return { success: true, message: '' }; + } +} diff --git a/packages/backend/src/modules/apps/app-files-manager.ts b/packages/backend/src/modules/apps/app-files-manager.ts new file mode 100644 index 0000000000..cd40642a20 --- /dev/null +++ b/packages/backend/src/modules/apps/app-files-manager.ts @@ -0,0 +1,210 @@ +import path from 'node:path'; +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { execAsync } from '@/common/helpers/exec-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { appInfoSchema } from '../marketplace/dto/marketplace.dto'; + +@Injectable() +export class AppFilesManager { + constructor( + private readonly configuration: ConfigurationService, + private readonly filesystem: FilesystemService, + private readonly logger: LoggerService, + ) {} + + private getInstalledAppsFolder() { + const { directories } = this.configuration.getConfig(); + + return path.join(directories.dataDir, 'apps'); + } + + public getAppPaths(appUrn: AppUrn) { + const { directories } = this.configuration.getConfig(); + + const { appStoreId, appName } = extractAppUrn(appUrn); + + return { + appDataDir: path.join(directories.appDataDir, appStoreId, appName), + appInstalledDir: path.join(this.getInstalledAppsFolder(), appStoreId, appName), + }; + } + + /** + * Get the app info from the installed apps apps + * @param id - The app id + */ + public async getInstalledAppInfo(appUrn: AppUrn) { + try { + const { appInstalledDir } = this.getAppPaths(appUrn); + + if (await this.filesystem.pathExists(path.join(appInstalledDir, 'config.json'))) { + const configFile = await this.filesystem.readTextFile(path.join(appInstalledDir, 'config.json')); + + const config = JSON.parse(configFile ?? '{}'); + const parsedConfig = appInfoSchema.safeParse({ ...config, urn: appUrn }); + + if (!parsedConfig.success) { + this.logger.debug(`App ${appUrn} config error:`); + this.logger.debug(parsedConfig.error); + } + + if (parsedConfig.success && parsedConfig.data.available) { + const description = (await this.filesystem.readTextFile(path.join(appInstalledDir, 'metadata', 'description.md'))) ?? ''; + + return { ...parsedConfig.data, description }; + } + } + } catch (error) { + return null; + } + } + + /** + * Get the docker-compose.json file content from the installed app + * @param appUrn - The app id + * @returns The content of docker-compose.yml as a string, or null if not found + */ + public async getDockerComposeYaml(appUrn: AppUrn) { + const arch = this.configuration.get('architecture'); + const { appInstalledDir } = this.getAppPaths(appUrn); + let dockerComposePath = path.join(appInstalledDir, 'docker-compose.yml'); + + if (arch === 'arm64' && (await this.filesystem.pathExists(path.join(appInstalledDir, 'docker-compose.arm64.yml')))) { + dockerComposePath = path.join(appInstalledDir, 'docker-compose.arm64.yml'); + } + + let content = null; + try { + if (await this.filesystem.pathExists(dockerComposePath)) { + content = await this.filesystem.readTextFile(dockerComposePath); + } + } catch (error) { + this.logger.error(`Error getting docker-compose.yml for installed app ${appUrn}: ${error}`); + } + + return { path: dockerComposePath, content }; + } + + /** + * Get the docker-compose.json file content from the installed app + * @param appUrn - The app id + * @returns The content of docker-compose.json as a string, or null if not found + */ + public async getDockerComposeJson(appUrn: AppUrn) { + const { appInstalledDir } = this.getAppPaths(appUrn); + const dockerComposePath = path.join(appInstalledDir, 'docker-compose.json'); + + let content = null; + try { + if (await this.filesystem.pathExists(dockerComposePath)) { + content = await this.filesystem.readJsonFile(dockerComposePath); + } + } catch (error) { + this.logger.error(`Error getting docker-compose.json for installed app ${appUrn}: ${error}`); + } + + return { path: dockerComposePath, content }; + } + + /** + * Write the docker-compose.yml file to the installed app folder + * @param appUrn - The app id + * @param composeFile - The content of the docker-compose.yml file + */ + public async writeDockerComposeYml(appUrn: AppUrn, composeFile: string) { + const { appInstalledDir } = this.getAppPaths(appUrn); + const dockerComposePath = path.join(appInstalledDir, 'docker-compose.yml'); + + await this.filesystem.writeTextFile(dockerComposePath, composeFile); + } + + public async deleteAppFolder(appUrn: AppUrn) { + const { appInstalledDir } = this.getAppPaths(appUrn); + await this.filesystem.removeDirectory(appInstalledDir); + } + + public async deleteAppDataDir(appUrn: AppUrn) { + const { appDataDir } = this.getAppPaths(appUrn); + await this.filesystem.removeDirectory(appDataDir); + } + + public async createAppDataDir(appUrn: AppUrn) { + const { appDataDir } = this.getAppPaths(appUrn); + await this.filesystem.createDirectory(appDataDir); + } + + /** + * Set the permissions for the app data directory + * @param appUrn - The app id + */ + public async setAppDataDirPermissions(appUrn: AppUrn) { + const { appDataDir } = this.getAppPaths(appUrn); + + await execAsync(`chmod -Rf a+rwx ${appDataDir}`).catch(() => { + this.logger.error(`Error setting permissions for app ${appUrn}`); + }); + } + + public async getAppEnv(appUrn: AppUrn) { + const { appDataDir } = this.getAppPaths(appUrn); + + const envPath = path.join(appDataDir, 'app.env'); + + let env = ''; + if (await this.filesystem.pathExists(envPath)) { + env = (await this.filesystem.readTextFile(envPath)) ?? ''; + } + + return { path: envPath, content: env }; + } + + public async writeAppEnv(appUrn: AppUrn, env: string) { + const { appDataDir } = this.getAppPaths(appUrn); + + const envPath = path.join(appDataDir, 'app.env'); + + await this.filesystem.writeTextFile(envPath, env); + } + + /** + * Get the user env file content + * @param appUrn - The app id + */ + public async getUserEnv(appUrn: AppUrn) { + const { directories } = this.configuration.getConfig(); + + const { appStoreId, appName } = extractAppUrn(appUrn); + + const userEnvFile = path.join(directories.dataDir, 'user-config', appStoreId, appName, 'app.env'); + let content = null; + + if (await this.filesystem.pathExists(userEnvFile)) { + content = await this.filesystem.readTextFile(userEnvFile); + } + + return { path: userEnvFile, content }; + } + + /** + * Get the user compose file content + * @param appUrn - The app id + */ + public async getUserComposeFile(appUrn: AppUrn) { + const { directories } = this.configuration.getConfig(); + + const { appStoreId, appName } = extractAppUrn(appUrn); + + const userComposeFile = path.join(directories.dataDir, 'user-config', appStoreId, appName, 'docker-compose.yml'); + let content = null; + + if (await this.filesystem.pathExists(userComposeFile)) { + content = await this.filesystem.readTextFile(userComposeFile); + } + + return { path: userComposeFile, content }; + } +} diff --git a/packages/backend/src/modules/apps/app.helpers.ts b/packages/backend/src/modules/apps/app.helpers.ts new file mode 100644 index 0000000000..1163542a33 --- /dev/null +++ b/packages/backend/src/modules/apps/app.helpers.ts @@ -0,0 +1,105 @@ +import path from 'node:path'; +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { EnvUtils } from '../env/env.utils'; +import type { AppEventFormInput } from '../queue/entities/app-events'; +import { AppFilesManager } from './app-files-manager'; + +@Injectable() +export class AppHelpers { + constructor( + private readonly appFilesManager: AppFilesManager, + private readonly config: ConfigurationService, + private readonly filesytem: FilesystemService, + private readonly envUtils: EnvUtils, + ) {} + + /** + * This function generates an env file for the provided app. + * It reads the config.json file for the app, parses it, + * and uses the app's form fields and domain to generate the env file + * if the app is exposed and has a domain set, it adds the domain to the env file, + * otherwise, it adds the internal IP address to the env file + * It also creates the app-data folder for the app if it does not exist + * + * @param {string} appUrn - The id of the app to generate the env file for. + * @param {AppEventFormInput} form - The config object for the app. + * @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing. + */ + public generateEnvFile = async (appUrn: AppUrn, form: AppEventFormInput) => { + const { internalIp, envFilePath, rootFolderHost, userSettings } = this.config.getConfig(); + + const config = await this.appFilesManager.getInstalledAppInfo(appUrn); + + if (!config) { + throw new Error(`App ${appUrn} not found`); + } + + const baseEnvFile = await this.filesytem.readTextFile(envFilePath); + const envMap = this.envUtils.envStringToMap(baseEnvFile?.toString() ?? ''); + + const { appName, appStoreId } = extractAppUrn(appUrn); + + // Default always present env variables + envMap.set('APP_PORT', form.port ? String(form.port) : String(config.port)); + envMap.set('APP_ID', appUrn); + envMap.set('ROOT_FOLDER_HOST', rootFolderHost); + envMap.set('APP_DATA_DIR', path.join(userSettings.appDataPath, appStoreId, appName)); + + const appEnv = await this.appFilesManager.getAppEnv(appUrn); + const existingAppEnvMap = this.envUtils.envStringToMap(appEnv.content); + + if (config.generate_vapid_keys) { + if (existingAppEnvMap.has('VAPID_PUBLIC_KEY') && existingAppEnvMap.has('VAPID_PRIVATE_KEY')) { + envMap.set('VAPID_PUBLIC_KEY', existingAppEnvMap.get('VAPID_PUBLIC_KEY') as string); + envMap.set('VAPID_PRIVATE_KEY', existingAppEnvMap.get('VAPID_PRIVATE_KEY') as string); + } else { + const vapidKeys = this.envUtils.generateVapidKeys(); + envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey); + envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey); + } + } + + await Promise.all( + config.form_fields.map(async (field) => { + const formValue = form[field.env_variable]; + const envVar = field.env_variable; + + if (formValue || typeof formValue === 'boolean') { + envMap.set(envVar, String(formValue)); + } else if (field.type === 'random') { + if (existingAppEnvMap.has(envVar)) { + envMap.set(envVar, existingAppEnvMap.get(envVar) as string); + } else { + const length = field.min || 32; + const randomString = this.envUtils.createRandomString(field.env_variable, length, field.encoding); + + envMap.set(envVar, randomString); + } + } else if (field.required) { + throw new Error(`Variable ${field.label || field.env_variable} is required`); + } + }), + ); + + if (form.exposed && form.domain && typeof form.domain === 'string') { + envMap.set('APP_EXPOSED', 'true'); + envMap.set('APP_DOMAIN', form.domain); + envMap.set('APP_HOST', form.domain); + envMap.set('APP_PROTOCOL', 'https'); + } else if (form.exposedLocal && !form.openPort) { + envMap.set('APP_DOMAIN', `${config.id}.${envMap.get('LOCAL_DOMAIN')}`); + envMap.set('APP_HOST', `${config.id}.${envMap.get('LOCAL_DOMAIN')}`); + envMap.set('APP_PROTOCOL', 'https'); + } else { + envMap.set('APP_DOMAIN', `${internalIp}:${config.port}`); + envMap.set('APP_HOST', internalIp); + envMap.set('APP_PROTOCOL', 'http'); + } + + await this.appFilesManager.writeAppEnv(appUrn, this.envUtils.envMapToString(envMap)); + }; +} diff --git a/packages/backend/src/modules/apps/apps.controller.ts b/packages/backend/src/modules/apps/apps.controller.ts new file mode 100644 index 0000000000..c0a009739d --- /dev/null +++ b/packages/backend/src/modules/apps/apps.controller.ts @@ -0,0 +1,33 @@ +import { castAppUrn } from '@/common/helpers/app-helpers'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AuthGuard } from '../auth/auth.guard'; +import { AppsService } from './apps.service'; +import { GetAppDto, GuestAppsDto, MyAppsDto } from './dto/app.dto'; + +@Controller('apps') +export class AppsController { + constructor(private readonly appsService: AppsService) {} + + @Get('installed') + @UseGuards(AuthGuard) + @ZodSerializerDto(MyAppsDto) + async getInstalledApps(): Promise { + const installed = await this.appsService.getInstalledApps(); + return { installed }; + } + + @Get('guest') + @ZodSerializerDto(GuestAppsDto) + async getGuestApps(): Promise { + const guest = await this.appsService.getGuestDashboardApps(); + return { installed: guest }; + } + + @Get(':urn') + @UseGuards(AuthGuard) + @ZodSerializerDto(GetAppDto) + async getApp(@Param('urn') urn: string): Promise { + return this.appsService.getApp(castAppUrn(urn)); + } +} diff --git a/packages/backend/src/modules/apps/apps.module.ts b/packages/backend/src/modules/apps/apps.module.ts new file mode 100644 index 0000000000..3e226106fb --- /dev/null +++ b/packages/backend/src/modules/apps/apps.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { EnvModule } from '../env/env.module'; +import { MarketplaceModule } from '../marketplace/marketplace.module'; +import { QueueModule } from '../queue/queue.module'; +import { AppFilesManager } from './app-files-manager'; +import { AppHelpers } from './app.helpers'; +import { AppsController } from './apps.controller'; +import { AppsRepository } from './apps.repository'; +import { AppsService } from './apps.service'; + +@Module({ + imports: [QueueModule, EnvModule, MarketplaceModule], + controllers: [AppsController], + providers: [AppFilesManager, AppsRepository, AppHelpers, AppsService], + exports: [AppsRepository, AppFilesManager, AppHelpers, AppsService], +}) +export class AppsModule {} diff --git a/packages/backend/src/modules/apps/apps.repository.ts b/packages/backend/src/modules/apps/apps.repository.ts new file mode 100644 index 0000000000..713b5a63d1 --- /dev/null +++ b/packages/backend/src/modules/apps/apps.repository.ts @@ -0,0 +1,114 @@ +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { DatabaseService } from '@/core/database/database.service'; +import { app } from '@/core/database/drizzle/schema'; +import type { AppStatus, NewApp } from '@/core/database/drizzle/types'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { and, asc, eq, ne, notInArray } from 'drizzle-orm'; + +@Injectable() +export class AppsRepository { + constructor(private db: DatabaseService) {} + + /** + * Given an app id, return the app + * + * @param {string} appId - The id of the app to return + */ + public async getAppById(appId: number) { + return this.db.db.query.app.findFirst({ where: eq(app.id, appId), with: { appStore: true } }); + } + + public async getAppByUrn(appUrn: AppUrn) { + const { appStoreId, appName } = extractAppUrn(appUrn); + + return this.db.db.query.app.findFirst({ where: and(eq(app.appName, appName), eq(app.appStoreSlug, appStoreId)) }); + } + + /** + * Given an app id, update the app with the given data + * + * @param {string} appId - The id of the app to update + * @param {Partial} data - The data to update the app with + */ + public async updateAppById(appId: number, data: Partial) { + const updatedApps = await this.db.db.update(app).set(data).where(eq(app.id, appId)).returning().execute(); + return updatedApps[0]; + } + + /** + * Given an app id, delete the app + * + * @param {string} appId - The id of the app to delete + */ + public async deleteAppById(appId: number) { + await this.db.db.delete(app).where(eq(app.id, appId)).execute(); + } + + /** + * Given app data, creates a new app + * + * @param {NewApp} data - The data to create the app with + */ + public async createApp(data: NewApp) { + const newApps = await this.db.db.insert(app).values(data).returning().execute(); + + const createdApp = newApps[0]; + + if (!createdApp) { + throw new Error('Failed to create app'); + } + + return createdApp; + } + + /** + * Returns all apps installed with the given status sorted by id ascending + * + * @param {AppStatus} status - The status of the apps to return + */ + public async getAppsByStatus(status: AppStatus) { + return this.db.db.query.app.findMany({ where: eq(app.status, status), orderBy: asc(app.appName) }); + } + + /** + * Returns all apps installed sorted by id ascending + */ + public async getApps() { + return this.db.db.query.app.findMany({ orderBy: asc(app.appName), with: { appStore: true } }); + } + + /** + * Returns all apps that are running and visible on guest dashboard sorted by id ascending + */ + public async getGuestDashboardApps() { + return this.db.db.query.app.findMany({ + where: and(eq(app.status, 'running'), eq(app.isVisibleOnGuestDashboard, true)), + orderBy: asc(app.appName), + with: { appStore: true }, + }); + } + + /** + * Given a domain, return all apps that have this domain, are exposed and not the given id + * + * @param {string} domain - The domain to search for + * @param {string} id - The id of the app to exclude + */ + public async getAppsByDomain(domain: string, id?: number) { + if (!id) { + return this.db.db.query.app.findMany({ where: and(eq(app.domain, domain), eq(app.exposed, true)) }); + } + return this.db.db.query.app.findMany({ where: and(eq(app.domain, domain), eq(app.exposed, true), ne(app.id, id)) }); + } + + /** + * Given an array of app status, update all apps that have a status not in the array with new values + * + * @param {AppStatus[]} statuses - The statuses to exclude from the update + * @param {Partial} data - The data to update the apps with + */ + public async updateAppsByStatusNotIn(statuses: AppStatus[], data: Partial) { + return this.db.db.update(app).set(data).where(notInArray(app.status, statuses)).returning().execute(); + } +} diff --git a/packages/backend/src/modules/apps/apps.service.ts b/packages/backend/src/modules/apps/apps.service.ts new file mode 100644 index 0000000000..905a16b725 --- /dev/null +++ b/packages/backend/src/modules/apps/apps.service.ts @@ -0,0 +1,91 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { createAppUrn } from '@/common/helpers/app-helpers'; +import { pLimit } from '@/common/helpers/file-helpers'; +import { LoggerService } from '@/core/logger/logger.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { MarketplaceService } from '../marketplace/marketplace.service'; +import { AppFilesManager } from './app-files-manager'; +import { AppsRepository } from './apps.repository'; + +type AppList = Awaited>; + +@Injectable() +export class AppsService { + constructor( + private readonly appsRepository: AppsRepository, + private readonly appFilesManager: AppFilesManager, + private readonly logger: LoggerService, + private readonly marketplaceService: MarketplaceService, + ) {} + + private async populateAppInfo(apps: AppList) { + const limit = pLimit(10); + + const populatedApps = await Promise.all( + apps.map(async (app) => { + return limit(async () => { + const appUrn = createAppUrn(app.appName, app.appStoreSlug); + const appInfo = await this.appFilesManager.getInstalledAppInfo(appUrn); + + const updateInfo = await this.marketplaceService.getAppUpdateInfo(appUrn).catch((_) => { + return { latestVersion: 0, latestDockerVersion: '0.0.0' }; + }); + + if (!appInfo) { + this.logger.debug(`App ${app.id} not found in app files`); + return null; + } + return { app, info: appInfo, metadata: updateInfo }; + }); + }), + ); + + return populatedApps.filter((app) => app !== null); + } + + /** + * Get the installed apps + */ + public async getInstalledApps() { + const apps = await this.appsRepository.getApps(); + + return this.populateAppInfo(apps); + } + + public async getGuestDashboardApps() { + this.logger.debug('Getting guest dashboard apps'); + const apps = await this.appsRepository.getGuestDashboardApps(); + this.logger.debug(`Got ${apps.length} guest dashboard apps`); + + return this.populateAppInfo(apps); + } + + public async getApp(appUrn: AppUrn) { + const app = await this.appsRepository.getAppByUrn(appUrn); + const updateInfo = await this.marketplaceService.getAppUpdateInfo(appUrn).catch((_) => { + return { latestVersion: 0, latestDockerVersion: '0.0.0' }; + }); + + let info = await this.appFilesManager.getInstalledAppInfo(appUrn); + + const userCompose = await this.appFilesManager.getUserComposeFile(appUrn); + const userEnv = await this.appFilesManager.getUserEnv(appUrn); + const hasCustomConfig = Boolean(userCompose.content) || Boolean(userEnv.content); + + if (!info) { + info = await this.marketplaceService.getAppInfoFromAppStore(appUrn); + } + + if (!info) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND'); + } + + const metadata = { + hasCustomConfig, + ...updateInfo, + }; + + return { app, info, metadata }; + } +} diff --git a/packages/backend/src/modules/apps/dto/app.dto.ts b/packages/backend/src/modules/apps/dto/app.dto.ts new file mode 100644 index 0000000000..17475bf50e --- /dev/null +++ b/packages/backend/src/modules/apps/dto/app.dto.ts @@ -0,0 +1,52 @@ +import { APP_STATUS } from '@/core/database/drizzle/types'; +import { AppInfoDto, AppInfoSimpleDto, MetadataDto } from '@/modules/marketplace/dto/marketplace.dto'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export class AppDto extends createZodDto( + z.object({ + id: z.number(), + port: z.number().nullable(), + status: z.enum(APP_STATUS), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + version: z.number(), + exposed: z.boolean(), + openPort: z.boolean(), + exposedLocal: z.boolean(), + domain: z.string().nullable(), + isVisibleOnGuestDashboard: z.boolean(), + config: z.record(z.any()).optional(), + }), +) {} + +export class MyAppsDto extends createZodDto( + z.object({ + installed: z.array( + z.object({ + app: AppDto.schema, + info: AppInfoSimpleDto.schema, + metadata: MetadataDto.schema, + }), + ), + }), +) {} + +export class GuestAppsDto extends createZodDto( + z.object({ + installed: z + .object({ + app: AppDto.schema, + info: AppInfoDto.schema, + }) + .array(), + }), +) {} + +export class GetAppDto extends createZodDto( + z.object({ + app: AppDto.schema.nullish(), + info: AppInfoDto.schema, + metadata: MetadataDto.schema, + }), +) {} diff --git a/packages/backend/src/modules/auth/auth.controller.ts b/packages/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000000..72891982d5 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,163 @@ +import { SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME } from '@/common/constants'; +import { TranslatableError } from '@/common/error/translatable-error'; +import { Body, Controller, Delete, Get, Patch, Post, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AuthGuard } from './auth.guard'; +import { AuthService } from './auth.service'; +import { + ChangePasswordBody, + ChangeUsernameBody, + CheckResetPasswordRequestDto, + DisableTotpBody, + GetTotpUriBody, + GetTotpUriDto, + LoginBody, + LoginDto, + RegisterBody, + RegisterDto, + ResetPasswordBody, + ResetPasswordDto, + SetupTotpBody, + VerifyTotpBody, +} from './dto/auth.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('/login') + @ZodSerializerDto(LoginDto) + async login(@Body() body: LoginBody, @Res({ passthrough: true }) res: Response): Promise { + const { sessionId, totpSessionId } = await this.authService.login(body); + + if (totpSessionId) { + return { success: true, totpSessionId }; + } + + res.cookie(SESSION_COOKIE_NAME, sessionId, { httpOnly: true, secure: false, sameSite: false, maxAge: SESSION_COOKIE_MAX_AGE }); + + return { success: true }; + } + + @Post('/verify-totp') + @ZodSerializerDto(LoginDto) + async verifyTotp(@Body() body: VerifyTotpBody, @Res({ passthrough: true }) res: Response): Promise { + const { sessionId } = await this.authService.verifyTotp(body); + + res.cookie(SESSION_COOKIE_NAME, sessionId, { httpOnly: true, secure: false, sameSite: false, maxAge: SESSION_COOKIE_MAX_AGE }); + + return { success: true }; + } + + @Post('/register') + @ZodSerializerDto(RegisterDto) + async register(@Body() body: RegisterBody, @Res({ passthrough: true }) res: Response): Promise { + const { sessionId } = await this.authService.register(body); + + res.cookie(SESSION_COOKIE_NAME, sessionId, { httpOnly: true, secure: false, sameSite: false, maxAge: SESSION_COOKIE_MAX_AGE }); + + return { success: true }; + } + + @Post('/logout') + async logout(@Res() res: Response, @Req() req: Request) { + res.clearCookie(SESSION_COOKIE_NAME); + const sessionId = req.cookies['tipi.sid']; + + if (!sessionId) { + return; + } + + await this.authService.logout(sessionId); + + return res.status(204).send(); + } + + @Patch('/username') + @UseGuards(AuthGuard) + async changeUsername(@Body() body: ChangeUsernameBody, @Req() req: Request, @Res() res: Response) { + const userId = req.user?.id; + + if (!userId) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + await this.authService.changeUsername({ userId, ...body }); + + res.clearCookie(SESSION_COOKIE_NAME); + return res.status(204).send(); + } + + @Patch('/password') + @UseGuards(AuthGuard) + async changePassword(@Body() body: ChangePasswordBody, @Req() req: Request, @Res() res: Response) { + const userId = req.user?.id; + + if (!userId) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + await this.authService.changePassword({ userId, ...body }); + + res.clearCookie(SESSION_COOKIE_NAME); + return res.status(204).send(); + } + + @Patch('/totp/get-uri') + @UseGuards(AuthGuard) + @ZodSerializerDto(GetTotpUriDto) + async getTotpUri(@Body() body: GetTotpUriBody, @Req() req: Request): Promise { + const userId = req.user?.id; + + if (!userId) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + return this.authService.getTotpUri({ userId, ...body }); + } + + @Patch('/totp/setup') + @UseGuards(AuthGuard) + async setupTotp(@Body() body: SetupTotpBody, @Req() req: Request): Promise { + const userId = req.user?.id; + + if (!userId) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + await this.authService.setupTotp({ userId, totpCode: body.code }); + } + + @Patch('/totp/disable') + @UseGuards(AuthGuard) + async disableTotp(@Body() body: DisableTotpBody, @Req() req: Request): Promise { + const userId = req.user?.id; + + if (!userId) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + await this.authService.disableTotp({ userId, ...body }); + } + + @Post('/reset-password') + @ZodSerializerDto(ResetPasswordDto) + async resetPassword(@Body() body: ResetPasswordBody): Promise { + const { email } = await this.authService.changeOperatorPassword(body); + + return { success: true, email }; + } + + @Delete('/reset-password') + async cancelResetPassword(): Promise { + await this.authService.cancelPasswordChangeRequest(); + } + + @Get('/reset-password') + async checkResetPasswordRequest(): Promise { + const isPending = await this.authService.checkPasswordChangeRequest(); + + return { isRequestPending: isPending }; + } +} diff --git a/packages/backend/src/modules/auth/auth.guard.ts b/packages/backend/src/modules/auth/auth.guard.ts new file mode 100644 index 0000000000..3c2fd7f0d3 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.guard.ts @@ -0,0 +1,15 @@ +import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import type { Request } from 'express'; + +@Injectable() +export class AuthGuard implements CanActivate { + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest() as Request; + + if (!request.user) { + throw new UnauthorizedException(); + } + + return true; + } +} diff --git a/packages/backend/src/modules/auth/auth.middleware.ts b/packages/backend/src/modules/auth/auth.middleware.ts new file mode 100644 index 0000000000..e3d41f27a7 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.middleware.ts @@ -0,0 +1,54 @@ +import { CacheService } from '@/core/cache/cache.service'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import jsonwebtoken from 'jsonwebtoken'; +import { UserRepository } from '../user/user.repository'; + +@Injectable() +export class AuthMiddleware implements NestMiddleware { + constructor( + private readonly cache: CacheService, + private readonly config: ConfigurationService, + private readonly userRepository: UserRepository, + ) {} + + async use(req: Request, _: Response, next: NextFunction) { + const sessionId = req.cookies['tipi.sid']; + const bearerToken = req.headers.authorization; + + if (sessionId) { + const userId = await this.cache.get(`session:${sessionId}`); + if (!Number.isNaN(Number(userId))) { + const user = await this.userRepository.getUserDtoById(Number(userId)); + req.user = user; + } + + return next(); + } + + if (bearerToken) { + const token = bearerToken.split(' ')[1]; + + if (!token) { + return next(); + } + + const jwtSecret = this.config.get('jwtSecret'); + + try { + const { sub } = jsonwebtoken.verify(token, jwtSecret) as { sub: string }; + if (sub === 'cli') { + const user = await this.userRepository.getFirstOperator(); + req.user = user; + } + + return next(); + } catch (error) { + return next(); + } + } + + return next(); + } +} diff --git a/packages/backend/src/modules/auth/auth.module.ts b/packages/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000000..de2317a7a7 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { EncryptionModule } from '@/core/encryption/encryption.module'; +import { UserModule } from '@/modules/user/user.module'; +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { SessionManager } from './session.manager'; + +@Module({ + imports: [UserModule, EncryptionModule], + controllers: [AuthController], + providers: [AuthService, SessionManager], +}) +export class AuthModule {} diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000000..c045b83adc --- /dev/null +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,393 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; +import { TranslatableError } from '@/common/error/translatable-error'; +import { CacheService } from '@/core/cache/cache.service'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { EncryptionService } from '@/core/encryption/encryption.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { UserRepository } from '@/modules/user/user.repository'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon2 from 'argon2'; +import validator from 'validator'; +import type { LoginBody, RegisterBody } from './dto/auth.dto'; +import { SessionManager } from './session.manager'; +import { TotpAuthenticator } from './utils/totp-authenticator'; + +@Injectable() +export class AuthService { + constructor( + private userRepository: UserRepository, + private sessionManager: SessionManager, + private config: ConfigurationService, + private encryption: EncryptionService, + private cache: CacheService, + private filesystem: FilesystemService, + ) {} + + /** + * Given a username and password, login the user and return the session ID. + * + * @param username - The username of the user to login. + * @param password - The password of the user to login. + * @returns The session ID. + */ + public login = async (input: LoginBody) => { + const { username, password } = input; + + const user = await this.userRepository.getUserByUsername(username); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND', {}, HttpStatus.BAD_REQUEST); + } + + const isPasswordValid = await argon2.verify(user.password, password); + + if (!isPasswordValid) { + throw new TranslatableError('AUTH_ERROR_INVALID_CREDENTIALS', {}, HttpStatus.BAD_REQUEST); + } + + if (user.totpEnabled) { + const totpSessionId = crypto.randomUUID(); + await this.cache.set(totpSessionId, user.id.toString()); + return { totpSessionId }; + } + + const sessionId = await this.sessionManager.createSession(user.id); + + return { + sessionId, + }; + }; + + /** + * Verify TOTP code and return a JWT token + * + * @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 + */ + public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => { + const { totpSessionId, totpCode } = params; + const userId = await this.cache.get(totpSessionId); + + if (!userId) { + throw new TranslatableError('AUTH_ERROR_TOTP_SESSION_NOT_FOUND'); + } + + const user = await this.userRepository.getUserById(Number(userId)); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + if (!user.totpEnabled || !user.totpSecret || !user.salt) { + throw new TranslatableError('AUTH_ERROR_TOTP_NOT_ENABLED'); + } + + const totpSecret = this.encryption.decrypt(user.totpSecret, user.salt); + const isValid = TotpAuthenticator.check(totpCode, totpSecret); + + if (!isValid) { + throw new TranslatableError('AUTH_ERROR_TOTP_INVALID_CODE'); + } + + const sessionId = await this.sessionManager.createSession(user.id); + + await this.cache.del(totpSessionId); + + return { + sessionId, + }; + }; + + /** + * Creates a new user with the provided email and password and returns a session token + * + * @param {LoginBody} input - An object containing the email and password fields + */ + public register = async (input: RegisterBody) => { + const operators = await this.userRepository.getOperators(); + + if (operators.length > 0) { + throw new TranslatableError('AUTH_ERROR_ADMIN_ALREADY_EXISTS', {}, HttpStatus.FORBIDDEN); + } + + const { password, username } = input; + const email = username.trim().toLowerCase(); + + if (!username || !password) { + throw new TranslatableError('AUTH_ERROR_MISSING_EMAIL_OR_PASSWORD', {}, HttpStatus.BAD_REQUEST); + } + + if (username.length < 3 || !validator.isEmail(email)) { + throw new TranslatableError('AUTH_ERROR_INVALID_USERNAME', {}, HttpStatus.BAD_REQUEST); + } + + const user = await this.userRepository.getUserByUsername(email); + + if (user) { + throw new TranslatableError('AUTH_ERROR_USER_ALREADY_EXISTS', {}, HttpStatus.BAD_REQUEST); + } + + const hash = await argon2.hash(password); + const newUser = await this.userRepository.createUser({ username: email, password: hash, operator: true }); + + if (!newUser) { + throw new TranslatableError('AUTH_ERROR_ERROR_CREATING_USER', {}, HttpStatus.INTERNAL_SERVER_ERROR); + } + + const sessionId = await this.sessionManager.createSession(newUser.id); + + return { + sessionId, + }; + }; + + /** + * Logs out the currently logged in user. + */ + public logout = async (sessionId: string) => { + await this.sessionManager.deleteSession(sessionId); + }; + + /** + * Change the username of the currently logged in user. + */ + public changeUsername = async (params: { password: string; userId: number; newUsername: string }) => { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const { newUsername, password, userId } = params; + + const user = await this.userRepository.getUserById(userId); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + const valid = await argon2.verify(user.password, password); + + if (!valid) { + throw new TranslatableError('AUTH_ERROR_INVALID_PASSWORD'); + } + + const email = newUsername.trim().toLowerCase(); + + if (!validator.isEmail(email)) { + throw new TranslatableError('AUTH_ERROR_INVALID_USERNAME'); + } + + const existingUser = await this.userRepository.getUserByUsername(email); + + if (existingUser) { + throw new TranslatableError('AUTH_ERROR_USER_ALREADY_EXISTS'); + } + + await this.userRepository.updateUser(user.id, { username: email }); + await this.sessionManager.destroyAllSessionsByUserId(user.id); + + return true; + }; + + public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const { currentPassword, newPassword, userId } = params; + + const user = await this.userRepository.getUserById(userId); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + const valid = await argon2.verify(user.password, currentPassword); + + if (!valid) { + throw new TranslatableError('AUTH_ERROR_INVALID_PASSWORD'); + } + + if (newPassword.length < 8) { + throw new TranslatableError('AUTH_ERROR_INVALID_PASSWORD_LENGTH'); + } + + const hash = await argon2.hash(newPassword); + await this.userRepository.updateUser(user.id, { password: hash }); + await this.sessionManager.destroyAllSessionsByUserId(user.id); + + return true; + }; + + /** + * Given a userId returns the TOTP URI and the secret key + * + * @param {object} params - An object containing the userId and the user's password + * @param {number} params.userId - The user's ID + * @param {string} params.password - The user's password + */ + public getTotpUri = async (params: { userId: number; password: string }) => { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const { userId, password } = params; + + const user = await this.userRepository.getUserById(userId); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + const isPasswordValid = await argon2.verify(user.password, password); + if (!isPasswordValid) { + throw new TranslatableError('AUTH_ERROR_INVALID_PASSWORD'); + } + + if (user.totpEnabled) { + throw new TranslatableError('AUTH_ERROR_TOTP_ALREADY_ENABLED'); + } + + let { salt } = user; + const newTotpSecret = TotpAuthenticator.generateSecret(); + + if (!salt) { + salt = this.sessionManager.generateSalt(); + } + + const encryptedTotpSecret = this.encryption.encrypt(newTotpSecret, salt); + + await this.userRepository.updateUser(userId, { totpSecret: encryptedTotpSecret, salt }); + + const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret); + + return { uri, key: newTotpSecret }; + }; + + public setupTotp = async (params: { userId: number; totpCode: string }) => { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const { userId, totpCode } = params; + const user = await this.userRepository.getUserById(userId); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + if (user.totpEnabled || !user.totpSecret || !user.salt) { + throw new TranslatableError('AUTH_ERROR_TOTP_ALREADY_ENABLED'); + } + + const totpSecret = this.encryption.decrypt(user.totpSecret, user.salt); + const isValid = TotpAuthenticator.check(totpCode, totpSecret); + + if (!isValid) { + throw new TranslatableError('AUTH_ERROR_TOTP_INVALID_CODE'); + } + + await this.userRepository.updateUser(userId, { totpEnabled: true }); + + return true; + }; + + public disableTotp = async (params: { userId: number; password: string }) => { + const { userId, password } = params; + + const user = await this.userRepository.getUserById(userId); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_USER_NOT_FOUND'); + } + + if (!user.totpEnabled) { + throw new TranslatableError('AUTH_ERROR_TOTP_NOT_ENABLED'); + } + + const isPasswordValid = await argon2.verify(user.password, password); + if (!isPasswordValid) { + throw new TranslatableError('AUTH_ERROR_INVALID_PASSWORD'); + } + + await this.userRepository.updateUser(userId, { totpEnabled: false, totpSecret: null }); + + return true; + }; + + /** + * Change the password of the operator user + * + * @param {object} params - An object containing the new password + * @param {string} params.newPassword - The new password + */ + public changeOperatorPassword = async (params: { newPassword: string }) => { + const isRequested = await this.checkPasswordChangeRequest(); + + if (!isRequested) { + throw new TranslatableError('AUTH_ERROR_NO_CHANGE_PASSWORD_REQUEST'); + } + + const { newPassword } = params; + + const user = await this.userRepository.getFirstOperator(); + + if (!user) { + throw new TranslatableError('AUTH_ERROR_OPERATOR_NOT_FOUND'); + } + + const hash = await argon2.hash(newPassword); + + await this.userRepository.updateUser(user.id, { password: hash, totpEnabled: false, totpSecret: null }); + + const { dataDir } = this.config.get('directories'); + await this.filesystem.removeFile(path.join(dataDir, 'state', 'password-change-request')); + + await this.sessionManager.destroyAllSessionsByUserId(user.id); + + return { email: user.username }; + }; + + /* + * Check if there is a pending password change request for the given email + * Returns true if there is a file in the password change requests folder with the given email + * + * @returns {boolean} - A boolean indicating if there is a password change request or not + */ + public checkPasswordChangeRequest = async () => { + const REQUEST_TIMEOUT_SECS = 15 * 60; // 15 minutes + + const { dataDir } = this.config.get('directories'); + const resetPasswordFilePath = path.join(dataDir, 'state', 'password-change-request'); + + try { + const timestamp = await this.filesystem.readTextFile(resetPasswordFilePath); + + if (!timestamp) { + return false; + } + + const requestCreation = Number(timestamp); + return requestCreation + REQUEST_TIMEOUT_SECS > Date.now() / 1000; + } catch { + return false; + } + }; + + /* + * If there is a pending password change request, remove it + * Returns true if the file is removed successfully + * + * @returns {boolean} - A boolean indicating if the file is removed successfully or not + * @throws {Error} - If the file cannot be removed + */ + public cancelPasswordChangeRequest = async () => { + const { dataDir } = this.config.get('directories'); + const changeRequestPath = path.join(dataDir, 'state', 'password-change-request'); + + await this.filesystem.removeFile(changeRequestPath); + + return true; + }; +} diff --git a/packages/backend/src/modules/auth/dto/auth.dto.ts b/packages/backend/src/modules/auth/dto/auth.dto.ts new file mode 100644 index 0000000000..d6b0040f61 --- /dev/null +++ b/packages/backend/src/modules/auth/dto/auth.dto.ts @@ -0,0 +1,100 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +// Login +export class LoginBody extends createZodDto( + z.object({ + username: z.string(), + password: z.string(), + }), +) {} + +export class VerifyTotpBody extends createZodDto( + z.object({ + totpCode: z.string(), + totpSessionId: z.string(), + }), +) {} + +export class LoginDto extends createZodDto( + z.object({ + success: z.boolean(), + totpSessionId: z.string().optional(), + }), +) {} + +// Register +export class RegisterBody extends createZodDto( + z.object({ + username: z.string(), + password: z.string(), + }), +) {} + +export class RegisterDto extends createZodDto( + z.object({ + success: z.boolean(), + }), +) {} + +// Change username +export class ChangeUsernameBody extends createZodDto( + z.object({ + newUsername: z.string(), + password: z.string(), + }), +) {} + +// Change password +export class ChangePasswordBody extends createZodDto( + z.object({ + currentPassword: z.string(), + newPassword: z.string(), + }), +) {} + +// TOTP +export class GetTotpUriBody extends createZodDto( + z.object({ + password: z.string(), + }), +) {} + +export class GetTotpUriDto extends createZodDto( + z.object({ + key: z.string(), + uri: z.string(), + }), +) {} + +export class SetupTotpBody extends createZodDto( + z.object({ + code: z.string(), + }), +) {} + +export class DisableTotpBody extends createZodDto( + z.object({ + password: z.string(), + }), +) {} + +// Reset password +export class ResetPasswordBody extends createZodDto( + z.object({ + newPassword: z.string(), + }), +) {} + +export class ResetPasswordDto extends createZodDto( + z.object({ + success: z.boolean(), + email: z.string(), + }), +) {} + +export class CheckResetPasswordRequestDto extends createZodDto( + z.object({ + isRequestPending: z.boolean(), + }), +) {} diff --git a/packages/backend/src/modules/auth/session.manager.ts b/packages/backend/src/modules/auth/session.manager.ts new file mode 100644 index 0000000000..796f0173a0 --- /dev/null +++ b/packages/backend/src/modules/auth/session.manager.ts @@ -0,0 +1,59 @@ +import crypto from 'node:crypto'; +import { CacheService } from '@/core/cache/cache.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SessionManager { + private COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day + + constructor(private cache: CacheService) {} + + /** + * Create a new session for the given user. + * @param userId - The ID of the user to create a session for. + * @returns The session ID. + */ + public async createSession(userId: number) { + const sessionId = crypto.randomUUID(); + const sessionKey = `session:${sessionId}`; + + await this.cache.set(sessionKey, userId.toString(), this.COOKIE_MAX_AGE * 7); + await this.cache.set(`session:${userId}:${sessionId}`, sessionKey, this.COOKIE_MAX_AGE * 7); + + return sessionId; + } + + public generateSalt() { + return crypto.randomBytes(16).toString('hex'); + } + + /** + * Delete a session by its ID. + * @param sessionId - The ID of the session to delete. + */ + public async deleteSession(sessionId: string) { + const sessionKey = `session:${sessionId}`; + const userId = await this.cache.get(sessionKey); + + await this.cache.del(sessionKey); + if (userId) { + await this.cache.del(`session:${userId}:${sessionId}`); + } + } + + /** + * Given a user ID, destroy all sessions for that user + * + * @param {number} userId - The user ID + */ + public destroyAllSessionsByUserId = async (userId: number) => { + const sessions = await this.cache.getByPrefix(`session:${userId}:`); + + await Promise.all( + sessions.map(async (session) => { + await this.cache.del(session.key); + if (session.val) await this.cache.del(session.val); + }), + ); + }; +} diff --git a/src/server/utils/totp.ts b/packages/backend/src/modules/auth/utils/totp-authenticator.ts similarity index 100% rename from src/server/utils/totp.ts rename to packages/backend/src/modules/auth/utils/totp-authenticator.ts diff --git a/packages/backend/src/modules/backups/backup.manager.ts b/packages/backend/src/modules/backups/backup.manager.ts new file mode 100644 index 0000000000..1cd0ea6ad7 --- /dev/null +++ b/packages/backend/src/modules/backups/backup.manager.ts @@ -0,0 +1,172 @@ +import path from 'node:path'; +import { ArchiveService } from '@/core/archive/archive.service'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { AppFilesManager } from '../apps/app-files-manager'; + +@Injectable() +export class BackupManager { + constructor( + private readonly archiveManager: ArchiveService, + private readonly logger: LoggerService, + private readonly config: ConfigurationService, + private readonly filesystem: FilesystemService, + private readonly appFilesManager: AppFilesManager, + ) {} + + public backupApp = async (appUrn: AppUrn) => { + const { dataDir } = this.config.get('directories'); + const backupName = `${appUrn}-${new Date().getTime()}`; + const [appName, storeName] = appUrn.split(':'); + + const backupDir = path.join(dataDir, 'backups', storeName, appName); + + const tempDir = await this.filesystem.createTempDirectory(appUrn); + + if (!tempDir) { + throw new Error('Failed to create temp directory'); + } + + this.logger.info('Copying files to backup location...'); + + // Ensure backup directory exists + await this.filesystem.createDirectory(tempDir); + + const { appDataDir, appInstalledDir } = this.appFilesManager.getAppPaths(appUrn); + + // Move app data and app directories + await this.filesystem.copyDirectory(appDataDir, path.join(tempDir, 'app-data'), { + recursive: true, + filter: (src) => !src.includes('backups'), + }); + + await this.filesystem.copyDirectory(appInstalledDir, path.join(tempDir, 'app')); + + this.logger.info('Creating archive...'); + + // Create the archive + const { stdout, stderr } = await this.archiveManager.createTarGz(tempDir, `${path.join(tempDir, backupName)}.tar.gz`); + this.logger.debug('--- archiveManager.createTarGz ---'); + this.logger.debug('stderr:', stderr); + this.logger.debug('stdout:', stdout); + + this.logger.info('Moving archive to backup directory...'); + + // Move the archive to the backup directory + await this.filesystem.createDirectory(backupDir); + await this.filesystem.copyFile(`${path.join(tempDir, backupName)}.tar.gz`, path.join(backupDir, `${backupName}.tar.gz`)); + + // Remove the temp backup folder + await this.filesystem.removeDirectory(tempDir); + + this.logger.info('Backup completed!'); + }; + + public restoreApp = async (appUrn: AppUrn, filename: string) => { + const { dataDir } = this.config.get('directories'); + const restoreDir = await this.filesystem.createTempDirectory(appUrn); + + if (!restoreDir) { + throw new Error('Failed to create temp directory'); + } + + const [appName, storeName] = appUrn.split(':'); + + const archive = path.join(dataDir, 'backups', storeName, appName, filename); + + this.logger.info('Restoring app from backup...'); + + // Verify the app has a backup + if (!(await this.filesystem.pathExists(archive))) { + throw new Error('The backup file does not exist'); + } + + // Unzip the archive + await this.filesystem.createDirectory(restoreDir); + + this.logger.info('Extracting archive...'); + const { stderr, stdout } = await this.archiveManager.extractTarGz(archive, restoreDir); + this.logger.debug('--- archiveManager.extractTarGz ---'); + this.logger.debug('stderr:', stderr); + this.logger.debug('stdout:', stdout); + + const { appInstalledDir, appDataDir } = this.appFilesManager.getAppPaths(appUrn); + + // Remove old data directories + await this.filesystem.removeDirectory(appDataDir); + await this.filesystem.removeDirectory(appInstalledDir); + + await this.filesystem.createDirectory(appDataDir); + await this.filesystem.createDirectory(appInstalledDir); + + // Copy data from the backup folder + await this.filesystem.copyDirectory(path.join(restoreDir, 'app-data'), appDataDir); + await this.filesystem.copyDirectory(path.join(restoreDir, 'app'), appInstalledDir); + + // Delete restore folder + await this.filesystem.removeDirectory(restoreDir); + }; + + /** + * Delete a backup file + * @param appUrn - The app id + * @param filename - The filename of the backup + */ + public async deleteBackup(appUrn: AppUrn, filename: string) { + const { dataDir } = this.config.get('directories'); + + const [appName, storeName] = appUrn.split(':'); + + const backupPath = path.join(dataDir, 'backups', storeName, appName, filename); + + if (await this.filesystem.pathExists(backupPath)) { + await this.filesystem.removeFile(backupPath); + } + } + + /** + * Delete all backups for an app + * @param appUrn - The app id + */ + public async deleteAppBackupsByUrn(appUrn: AppUrn): Promise { + const backups = await this.listBackupsByAppId(appUrn); + + await Promise.all(backups.map((backup) => this.deleteBackup(appUrn, backup.id))); + } + + /** + * List the backups for an app + * @param appUrn - The app id + * @returns The list of backups + */ + public async listBackupsByAppId(appUrn: AppUrn) { + const { dataDir } = this.config.get('directories'); + + const [appName, storeName] = appUrn.split(':'); + + const backupsDir = path.join(dataDir, 'backups', storeName, appName); + + if (!(await this.filesystem.pathExists(backupsDir))) { + return []; + } + + try { + const list = await this.filesystem.listFiles(backupsDir); + + const backups = await Promise.all( + list.map(async (backup) => { + const stats = await this.filesystem.getStats(path.join(backupsDir, backup)); + return { id: backup, size: stats.size, date: stats.mtime.getTime() }; + }), + ); + + return backups; + } catch (error) { + this.logger.error(`Error listing backups for app ${appUrn}: ${error}`); + return []; + } + } +} diff --git a/packages/backend/src/modules/backups/backups.controller.ts b/packages/backend/src/modules/backups/backups.controller.ts new file mode 100644 index 0000000000..6e1975d144 --- /dev/null +++ b/packages/backend/src/modules/backups/backups.controller.ts @@ -0,0 +1,39 @@ +import { castAppUrn } from '@/common/helpers/app-helpers'; +import { Body, Controller, Delete, Get, Injectable, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AuthGuard } from '../auth/auth.guard'; +import { BackupsService } from './backups.service'; +import { DeleteAppBackupBodyDto, GetAppBackupsDto, GetAppBackupsQueryDto, RestoreAppBackupDto } from './dto/backups.dto'; + +@Injectable() +@UseGuards(AuthGuard) +@Controller('backups') +export class BackupsController { + constructor(private readonly backupsService: BackupsService) {} + + @Post(':urn/backup') + async backupApp(@Param('urn') urn: string) { + return this.backupsService.backupApp({ appUrn: castAppUrn(urn) }); + } + + @Post(':urn/restore') + async restoreAppBackup(@Param('urn') urn: string, @Body() body: RestoreAppBackupDto) { + return this.backupsService.restoreApp({ appUrn: castAppUrn(urn), filename: body.filename }); + } + + @Get(':urn') + @ApiQuery({ name: 'pageSize', type: Number, required: false }) + @ApiQuery({ name: 'page', type: Number, required: false }) + @ZodSerializerDto(GetAppBackupsDto) + async getAppBackups(@Param('urn') urn: string, @Query() query: GetAppBackupsQueryDto): Promise { + const backups = await this.backupsService.getAppBackups({ appUrn: castAppUrn(urn), page: query.page ?? 0, pageSize: query.pageSize ?? 10 }); + + return backups; + } + + @Delete(':urn') + async deleteAppBackup(@Param('urn') urn: string, @Body() body: DeleteAppBackupBodyDto) { + return this.backupsService.deleteAppBackup({ appUrn: castAppUrn(urn), filename: body.filename }); + } +} diff --git a/packages/backend/src/modules/backups/backups.module.ts b/packages/backend/src/modules/backups/backups.module.ts new file mode 100644 index 0000000000..2ed6b680bb --- /dev/null +++ b/packages/backend/src/modules/backups/backups.module.ts @@ -0,0 +1,17 @@ +import { ArchiveModule } from '@/core/archive/archive.module'; +import { SocketModule } from '@/core/socket/socket.module'; +import { Module, forwardRef } from '@nestjs/common'; +import { AppLifecycleModule } from '../app-lifecycle/app-lifecycle.module'; +import { AppsModule } from '../apps/apps.module'; +import { QueueModule } from '../queue/queue.module'; +import { BackupManager } from './backup.manager'; +import { BackupsController } from './backups.controller'; +import { BackupsService } from './backups.service'; + +@Module({ + imports: [forwardRef(() => AppLifecycleModule), AppsModule, QueueModule, SocketModule, ArchiveModule], + controllers: [BackupsController], + providers: [BackupsService, BackupManager], + exports: [BackupsService, BackupManager], +}) +export class BackupsModule {} diff --git a/packages/backend/src/modules/backups/backups.service.ts b/packages/backend/src/modules/backups/backups.service.ts new file mode 100644 index 0000000000..030d963030 --- /dev/null +++ b/packages/backend/src/modules/backups/backups.service.ts @@ -0,0 +1,117 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { SocketManager } from '@/core/socket/socket.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { AppLifecycleService } from '../app-lifecycle/app-lifecycle.service'; +import { AppFilesManager } from '../apps/app-files-manager'; +import { AppsRepository } from '../apps/apps.repository'; +import { AppEventsQueue } from '../queue/entities/app-events'; +import { BackupManager } from './backup.manager'; + +@Injectable() +export class BackupsService { + constructor( + private appsRepository: AppsRepository, + private logger: LoggerService, + private socketManager: SocketManager, + private config: ConfigurationService, + private appEventsQueue: AppEventsQueue, + private appLifecycle: AppLifecycleService, + private appFilesManager: AppFilesManager, + private backupManager: BackupManager, + ) {} + + public async backupApp(params: { appUrn: AppUrn }) { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + const { appUrn } = params; + const app = await this.appsRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + const appStatusBeforeUpdate = app.status; + + // Run script + await this.appsRepository.updateAppById(app.id, { status: 'backing_up' }); + this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'starting' } }); + + this.appEventsQueue.publish({ appUrn, command: 'backup', form: app.config }).then(async ({ success, message }) => { + if (success) { + if (appStatusBeforeUpdate === 'running') { + await this.appLifecycle.startApp({ appUrn }); + } else { + await this.appsRepository.updateAppById(app.id, { status: appStatusBeforeUpdate }); + this.socketManager.emit({ type: 'app', event: 'backup_success', data: { appUrn, appStatus: 'stopped' } }); + } + } else { + this.logger.error(`Failed to backup app ${appUrn}: ${message}`); + await this.appsRepository.updateAppById(app.id, { status: 'stopped' }); + } + }); + } + + public async restoreApp(params: { appUrn: AppUrn; filename: string }) { + const { appUrn, filename } = params; + const app = await this.appsRepository.getAppByUrn(appUrn); + + if (!app) { + throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appUrn }); + } + + const appStatusBeforeUpdate = app.status; + + // Run script + await this.appsRepository.updateAppById(app.id, { status: 'restoring' }); + await this.socketManager.emit({ type: 'app', event: 'status_change', data: { appUrn, appStatus: 'restoring' } }); + + this.appEventsQueue.publish({ appUrn, command: 'restore', filename, form: app.config }).then(async ({ success, message }) => { + if (success) { + const restoredAppConfig = await this.appFilesManager.getInstalledAppInfo(appUrn); + + if (restoredAppConfig?.tipi_version) { + await this.appsRepository.updateAppById(app.id, { version: restoredAppConfig?.tipi_version }); + } + + if (appStatusBeforeUpdate === 'running') { + await this.appLifecycle.startApp({ appUrn }); + } else { + await this.appsRepository.updateAppById(app.id, { status: appStatusBeforeUpdate }); + this.socketManager.emit({ type: 'app', event: 'restore_success', data: { appUrn, appStatus: 'stopped' } }); + } + } else { + this.logger.error(`Failed to restore app ${appUrn}: ${message}`); + await this.appsRepository.updateAppById(app.id, { status: 'stopped' }); + } + }); + } + + public async getAppBackups(params: { appUrn: AppUrn; page: number; pageSize: number }) { + const { appUrn, page, pageSize } = params; + const backups = await this.backupManager.listBackupsByAppId(appUrn); + + backups.sort((a, b) => b.date - a.date); + + const start = (page - 1) * pageSize; + const end = start + pageSize; + const data = backups.slice(start, end); + + return { + data, + total: backups.length, + currentPage: Math.floor(start / pageSize) + 1, + lastPage: Math.ceil(backups.length / pageSize), + }; + } + + public async deleteAppBackup(params: { appUrn: AppUrn; filename: string }): Promise { + const { appUrn, filename } = params; + + await this.backupManager.deleteBackup(appUrn, filename); + } +} diff --git a/packages/backend/src/modules/backups/dto/backups.dto.ts b/packages/backend/src/modules/backups/dto/backups.dto.ts new file mode 100644 index 0000000000..34c4232071 --- /dev/null +++ b/packages/backend/src/modules/backups/dto/backups.dto.ts @@ -0,0 +1,38 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export class BackupDto extends createZodDto( + z.object({ + id: z.string(), + size: z.number(), + date: z.number(), + }), +) {} + +export class RestoreAppBackupDto extends createZodDto( + z.object({ + filename: z.string(), + }), +) {} + +export class GetAppBackupsDto extends createZodDto( + z.object({ + data: BackupDto.schema.array(), + total: z.number(), + currentPage: z.number(), + lastPage: z.number(), + }), +) {} + +export class GetAppBackupsQueryDto extends createZodDto( + z.object({ + page: z.coerce.number().optional(), + pageSize: z.coerce.number().optional(), + }), +) {} + +export class DeleteAppBackupBodyDto extends createZodDto( + z.object({ + filename: z.string(), + }), +) {} diff --git a/packages/backend/src/modules/docker/builders/compose.builder.ts b/packages/backend/src/modules/docker/builders/compose.builder.ts new file mode 100644 index 0000000000..29ae603c19 --- /dev/null +++ b/packages/backend/src/modules/docker/builders/compose.builder.ts @@ -0,0 +1,100 @@ +import type { AppEventFormInput } from '@/modules/queue/entities/app-events'; +import * as yaml from 'yaml'; +import { type Service, type ServiceInput, serviceSchema } from './schemas'; +import { type BuiltService, ServiceBuilder } from './service.builder'; +import { TraefikLabelsBuilder } from './traefik-labels.builder'; + +interface Network { + key?: string; + name: string; + external: boolean; +} + +export class DockerComposeBuilder { + private services: Record = {}; + private networks: Record = {}; + + private addService(service: BuiltService) { + this.services[service.container_name] = service; + return this; + } + + private addServices(services: BuiltService[]) { + for (const service of services) { + this.addService(service); + } + return this; + } + + private addNetwork(network: Network) { + this.networks[network.key || network.name] = { + name: network.name, + external: network.external, + }; + return this; + } + + private build() { + return yaml.stringify({ + services: this.services, + networks: this.networks, + }); + } + + private buildService = (params: Service, form: AppEventFormInput, storeId: string) => { + const service = new ServiceBuilder(); + service + .setImage(params.image) + .setName(`${params.name}_${storeId}`) + .setEnvironment(params.environment) + .setCommand(params.command) + .setHealthCheck(params.healthCheck) + .setDependsOn(params.dependsOn) + .addVolumes(params.volumes) + .setRestartPolicy('unless-stopped') + .addExtraHosts(params.extraHosts) + .addUlimits(params.ulimits) + .addPorts(params.addPorts) + .addNetwork('tipi_main_network') + .setNetworkMode(params.networkMode); + + if (params.isMain) { + if (!params.internalPort) { + throw new Error('Main service must have an internal port specified'); + } + + if (form.openPort) { + service.addPort({ + containerPort: params.internalPort, + hostPort: '${APP_PORT}', + }); + } + + const traefikLabels = new TraefikLabelsBuilder({ + internalPort: params.internalPort, + appId: params.name, + exposedLocal: form.exposedLocal, + exposed: form.exposed, + storeId, + }) + .addExposedLabels() + .addExposedLocalLabels(); + + service.setLabels(traefikLabels.build()); + } + + return service.build(); + }; + + public getDockerCompose = (services: ServiceInput[], form: AppEventFormInput, storeId: string) => { + const myServices = services.map((service) => this.buildService(serviceSchema.parse(service), form, storeId)); + + const dockerCompose = this.addServices(myServices).addNetwork({ + key: 'tipi_main_network', + name: 'runtipi_tipi_main_network', + external: true, + }); + + return dockerCompose.build(); + }; +} diff --git a/packages/worker/src/lib/docker/builders/schemas.ts b/packages/backend/src/modules/docker/builders/schemas.ts similarity index 85% rename from packages/worker/src/lib/docker/builders/schemas.ts rename to packages/backend/src/modules/docker/builders/schemas.ts index c93c99df45..a3556f5b08 100644 --- a/packages/worker/src/lib/docker/builders/schemas.ts +++ b/packages/backend/src/modules/docker/builders/schemas.ts @@ -44,7 +44,7 @@ export const serviceSchema = z.object({ }), ) .optional(), - environment: z.record(z.string()).optional(), + environment: z.record(z.union([z.string(), z.number()])).optional(), healthCheck: z .object({ test: z.string(), @@ -58,4 +58,10 @@ export const serviceSchema = z.object({ dependsOn: dependsOnSchema.optional(), }); +export const dynamicComposeSchema = z.object({ + services: serviceSchema.array(), +}); + export type DependsOn = z.output; +export type ServiceInput = z.input; +export type Service = z.output; diff --git a/packages/worker/src/lib/docker/builders/service-builder.ts b/packages/backend/src/modules/docker/builders/service.builder.ts similarity index 98% rename from packages/worker/src/lib/docker/builders/service-builder.ts rename to packages/backend/src/modules/docker/builders/service.builder.ts index 41ce2d02fb..57fc2ffcef 100644 --- a/packages/worker/src/lib/docker/builders/service-builder.ts +++ b/packages/backend/src/modules/docker/builders/service.builder.ts @@ -33,7 +33,7 @@ export interface BuilderService { image: string; containerName: string; restart: 'always' | 'unless-stopped' | 'on-failure'; - environment?: Record; + environment?: Record; command?: string | string[]; volumes?: string[]; ports?: string[]; @@ -222,7 +222,7 @@ export class ServiceBuilder { * service.setEnvironment({ key: 'value' }); * ``` */ - setEnvironment(environment?: Record) { + setEnvironment(environment?: Record) { if (environment) { this.service.environment = { ...this.service.environment, ...environment }; } diff --git a/packages/backend/src/modules/docker/builders/traefik-labels.builder.ts b/packages/backend/src/modules/docker/builders/traefik-labels.builder.ts new file mode 100644 index 0000000000..38e1a0a624 --- /dev/null +++ b/packages/backend/src/modules/docker/builders/traefik-labels.builder.ts @@ -0,0 +1,58 @@ +interface TraefikLabelsArgs { + internalPort: number; + appId: string; + exposedLocal?: boolean; + exposed?: boolean; + storeId: string; +} + +export class TraefikLabelsBuilder { + private labels: Record = {}; + + constructor(private params: TraefikLabelsArgs) { + this.labels = { + generated: true, + 'traefik.enable': false, + [`traefik.http.middlewares.${params.appId}_${params.storeId}-web-redirect.redirectscheme.scheme`]: 'https', + [`traefik.http.services.${params.appId}_${params.storeId}.loadbalancer.server.port`]: `${params.internalPort}`, + }; + } + + addExposedLabels() { + if (this.params.exposed) { + Object.assign(this.labels, { + 'traefik.enable': true, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-insecure.rule`]: 'Host(`${APP_DOMAIN}`)', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-insecure.entrypoints`]: 'web', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-insecure.service`]: `${this.params.appId}_${this.params.storeId}`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-insecure.middlewares`]: `${this.params.appId}_${this.params.storeId}-web-redirect`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}.rule`]: 'Host(`${APP_DOMAIN}`)', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}.entrypoints`]: 'websecure', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}.service`]: `${this.params.appId}_${this.params.storeId}`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}.tls.certresolver`]: 'myresolver', + }); + } + return this; + } + + addExposedLocalLabels() { + if (this.params.exposedLocal) { + Object.assign(this.labels, { + 'traefik.enable': true, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local-insecure.rule`]: `Host(\`${this.params.appId}-${this.params.storeId}.\${LOCAL_DOMAIN}\`)`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local-insecure.entrypoints`]: 'web', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local-insecure.service`]: `${this.params.appId}_${this.params.storeId}`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local-insecure.middlewares`]: `${this.params.appId}_${this.params.storeId}-web-redirect`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local.rule`]: `Host(\`${this.params.appId}-${this.params.storeId}.\${LOCAL_DOMAIN}\`)`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local.entrypoints`]: 'websecure', + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local.service`]: `${this.params.appId}_${this.params.storeId}`, + [`traefik.http.routers.${this.params.appId}_${this.params.storeId}-local.tls`]: true, + }); + } + return this; + } + + build() { + return this.labels; + } +} diff --git a/packages/backend/src/modules/docker/commands/app-logs-init.ts b/packages/backend/src/modules/docker/commands/app-logs-init.ts new file mode 100644 index 0000000000..9ea9e3bac1 --- /dev/null +++ b/packages/backend/src/modules/docker/commands/app-logs-init.ts @@ -0,0 +1,61 @@ +import { spawn } from 'node:child_process'; +import type { AppUrn } from '@/types/app/app.types'; +import type { Socket } from 'socket.io'; +import { type SocketEvent, socketEventSchema } from '../../../core/socket/socket-schemas'; +import type { DockerService } from '../docker.service'; +import { colorizeLogs } from '../helpers/colorize-logs'; +import type { DockerCommand } from './type'; + +export class AppLogsCommand implements DockerCommand { + constructor(private readonly dockerService: DockerService) {} + + async execute(socket: Socket, event: SocketEvent, emit: (event: SocketEvent) => Promise) { + const parsedEvent = socketEventSchema.safeParse(event); + + if (!parsedEvent.success) { + return; + } + + if (parsedEvent.data.type !== 'app-logs-init') { + return; + } + + const { appUrn, maxLines } = parsedEvent.data.data; + const { args } = await this.dockerService.getBaseComposeArgsApp(appUrn as AppUrn); + + args.push(`logs --follow -n ${maxLines || 25}`); + + const logsCommand = `docker-compose ${args.join(' ')}`; + + const logs = spawn('sh', ['-c', logsCommand]); + + socket.on('disconnect', () => { + logs.kill('SIGINT'); + }); + + socket.on('app-logs', (data) => { + if (data.event === 'stopLogs') { + logs.kill('SIGINT'); + } + }); + + logs.on('error', () => { + logs.kill('SIGINT'); + }); + + logs.stdout.on('data', async (data) => { + const lines = await colorizeLogs( + data + .toString() + .split(/(?:\r\n|\r|\n)/g) + .filter(Boolean), + ); + + await emit({ + type: 'app-logs', + event: 'newLogs', + data: { lines, appUrn }, + }); + }); + } +} diff --git a/packages/backend/src/modules/docker/commands/runtipi-logs-init.ts b/packages/backend/src/modules/docker/commands/runtipi-logs-init.ts new file mode 100644 index 0000000000..ce7c523ed1 --- /dev/null +++ b/packages/backend/src/modules/docker/commands/runtipi-logs-init.ts @@ -0,0 +1,61 @@ +import { spawn } from 'node:child_process'; +import type { Socket } from 'socket.io'; +import { type SocketEvent, socketEventSchema } from '../../../core/socket/socket-schemas'; +import type { DockerService } from '../docker.service'; +import { colorizeLogs } from '../helpers/colorize-logs'; +import type { DockerCommand } from './type'; + +export class RuntipiLogsCommand implements DockerCommand { + constructor(private readonly dockerService: DockerService) {} + + async execute(socket: Socket, event: SocketEvent, emit: (event: SocketEvent) => Promise) { + const { success, data } = socketEventSchema.safeParse(event); + + if (!success) { + return; + } + + if (data.type !== 'runtipi-logs-init') { + return; + } + + const { maxLines } = data.data; + + const args = await this.dockerService.getBaseComposeArgsRuntipi(); + + args.push(`logs --follow -n ${maxLines || 25}`); + + const logsCommand = `docker-compose ${args.join(' ')}`; + + const logs = spawn('sh', ['-c', logsCommand]); + + socket.on('disconnect', () => { + logs.kill('SIGINT'); + }); + + socket.on('runtipi-logs', (data) => { + if (data.event === 'stopLogs') { + logs.kill('SIGINT'); + } + }); + + logs.on('error', () => { + logs.kill('SIGINT'); + }); + + logs.stdout.on('data', async (data) => { + const lines = await colorizeLogs( + data + .toString() + .split(/(?:\r\n|\r|\n)/g) + .filter(Boolean), + ); + + await emit({ + type: 'runtipi-logs', + event: 'newLogs', + data: { lines }, + }); + }); + } +} diff --git a/packages/backend/src/modules/docker/commands/type.ts b/packages/backend/src/modules/docker/commands/type.ts new file mode 100644 index 0000000000..40e70f9b86 --- /dev/null +++ b/packages/backend/src/modules/docker/commands/type.ts @@ -0,0 +1,6 @@ +import type { Socket } from 'socket.io'; +import { type SocketEvent } from '../../../core/socket/socket-schemas'; + +export interface DockerCommand { + execute(Socket: Socket, event: SocketEvent, emit: (event: SocketEvent) => Promise): Promise; +} diff --git a/packages/backend/src/modules/docker/docker-command.factory.ts b/packages/backend/src/modules/docker/docker-command.factory.ts new file mode 100644 index 0000000000..3b572c9f4d --- /dev/null +++ b/packages/backend/src/modules/docker/docker-command.factory.ts @@ -0,0 +1,19 @@ +import { AppLogsCommand } from './commands/app-logs-init'; +import { RuntipiLogsCommand } from './commands/runtipi-logs-init'; +import type { DockerCommand } from './commands/type'; +import type { DockerService } from './docker.service'; + +export class DockerCommandFactory { + constructor(private readonly dockerService: DockerService) {} + + createCommand(event: string): DockerCommand | null { + switch (event) { + case 'app-logs-init': + return new AppLogsCommand(this.dockerService); + case 'runtipi-logs-init': + return new RuntipiLogsCommand(this.dockerService); + default: + return null; + } + } +} diff --git a/packages/backend/src/modules/docker/docker.module.ts b/packages/backend/src/modules/docker/docker.module.ts new file mode 100644 index 0000000000..bb7c7857a0 --- /dev/null +++ b/packages/backend/src/modules/docker/docker.module.ts @@ -0,0 +1,12 @@ +import { SocketModule } from '@/core/socket/socket.module'; +import { Module } from '@nestjs/common'; +import { AppStoreModule } from '../app-stores/app-store.module'; +import { AppsModule } from '../apps/apps.module'; +import { DockerService } from './docker.service'; + +@Module({ + imports: [AppsModule, AppStoreModule, SocketModule], + providers: [DockerService], + exports: [DockerService], +}) +export class DockerModule {} diff --git a/packages/backend/src/modules/docker/docker.service.ts b/packages/backend/src/modules/docker/docker.service.ts new file mode 100644 index 0000000000..43a654a8a4 --- /dev/null +++ b/packages/backend/src/modules/docker/docker.service.ts @@ -0,0 +1,117 @@ +import path from 'node:path'; +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { execAsync } from '@/common/helpers/exec-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { SocketManager } from '@/core/socket/socket.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { AppFilesManager } from '../apps/app-files-manager'; +import { DockerCommandFactory } from './docker-command.factory'; + +@Injectable() +export class DockerService { + dockerCommandFactory: DockerCommandFactory; + + constructor( + private readonly logger: LoggerService, + private readonly config: ConfigurationService, + private readonly appFilesManager: AppFilesManager, + private readonly socketManager: SocketManager, + private readonly filesystem: FilesystemService, + ) { + this.dockerCommandFactory = new DockerCommandFactory(this); + + const io = this.socketManager.init(); + + io.on('connection', async (socket) => { + socket.onAny((event, body) => { + const command = this.dockerCommandFactory.createCommand(event); + + if (command) { + command.execute(socket, body, this.socketManager.emit.bind(this.socketManager)); + } + }); + }); + } + + /** + * Get the base compose args for an app + * @param {string} appUrn - App name + */ + public getBaseComposeArgsApp = async (appUrn: AppUrn) => { + let isCustomConfig = false; + const { directories } = this.config.getConfig(); + + const { appStoreId } = extractAppUrn(appUrn); + + const appEnv = await this.appFilesManager.getAppEnv(appUrn); + const args: string[] = [`--env-file ${appEnv.path}`]; + + // User custom env file + const userEnvFile = await this.appFilesManager.getUserEnv(appUrn); + if (userEnvFile.content) { + isCustomConfig = true; + args.push(`--env-file ${userEnvFile.path}`); + } + + args.push(`--project-name ${appUrn.replace(':', '_')}`); + + const composeFile = await this.appFilesManager.getDockerComposeYaml(appUrn); + args.push(`-f ${composeFile.path}`); + + const commonComposeFile = path.join(directories.dataDir, 'repos', appStoreId, 'apps', 'docker-compose.common.yml'); + args.push(`-f ${commonComposeFile}`); + + // User defined overrides + const userComposeFile = await this.appFilesManager.getUserComposeFile(appUrn); + if (userComposeFile.content) { + isCustomConfig = true; + args.push(`--file ${userComposeFile.path}`); + } + + return { args, isCustomConfig }; + }; + + public getBaseComposeArgsRuntipi = async () => { + const { dataDir } = this.config.get('directories'); + const args: string[] = [`--env-file ${path.join(dataDir, '.env')}`]; + + args.push('--project-name runtipi'); + + const composeFile = path.join(dataDir, 'docker-compose.yml'); + args.push(`-f ${composeFile}`); + + // User defined overrides + const userComposeFile = path.join(dataDir, 'user-config', 'tipi-compose.yml'); + if (await this.filesystem.pathExists(userComposeFile)) { + args.push(`--file ${userComposeFile}`); + } + + return args; + }; + + /** + * Helpers to execute docker compose commands + * @param {string} appUrn - App name + * @param {string} command - Command to execute + */ + public composeApp = async (appUrn: AppUrn, command: string) => { + const { args, isCustomConfig } = await this.getBaseComposeArgsApp(appUrn); + args.push(command); + + this.logger.info(`Running docker compose with args ${args.join(' ')}`); + const { stdout, stderr } = await execAsync(`docker-compose ${args.join(' ')}`); + + if (stderr?.includes('Command failed:')) { + if (isCustomConfig) { + throw new Error(`Error with your custom app: ${stderr}`); + } + + throw new Error(stderr); + } + + return { stdout, stderr }; + }; +} diff --git a/packages/backend/src/modules/docker/helpers/colorize-logs.ts b/packages/backend/src/modules/docker/helpers/colorize-logs.ts new file mode 100644 index 0000000000..c19d744be0 --- /dev/null +++ b/packages/backend/src/modules/docker/helpers/colorize-logs.ts @@ -0,0 +1,14 @@ +import Convert from 'ansi-to-html'; + +const convert = new Convert(); + +export const colorizeLogs = async (lines: string[]) => + await Promise.all( + lines.map(async (line: string) => { + try { + return convert.toHtml(line); + } catch (e) { + return line; + } + }), + ); diff --git a/packages/backend/src/modules/env/env.module.ts b/packages/backend/src/modules/env/env.module.ts new file mode 100644 index 0000000000..db9803ffe2 --- /dev/null +++ b/packages/backend/src/modules/env/env.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { EnvUtils } from './env.utils'; + +@Module({ + imports: [], + providers: [EnvUtils], + exports: [EnvUtils], +}) +export class EnvModule {} diff --git a/packages/backend/src/modules/env/env.utils.ts b/packages/backend/src/modules/env/env.utils.ts new file mode 100644 index 0000000000..74c33d382f --- /dev/null +++ b/packages/backend/src/modules/env/env.utils.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import webpush from 'web-push'; +import fs from 'node:fs'; +import path from 'node:path'; +import { DATA_DIR } from '@/common/constants'; + +type RandomFieldEncoding = 'hex' | 'base64'; + +@Injectable() +export class EnvUtils { + public generateVapidKeys = () => { + const vapidKeys = webpush.generateVAPIDKeys(); + return { + publicKey: vapidKeys.publicKey, + privateKey: vapidKeys.privateKey, + }; + }; + + private getSeed = (): string => { + const seedFilePath = path.join(DATA_DIR, 'state', 'seed'); + if (!fs.existsSync(seedFilePath)) { + throw new Error('Seed file not found'); + } + return fs.readFileSync(seedFilePath, 'utf-8'); + }; + + public getArchitecture = () => { + const arch = os.arch(); + + if (arch === 'arm64') return 'arm64'; + if (arch === 'x64') return 'amd64'; + + throw new Error(`Unsupported architecture: ${arch}`); + }; + + /** + * 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. + * It then returns a substring of the resulting hash of the provided length. + * + * @param {string} name - A name used as input for the hash algorithm. + * @param {number} length - The desired length of the random string. + */ + public createRandomString = (name: string, length: number, encoding: RandomFieldEncoding = 'hex') => { + const seed = this.getSeed(); + const hash = crypto.createHash('sha256'); + + hash.update(name + seed.toString()); + + if (encoding === 'base64') { + // Generate the hash and slice the buffer to get the exact number of bytes + const randomBytes = hash.digest().slice(0, length); + return randomBytes.toString('base64'); + } + + return hash.digest('hex').substring(0, length); + }; + + /** + * Derives a new entropy value from the provided entropy and the seed + * @param {string} entropy - The entropy value to derive from + */ + public deriveEntropy = (entropy: string): string => { + const seed = this.getSeed(); + const hmac = crypto.createHmac('sha256', seed); + hmac.update(entropy); + return hmac.digest('hex'); + }; + + /** + * Convert a Map of environment variables to a valid string of environment variables + * that can be used in a .env file + * + * @param {Map} envMap - Map of environment variables + */ + public envMapToString = (envMap: Map) => { + const envArray = Array.from(envMap).map(([key, value]) => `${key}=${value}`); + return envArray.join('\n'); + }; + + /** + * Convert a string of environment variables to a Map + * + * @param {string} envString - String of environment variables + */ + public envStringToMap = (envString: string) => { + const envMap = new Map(); + const envArray = envString.split('\n'); + + for (const env of envArray) { + if (env.startsWith('#')) continue; + + const [key, ...rest] = env.split('='); + + if (key && rest.length) envMap.set(key, rest.join('=')); + } + + return envMap; + }; +} diff --git a/packages/backend/src/modules/i18n/i18n.controller.ts b/packages/backend/src/modules/i18n/i18n.controller.ts new file mode 100644 index 0000000000..8c436cc144 --- /dev/null +++ b/packages/backend/src/modules/i18n/i18n.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { I18nService } from './i18n.service'; + +@Controller('i18n') +export class I18nController { + constructor(private readonly i18nService: I18nService) {} + + @Get('/locales/:ns/:lng.json') + async getTranslation(@Param('ns') ns: string, @Param('lng') lng: string) { + const translations = await this.i18nService.getTranslation(lng, ns); + return translations || {}; + } +} diff --git a/packages/backend/src/modules/i18n/i18n.module.ts b/packages/backend/src/modules/i18n/i18n.module.ts new file mode 100644 index 0000000000..11ba947146 --- /dev/null +++ b/packages/backend/src/modules/i18n/i18n.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { I18nController } from './i18n.controller'; +import { I18nService } from './i18n.service'; + +@Module({ + imports: [], + controllers: [I18nController], + providers: [I18nService], +}) +export class I18nModule {} diff --git a/packages/backend/src/modules/i18n/i18n.service.ts b/packages/backend/src/modules/i18n/i18n.service.ts new file mode 100644 index 0000000000..f829caf862 --- /dev/null +++ b/packages/backend/src/modules/i18n/i18n.service.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import i18n from 'i18next'; +import Backend, { type FsBackendOptions } from 'i18next-fs-backend'; + +@Injectable() +export class I18nService { + constructor() { + let directory = path.join(process.cwd(), 'assets', 'translations'); + + if (process.env.NODE_ENV !== 'production') { + directory = path.join(process.cwd(), 'src', 'modules', 'i18n', 'translations'); + } + + i18n.use(Backend).init({ + initAsync: false, + fallbackLng: 'en', + lng: 'en', + preload: fs.readdirSync(directory).map((file) => file.replace('.json', '')), + backend: { + loadPath: path.join(directory, '{{lng}}.json'), + }, + }); + } + + async getTranslation(language: string, namespace: string) { + // Load translation for specific language and namespace + return i18n.getResourceBundle(language, namespace); + } +} diff --git a/src/client/messages/af-ZA.json b/packages/backend/src/modules/i18n/translations/af-ZA.json similarity index 77% rename from src/client/messages/af-ZA.json rename to packages/backend/src/modules/i18n/translations/af-ZA.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/af-ZA.json +++ b/packages/backend/src/modules/i18n/translations/af-ZA.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/ar-SA.json b/packages/backend/src/modules/i18n/translations/ar-SA.json similarity index 75% rename from src/client/messages/ar-SA.json rename to packages/backend/src/modules/i18n/translations/ar-SA.json index 26c2547545..2569045725 100644 --- a/src/client/messages/ar-SA.json +++ b/packages/backend/src/modules/i18n/translations/ar-SA.json @@ -28,7 +28,7 @@ "APP_DETAILS_BASE_INFO": "معلومات أساسية", "APP_DETAILS_CATEGORIES_TITLE": "الأقسام", "APP_DETAILS_CHOOSE_OPEN_METHOD": "اختر طريقة الفتح", - "APP_DETAILS_DEPRECATED_ALERT_SUBTITLE": "تغيير كبير في هذا التطبيق يمنع تحديثه تلقائيًا. يمكنك ما زلت استخدام هذه النسخة وتحديثها يدويًا، ولكن من المُوصى بالتحول إلى نسخة أحدث وهجرة بياناتك. يمكنك العثور على نسخة محدثة في متجر التطبيقات تحت نفس الاسم.", + "APP_DETAILS_DEPRECATED_ALERT_SUBTITLE": "تغيير كبير في هذا التطبيق يمنع تحديثه تلقائيا. يمكنك ما زلت استخدام هذه النسخة وتحديثها يدويا، ولكن من الموصى بالتحول إلى نسخة أحدث وهجرة بياناتك. يمكنك العثور على نسخة محدثة في متجر التطبيقات تحت نفس الاسم.", "APP_DETAILS_DEPRECATED_ALERT_TITLE": "تم إلغاء الدعم لي هذا التطبيق", "APP_DETAILS_DESCRIPTION": "الوصف", "APP_DETAILS_LINK": "رابط", @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "تفاصيل التطبيق", "APP_DETAILS_VERSION": "الإصدار", "APP_DETAILS_WEBSITE": "موقع", - "APP_ERROR_APP_FAILED_TO_INSTALL": "فشل تثبيت التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_RESET": "فشل إعادة تعيين التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_START": "فشل تشغيل التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_STOP": "فشل إيقاف التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_RESTART": "فشل إعادة تشغيل التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "فشل شتب التطبيق {id}، أنظر إلى السجلات للمزيد من التفاصيل", - "APP_ERROR_APP_FAILED_TO_UPDATE": "فشل تحديث التطبيق {id}، أنظر إلى السجلات لمزيد من التفاصيل", - "APP_ERROR_APP_FORCE_EXPOSED": "التطبيق {id} يشتغل فقط مع الdomain المكشوف", - "APP_ERROR_APP_NOT_EXPOSABLE": "التطبيق {id} غير قابل للكشف", - "APP_ERROR_APP_NOT_FOUND": "لم يتم العثور على التطبيق {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "الarchitecture الذي ألك {arch} غير مدعومة من التطبيق {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "{domain} مستعمل من التطبيق {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "{domain} ليس صالحًا", + "APP_ERROR_APP_FAILED_TO_INSTALL": "فشل تثبيت التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_RESET": "فشل إعادة تعيين التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_START": "فشل تشغيل التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_STOP": "فشل إيقاف التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_RESTART": "فشل إعادة تشغيل التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "فشل شتب التطبيق {{id}}، أنظر إلى السجلات للمزيد من التفاصيل", + "APP_ERROR_APP_FAILED_TO_UPDATE": "فشل تحديث التطبيق {{id}}، أنظر إلى السجلات لمزيد من التفاصيل", + "APP_ERROR_APP_FORCE_EXPOSED": "التطبيق {{id}} يشتغل فقط مع الdomain المكشوف", + "APP_ERROR_APP_NOT_EXPOSABLE": "التطبيق {{id}} غير قابل للكشف", + "APP_ERROR_APP_NOT_FOUND": "لم يتم العثور على التطبيق {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "الarchitecture الذي ألك {{arch}} غير مدعومة من التطبيق {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "{{domain}} مستعمل من التطبيق {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "{{domain}} ليس صالحا", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "الDomain مطلوب إذا التطبيق مكشوف", - "APP_ERROR_INVALID_CONFIG": "التطبيق {id} لديه مِلَفّ config.json غير صالح", + "APP_ERROR_INVALID_CONFIG": "التطبيق {{id}} لديه ملف config.json غير صالح", "APP_INSTALL_FORM_CHOOSE_OPTION": "اختر خيار...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "اعرض على لوحة تحكم الضيف", "APP_INSTALL_FORM_DOMAIN_NAME": "الDomain", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "تأكد هذا الDomain الدقيق يحتوي على سجل A يشير إلى عنوان IP الخاص بك.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} يجب أن يكون بين {min} و {max} أحرف", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} يجب أن يكون Domain صالح", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} يجب أن يكون Domain صالح أو عنوان IP", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} يجب أن تكون عنوان بريد إلكتروني صالح", - "APP_INSTALL_FORM_ERROR_IP": "{label} يجب أن تكون عنوان IP صالح", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} يجب أن يكون أقل من {max} أحرف", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} يجب أن يكون على الأقل {min} حرفًا", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} يجب أن يكون رقم", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} يجب أن يتطابق مع النمط {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} مطلوب", - "APP_INSTALL_FORM_ERROR_URL": "{label} يجب أن يكون عنوان URL صالح", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} يجب أن يكون بين {{min}} و {{max}} أحرف", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} يجب أن يكون Domain صالح", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} يجب أن يكون Domain صالح أو عنوان IP", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} يجب أن تكون عنوان بريد إلكتروني صالح", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} يجب أن تكون عنوان IP صالح", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} يجب أن يكون أقل من {{max}} أحرف", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} يجب أن يكون على الأقل {{min}} حرفا", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} يجب أن يكون رقم", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} يجب أن يتطابق مع النمط {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} مطلوب", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} يجب أن يكون عنوان URL صالح", "APP_INSTALL_FORM_EXPOSE_APP": "اكشف التطبيق على الإنترنت", "APP_INSTALL_FORM_OPEN_PORT": "افتح منفذ", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/ca-ES.json b/packages/backend/src/modules/i18n/translations/ca-ES.json similarity index 79% rename from src/client/messages/ca-ES.json rename to packages/backend/src/modules/i18n/translations/ca-ES.json index cb37c7bca9..cf55ab8a31 100644 --- a/src/client/messages/ca-ES.json +++ b/packages/backend/src/modules/i18n/translations/ca-ES.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Detalls de l'aplicació", "APP_DETAILS_VERSION": "Versió", "APP_DETAILS_WEBSITE": "Lloc web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Ha fallat la instal·lació de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FAILED_TO_RESET": "Ha fallat l'inici de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FAILED_TO_START": "Ha fallat l'inici de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FAILED_TO_STOP": "Ha fallat l'aturada de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Ha fallat la desinstal·lació de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Ha fallat l'actualització de l'aplicació {id}, consulta els logs pels detalls", - "APP_ERROR_APP_FORCE_EXPOSED": "L'aplicació {id} només funciona amb un domini exposat", - "APP_ERROR_APP_NOT_EXPOSABLE": "L'aplicació {id} no es pot exposar", - "APP_ERROR_APP_NOT_FOUND": "No s'ha trobat l'aplicació {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "L'aplicació {id} ja està utilitzant el domini {domain}", - "APP_ERROR_DOMAIN_NOT_VALID": "El domini {domain} no és un nom vàlid de domini", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Ha fallat la instal·lació de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FAILED_TO_RESET": "Ha fallat l'inici de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FAILED_TO_START": "Ha fallat l'inici de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FAILED_TO_STOP": "Ha fallat l'aturada de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Ha fallat la desinstal·lació de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Ha fallat l'actualització de l'aplicació {{id}}, consulta els logs pels detalls", + "APP_ERROR_APP_FORCE_EXPOSED": "L'aplicació {{id}} només funciona amb un domini exposat", + "APP_ERROR_APP_NOT_EXPOSABLE": "L'aplicació {{id}} no es pot exposar", + "APP_ERROR_APP_NOT_FOUND": "No s'ha trobat l'aplicació {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "L'aplicació {{id}} ja està utilitzant el domini {{domain}}", + "APP_ERROR_DOMAIN_NOT_VALID": "El domini {{domain}} no és un nom vàlid de domini", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Cal un nom de domini si s'exposa l'aplicació", - "APP_ERROR_INVALID_CONFIG": "L'app {id} té un fitxer config.json invàlid", + "APP_ERROR_INVALID_CONFIG": "L'app {{id}} té un fitxer config.json invàlid", "APP_INSTALL_FORM_CHOOSE_OPTION": "Escull una opció...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Nom de domini", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Assegura't que aquest domini concret conté un registre A que apunta a la teva adreça IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} ha de tenir entre {min} i {max} caràcters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} ha de ser un domini vàlid", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} ha de ser un domini o una adreça IP vàlida", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} ha de ser una adreça de correu vàlida", - "APP_INSTALL_FORM_ERROR_IP": "{label} ha de ser una adreça IP vàlida", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} ha de tenir menys de {max} caràcters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} ha de tenir almenys {min} caràcters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} ha de ser un número", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} ha de coincidir amb el patró {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} és obligatori", - "APP_INSTALL_FORM_ERROR_URL": "{label} ha de ser una URL vàlida", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} ha de tenir entre {{min}} i {{max}} caràcters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} ha de ser un domini vàlid", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} ha de ser un domini o una adreça IP vàlida", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} ha de ser una adreça de correu vàlida", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} ha de ser una adreça IP vàlida", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} ha de tenir menys de {{max}} caràcters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} ha de tenir almenys {{min}} caràcters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} ha de ser un número", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} ha de coincidir amb el patró {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} és obligatori", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} ha de ser una URL vàlida", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Instal·la", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Actualitza", - "APP_INSTALL_FORM_TITLE": "Instal·la {name}", + "APP_INSTALL_FORM_TITLE": "Instal·la {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "Tota la informació d'aquesta aplicació es perdrà.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "N'esteu segur? Aquesta acció no es pot desfer.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Atura", "APP_STOP_FORM_SUBTITLE": "Totes les dades es conservaran", - "APP_STOP_FORM_TITLE": "Atura {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Atura {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No s'ha trobat cap aplicació", "APP_STORE_NO_RESULTS_SUBTITLE": "Intenta afinar la teva cerca", "APP_STORE_SEARCH_PLACEHOLDER": "Cerca aplicacions", "APP_STORE_TITLE": "Botiga d'aplicacions", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Desinstal·la", "APP_UNINSTALL_FORM_SUBTITLE": "Tota la informació d'aquesta aplicació es perdrà.", - "APP_UNINSTALL_FORM_TITLE": "Desinstal·la {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Desinstal·la {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "N'esteu segur? Aquesta acció no es pot desfer.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "La configuració de l'aplicació s'ha actualitzat correctament. Reinicia l'aplicació per aplicar els canvis", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Actualitza", "APP_UPDATE_FORM_SUBTITLE_1": "Actualitza l'aplicació a l'última versió:", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Actualitza {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualitza la configuració {name}", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Actualitza {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualitza la configuració {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Ja hi ha un usuari admin. Per favor, inicia la sessió per crear un nou usuari des del panell d'administració.", "AUTH_ERROR_ERROR_CREATING_USER": "S'ha produït un error en crear l'usuari", "AUTH_ERROR_INVALID_CREDENTIALS": "Les credencials no són vàlides", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel·la la sol·licitud de canvi de contrasenya", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Restableix la contrasenya", - "AUTH_RESET_PASSWORD_SUCCESS": "La teva contrasenya s'ha restablert. Ara pots iniciar sessió amb la nova contrasenya. I el correu electrònic {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "La teva contrasenya s'ha restablert. Ara pots iniciar sessió amb la nova contrasenya. I el correu electrònic {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "La contrasenya s'ha restablert", "AUTH_RESET_PASSWORD_TITLE": "Restableix la contrasenya", "AUTH_TOTP_INSTRUCTIONS": "Introduïu el codi de la vostra aplicació d'autenticació", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Desinstal·la aplicacions per reduir la càrrega", "DASHBOARD_CPU_TITLE": "Càrrega de CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilitzats del total de {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilitzats del total de {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Espai de disc", "DASHBOARD_MEMORY_TITLE": "Memoria en ús", "DASHBOARD_TITLE": "Panell de control", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "\"Locale\" incorrecte", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "No permès en mode demo", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "No permès en mode dev", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Ja està actualitzat", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versió actual: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versió actual: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Accions comunes per realitzar a la teva instància", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Manteniment", - "SETTINGS_ACTIONS_NEW_VERSION": "Hi ha disponible una nova versió ({version}) de Tipi", + "SETTINGS_ACTIONS_NEW_VERSION": "Hi ha disponible una nova versió ({{version}}) de Tipi", "SETTINGS_ACTIONS_RESTART": "Reinicia", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Estigues actualitzat amb l'última versió de Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Accions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/cs-CZ.json b/packages/backend/src/modules/i18n/translations/cs-CZ.json similarity index 77% rename from src/client/messages/cs-CZ.json rename to packages/backend/src/modules/i18n/translations/cs-CZ.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/cs-CZ.json +++ b/packages/backend/src/modules/i18n/translations/cs-CZ.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/da-DK.json b/packages/backend/src/modules/i18n/translations/da-DK.json similarity index 77% rename from src/client/messages/da-DK.json rename to packages/backend/src/modules/i18n/translations/da-DK.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/da-DK.json +++ b/packages/backend/src/modules/i18n/translations/da-DK.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/de-DE.json b/packages/backend/src/modules/i18n/translations/de-DE.json similarity index 72% rename from src/client/messages/de-DE.json rename to packages/backend/src/modules/i18n/translations/de-DE.json index 790914375d..f96fd2368d 100644 --- a/src/client/messages/de-DE.json +++ b/packages/backend/src/modules/i18n/translations/de-DE.json @@ -16,8 +16,8 @@ "APP_CATEGORY_DEVELOPMENT": "Entwicklung", "APP_CATEGORY_FEATURED": "Empfohlen", "APP_CATEGORY_FINANCE": "Finanzen", - "APP_CATEGORY_GAMING": "Gaming", - "APP_CATEGORY_MEDIA": "Media", + "APP_CATEGORY_GAMING": "Spiele", + "APP_CATEGORY_MEDIA": "Medien", "APP_CATEGORY_MUSIC": "Musik", "APP_CATEGORY_NETWORK": "Netzwerk", "APP_CATEGORY_PHOTOGRAPHY": "Fotografie", @@ -38,64 +38,64 @@ "APP_DETAILS_TITLE": "App-Details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Webseite", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Installieren der App {id} fehlgeschlagen, schau dir die Logs an für weitere Informationen", - "APP_ERROR_APP_FAILED_TO_RESET": "Zurücksetzen der App {id} fehlgeschlagen, schau dir die Logs an für weitere Details", - "APP_ERROR_APP_FAILED_TO_START": "Starten der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen", - "APP_ERROR_APP_FAILED_TO_STOP": "Stoppen der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen", - "APP_ERROR_APP_FAILED_TO_RESTART": "Neustart der App {id} fehlgeschlagen, schau dir die Logs an für weitere Details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Deinstallieren der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Aktualisieren der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} funktioniert nur mit veröffentlichter Domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} nicht veröffentlichbar", - "APP_ERROR_APP_NOT_FOUND": "App {id} nicht gefunden", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Deine Architektur {arch} wird von der App {id} nicht unterstützt", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Die Domain {domain} ist bereits in Verwendung von der App {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Die Domain {domain} ist ungültig", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Installieren der App {{id}} fehlgeschlagen, schau dir die Logs an für weitere Informationen", + "APP_ERROR_APP_FAILED_TO_RESET": "Zurücksetzen der App {{id}} fehlgeschlagen, schau dir die Logs an für weitere Details", + "APP_ERROR_APP_FAILED_TO_START": "Starten der App {{id}} fehlgeschlagen. Siehe die Logs für weitere Informationen", + "APP_ERROR_APP_FAILED_TO_STOP": "Stoppen der App {{id}} fehlgeschlagen. Siehe die Logs für weitere Informationen", + "APP_ERROR_APP_FAILED_TO_RESTART": "Neustart der App {{id}} fehlgeschlagen, schau dir die Logs an für weitere Details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Deinstallieren der App {{id}} fehlgeschlagen. Siehe die Logs für weitere Informationen", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Aktualisieren der App {{id}} fehlgeschlagen. Siehe die Logs für weitere Informationen", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} funktioniert nur mit veröffentlichter Domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} nicht veröffentlichbar", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} nicht gefunden", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Deine Architektur {{arch}} wird von der App {{id}} nicht unterstützt", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Die Domain {{domain}} ist bereits in Verwendung von der App {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Die Domain {{domain}} ist ungültig", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Eine Domain ist erforderlich, wenn die App freigegeben ist", - "APP_ERROR_INVALID_CONFIG": "Die App {id} hat eine ungültige config.json Datei", + "APP_ERROR_INVALID_CONFIG": "Die App {{id}} hat eine ungültige config.json Datei", "APP_INSTALL_FORM_CHOOSE_OPTION": "Wähle eine Option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Anzeige auf dem Gast-Dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domainname", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Stelle sicher, dass genau diese Domain einen A-Record enthält, der auf deine IP zeigt.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} muss zwischen {min} und {max} Zeichen lang sein", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} muss eine gültige Domain sein", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} muss eine gültige Domain oder IP-Adresse sein", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} muss eine gültige E-Mail-Adresse sein", - "APP_INSTALL_FORM_ERROR_IP": "{label} muss eine gültige IP-Adresse sein", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} darf nicht mehr als {max} Zeichen enthalten", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} muss mindestens {min} Zeichen lang sein", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} muss eine Zahl sein", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} stimmt nicht mit dem Format {pattern} überein", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} ist erforderlich", - "APP_INSTALL_FORM_ERROR_URL": "{label} muss eine gültige URL sein", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} muss zwischen {{min}} und {{max}} Zeichen lang sein", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} muss eine gültige Domain sein", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} muss eine gültige Domain oder IP-Adresse sein", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} muss eine gültige E-Mail-Adresse sein", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} muss eine gültige IP-Adresse sein", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} darf nicht mehr als {{max}} Zeichen enthalten", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} muss mindestens {{min}} Zeichen lang sein", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} muss eine Zahl sein", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} stimmt nicht mit dem Format {{pattern}} überein", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} ist erforderlich", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} muss eine gültige URL sein", "APP_INSTALL_FORM_EXPOSE_APP": "App im Internet verfügbar machen", "APP_INSTALL_FORM_OPEN_PORT": "Port öffnen", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Einen Port auf dem Host öffnen? Diese App wird unter {internalIp}:{port} erreichbar sein. (Einfachste, aber weniger sichere Methode)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Einen Port auf dem Host öffnen? Diese App wird unter {{internalIp}}:{{port}} erreichbar sein. (Einfachste, aber weniger sichere Methode)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Die App im lokalen Netzwerk freigeben", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Die App im lokalen Netzwerk freigeben? Diese App wird unter {appId}.{domain} erreichbar sein. (Besuche die Einstellungsseite, um deine lokale Domain einzurichten)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Die App im lokalen Netzwerk freigeben? Diese App wird unter {{appId}}.{{domain}} erreichbar sein. (Besuche die Einstellungsseite, um deine lokale Domain einzurichten)", "APP_INSTALL_FORM_RESET": "App zurücksetzen", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Installieren", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Aktualisieren", - "APP_INSTALL_FORM_TITLE": "{name} installieren", + "APP_INSTALL_FORM_TITLE": "{{name}} installieren", "APP_INSTALL_FORM_GENERAL": "Allgemein", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse Proxy", - "APP_INSTALL_SUCCESS": "App- {id} erfolgreich installiert", + "APP_INSTALL_SUCCESS": "App- {{id}} erfolgreich installiert", "APP_LOGS_TAB_FOLLOW": "Protokolle verfolgen", "APP_LOGS_TAB_MAX_LINES": "Maximale Zeilen:", - "APP_LOGS_TAB_TITLE": "Logs", + "APP_LOGS_TAB_TITLE": "Log-Dateien", "APP_LOGS_TAB_WRAP_LINES": "Zeilen umbrechen", "APP_NEW": "NEU", "APP_RESET_FORM_SUBMIT": "Zurücksetzen", "APP_RESET_FORM_SUBTITLE": "Alle Daten für diese App gehen verloren.", - "APP_RESET_FORM_TITLE": "{name} zurücksetzen?", + "APP_RESET_FORM_TITLE": "{{name}} zurücksetzen?", "APP_RESET_FORM_WARNING": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.", - "APP_RESET_SUCCESS": "App {id} erfolgreich zurückgesetzt", - "APP_START_SUCCESS": "App {id} erfolgreich gestartet", - "APP_BACKUP_TITLE": "Sichere {name}", + "APP_RESET_SUCCESS": "App {{id}} erfolgreich zurückgesetzt", + "APP_START_SUCCESS": "App {{id}} erfolgreich gestartet", + "APP_BACKUP_TITLE": "Sichere {{name}}", "APP_BACKUP_SUBTITLE": "Ein Tar-Archiv wird im Backup-ordner erstellt, um die Daten deiner App zu speichern.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Backup {name} wiederherstellen", - "APP_RESTORE_WARNING": "Möchtest du das Backup {id} vom {date} wirklich wiederherstellen?", + "APP_RESTORE_TITLE": "Backup {{name}} wiederherstellen", + "APP_RESTORE_WARNING": "Möchtest du das Backup {{id}} vom {{date}} wirklich wiederherstellen?", "APP_RESTORE_SUBTITLE": "Alle aktuellen Daten der App werden gelöscht und durch die Daten aus dem Backup ersetzt. Es wird empfohlen, deine App vor der Wiederherstellung zu sichern.", "APP_RESTORE_SUBMIT": "Wiederherstellen", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Wiederherstellen", "APP_STOP_FORM_SUBMIT": "Anhalten", "APP_STOP_FORM_SUBTITLE": "Alle Daten werden aufbewahrt", - "APP_STOP_FORM_TITLE": "{name} anhalten?", - "APP_STOP_SUCCESS": "App {id} erfolgreich angehalten", + "APP_STOP_FORM_TITLE": "{{name}} anhalten?", + "APP_STOP_SUCCESS": "App {{id}} erfolgreich angehalten", "APP_RESTART_FORM_SUBMIT": "Neu starten", "APP_RESTART_FORM_SUBTITLE": "Alle Daten bleiben erhalten", - "APP_RESTART_FORM_TITLE": "{name} neu starten?", - "APP_RESTART_SUCCESS": "App {id} erfolgreich neu gestartet", + "APP_RESTART_FORM_TITLE": "{{name}} neu starten?", + "APP_RESTART_SUCCESS": "App {{id}} erfolgreich neu gestartet", "APP_STORE_CATEGORY_PLACEHOLDER": "Kategorie wählen", "APP_STORE_NO_RESULTS": "Keine App gefunden", "APP_STORE_NO_RESULTS_SUBTITLE": "Versuche, deine Suche zu verbessern", "APP_STORE_SEARCH_PLACEHOLDER": "Apps suchen", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Bearbeiten", + "APP_STORE_TABLE_DELETE": "Löschen", + "APP_STORE_EDIT_DIALOG_TITLE": "App Store bearbeiten", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Speichern", + "APP_STORE_EDIT_DIALOG_SUCCESS": "App Store erfolgreich bearbeitet", + "APP_STORE_EDIT_DIALOG_ENABLED": "Aktiviert", + "APP_STORE_DELETE_DIALOG_TITLE": "App Store löschen", + "APP_STORE_DELETE_DIALOG_WARNING": "Sind Sie sicher, dass Sie den App Store {{name}} löschen möchten?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Löschen", + "APP_STORE_DELETE_SUCCESS": "App Store erfolgreich gelöscht", + "APP_STORE_DELETE_ERROR_LAST_STORE": "Der letzte App Store kann nicht gelöscht werden", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "Es gibt installierte Apps, die zu diesem App Store gehören. Bitte deinstallieren Sie diese zuerst", + "APP_STORE_ADD_DIALOG_TITLE": "App Store hinzufügen", + "APP_STORE_ADD_FORM_NAME": "App Store Name", + "APP_STORE_ADD_FORM_URL": "App Store URL", + "APP_STORE_ADD_FORM_SUBMIT": "Neuen App Store erstellen", + "APP_STORE_ADD_SUCCESS": "App Store erfolgreich hinzugefügt", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Backups entfernen", "APP_UNINSTALL_FORM_SUBMIT": "Deinstallieren", "APP_UNINSTALL_FORM_SUBTITLE": "Alle Daten für diese Anwendung werden gelöscht.", - "APP_UNINSTALL_FORM_TITLE": "{name} deinstallieren?", + "APP_UNINSTALL_FORM_TITLE": "{{name}} deinstallieren?", "APP_UNINSTALL_FORM_WARNING": "Sind Sie sicher? Dieser Schritt kann nicht rückgängig gemacht werden.", - "APP_UNINSTALL_SUCCESS": "App {id} erfolgreich deinstalliert", + "APP_UNINSTALL_SUCCESS": "App {{id}} erfolgreich deinstalliert", "APP_UPDATE_CONFIG_SUCCESS": "App-Konfiguration erfolgreich aktualisiert. Starte die App neu, um die Änderungen zu übernehmen", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Update der App {id} erfordert Tipi-Version {minVersion} oder höher. Bitte aktualisiere deine Instanz.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Update der App {{id}} erfordert Tipi-Version {{minVersion}} oder höher. Bitte aktualisiere deine Instanz.", "APP_UPDATE_FORM_SUBMIT": "Aktualisieren", "APP_UPDATE_FORM_SUBTITLE_1": "App auf die neueste Version aktualisieren:", "APP_UPDATE_FORM_SUBTITLE_2": "Dies wird Ihre benutzerdefinierte Konfiguration zurücksetzen (z.B. Änderungen in docker-compose.yml).", - "APP_UPDATE_FORM_TITLE": "Aktualisieren {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Aktualisieren {name} Konfiguration", - "APP_UPDATE_SUCCESS": "App {id} erfolgreich aktualisiert", + "APP_UPDATE_FORM_TITLE": "Aktualisieren {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Aktualisieren {{name}} Konfiguration", + "APP_UPDATE_SUCCESS": "App {{id}} erfolgreich aktualisiert", "APP_UPDATE_FORM_BACKUP": "App vor dem Aktualisieren sichern", - "APP_BACKUP_SUCCESS": "App {id} erfolgreich gesichert", - "APP_BACKUP_ERROR": "Backup der App {id} fehlgeschlagen, schau dir die Logs an für weitere Informationen", - "APP_RESTORE_SUCCESS": "App {id} erfolgreich wiederhergestellt", - "APP_RESTORE_ERROR": "Wiederherstellen der App {id} fehlgeschlagen, schau dir die Logs an für weitere Informationen", + "APP_BACKUP_SUCCESS": "App {{id}} erfolgreich gesichert", + "APP_BACKUP_ERROR": "Backup der App {{id}} fehlgeschlagen, schau dir die Logs an für weitere Informationen", + "APP_RESTORE_SUCCESS": "App {{id}} erfolgreich wiederhergestellt", + "APP_RESTORE_ERROR": "Wiederherstellen der App {{id}} fehlgeschlagen, schau dir die Logs an für weitere Informationen", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Es existiert bereits ein Admin-Benutzer. Bitte melde dich an, um einen neuen Benutzer im Admin-Panel zu erzeugen.", "AUTH_ERROR_ERROR_CREATING_USER": "Fehler bei der Erstellung des Nutzers", "AUTH_ERROR_INVALID_CREDENTIALS": "Ungültige Zugangsdaten", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Passwortänderung abbrechen", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Um zu beginnen, führe diesen Befehl auf deinem Server aus und aktualisiere dann diese Seite. Falls du das bereits zuvor getan hast, könnte die Anforderung zum Zurücksetzen des Passworts abgelaufen sein. In diesem Fall versuche es bitte erneut", "AUTH_RESET_PASSWORD_SUBMIT": "Zurücksetzen", - "AUTH_RESET_PASSWORD_SUCCESS": "Ihr Passwort wurde gespeichert. Sie können sich nun mit Ihrem neuen Passwort und ihrer E-Mail {email} anmelden", + "AUTH_RESET_PASSWORD_SUCCESS": "Ihr Passwort wurde gespeichert. Sie können sich nun mit Ihrem neuen Passwort und ihrer E-Mail {{email}} anmelden", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Passwort zurückgesetzt", "AUTH_RESET_PASSWORD_TITLE": "Passwort zurücksetzen", "AUTH_TOTP_INSTRUCTIONS": "Code aus der Authenticator-App eingeben", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Größe", "BACKUPS_LIST_DELETE_SUCCESS": "Backup erfolgreich gelöscht", "COMMON_CLOSE": "Schließen", + "COMMON_WARNING": "Warnung", "DASHBOARD_CPU_SUBTITLE": "Apps deinstallieren um Last zu reduzieren", "DASHBOARD_CPU_TITLE": "CPU Last", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Wird von {total} GB verwendet", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Wird von {{total}} GB verwendet", "DASHBOARD_DISK_SPACE_TITLE": "Speicherplatz", "DASHBOARD_MEMORY_TITLE": "Benutzter Speicher", - "DASHBOARD_TITLE": "Dashboard", + "DASHBOARD_TITLE": "Übersicht", "DASHBOARD_IP_WARNING_TITLE": "Unsichere Konfiguration", "DASHBOARD_IP_WARNING": "Warnung, du könntest gefährdet sein! Es sieht so aus, als würdest du auf deine Instanz über eine öffentliche IP-Adresse zugreifen. Dies macht dein Dashboard und alle installierten Apps anfällig für Angreifer", "DELETE_BACKUP_MODAL_TITLE": "Backup löschen", - "DELETE_BACKUP_MODAL_WARNING": "Bist du sicher, dass du das Backup {id} vom {date} löschen möchtest?", + "DELETE_BACKUP_MODAL_WARNING": "Bist du sicher, dass du das Backup {{id}} vom {{date}} löschen möchtest?", "DELETE_BACKUP_MODAL_SUBTITLE": "Diese Aktion kann nicht rückgängig gemacht werden", "DELETE_BACKUP_MODAL_SUBMIT": "Löschen", "GUEST_DASHBOARD": "Gast-Dashboard", @@ -216,7 +235,7 @@ "HEADER_APPS": "Meine Apps", "HEADER_APP_STORE": "App Store", "HEADER_DARK_MODE": "Dunkler Modus", - "HEADER_DASHBOARD": "Dashboard", + "HEADER_DASHBOARD": "Übersicht", "HEADER_LIGHT_MODE": "Heller Modus", "HEADER_LOGIN": "Anmelden", "HEADER_LOGOUT": "Abmelden", @@ -238,7 +257,7 @@ "LINKS_EDIT_SUBMIT": "Speichern", "LINKS_EDIT_SUCCESS": "Link erfolgreich bearbeitet", "LINKS_EDIT_TITLE": "Link bearbeiten", - "LINKS_FORM_ICON_PLACEHOLDER": "Link logo URL", + "LINKS_FORM_ICON_PLACEHOLDER": "Logo Link-URL", "LINKS_FORM_ICON_URL": "Icon-URL", "LINKS_FORM_LINK_TITLE": "Linktitel", "LINKS_FORM_LINK_URL": "Link-URL", @@ -258,20 +277,28 @@ "SERVER_ERROR_INVALID_LOCALE": "Ungültige Region", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Im Demo-Modus nicht erlaubt", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Im Dev-Modus nicht erlaubt", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "Ein App Store mit derselben URL existiert bereits", + "APP_STORE_CLONE_ERROR": "Fehler beim Klonen des App Stores unter der URL {{url}}. Ist es ein gültiges Git-Repository?", + "APP_STORE_CHOOSE_CATEGORY": "Wählen Sie eine Kategorie", + "APP_STORE_CHOOSE_STORE": "Wählen Sie einen App Store", "SETTINGS_ACTIONS_ALREADY_LATEST": "Bereits auf dem neuesten Stand", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Aktuelle Version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Aktuelle Version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Beliebte Aktionen, die Sie auf Ihrer Instanz durchführen können", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Wartung", - "SETTINGS_ACTIONS_NEW_VERSION": "Eine neue Version ({version}) von Tipi ist verfügbar", + "SETTINGS_ACTIONS_NEW_VERSION": "Eine neue Version ({{version}}) von Tipi ist verfügbar", "SETTINGS_ACTIONS_RESTART": "Neustart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Bleiben Sie auf dem Laufenden mit der neuesten Version von Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Aktionen", "SETTINGS_ACTIONS_TITLE": "Aktionen", - "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Update Repository", - "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", - "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Repository aktualisieren", + "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Nutzen Sie diesen Button, um Ihren App Store zu aktualisieren", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Dies wird Ihr Repository zurücksetzen und die neusten Änderungen von GitHub herunterladen", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Aktualisieren", + "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "App Store Repository wurde erfolgreich aktualisiert", + "SETTINGS_APPSTORES_TITLE": "App Store", + "SETTINGS_APPSTORES_SUBTITLE": "App Stores hinzufügen oder entfernen", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Stellen Sie sicher, dass Sie den hinzugefügten App Stores vertrauen!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Automatische Themes zulassen", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Lass dich von Themes überraschen, die sich automatisch je nach Jahreszeit ändern.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Anonymes Fehler-Monitoring zulassen", @@ -291,7 +318,7 @@ "SETTINGS_GENERAL_INVALID_URL": "Ungültige URL", "SETTINGS_GENERAL_LANGUAGE": "Sprache", "SETTINGS_GENERAL_LANGUAGE_HELP_TRANSLATE": "Beim Übersetzen helfen", - "SETTINGS_GENERAL_LOCAL_DOMAIN": "Local domain", + "SETTINGS_GENERAL_LOCAL_DOMAIN": "Lokale Domain", "SETTINGS_GENERAL_LOCAL_DOMAIN_HINT": "Domainname für den Zugriff auf Apps in deinem lokalen Netzwerk. Deine Apps werden unter app-name.local-domain erreichbar sein.", "SETTINGS_GENERAL_SETTINGS_UPDATED": "Einstellungen aktualisiert. Starten Sie Ihre Instanz neu, um die Einstellungen zu übernehmen.", "SETTINGS_GENERAL_STORAGE_PATH": "Speicherpfad", @@ -332,7 +359,7 @@ "SETTINGS_SECURITY_PASSWORD_NEEDED_HINT": "Ihr Passwort wird benötigt, um die Zwei-Faktor-Authentifizierungseinstellungen zu ändern.", "SETTINGS_SECURITY_SCAN_QR_CODE": "Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App.", "SETTINGS_SECURITY_TAB_TITLE": "Sicherheit", - "SETTINGS_LOGS_TAB_TITLE": "Logs", + "SETTINGS_LOGS_TAB_TITLE": "Log-Dateien", "SETTINGS_TITLE": "Einstellungen", "SYSTEM_ERROR_COULD_NOT_GET_LATEST_VERSION": "Konnte aktuellste Version nicht abfragen", "SYSTEM_ERROR_CURRENT_VERSION_IS_LATEST": "Die aktuelle Version ist bereits auf dem neuesten Stand", diff --git a/src/client/messages/el-GR.json b/packages/backend/src/modules/i18n/translations/el-GR.json similarity index 79% rename from src/client/messages/el-GR.json rename to packages/backend/src/modules/i18n/translations/el-GR.json index 771e492b22..7b6b54d9fc 100644 --- a/src/client/messages/el-GR.json +++ b/packages/backend/src/modules/i18n/translations/el-GR.json @@ -14,7 +14,7 @@ "APP_CATEGORY_BOOKS": "Βιβλία", "APP_CATEGORY_DATA": "Δεδομένα", "APP_CATEGORY_DEVELOPMENT": "Ανάπτυξη", - "APP_CATEGORY_FEATURED": "Προτεινόμενα", + "APP_CATEGORY_FEATURED": "�Προτεινόμενα", "APP_CATEGORY_FINANCE": "Οικονομικά", "APP_CATEGORY_GAMING": "Παιχνίδια", "APP_CATEGORY_MEDIA": "Πολυμέσα", @@ -38,64 +38,64 @@ "APP_DETAILS_TITLE": "Λεπτομέρειες εφαρμογής", "APP_DETAILS_VERSION": "Έκδοση", "APP_DETAILS_WEBSITE": "Ιστοσελίδα", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Αποτυχία εγκατάστασης της εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_RESET": "Αποτυχία επαναφοράς της εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_START": "Αποτυχία εκκίνησης της εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_STOP": "Αποτυχία διακοπής της εφαρμογής {id}, δείτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_RESTART": "Αποτυχία επανεκκίνησης της εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Αποτυχία απεγκατάστασης εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Αποτυχία ενημέρωσης της εφαρμογής {id}, δείτε τα logs για περισσότερες λεπτομέρειες", - "APP_ERROR_APP_FORCE_EXPOSED": "Η εφαρμογή {id} λειτουργεί μόνο με εκτεθειμένο domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "Η εφαρμογή {id} δεν μπορεί να εκτεθεί", - "APP_ERROR_APP_NOT_FOUND": "Η εφαρμογή {id} δε βρέθηκε", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Η τύπος επεξεργαστή σας {arch} δεν υποστηρίζεται από την εφαρμογή {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Ο τομέας {domain} χρησιμοποιείται ήδη από την εφαρμογή {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Το domain {domain} δεν είναι έγκυρο", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Αποτυχία εγκατάστασης της εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_RESET": "Αποτυχία επαναφοράς της εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_START": "Αποτυχία εκκίνησης της εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_STOP": "Αποτυχία διακοπής της εφαρμογής {{id}}, δείτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_RESTART": "Αποτυχία επανεκκίνησης της εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Αποτυχία απεγκατάστασης εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Αποτυχία ενημέρωσης της εφαρμογής {{id}}, δείτε τα logs για περισσότερες λεπτομέρειες", + "APP_ERROR_APP_FORCE_EXPOSED": "Η εφαρμογή {{id}} λειτουργεί μόνο με εκτεθειμένο domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "Η εφαρμογή {{id}} δεν μπορεί να εκτεθεί", + "APP_ERROR_APP_NOT_FOUND": "Η εφαρμογή {{id}} δε βρέθηκε", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Η τύπος επεξεργαστή σας {{arch}} δεν υποστηρίζεται από την εφαρμογή {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Ο τομέας {{domain}} χρησιμοποιείται ήδη από την εφαρμογή {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Το domain {{domain}} δεν είναι έγκυρο", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Ο τομέας απαιτείται εάν η εφαρμογή εκτεθεί", - "APP_ERROR_INVALID_CONFIG": "Η εφαρμογή {id} δεν έχει έγκυρο αρχείο config.json", + "APP_ERROR_INVALID_CONFIG": "Η εφαρμογή {{id}} δεν έχει έγκυρο αρχείο config.json", "APP_INSTALL_FORM_CHOOSE_OPTION": "Επιλέξτε...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Εμφάνιση στον πίνακα επισκεπτών", "APP_INSTALL_FORM_DOMAIN_NAME": "Όνομα domain", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Βεβαιωθείτε ότι αυτό το ακριβές domain περιέχει ένα A record που δείχνει στην IP σας.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "Το {label} πρέπει να είναι μεταξύ {min} και {max} χαρακτήρες", - "APP_INSTALL_FORM_ERROR_FQDN": "Το πεδίο {label} πρέπει να είναι ένας έγκυρο domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "Το πεδίο {label} πρέπει να είναι ένας έγκυρος τομέας ή μία διεύθυνση IP", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "Το πεδίο {label} πρέπει να είναι μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου", - "APP_INSTALL_FORM_ERROR_IP": "Το πεδίο {label} πρέπει να είναι μια έγκυρη διεύθυνση IP", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "To πεδίο {label} πρέπει να είναι μικρότερο από {max} χαρακτήρες", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "To πεδίο {label} πρέπει να είναι τουλάχιστον {min} χαρακτήρες", - "APP_INSTALL_FORM_ERROR_NUMBER": "Το πεδίο {label} πρέπει να είναι ένας αριθμός", - "APP_INSTALL_FORM_ERROR_REGEX": "Το {label} πρέπει να ταιριάζει με το μοτίβο {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "Το πεδίο {label} είναι απαραίτητο", - "APP_INSTALL_FORM_ERROR_URL": "Το πεδίο {label} πρέπει να είναι μια έγκυρη διεύθυνση URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "Το {{label}} πρέπει να είναι μεταξύ {{min}} και {{max}} χαρακτήρες", + "APP_INSTALL_FORM_ERROR_FQDN": "Το πεδίο {{label}} πρέπει να είναι ένας έγκυρο domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "Το πεδίο {{label}} πρέπει να είναι ένας έγκυρος τομέας ή μία διεύθυνση IP", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "Το πεδίο {{label}} πρέπει να είναι μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου", + "APP_INSTALL_FORM_ERROR_IP": "Το πεδίο {{label}} πρέπει να είναι μια έγκυρη διεύθυνση IP", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "To πεδίο {{label}} πρέπει να είναι μικρότερο από {{max}} χαρακτήρες", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "To πεδίο {{label}} πρέπει να είναι τουλάχιστον {{min}} χαρακτήρες", + "APP_INSTALL_FORM_ERROR_NUMBER": "Το πεδίο {{label}} πρέπει να είναι ένας αριθμός", + "APP_INSTALL_FORM_ERROR_REGEX": "Το {{label}} πρέπει να ταιριάζει με το μοτίβο {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "Το πεδίο {{label}} είναι απαραίτητο", + "APP_INSTALL_FORM_ERROR_URL": "Το πεδίο {{label}} πρέπει να είναι μια έγκυρη διεύθυνση URL", "APP_INSTALL_FORM_EXPOSE_APP": "Άνοιγμα εφαρμογής στο διαδίκτυο", "APP_INSTALL_FORM_OPEN_PORT": "Άνοιγμα θύρας", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ανοίξτε μια θύρα στον υπολογιστή; Αυτή η εφαρμογή θα είναι προσβάσιμη στο {internalIp}:{port}. (Πιο εύκολο αλλά λιγότερο ασφαλές)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ανοίξτε μια θύρα στον υπολογιστή; Αυτή η εφαρμογή θα είναι προσβάσιμη στο {{internalIp}}:{{port}}. (Πιο εύκολο αλλά λιγότερο ασφαλές)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Άνοιγμα εφαρμογής στο τοπικό δίκτυο", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Ανοίξτε την εφαρμογή στο τοπικό δίκτυο; Αυτή η εφαρμογή θα είναι προσβάσιμη στο {appId}.{domain}. (Επισκεφθείτε τη σελίδα ρυθμίσεων για να ρυθμίσετε το τοπικό σας domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Ανοίξτε την εφαρμογή στο τοπικό δίκτυο; Αυτή η εφαρμογή θα είναι προσβάσιμη στο {{appId}}.{{domain}}. (Επισκεφθείτε τη σελίδα ρυθμίσεων για να ρυθμίσετε το τοπικό σας domain)", "APP_INSTALL_FORM_RESET": "Επαναφορά εφαρμογής", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Εγκατάσταση", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Ενημέρωση", - "APP_INSTALL_FORM_TITLE": "Εγκατάσταση {name}", + "APP_INSTALL_FORM_TITLE": "Εγκατάσταση {{name}}", "APP_INSTALL_FORM_GENERAL": "Γενικά", "APP_INSTALL_FORM_REVERSE_PROXY": "Αντίστροφος διακομιστής", - "APP_INSTALL_SUCCESS": "Η εφαρμογή {id} εγκαταστάθηκε με επιτυχία", + "APP_INSTALL_SUCCESS": "Η εφαρμογή {{id}} εγκαταστάθηκε με επιτυχία", "APP_LOGS_TAB_FOLLOW": "Ακολουθήστε τα logs", "APP_LOGS_TAB_MAX_LINES": "Μέγιστες γραμμές:", - "APP_LOGS_TAB_TITLE": "Logs", + "APP_LOGS_TAB_TITLE": "Αρχεία καταγραφής", "APP_LOGS_TAB_WRAP_LINES": "Αναδίπλωση γραμμών", "APP_NEW": "Νέο", "APP_RESET_FORM_SUBMIT": "Επαναφορά", "APP_RESET_FORM_SUBTITLE": "Όλα τα δεδομένα αυτής της εφαρμογής θα χαθούν.", - "APP_RESET_FORM_TITLE": "Επαναφορά {name};", + "APP_RESET_FORM_TITLE": "Επαναφορά {{name}};", "APP_RESET_FORM_WARNING": "Είστε βέβαιοι; Αυτό δεν μπορεί να αναιρεθεί.", - "APP_RESET_SUCCESS": "Η εφαρμογή {id} επαναφέρθηκε με επιτυχία", - "APP_START_SUCCESS": "Η εφαρμογή {id} ξεκίνησε με επιτυχία", - "APP_BACKUP_TITLE": "Δημιουργία αντιγράφου ασφαλείας για την εφαρμογή {name}", + "APP_RESET_SUCCESS": "Η εφαρμογή {{id}} επαναφέρθηκε με επιτυχία", + "APP_START_SUCCESS": "Η εφαρμογή {{id}} ξεκίνησε με επιτυχία", + "APP_BACKUP_TITLE": "Δημιουργία αντιγράφου ασφαλείας για την εφαρμογή {{name}}", "APP_BACKUP_SUBTITLE": "Ένα αρχείο tar θα δημιουργηθεί στον φάκελο αντιγράφων ασφαλείας για την αποθήκευση των δεδομένων της εφαρμογής σας.", "APP_BACKUP_SUBMIT": "Αντίγραφο ασφαλείας", - "APP_RESTORE_TITLE": "Επαναφορά αντιγράφου ασφαλείας {name}", - "APP_RESTORE_WARNING": "Θέλετε πραγματικά να επαναφέρετε το αντίγραφο ασφαλείας {id} που δημιουργήθηκε στις {date};", + "APP_RESTORE_TITLE": "Επαναφορά αντιγράφου ασφαλείας {{name}}", + "APP_RESTORE_WARNING": "Θέλετε πραγματικά να επαναφέρετε το αντίγραφο ασφαλείας {{id}} που δημιουργήθηκε στις {{date}};", "APP_RESTORE_SUBTITLE": "Όλα τα τρέχοντα δεδομένα της εφαρμογής θα διαγραφούν και θα αντικατασταθούν με τα δεδομένα από το αντίγραφο ασφαλείας. Συνιστάται η δημιουργία αντιγράφου ασφαλείας της εφαρμογής σας πριν από την ανάκτηση.", "APP_RESTORE_SUBMIT": "Επαναφορά", "APP_BACKUPS_TAB_TITLE": "Αντίγραφα ασφαλείας", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Επαναφορά", "APP_STOP_FORM_SUBMIT": "Παύση", "APP_STOP_FORM_SUBTITLE": "Όλα τα δεδομένα θα διατηρηθούν", - "APP_STOP_FORM_TITLE": "Παύση {name};", - "APP_STOP_SUCCESS": "Η εφαρμογή {id} σταμάτησε με επιτυχία", + "APP_STOP_FORM_TITLE": "Παύση {{name}};", + "APP_STOP_SUCCESS": "Η εφαρμογή {{id}} σταμάτησε με επιτυχία", "APP_RESTART_FORM_SUBMIT": "Επανεκκίνηση", "APP_RESTART_FORM_SUBTITLE": "Όλα τα δεδομένα θα διατηρηθούν", - "APP_RESTART_FORM_TITLE": "Επανεκκίνηση {name};", - "APP_RESTART_SUCCESS": "Η εφαρμογή {id} επανεκκίνηθηκε με επιτυχία", + "APP_RESTART_FORM_TITLE": "Επανεκκίνηση {{name}};", + "APP_RESTART_SUCCESS": "Η εφαρμογή {{id}} επανεκκίνηθηκε με επιτυχία", "APP_STORE_CATEGORY_PLACEHOLDER": "Διαλέξτε μία κατηγορία", "APP_STORE_NO_RESULTS": "Δε βρέθηκαν εφαρμογές", "APP_STORE_NO_RESULTS_SUBTITLE": "Προσπαθήστε να βελτιώσετε την αναζήτησή σας", "APP_STORE_SEARCH_PLACEHOLDER": "Αναζήτηση εφαρμογών", "APP_STORE_TITLE": "Κατάστημα εφαρμογών", + "APP_STORE_TABLE_EDIT": "Επεξεργασία", + "APP_STORE_TABLE_DELETE": "Διαγραφή", + "APP_STORE_EDIT_DIALOG_TITLE": "Επεξεργασία appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Αποθήκευση", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Το Appstore επεξεργάστηκε επιτυχώς", + "APP_STORE_EDIT_DIALOG_ENABLED": "Ενεργό", + "APP_STORE_DELETE_DIALOG_TITLE": "Διαγραφή appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Είστε σίγουροι ότι θέλετε να διαγράψετε το appstore {{name}};", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Διαγραφή", + "APP_STORE_DELETE_SUCCESS": "Το Appstore διαγράφηκε επιτυχώς", + "APP_STORE_DELETE_ERROR_LAST_STORE": "Δεν μπορείτε να διαγράψετε το τελευταίο appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "Υπάρχουν εγκατεστημένες εφαρμογές που ανήκουν σε αυτό το appstore. Παρακαλώ απεγκαταστήστε τις πρώτα", + "APP_STORE_ADD_DIALOG_TITLE": "Προσθήκη appstore", + "APP_STORE_ADD_FORM_NAME": "Όνομα appstore", + "APP_STORE_ADD_FORM_URL": "Διεύθυνση appstore", + "APP_STORE_ADD_FORM_SUBMIT": "Δημιουργία νέου appstore", + "APP_STORE_ADD_SUCCESS": "Το Appstore προστέθηκε με επιτυχία", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Κατάργηση αντιγράφων ασφαλείας", "APP_UNINSTALL_FORM_SUBMIT": "Απεγκατάσταση", "APP_UNINSTALL_FORM_SUBTITLE": "Όλα τα δεδομένα αυτής της εφαρμογής θα χαθούν.", - "APP_UNINSTALL_FORM_TITLE": "Απεγκατάσταση {name};", + "APP_UNINSTALL_FORM_TITLE": "Απεγκατάσταση {{name}};", "APP_UNINSTALL_FORM_WARNING": "Είστε βέβαιοι; Αυτό δεν μπορεί να αναιρεθεί.", - "APP_UNINSTALL_SUCCESS": "Η εφαρμογή {id} απεγκαταστάθηκε με επιτυχία", + "APP_UNINSTALL_SUCCESS": "Η εφαρμογή {{id}} απεγκαταστάθηκε με επιτυχία", "APP_UPDATE_CONFIG_SUCCESS": "Η ρύθμιση της εφαρμογής ενημερώθηκε με επιτυχία. Επανεκκινήστε την εφαρμογή για να εφαρμόσετε τις αλλαγές", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Η ενημέρωση της εφαρμογής {id} απαιτεί την έκδοση του Tipi {minVersion} ή νεότερη. Παρακαλώ ενημερώστε το Tipi.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Η ενημέρωση της εφαρμογής {{id}} απαιτεί την έκδοση του Tipi {{minVersion}} ή νεότερη. Παρακαλώ ενημερώστε το Tipi.", "APP_UPDATE_FORM_SUBMIT": "Ενημέρωση", "APP_UPDATE_FORM_SUBTITLE_1": "Ενημέρωση στην την πιο πρόσφατη έκδοση:", "APP_UPDATE_FORM_SUBTITLE_2": "Αυτό θα επαναφέρει τις προσαρμοσμένες ρυθμίσεις σας (π.χ. αλλαγές στο docker-compose.yml).", - "APP_UPDATE_FORM_TITLE": "Ενημέρωση {name};", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Ενημέρωση ρυθμίσεων {name}", - "APP_UPDATE_SUCCESS": "Η εφαρμογή {id} ενημερώθηκε με επιτυχία", + "APP_UPDATE_FORM_TITLE": "Ενημέρωση {{name}};", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Ενημέρωση ρυθμίσεων {{name}}", + "APP_UPDATE_SUCCESS": "Η εφαρμογή {{id}} ενημερώθηκε με επιτυχία", "APP_UPDATE_FORM_BACKUP": "Δημιουργία αντιγράφου ασφαλείας πριν από την ενημέρωση", - "APP_BACKUP_SUCCESS": "Το αντίγραφο ασφαλείας της εφαρμογής {id} δημιουργήθηκε με επιτυχία", - "APP_BACKUP_ERROR": "Αποτυχία δημιουργίας αντιγράφων ασφαλείας για την εφαρμογή {id}, δείτε τα logs για περισσότερες πληροφορίες", - "APP_RESTORE_SUCCESS": "Η εφαρμογή {id} επαναφέρθηκε με επιτυχία", - "APP_RESTORE_ERROR": "Αποτυχία επαναφοράς της εφαρμογής {id}, δείτε τα logs για περισσότερες πληροφορίες", + "APP_BACKUP_SUCCESS": "Το αντίγραφο ασφαλείας της εφαρμογής {{id}} δημιουργήθηκε με επιτυχία", + "APP_BACKUP_ERROR": "Αποτυχία δημιουργίας αντιγράφων ασφαλείας για την εφαρμογή {{id}}, δείτε τα logs για περισσότερες πληροφορίες", + "APP_RESTORE_SUCCESS": "Η εφαρμογή {{id}} επαναφέρθηκε με επιτυχία", + "APP_RESTORE_ERROR": "Αποτυχία επαναφοράς της εφαρμογής {{id}}, δείτε τα logs για περισσότερες πληροφορίες", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Υπάρχει ήδη ένας διαχειριστής. Παρακαλούμε συνδεθείτε για να δημιουργήσετε ένα νέο χρήστη από τον πίνακα διαχείρισης.", "AUTH_ERROR_ERROR_CREATING_USER": "Σφάλμα στη δημιουργία του χρήστη", "AUTH_ERROR_INVALID_CREDENTIALS": "Μη έγκυρα στοιχεία", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Ακύρωση αιτήματος αλλαγής κωδικού πρόσβασης", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Για να ξεκινήσετε, εκτελέστε αυτή την εντολή στον διακομιστή σας και μετά ανανεώστε αυτή τη σελίδα. Αν το έχετε κάνει ήδη, η αίτηση επαναφοράς κωδικού πρόσβασης μπορεί να έχει λήξει. Σε αυτή την περίπτωση, παρακαλούμε δοκιμάστε ξανά", "AUTH_RESET_PASSWORD_SUBMIT": "Επαναφορά κωδικού πρόσβασης", - "AUTH_RESET_PASSWORD_SUCCESS": "Ο κωδικός σας έχει επαναφερθεί. Μπορείτε τώρα να συνδεθείτε με τον καινούριο κωδικό πρόσβασης και τη διεύθυνση ηλεκτρονικού ταχυδρομείου {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Ο κωδικός σας έχει επαναφερθεί. Μπορείτε τώρα να συνδεθείτε με τον καινούριο κωδικό πρόσβασης και τη διεύθυνση ηλεκτρονικού ταχυδρομείου {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Επαναφορά κωδικού πρόσβασης", "AUTH_RESET_PASSWORD_TITLE": "Επαναφέρετε τον κωδικό πρόσβασής σας", "AUTH_TOTP_INSTRUCTIONS": "Πληκτρολογήστε τον κωδικό από την εφαρμογή ταυτοποίησης", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Μέγεθος", "BACKUPS_LIST_DELETE_SUCCESS": "Η διαγραφή του αντιγράφου ασφαλείας ολοκληρώθηκε επιτυχώς", "COMMON_CLOSE": "Κλείσιμο", + "COMMON_WARNING": "Προσοχή", "DASHBOARD_CPU_SUBTITLE": "Κατάργηση εγκατάστασης εφαρμογών για μείωση φορτίου", "DASHBOARD_CPU_TITLE": "Χρήση CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Χρησιμοποιούνται από τα {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Χρησιμοποιούνται από τα {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Αποθηκευτικός χώρος", "DASHBOARD_MEMORY_TITLE": "Χρησιμοποιημένη μνήμη", "DASHBOARD_TITLE": "Πίνακας Ελέγχου", "DASHBOARD_IP_WARNING_TITLE": "Εσφαλμένη ρύθμιση", "DASHBOARD_IP_WARNING": "Προειδοποίηση, ενδέχεται να βρίσκεστε σε κίνδυνο! Φαίνεται ότι έχετε πρόσβαση στον πίνακα ελέγχου μέσω δημόσιας διεύθυνσης IP. Αυτό καθιστά ευάλωτο τον πίνακα ελέγχου σας και όλες τις εφαρμογές που εγκαθιστάτε για επιθέσεις από κακόβουλους attackers", "DELETE_BACKUP_MODAL_TITLE": "Διαγραφή αντιγράφου ασφάλειας", - "DELETE_BACKUP_MODAL_WARNING": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας {id} που δημιουργήθηκε στις {date};", + "DELETE_BACKUP_MODAL_WARNING": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας {{id}} που δημιουργήθηκε στις {{date}};", "DELETE_BACKUP_MODAL_SUBTITLE": "Αυτή η ενέργεια δεν μπορεί να ανεραιθεί", "DELETE_BACKUP_MODAL_SUBMIT": "Διαγραφή", "GUEST_DASHBOARD": "Πίνακας επισκεπτών", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Μη έγκυρη ρύθμιση τοποθεσίας", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Δεν επιτρέπεται σε λειτουργία επίδειξης", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Δεν επιτρέπεται σε λειτουργία προγραμματιστή", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "Ένα appstore με το ίδιο url υπάρχει ήδη", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Ήδη ενημερωμένο", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Τρέχουσα έκδοση: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Τρέχουσα έκδοση: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Κοινές ενέργειες για να εκτελέσετε", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Συντήρηση", - "SETTINGS_ACTIONS_NEW_VERSION": "Μια νέα έκδοση ({version}) του Tipi είναι διαθέσιμη", + "SETTINGS_ACTIONS_NEW_VERSION": "Μια νέα έκδοση ({{version}}) του Tipi είναι διαθέσιμη", "SETTINGS_ACTIONS_RESTART": "Επανεκκίνηση", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Μείνετε ενημερωμένοι με την τελευταία έκδοση του Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Ενέργειες", @@ -270,8 +293,12 @@ "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Update Repository", "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Ενημέρωση", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Βεβαιωθείτε ότι εμπιστεύεστε τα appstores που προσθέτετε!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Επιτρέψτε αυτόματα θέματα", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Εκπλαγείτε από θέματα που αλλάζουν αυτόματα με βάση την εποχή του έτους.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Να επιτρέπεται η ανώνυμη παρακολούθηση σφαλμάτων", @@ -332,7 +359,7 @@ "SETTINGS_SECURITY_PASSWORD_NEEDED_HINT": "Απαιτείται κωδικός πρόσβασης για να αλλάξετε τις ρυθμίσεις ελέγχου ταυτότητας δύο βημάτων.", "SETTINGS_SECURITY_SCAN_QR_CODE": "Σαρώστε αυτόν τον κώδικα QR με την εφαρμογή επαλήθευσης.", "SETTINGS_SECURITY_TAB_TITLE": "Ασφάλεια", - "SETTINGS_LOGS_TAB_TITLE": "Logs", + "SETTINGS_LOGS_TAB_TITLE": "Αρχεία καταγραφής", "SETTINGS_TITLE": "Ρυθμίσεις", "SYSTEM_ERROR_COULD_NOT_GET_LATEST_VERSION": "Αδυναμία λήψης της τελευταίας έκδοσης", "SYSTEM_ERROR_CURRENT_VERSION_IS_LATEST": "Η τρέχουσα έκδοση είναι ενημερωμένη", diff --git a/src/client/messages/en-US.json b/packages/backend/src/modules/i18n/translations/en-US.json similarity index 76% rename from src/client/messages/en-US.json rename to packages/backend/src/modules/i18n/translations/en-US.json index 23417edfd9..6e6ee22d5f 100644 --- a/src/client/messages/en-US.json +++ b/packages/backend/src/modules/i18n/translations/en-US.json @@ -38,48 +38,49 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_DETAILS_USER_CONFIG": "User config", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +88,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +116,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +203,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +217,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +278,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +296,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", @@ -340,5 +368,7 @@ "SYSTEM_ERROR_MAJOR_VERSION_UPDATE": "The major version has changed. Please update manually (instructions in release notes)", "SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN": "You must be logged in to perform this action", "TIMEZONE_SELECTOR_LABEL": "Timezone", - "TIMEZONE_SELECTOR_PLACEHOLDER": "Select a timezone" + "TIMEZONE_SELECTOR_PLACEHOLDER": "Select a timezone", + "YES": "Yes", + "NO": "No" } diff --git a/src/client/messages/en.json b/packages/backend/src/modules/i18n/translations/en.json similarity index 75% rename from src/client/messages/en.json rename to packages/backend/src/modules/i18n/translations/en.json index 23417edfd9..c18ec8f326 100644 --- a/src/client/messages/en.json +++ b/packages/backend/src/modules/i18n/translations/en.json @@ -38,48 +38,51 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_DETAILS_USER_CONFIG": "User config", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", + "APP_INSTALL_FORM_ERROR_PORT": "Port must be a number between 1024 and 65535", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_FORM_PORT_HINT": "Port on which the app will be accessible", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +90,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +118,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +205,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +219,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +280,16 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "SERVER_ERROR_DUPLICATE_APP_STORE_NAME": "An app store with the same name already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +299,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", @@ -340,5 +371,7 @@ "SYSTEM_ERROR_MAJOR_VERSION_UPDATE": "The major version has changed. Please update manually (instructions in release notes)", "SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN": "You must be logged in to perform this action", "TIMEZONE_SELECTOR_LABEL": "Timezone", - "TIMEZONE_SELECTOR_PLACEHOLDER": "Select a timezone" + "TIMEZONE_SELECTOR_PLACEHOLDER": "Select a timezone", + "YES": "Yes", + "NO": "No" } diff --git a/src/client/messages/es-ES.json b/packages/backend/src/modules/i18n/translations/es-ES.json similarity index 77% rename from src/client/messages/es-ES.json rename to packages/backend/src/modules/i18n/translations/es-ES.json index 41e7cd6664..a2cacd91d4 100644 --- a/src/client/messages/es-ES.json +++ b/packages/backend/src/modules/i18n/translations/es-ES.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Detalles de la aplicación", "APP_DETAILS_VERSION": "Versión", "APP_DETAILS_WEBSITE": "Página web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Error al instalar la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FAILED_TO_RESET": "Error restableciendo {id}, mira las trazas para más detalles", - "APP_ERROR_APP_FAILED_TO_START": "No se pudo iniciar la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FAILED_TO_STOP": "No se pudo detener la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FAILED_TO_RESTART": "No se pudo iniciar la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "No se pudo desinstalar la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FAILED_TO_UPDATE": "No se pudo actualizar la aplicación {id}, consulta los registros para obtener más detalles", - "APP_ERROR_APP_FORCE_EXPOSED": "La aplicación {id} solo funciona con un dominio expuesto", - "APP_ERROR_APP_NOT_EXPOSABLE": "La aplicación {id} no se puede exponer", - "APP_ERROR_APP_NOT_FOUND": "No se encontró la aplicación {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Su arquitectura {arch} no es compatible con la aplicación {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "El dominio {domain} ya está en uso por la aplicación {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "El dominio {domain} no es un dominio válido", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Error al instalar la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FAILED_TO_RESET": "Error restableciendo {{id}}, mira las trazas para más detalles", + "APP_ERROR_APP_FAILED_TO_START": "No se pudo iniciar la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FAILED_TO_STOP": "No se pudo detener la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FAILED_TO_RESTART": "No se pudo iniciar la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "No se pudo desinstalar la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FAILED_TO_UPDATE": "No se pudo actualizar la aplicación {{id}}, consulta los registros para obtener más detalles", + "APP_ERROR_APP_FORCE_EXPOSED": "La aplicación {{id}} solo funciona con un dominio expuesto", + "APP_ERROR_APP_NOT_EXPOSABLE": "La aplicación {{id}} no se puede exponer", + "APP_ERROR_APP_NOT_FOUND": "No se encontró la aplicación {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Su arquitectura {{arch}} no es compatible con la aplicación {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "El dominio {{domain}} ya está en uso por la aplicación {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "El dominio {{domain}} no es un dominio válido", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Necesitas un dominio si la aplicación está expuesta", - "APP_ERROR_INVALID_CONFIG": "La aplicación {id} tiene un archivo config.json inválido", + "APP_ERROR_INVALID_CONFIG": "La aplicación {{id}} tiene un archivo config.json inválido", "APP_INSTALL_FORM_CHOOSE_OPTION": "Elige una opción...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Mostrar en el panel de invitados", "APP_INSTALL_FORM_DOMAIN_NAME": "Nombre de dominio", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Asegúrate de que este dominio exacto contiene un registro A apuntando a tu IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} debe tener entre {min} y {max} caracteres", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} debe ser un dominio válido", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} debe ser un dominio o IP válida", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} debe ser una dirección de correo electrónico válida", - "APP_INSTALL_FORM_ERROR_IP": "{label} debe ser una dirección IP válida", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} debe tener menos de {max} caracteres", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} debe tener al menos {min} caracteres", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} debe ser un número", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} debe coincidir con el patrón {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} es obligatorio", - "APP_INSTALL_FORM_ERROR_URL": "{label} tiene que ser una URL válida", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} debe tener entre {{min}} y {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} debe ser un dominio válido", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} debe ser un dominio o IP válida", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} debe ser una dirección de correo electrónico válida", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} debe ser una dirección IP válida", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} debe tener menos de {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} debe tener al menos {{min}} caracteres", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} debe ser un número", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} debe coincidir con el patrón {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} es obligatorio", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} tiene que ser una URL válida", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Puerto abierto", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Exponer la aplicación en la red local", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "¿Exponer la aplicación en la red local? Esta aplicación será accesible en {appId}.{domain}. (Visita la página de configuración para configurar tu dominio local)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "¿Exponer la aplicación en la red local? Esta aplicación será accesible en {{appId}}.{{domain}}. (Visita la página de configuración para configurar tu dominio local)", "APP_INSTALL_FORM_RESET": "Restablecer aplicación", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Instalar", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Actualizar", - "APP_INSTALL_FORM_TITLE": "Instalar {name}", + "APP_INSTALL_FORM_TITLE": "Instalar {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Proxy inverso", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Seguir trazas", "APP_LOGS_TAB_MAX_LINES": "Líneas máximas:", "APP_LOGS_TAB_TITLE": "Trazas", @@ -87,15 +87,15 @@ "APP_NEW": "NUEVA", "APP_RESET_FORM_SUBMIT": "Restablecer", "APP_RESET_FORM_SUBTITLE": "Todos los datos de esta aplicación se perderán.", - "APP_RESET_FORM_TITLE": "Restablecer {name} ?", + "APP_RESET_FORM_TITLE": "Restablecer {{name}} ?", "APP_RESET_FORM_WARNING": "Estás seguro? Esta acción no se puede deshacer.", - "APP_RESET_SUCCESS": "App {id} restablecida correctamente", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", - "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", + "APP_RESET_SUCCESS": "App {{id}} restablecida correctamente", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", + "APP_BACKUP_SUBTITLE": "Un archivo tar se creará en la carpeta de copias de seguridad para almacenar los datos de su aplicación.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Copias de seguridad", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Detener", "APP_STOP_FORM_SUBTITLE": "Todos los datos serán conservados", - "APP_STOP_FORM_TITLE": "Detener {name}?", - "APP_STOP_SUCCESS": "App {id} detenida correctamente", + "APP_STOP_FORM_TITLE": "Detener {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} detenida correctamente", "APP_RESTART_FORM_SUBMIT": "Reiniciar", "APP_RESTART_FORM_SUBTITLE": "Todos los datos serán conservados", - "APP_RESTART_FORM_TITLE": "Reiniciar {name} ?", - "APP_RESTART_SUCCESS": "La aplicación {id} se reinició correctamente", + "APP_RESTART_FORM_TITLE": "Reiniciar {{name}} ?", + "APP_RESTART_SUCCESS": "La aplicación {{id}} se reinició correctamente", "APP_STORE_CATEGORY_PLACEHOLDER": "Selecciona una categoría", "APP_STORE_NO_RESULTS": "No se encontraron aplicaciones", "APP_STORE_NO_RESULTS_SUBTITLE": "Intenta refinar tu búsqueda", "APP_STORE_SEARCH_PLACEHOLDER": "Buscar aplicaciones", "APP_STORE_TITLE": "Tienda de aplicaciones", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Desinstalar", "APP_UNINSTALL_FORM_SUBTITLE": "Todos los datos de esta aplicación se perderán.", - "APP_UNINSTALL_FORM_TITLE": "Desinstalar {name}?", + "APP_UNINSTALL_FORM_TITLE": "Desinstalar {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Estás seguro? Esta acción no se puede deshacer.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "La configuración de la aplicación se ha actualizado correctamente. Reinicie la aplicación para aplicar los cambios", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "La actualización de la aplicación {id} requiere que la versión de Tipi sea {minVersion} o superior. Por favor actualiza tu instancia.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "La actualización de la aplicación {{id}} requiere que la versión de Tipi sea {{minVersion}} o superior. Por favor actualiza tu instancia.", "APP_UPDATE_FORM_SUBMIT": "Actualizar", "APP_UPDATE_FORM_SUBTITLE_1": "Actualizar la aplicación a la última versión:", "APP_UPDATE_FORM_SUBTITLE_2": "Esto restablecerá su configuración personalizada (por ejemplo, los cambios en docker-compose.yml).", - "APP_UPDATE_FORM_TITLE": "Actualizar {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualizar la configuración de {name}", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Actualizar {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualizar la configuración de {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Ya existe un usuario administrador. Inicie sesión para crear un nuevo usuario desde el panel de administración.", "AUTH_ERROR_ERROR_CREATING_USER": "Error al crear el usuario", "AUTH_ERROR_INVALID_CREDENTIALS": "Credenciales inválidas", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancelar cambio de contraseña", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Ejecuta este comando en el servidor y recarga la página. Si ya has hecho esto previamente, es posible que la solicitud haya caducado. En ese caso, inténtelo de nuevo por favor", "AUTH_RESET_PASSWORD_SUBMIT": "Restablecer contraseña", - "AUTH_RESET_PASSWORD_SUCCESS": "Su contraseña ha sido restablecida. Ya puedes iniciar sesión con tu nueva contraseña. Y tu correo electrónico {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Su contraseña ha sido restablecida. Ya puedes iniciar sesión con tu nueva contraseña. Y tu correo electrónico {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Restablecer contraseña", "AUTH_RESET_PASSWORD_TITLE": "Restablece tu contraseña", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -196,18 +214,19 @@ "BACKUPS_LIST_ROW_TITLE_DATE": "Fecha", "BACKUPS_LIST_ROW_TITLE_ACTIONS": "Actions", "BACKUPS_LIST_ROW_TITLE_SIZE": "Tamaño", - "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", + "BACKUPS_LIST_DELETE_SUCCESS": "Copia de seguridad eliminada exitosamente", "COMMON_CLOSE": "Cerrar", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Espacio en disco", "DASHBOARD_MEMORY_TITLE": "Memoria en uso", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Configuración insegura", "DASHBOARD_IP_WARNING": "¡Advertencia, podrías estar en riesgo! Parece que estás accediendo a tu instancia a través de una dirección IP pública. Esto hace que tu panel de control y todas las aplicaciones que instales sean vulnerables a los atacantes", "DELETE_BACKUP_MODAL_TITLE": "Eliminar la copia de seguridad", - "DELETE_BACKUP_MODAL_WARNING": "¿Estás seguro de que quieres eliminar la copia de seguridad {id} realizada en {date}?", + "DELETE_BACKUP_MODAL_WARNING": "¿Estás seguro de que quieres eliminar la copia de seguridad {{id}} realizada en {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "Esta acción no se puede deshacer", "DELETE_BACKUP_MODAL_SUBMIT": "Eliminar", "GUEST_DASHBOARD": "Panel de invitado", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Local inválido", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "No se permite en modo demo", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "No está permitido en el modo desarrollo", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Ya está actualizado", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versión actual: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versión actual: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Acciones comunes a realizar en tu instancia", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Mantenimiento", - "SETTINGS_ACTIONS_NEW_VERSION": "Una nueva versión ({version}) de Tipi está disponible", + "SETTINGS_ACTIONS_NEW_VERSION": "Una nueva versión ({{version}}) de Tipi está disponible", "SETTINGS_ACTIONS_RESTART": "Reiniciar", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Mantente al día con la última versión de Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Acciones", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Actualizar", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/fi-FI.json b/packages/backend/src/modules/i18n/translations/fi-FI.json similarity index 77% rename from src/client/messages/fi-FI.json rename to packages/backend/src/modules/i18n/translations/fi-FI.json index 23417edfd9..b17c94a98a 100644 --- a/src/client/messages/fi-FI.json +++ b/packages/backend/src/modules/i18n/translations/fi-FI.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Sovellus {{id}} ei tue arkkitehtuuria {{arch}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/fr-FR.json b/packages/backend/src/modules/i18n/translations/fr-FR.json similarity index 77% rename from src/client/messages/fr-FR.json rename to packages/backend/src/modules/i18n/translations/fr-FR.json index b3b5e82a7b..96aa9166f8 100644 --- a/src/client/messages/fr-FR.json +++ b/packages/backend/src/modules/i18n/translations/fr-FR.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Infos sur l'application", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Site Web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Impossible d'installer l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_RESET": "Impossible de réinitialiser l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_START": "Impossible de démarrer l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_STOP": "Impossible d'arrêter l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_RESTART": "Échec de redémarrage l'application {id}, consultez les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Impossible de désinstaller l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Impossible de mettre à jour l'application {id}, voir les logs pour plus de détails", - "APP_ERROR_APP_FORCE_EXPOSED": "L'application {id} ne fonctionne qu'avec un domaine exposé", - "APP_ERROR_APP_NOT_EXPOSABLE": "L'application {id} n'est pas exposable", - "APP_ERROR_APP_NOT_FOUND": "L'application {id} est introuvable", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Votre architecture {arch} n'est pas supportée par l'application {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Le domaine {domain} est déjà utilisé par l'application {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Le domaine {domain} n'est pas valide", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Impossible d'installer l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_RESET": "Impossible de réinitialiser l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_START": "Impossible de démarrer l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_STOP": "Impossible d'arrêter l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_RESTART": "Échec de redémarrage l'application {{id}}, consultez les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Impossible de désinstaller l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Impossible de mettre à jour l'application {{id}}, voir les logs pour plus de détails", + "APP_ERROR_APP_FORCE_EXPOSED": "L'application {{id}} ne fonctionne qu'avec un domaine exposé", + "APP_ERROR_APP_NOT_EXPOSABLE": "L'application {{id}} n'est pas exposable", + "APP_ERROR_APP_NOT_FOUND": "L'application {{id}} est introuvable", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Votre architecture {{arch}} n'est pas supportée par l'application {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Le domaine {{domain}} est déjà utilisé par l'application {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Le domaine {{domain}} n'est pas valide", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Le domaine est requis si l'application est exposée", - "APP_ERROR_INVALID_CONFIG": "L'application {id} a un fichier config.json invalide", + "APP_ERROR_INVALID_CONFIG": "L'application {{id}} a un fichier config.json invalide", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choisissez une option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Afficher sur le tableau de bord invité", "APP_INSTALL_FORM_DOMAIN_NAME": "Nom de domaine", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Assurez-vous que ce domaine contienne un enregistrement A pointant vers votre IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} doit contenir entre {min} et {max} caractères", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} doit être un domaine valide", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} doit être un domaine ou une adresse IP valide", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} doit être une adresse e-mail valide", - "APP_INSTALL_FORM_ERROR_IP": "{label} doit être une adresse IP valide", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} doit contenir moins de {max} caractères", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} doit contenir au moins {min} caractères", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} doit être un nombre", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} doit correspondre à la règle {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} est obligatoire", - "APP_INSTALL_FORM_ERROR_URL": "{label} doit être une URL valide", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} doit contenir entre {{min}} et {{max}} caractères", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} doit être un domaine valide", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} doit être un domaine ou une adresse IP valide", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} doit être une adresse e-mail valide", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} doit être une adresse IP valide", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} doit contenir moins de {{max}} caractères", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} doit contenir au moins {{min}} caractères", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} doit être un nombre", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} doit correspondre à la règle {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} est obligatoire", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} doit être une URL valide", "APP_INSTALL_FORM_EXPOSE_APP": "Exposer l'application sur internet", "APP_INSTALL_FORM_OPEN_PORT": "Port ouvert", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ouvrir un port sur l'hôte ? Cette application sera accessible sur {internalIp}:{port}. (Plus facile mais moins sûr)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ouvrir un port sur l'hôte ? Cette application sera accessible sur {{internalIp}}:{{port}}. (Plus facile mais moins sûr)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Exposer l'application sur le réseau local", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Exposer l'application sur le réseau local ? Cette application sera accessible à {appId}.{domain}. (Visitez la page des paramètres pour configurer votre domaine local)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Exposer l'application sur le réseau local ? Cette application sera accessible à {{appId}}.{{domain}}. (Visitez la page des paramètres pour configurer votre domaine local)", "APP_INSTALL_FORM_RESET": "Réinitialiser l’application", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Installer", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Mettre à jour", - "APP_INSTALL_FORM_TITLE": "Installer {name}", + "APP_INSTALL_FORM_TITLE": "Installer {{name}}", "APP_INSTALL_FORM_GENERAL": "Général", "APP_INSTALL_FORM_REVERSE_PROXY": "Proxy", - "APP_INSTALL_SUCCESS": "Application {id} installée avec succès", + "APP_INSTALL_SUCCESS": "Application {{id}} installée avec succès", "APP_LOGS_TAB_FOLLOW": "Suivre les logs", "APP_LOGS_TAB_MAX_LINES": "Nombre de lignes :", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NOUVEAU", "APP_RESET_FORM_SUBMIT": "Réinitialiser", "APP_RESET_FORM_SUBTITLE": "Toutes les données de cette application seront perdues.", - "APP_RESET_FORM_TITLE": "Réinitialiser {name} ?", + "APP_RESET_FORM_TITLE": "Réinitialiser {{name}} ?", "APP_RESET_FORM_WARNING": "Êtes-vous sûr ? Cette action ne peut pas être annulée.", - "APP_RESET_SUCCESS": "Application {id} réinitialisée avec succès", - "APP_START_SUCCESS": "Application {id} démarrée avec succès", - "APP_BACKUP_TITLE": "Sauvegarde {name}", + "APP_RESET_SUCCESS": "Application {{id}} réinitialisée avec succès", + "APP_START_SUCCESS": "Application {{id}} démarrée avec succès", + "APP_BACKUP_TITLE": "Sauvegarde {{name}}", "APP_BACKUP_SUBTITLE": "Une archive tar sera créée dans le dossier de sauvegarde pour stocker les données de l'application.", "APP_BACKUP_SUBMIT": "Sauvegarde", - "APP_RESTORE_TITLE": "Restaurer la sauvegarde {name}", - "APP_RESTORE_WARNING": "Voulez-vous vraiment restaurer la sauvegarde {id} réalisée le {date}?", + "APP_RESTORE_TITLE": "Restaurer la sauvegarde {{name}}", + "APP_RESTORE_WARNING": "Voulez-vous vraiment restaurer la sauvegarde {{id}} réalisée le {{date}}?", "APP_RESTORE_SUBTITLE": "Toutes les données actuelles de l'application seront effacées et remplacées par les données de la sauvegarde. Il est recommandé de sauvegarder votre application avant de restaurer.", "APP_RESTORE_SUBMIT": "Restaurer", "APP_BACKUPS_TAB_TITLE": "Sauvegardes", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restauration en cours", "APP_STOP_FORM_SUBMIT": "Arrêter", "APP_STOP_FORM_SUBTITLE": "Toutes les données seront conservées", - "APP_STOP_FORM_TITLE": "Arrêter {name}?", - "APP_STOP_SUCCESS": "Application {id} arrêtée avec succès", + "APP_STOP_FORM_TITLE": "Arrêter {{name}}?", + "APP_STOP_SUCCESS": "Application {{id}} arrêtée avec succès", "APP_RESTART_FORM_SUBMIT": "Redémarrer", "APP_RESTART_FORM_SUBTITLE": "Toutes les données seront conservées", - "APP_RESTART_FORM_TITLE": "Redémarrer {name} ?", - "APP_RESTART_SUCCESS": "Application {id} redémarrée avec succès", + "APP_RESTART_FORM_TITLE": "Redémarrer {{name}} ?", + "APP_RESTART_SUCCESS": "Application {{id}} redémarrée avec succès", "APP_STORE_CATEGORY_PLACEHOLDER": "Sélectionnez une catégorie", "APP_STORE_NO_RESULTS": "Aucune application trouvée", "APP_STORE_NO_RESULTS_SUBTITLE": "Essayez d'affiner votre recherche", "APP_STORE_SEARCH_PLACEHOLDER": "Chercher des applications", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Supprimer les sauvegardes", "APP_UNINSTALL_FORM_SUBMIT": "Désinstaller", "APP_UNINSTALL_FORM_SUBTITLE": "Toutes les données de cette application seront perdues.", - "APP_UNINSTALL_FORM_TITLE": "Désinstaller {name}?", + "APP_UNINSTALL_FORM_TITLE": "Désinstaller {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Êtes-vous sûr ? Cette action ne peut pas être annulée.", - "APP_UNINSTALL_SUCCESS": "Application {id} désinstallée avec succès", + "APP_UNINSTALL_SUCCESS": "Application {{id}} désinstallée avec succès", "APP_UPDATE_CONFIG_SUCCESS": "Configuration de l'application mise à jour avec succès. Redémarrez l'application pour appliquer les modifications", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "La mise à jour de l'application {id} nécessite une version de Tipi {minVersion} ou supérieure. Veuillez mettre à jour votre instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "La mise à jour de l'application {{id}} nécessite une version de Tipi {{minVersion}} ou supérieure. Veuillez mettre à jour votre instance.", "APP_UPDATE_FORM_SUBMIT": "Mettre à jour", "APP_UPDATE_FORM_SUBTITLE_1": "Mettre à jour l'application vers la dernière version :", "APP_UPDATE_FORM_SUBTITLE_2": "Assurez-vous que vous avez lu les notes de version de l'application et que vous avez sauvegardé vos données d'application.", - "APP_UPDATE_FORM_TITLE": "Mettre à jour {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Mettre à jour la configuration de {name}", - "APP_UPDATE_SUCCESS": "Application {id} mise à jour avec succès", + "APP_UPDATE_FORM_TITLE": "Mettre à jour {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Mettre à jour la configuration de {{name}}", + "APP_UPDATE_SUCCESS": "Application {{id}} mise à jour avec succès", "APP_UPDATE_FORM_BACKUP": "Sauvegarde de l'application avant la mise à jour", - "APP_BACKUP_SUCCESS": "Application {id} sauvegardée avec succès", - "APP_BACKUP_ERROR": "Impossible de sauvegarder {id}, voir les logs pour plus de détails", - "APP_RESTORE_SUCCESS": "Application {id} restaurée avec succès", - "APP_RESTORE_ERROR": "Impossible de restaurer {id}, voir les logs pour plus de détails", + "APP_BACKUP_SUCCESS": "Application {{id}} sauvegardée avec succès", + "APP_BACKUP_ERROR": "Impossible de sauvegarder {{id}}, voir les logs pour plus de détails", + "APP_RESTORE_SUCCESS": "Application {{id}} restaurée avec succès", + "APP_RESTORE_ERROR": "Impossible de restaurer {{id}}, voir les logs pour plus de détails", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Il y a déjà un utilisateur administrateur. Veuillez vous connecter pour créer un nouvel utilisateur à partir du panneau d'administration.", "AUTH_ERROR_ERROR_CREATING_USER": "Erreur lors de la création de l'utilisateur", "AUTH_ERROR_INVALID_CREDENTIALS": "Identifiants invalides", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Annuler la demande de changement de mot de passe", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Pour commencer, exécutez cette commande sur votre serveur puis actualisez cette page. Si vous l'avez déjà fait, la demande de réinitialisation du mot de passe peut avoir expiré. Dans ce cas, veuillez réessayer.", "AUTH_RESET_PASSWORD_SUBMIT": "Réinitialiser", - "AUTH_RESET_PASSWORD_SUCCESS": "Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe. Et votre e-mail {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe. Et votre e-mail {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Mot de passe réinitialisé", "AUTH_RESET_PASSWORD_TITLE": "Réinitialiser votre mot de passe", "AUTH_TOTP_INSTRUCTIONS": "Entrez le code de votre application d'authentification", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Taille", "BACKUPS_LIST_DELETE_SUCCESS": "Sauvegarde supprimée avec succès", "COMMON_CLOSE": "Fermer", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Désinstallez des applications pour réduire la charge", "DASHBOARD_CPU_TITLE": "Utilisation du CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilisé sur {total} Go", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilisé sur {{total}} Go", "DASHBOARD_DISK_SPACE_TITLE": "Espace disque", "DASHBOARD_MEMORY_TITLE": "Mémoire utilisée", "DASHBOARD_TITLE": "Tableau de bord", "DASHBOARD_IP_WARNING_TITLE": "Configuration non sécurisée", "DASHBOARD_IP_WARNING": "Attention, vous pourriez courir un risque ! Il semble que vous accédez à votre instance via une adresse IP publique. Cela rend votre tableau de bord et toutes les applications que vous installez vulnérables aux attaquants", "DELETE_BACKUP_MODAL_TITLE": "Supprimer la sauvegarde", - "DELETE_BACKUP_MODAL_WARNING": "Êtes-vous sûr de vouloir supprimer la sauvegarde {id} faite le {date} ?", + "DELETE_BACKUP_MODAL_WARNING": "Êtes-vous sûr de vouloir supprimer la sauvegarde {{id}} faite le {{date}} ?", "DELETE_BACKUP_MODAL_SUBTITLE": "Cette action ne peut pas être annulée", "DELETE_BACKUP_MODAL_SUBMIT": "Supprimer", "GUEST_DASHBOARD": "Tableau de bord invité", @@ -258,20 +277,28 @@ "SERVER_ERROR_INVALID_LOCALE": "Langue invalide", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Non autorisé en mode démo", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Non autorisé en mode développement", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Déjà à jour", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Version actuelle : {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Version actuelle : {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Actions courantes à effectuer sur votre instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "Une nouvelle version ({version}) est disponible", + "SETTINGS_ACTIONS_NEW_VERSION": "Une nouvelle version ({{version}}) est disponible", "SETTINGS_ACTIONS_RESTART": "Redémarrer", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Restez à jour avec la dernière version de Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", "SETTINGS_ACTIONS_TITLE": "Actions", - "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Update Repository", - "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", - "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Mise à jour du dépôt", + "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Utilisez ce bouton pour mettre à jour votre app store", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Cela va réinitialiser votre dépôt et récupérer les dernières modifications depuis GitHub", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Mettre à jour", + "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Le dépôt a été mit à jour avec succès", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Autoriser les thèmes automatiques", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Soyez surpris par les thèmes qui changent automatiquement en fonction de la période de l'année.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Autoriser le suivi anonyme des erreurs", diff --git a/src/client/messages/he-IL.json b/packages/backend/src/modules/i18n/translations/he-IL.json similarity index 77% rename from src/client/messages/he-IL.json rename to packages/backend/src/modules/i18n/translations/he-IL.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/he-IL.json +++ b/packages/backend/src/modules/i18n/translations/he-IL.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/hu-HU.json b/packages/backend/src/modules/i18n/translations/hu-HU.json similarity index 69% rename from src/client/messages/hu-HU.json rename to packages/backend/src/modules/i18n/translations/hu-HU.json index 2c2f85a8a5..a3dbdfc7ba 100644 --- a/src/client/messages/hu-HU.json +++ b/packages/backend/src/modules/i18n/translations/hu-HU.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Alkalmazás részletek", "APP_DETAILS_VERSION": "Verzió", "APP_DETAILS_WEBSITE": "Weboldal", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Nem sikerült telepíteni az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_RESET": "Nem sikerült alaphelyzetbe állítani a(z) {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_START": "Nem sikerült elindítani az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_STOP": "Nem sikerült leállítani az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_RESTART": "Nem sikerült elindítani az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Nem sikerült eltávolítani az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Nem sikerült frissíteni az {id} alkalmazást. További részletekért tekintse meg a naplókat", - "APP_ERROR_APP_FORCE_EXPOSED": "Az {id} alkalmazás csak nyilvános domainnel működik", - "APP_ERROR_APP_NOT_EXPOSABLE": "Az {id} alkalmazás nem hozzáférhető", - "APP_ERROR_APP_NOT_FOUND": "A(z) {id} alkalmazás nem található", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "A(z) {domain} domaint már használja a(z) {id} alkalmazás", - "APP_ERROR_DOMAIN_NOT_VALID": "A(z) {domain} domain nem érvényes domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Nem sikerült telepíteni az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_RESET": "Nem sikerült alaphelyzetbe állítani a(z) {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_START": "Nem sikerült elindítani az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_STOP": "Nem sikerült leállítani az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_RESTART": "Nem sikerült elindítani az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Nem sikerült eltávolítani az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Nem sikerült frissíteni az {{id}} alkalmazást. További részletekért tekintse meg a naplókat", + "APP_ERROR_APP_FORCE_EXPOSED": "Az {{id}} alkalmazás csak nyilvános domainnel működik", + "APP_ERROR_APP_NOT_EXPOSABLE": "Az {{id}} alkalmazás nem hozzáférhető", + "APP_ERROR_APP_NOT_FOUND": "A(z) {{id}} alkalmazás nem található", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Az Ön {{arch}} architektúráját az {{id}} alkalmazás nem támogatja", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "A(z) {{domain}} domaint már használja a(z) {{id}} alkalmazás", + "APP_ERROR_DOMAIN_NOT_VALID": "A(z) {{domain}} domain nem érvényes domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain szükséges, ha az alkalmazás exponálva van", - "APP_ERROR_INVALID_CONFIG": "A(z) {id} alkalmazás érvénytelen config.json fájlt tartalmaz", + "APP_ERROR_INVALID_CONFIG": "A(z) {{id}} alkalmazás érvénytelen config.json fájlt tartalmaz", "APP_INSTALL_FORM_CHOOSE_OPTION": "Válasszon egy lehetőséget...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "A vendég műszerfalon való megjelenítés", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain név", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Győződjön meg arról, hogy ez a domain tartalmaz egy A rekordot, amely az Ön IP-jére mutat.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "A {label} {min} és {max} karakterek között kell lennie", - "APP_INSTALL_FORM_ERROR_FQDN": "A {label} érvényes domainnek kell lennie", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} érvényes domain vagy IP-cím lehet", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} érvényes e-mail címnek kell lennie", - "APP_INSTALL_FORM_ERROR_IP": "{label} érvényes IP-címnek kell lennie", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "A {label} karakternek kevesebbnek kell lennie, mint a {max} karakter(ek)", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} legalább {min} karakter hosszú legyen", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} számnak kell lennie", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} meg kell egyeznie a mintával {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} megadása kötelező", - "APP_INSTALL_FORM_ERROR_URL": "{label} érvényes URL címnek kell lennie", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "A {{label}} {{min}} és {{max}} karakterek között kell lennie", + "APP_INSTALL_FORM_ERROR_FQDN": "A {{label}} érvényes domainnek kell lennie", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} érvényes domain vagy IP-cím lehet", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} érvényes e-mail címnek kell lennie", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} érvényes IP-címnek kell lennie", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "A {{label}} karakternek kevesebbnek kell lennie, mint a {{max}} karakter(ek)", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} legalább {{min}} karakter hosszú legyen", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} számnak kell lennie", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} meg kell egyeznie a mintával {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} megadása kötelező", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} érvényes URL címnek kell lennie", "APP_INSTALL_FORM_EXPOSE_APP": "Alkalmazás közzététele az interneten", "APP_INSTALL_FORM_OPEN_PORT": "Port megnyitása", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Nyissunk portot az állomáson? Ez az alkalmazás a {internalIp}:{port} címen lesz elérhető. (A legegyszerűbb, de kevésbé biztonságos)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Nyissunk portot az állomáson? Ez az alkalmazás a {{internalIp}}:{{port}} címen lesz elérhető. (A legegyszerűbb, de kevésbé biztonságos)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Alkalmazás közzététele a helyi hálózaton", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Kiteszi az alkalmazást a helyi hálózaton? Ez az alkalmazás a {appId}.{domain} címen lesz elérhető. (Látogasson el a beállítások oldalra a helyi tartomány beállításához)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Kiteszi az alkalmazást a helyi hálózaton? Ez az alkalmazás a {{appId}}.{{domain}} címen lesz elérhető. (Látogasson el a beállítások oldalra a helyi tartomány beállításához)", "APP_INSTALL_FORM_RESET": "Alkalmazás alaphelyzetre állítása", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Telepítés", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Frissítés", - "APP_INSTALL_FORM_TITLE": "{name} telepítése", + "APP_INSTALL_FORM_TITLE": "{{name}} telepítése", "APP_INSTALL_FORM_GENERAL": "Általános", "APP_INSTALL_FORM_REVERSE_PROXY": "Fordított proxy", - "APP_INSTALL_SUCCESS": "A(z) {id} alkalmazás sikeresen telepítve", + "APP_INSTALL_SUCCESS": "A(z) {{id}} alkalmazás sikeresen telepítve", "APP_LOGS_TAB_FOLLOW": "Naplók követése", "APP_LOGS_TAB_MAX_LINES": "Max sorok száma:", "APP_LOGS_TAB_TITLE": "Naplók", @@ -87,20 +87,20 @@ "APP_NEW": "ÚJ", "APP_RESET_FORM_SUBMIT": "Alaphelyzetbe állítás", "APP_RESET_FORM_SUBTITLE": "Az alkalmazás összes adata elveszik.", - "APP_RESET_FORM_TITLE": "Alaphelyzetbe állítod a következőt: {name} ?", + "APP_RESET_FORM_TITLE": "Alaphelyzetbe állítod a következőt: {{name}} ?", "APP_RESET_FORM_WARNING": "Biztos benne? Ezt a műveletet nem lehet visszavonni.", - "APP_RESET_SUCCESS": "{id} Alkalmazás sikeresen alaphelyzetbe állítva", - "APP_START_SUCCESS": "{id} Alkalmazás sikeresen elindítva", - "APP_BACKUP_TITLE": "Backup {name}", - "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", - "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", - "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", - "APP_RESTORE_SUBMIT": "Restore", - "APP_BACKUPS_TAB_TITLE": "Backups", - "APP_SETTINGS_GENERAL_TITLE": "General", - "APP_SETTINGS_BACKUPS_TITLE": "Backups", + "APP_RESET_SUCCESS": "{{id}} Alkalmazás sikeresen alaphelyzetbe állítva", + "APP_START_SUCCESS": "{{id}} Alkalmazás sikeresen elindítva", + "APP_BACKUP_TITLE": "Biztonsági mentés: {{name}}", + "APP_BACKUP_SUBTITLE": "A biztonsági mentések mappában létrejön egy tar-archívum az alkalmazás adatainak tárolására.", + "APP_BACKUP_SUBMIT": "Biztonsági mentés", + "APP_RESTORE_TITLE": "{{name}} biztonsági mentés visszaállítása", + "APP_RESTORE_WARNING": "Tényleg vissza akarja állítani a {{date}} készült {{id}} biztonsági mentést?", + "APP_RESTORE_SUBTITLE": "Az alkalmazás összes aktuális adatát törli, és a biztonsági mentésből származó adatokkal helyettesíti. A visszaállítás előtt ajánlott biztonsági mentést készíteni az alkalmazásról.", + "APP_RESTORE_SUBMIT": "Visszaállítás", + "APP_BACKUPS_TAB_TITLE": "Biztonsági mentések", + "APP_SETTINGS_GENERAL_TITLE": "Általános", + "APP_SETTINGS_BACKUPS_TITLE": "Biztonsági mentések", "APP_STATUS_INSTALLING": "Telepítés folyamatban", "APP_STATUS_MISSING": "Hiányzó", "APP_STATUS_RESETTING": "Alaphelyzetbe állítás", @@ -111,39 +111,57 @@ "APP_STATUS_RESTARTING": "Újraindítás", "APP_STATUS_UNINSTALLING": "Eltávolítás", "APP_STATUS_UPDATING": "Frissítés", - "APP_STATUS_BACKING_UP": "Backing up", - "APP_STATUS_RESTORING": "Restoring", + "APP_STATUS_BACKING_UP": "Biztonsági mentés folyamatban", + "APP_STATUS_RESTORING": "Visszaállítás", "APP_STOP_FORM_SUBMIT": "Megállít", "APP_STOP_FORM_SUBTITLE": "Minden adat megmarad", - "APP_STOP_FORM_TITLE": "Megállít {name} ?", - "APP_STOP_SUCCESS": "{id} Alkalmazás sikeresen leállítva", + "APP_STOP_FORM_TITLE": "Megállít {{name}} ?", + "APP_STOP_SUCCESS": "{{id}} Alkalmazás sikeresen leállítva", "APP_RESTART_FORM_SUBMIT": "Újraindítás", "APP_RESTART_FORM_SUBTITLE": "Minden adat megmarad", - "APP_RESTART_FORM_TITLE": "Újraindítja a következőt: {name} ?", - "APP_RESTART_SUCCESS": "Az alkalmazás {id} sikeresen újraindult", + "APP_RESTART_FORM_TITLE": "Újraindítja a következőt: {{name}} ?", + "APP_RESTART_SUCCESS": "Az alkalmazás {{id}} sikeresen újraindult", "APP_STORE_CATEGORY_PLACEHOLDER": "Kategória választása", "APP_STORE_NO_RESULTS": "Nem található alkalmazás", "APP_STORE_NO_RESULTS_SUBTITLE": "Próbálja meg finomítani a keresést", "APP_STORE_SEARCH_PLACEHOLDER": "Alkalmazások keresése", "APP_STORE_TITLE": "Alkalmazásbolt", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Biztonsági mentés eltávolítása", "APP_UNINSTALL_FORM_SUBMIT": "Eltávolit", "APP_UNINSTALL_FORM_SUBTITLE": "Az alkalmazás összes adata elveszik.", - "APP_UNINSTALL_FORM_TITLE": "Távolítsa el a {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Távolítsa el a {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Biztos benne? Ezt a műveletet nem lehet visszavonni.", - "APP_UNINSTALL_SUCCESS": "{id} Alkalmazás sikeresen eltávolítva", + "APP_UNINSTALL_SUCCESS": "{{id}} Alkalmazás sikeresen eltávolítva", "APP_UPDATE_CONFIG_SUCCESS": "Az alkalmazás konfigurációja sikeresen frissült. Indítsa újra az alkalmazást a módosítások alkalmazásához", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Az alkalmazás {id} frissítéséhez a Tipi {minVersion} vagy magasabb verziója szükséges. Kérjük, frissítse a példányát.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Az alkalmazás {{id}} frissítéséhez a Tipi {{minVersion}} vagy magasabb verziója szükséges. Kérjük, frissítse a példányát.", "APP_UPDATE_FORM_SUBMIT": "Frissítés", "APP_UPDATE_FORM_SUBTITLE_1": "Frissítse az alkalmazást a legújabb verzióra:", "APP_UPDATE_FORM_SUBTITLE_2": "Ez visszaállítja az egyéni konfigurációt (pl. változások a docker-compose.yml-ben)", - "APP_UPDATE_FORM_TITLE": "Frissíti {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Frissítse a {name} konfigurációt", - "APP_UPDATE_SUCCESS": "{id} Alkalmazás sikeresen frissítve", - "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_UPDATE_FORM_TITLE": "Frissíti {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Frissítse a {{name}} konfigurációt", + "APP_UPDATE_SUCCESS": "{{id}} Alkalmazás sikeresen frissítve", + "APP_UPDATE_FORM_BACKUP": "Biztonsági mentés alkalmazása frissítés előtt", + "APP_BACKUP_SUCCESS": "Alkalmazás {{id}} biztonsági mentés sikeresen végrehajtva", + "APP_BACKUP_ERROR": "Nem sikerült a biztonsági mentés {{id}}, további részletekért lásd a naplófájlokat", + "APP_RESTORE_SUCCESS": "Alkalmazás {{id}} sikeresen helyreállítva", + "APP_RESTORE_ERROR": "Nem sikerült visszaállítani a {{id}}, további részletekért lásd a naplófájlokat", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Már van egy admin felhasználó. Kérjük, jelentkezzen be, hogy új felhasználót hozzon létre az admin panelen.", "AUTH_ERROR_ERROR_CREATING_USER": "Hiba a felhasználó létrehozásakor", "AUTH_ERROR_INVALID_CREDENTIALS": "Érvénytelen hitelesítő adatok", @@ -184,32 +202,33 @@ "AUTH_RESET_PASSWORD_CANCEL": "Jelszómódosítási kérés visszavonása", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "A kezdéshez futtassa ezt a parancsot a kiszolgálón, majd frissítse ezt az oldalt. Ha ezt már korábban megtette, akkor a jelszó-visszaállítási kérelem már lejárt. Ebben az esetben kérjük, próbálja meg újra", "AUTH_RESET_PASSWORD_SUBMIT": "Jelszó visszaállítása", - "AUTH_RESET_PASSWORD_SUCCESS": "A jelszava vissza lett állítva. Most már bejelentkezhet az új jelszavával. Ez az e-mail címe {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "A jelszava vissza lett állítva. Most már bejelentkezhet az új jelszavával. Ez az e-mail címe {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Jelszó visszaállítása", "AUTH_RESET_PASSWORD_TITLE": "Jelszó visszaállítása", "AUTH_TOTP_INSTRUCTIONS": "Írja be a kódot a hitelesítő alkalmazásból", "AUTH_TOTP_SUBMIT": "Megerősít", "AUTH_TOTP_TITLE": "Kétlépcsős hitelesítés", - "BACKUPS_LIST": "Backups list", - "BACKUPS_LIST_BACKUP_NOW": "Backup now", + "BACKUPS_LIST": "Biztonsági mentések listája", + "BACKUPS_LIST_BACKUP_NOW": "Biztonsági mentés most", "BACKUPS_LIST_ROW_TITLE_ID": "ID", - "BACKUPS_LIST_ROW_TITLE_DATE": "Date", - "BACKUPS_LIST_ROW_TITLE_ACTIONS": "Actions", - "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", - "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", + "BACKUPS_LIST_ROW_TITLE_DATE": "Dátum", + "BACKUPS_LIST_ROW_TITLE_ACTIONS": "Tevékenységek", + "BACKUPS_LIST_ROW_TITLE_SIZE": "Méret", + "BACKUPS_LIST_DELETE_SUCCESS": "Biztonsági mentés sikeresen törölve", "COMMON_CLOSE": "Bezár", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Távolítson el alkalmazásokat a terhelés csökkentése érdekében", "DASHBOARD_CPU_TITLE": "CPU kihasználtság", - "DASHBOARD_DISK_SPACE_SUBTITLE": "A {total} GB-ból használható", + "DASHBOARD_DISK_SPACE_SUBTITLE": "A {{total}} GB-ból használható", "DASHBOARD_DISK_SPACE_TITLE": "Lemezterület", "DASHBOARD_MEMORY_TITLE": "Memória használat", "DASHBOARD_TITLE": "Irányítópult", "DASHBOARD_IP_WARNING_TITLE": "Nem biztonságos konfiguráció", "DASHBOARD_IP_WARNING": "Figyelmeztetés, veszélyben lehetsz! Úgy tűnik, hogy nyilvános IP-címen keresztül lépsz be a példányodba. Ezáltal a műszerfalad és az összes telepített alkalmazásod sebezhetővé válik a támadók számára", - "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", - "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", - "DELETE_BACKUP_MODAL_SUBMIT": "Delete", + "DELETE_BACKUP_MODAL_TITLE": "Biztonsági mentés törlése", + "DELETE_BACKUP_MODAL_WARNING": "Biztosan törölni szeretné a {{date}} készült {{id}} biztonsági mentést?", + "DELETE_BACKUP_MODAL_SUBTITLE": "Ez a művelet nem vonható vissza", + "DELETE_BACKUP_MODAL_SUBMIT": "Törlés", "GUEST_DASHBOARD": "Vendég műszerfal", "GUEST_DASHBOARD_NO_APPS": "Nincs megjeleníthető alkalmazás", "GUEST_DASHBOARD_NO_APPS_SUBTITLE": "Kérje meg a rendszergazdát, hogy adjon hozzá alkalmazásokat a vendég műszerfalhoz, vagy jelentkezzen be az alkalmazások megtekintéséhez.", @@ -227,16 +246,16 @@ "INTERNAL_SERVER_ERROR": "Belső szerverhiba", "LINKS_ADD_SUBMIT": "Küldés", "LINKS_ADD_SUBTITLE": "Külső hivatkozás hozzáadása a műszerfalhoz", - "LINKS_ADD_SUCCESS": "Link added successfully", + "LINKS_ADD_SUCCESS": "A hivatkozás sikeresen hozzáadva.", "LINKS_ADD_TITLE": "Külső hivatkozás hozzáadása", "LINKS_DELETE_CONTEXT_MENU": "Törlés", "LINKS_DELETE_SUBMIT": "Törlés", "LINKS_DELETE_SUBTITLE": "Biztosan törli ezt a külső hivatkozást?", - "LINKS_DELETE_SUCCESS": "Link deleted successfully", + "LINKS_DELETE_SUCCESS": "A hivatkozás sikeresen törölve", "LINKS_DELETE_TITLE": "Külső hivatkozás törlése", "LINKS_EDIT_CONTEXT_MENU": "Szerkesztés", "LINKS_EDIT_SUBMIT": "Mentés", - "LINKS_EDIT_SUCCESS": "Link edited successfully", + "LINKS_EDIT_SUCCESS": "A hivatkozás sikeresen szerkesztve", "LINKS_EDIT_TITLE": "Hivatkozás szerkesztése", "LINKS_FORM_ICON_PLACEHOLDER": "Logo hivatkozás (URL)", "LINKS_FORM_ICON_URL": "Ikon cím (URL)", @@ -258,20 +277,28 @@ "SERVER_ERROR_INVALID_LOCALE": "Érvénytelen nyelvi beállítás", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Demó üzemmódban nem engedélyezett", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Dev módban nem megengedett", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Már naprakész", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Jelenlegi verzió: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Jelenlegi verzió: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Általános műveletek ezen a példányon", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Karbantartás", - "SETTINGS_ACTIONS_NEW_VERSION": "A Tipi új verziója ({version}) elérhető", + "SETTINGS_ACTIONS_NEW_VERSION": "A Tipi új verziója ({{version}}) elérhető", "SETTINGS_ACTIONS_RESTART": "Újraindítás", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Legyen naprakész a Tipi legújabb verziójával", "SETTINGS_ACTIONS_TAB_TITLE": "Műveletek", "SETTINGS_ACTIONS_TITLE": "Műveletek", - "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Update Repository", - "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", - "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Repozitórium frissítése", + "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Használja ezt a gombot az appstore frissítéséhez", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Ez visszaállítja az adattárat, és a legfrissebb változásokat húzza le a GitHubról", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Frissítés", + "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Az Appstore tároló sikeresen frissült", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Automatikus témák engedélyezése", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Legyen meglepve a témákkal, amelyek az évszaknak megfelelően automatikusan változnak.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Anonim hibafigyelés engedélyezése", @@ -339,6 +366,6 @@ "SYSTEM_ERROR_DEMO_MODE_LIMIT": "A demo verzióban csak 6 applikáció telepíthető. Kérjük töröljön egy applikációt, hogy újat telepíthessen.", "SYSTEM_ERROR_MAJOR_VERSION_UPDATE": "A fő verzió megváltozott, kérjük frissítse manuálisan (utasítások a kiadás megjegyzéseiben)", "SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN": "A művelet végrehajtásához be kell jelentkeznie", - "TIMEZONE_SELECTOR_LABEL": "Timezone", - "TIMEZONE_SELECTOR_PLACEHOLDER": "Select a timezone" + "TIMEZONE_SELECTOR_LABEL": "Időzóna", + "TIMEZONE_SELECTOR_PLACEHOLDER": "Időzóna kiválasztása" } diff --git a/src/client/messages/it-IT.json b/packages/backend/src/modules/i18n/translations/it-IT.json similarity index 78% rename from src/client/messages/it-IT.json rename to packages/backend/src/modules/i18n/translations/it-IT.json index 41ee1607d1..a4106c9e00 100644 --- a/src/client/messages/it-IT.json +++ b/packages/backend/src/modules/i18n/translations/it-IT.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Dettagli app", "APP_DETAILS_VERSION": "Versione", "APP_DETAILS_WEBSITE": "Sito web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Non possibile installare l'app {id}, guarda i log per maggiori dettagli", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Non possibile avviare l'app {id}, guarda i log per maggiori dettagli", - "APP_ERROR_APP_FAILED_TO_STOP": "Non possibile arrestare l'app {id}, guarda i log per maggiori dettagli", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Non possibile disinstallare l'app {id}, guarda i log per maggiori dettagli", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Non possibile aggiornare l'app {id}, guarda i log per maggiori dettagli", - "APP_ERROR_APP_FORCE_EXPOSED": "L'app {id} può funzionare solo con un dominio esposto", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} non può essere esposta", - "APP_ERROR_APP_NOT_FOUND": "App {id} non trovata", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Il dominio {domain} è già in uso dall'app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Dominio {domain} non è un dominio valido", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Non possibile installare l'app {{id}}, guarda i log per maggiori dettagli", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Non possibile avviare l'app {{id}}, guarda i log per maggiori dettagli", + "APP_ERROR_APP_FAILED_TO_STOP": "Non possibile arrestare l'app {{id}}, guarda i log per maggiori dettagli", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Non possibile disinstallare l'app {{id}}, guarda i log per maggiori dettagli", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Non possibile aggiornare l'app {{id}}, guarda i log per maggiori dettagli", + "APP_ERROR_APP_FORCE_EXPOSED": "L'app {{id}} può funzionare solo con un dominio esposto", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} non può essere esposta", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} non trovata", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Il dominio {{domain}} è già in uso dall'app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Dominio {{domain}} non è un dominio valido", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Dominio richiesto quando l'app è esposta", - "APP_ERROR_INVALID_CONFIG": "L'app {id} ha un file config.json non valido", + "APP_ERROR_INVALID_CONFIG": "L'app {{id}} ha un file config.json non valido", "APP_INSTALL_FORM_CHOOSE_OPTION": "Scegli un opzione...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Nome di dominio", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Assicurati che questo dominio contenga un record di tipo A che punti al tuo indirizzo IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} deve contenere tra {min} e {max} caratteri", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} deve essere un dominio valido", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} deve essere un indirizzo IP o un dominio valido", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} deve essere un indirizzo email valido", - "APP_INSTALL_FORM_ERROR_IP": "{label} deve essere un indirizzo IP valido", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} deve contenere meno di {max} caratteri", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} deve contenere almeno {min} caratteri", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} deve essere un numero", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} deve rispettare il pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} è obbligatorio", - "APP_INSTALL_FORM_ERROR_URL": "{label} deve essere un URL valido", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} deve contenere tra {{min}} e {{max}} caratteri", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} deve essere un dominio valido", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} deve essere un indirizzo IP o un dominio valido", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} deve essere un indirizzo email valido", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} deve essere un indirizzo IP valido", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} deve contenere meno di {{max}} caratteri", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} deve contenere almeno {{min}} caratteri", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} deve essere un numero", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} deve rispettare il pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} è obbligatorio", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} deve essere un URL valido", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Installa", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Aggiorna", - "APP_INSTALL_FORM_TITLE": "Installa {name}", + "APP_INSTALL_FORM_TITLE": "Installa {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installata con successo", + "APP_INSTALL_SUCCESS": "App {{id}} installata con successo", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "Tutti i dati relativi a questa app verranno persi.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Sei sicuro? Questa azione è irreversibile.", - "APP_RESET_SUCCESS": "App {id} resettata con successo", - "APP_START_SUCCESS": "App {id} avviata con successo", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} resettata con successo", + "APP_START_SUCCESS": "App {{id}} avviata con successo", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Arresta", "APP_STOP_FORM_SUBTITLE": "Tutti i dati verranno mantenuti", - "APP_STOP_FORM_TITLE": "Arrestare {name} ?", - "APP_STOP_SUCCESS": "App {id} arrestata con successo", + "APP_STOP_FORM_TITLE": "Arrestare {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} arrestata con successo", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Seleziona una categoria", "APP_STORE_NO_RESULTS": "Nessuna app trovata", "APP_STORE_NO_RESULTS_SUBTITLE": "Prova a ottimizare la tua ricerca", "APP_STORE_SEARCH_PLACEHOLDER": "Cerca app", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Disinstalla", "APP_UNINSTALL_FORM_SUBTITLE": "Tutti i dati relativi a questa app verranno persi.", - "APP_UNINSTALL_FORM_TITLE": "Disinstallare {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Disinstallare {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Sei sicuro? Questa azione è irreversibile.", - "APP_UNINSTALL_SUCCESS": "App {id} disinstallata con successo", + "APP_UNINSTALL_SUCCESS": "App {{id}} disinstallata con successo", "APP_UPDATE_CONFIG_SUCCESS": "App configurata con successo. Riavvia l'app per applicare le modifiche", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Aggiorna", "APP_UPDATE_FORM_SUBTITLE_1": "Aggiorna app all'ultima versione :", "APP_UPDATE_FORM_SUBTITLE_2": "Questo resetterà la tua configurazione personalizzata (es. le modifiche in docker-compose.yml).", - "APP_UPDATE_FORM_TITLE": "Aggiornare {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Aggiorna configurazione {name}", - "APP_UPDATE_SUCCESS": "App {id} aggiornata con successo", + "APP_UPDATE_FORM_TITLE": "Aggiornare {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Aggiorna configurazione {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} aggiornata con successo", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Esiste già un utente amministratore. Accedi per creare un nuovo utente dal pannello di amministrazione.", "AUTH_ERROR_ERROR_CREATING_USER": "Errore durante la creazione dell'utente", "AUTH_ERROR_INVALID_CREDENTIALS": "Credenziali invalide", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Annulla richiesta di cambio password", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Resetta password", - "AUTH_RESET_PASSWORD_SUCCESS": "La tua password è stata resettata. Ora puoi accedere con la tua nuova password. E la tua email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "La tua password è stata resettata. Ora puoi accedere con la tua nuova password. E la tua email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Resettata la password", "AUTH_RESET_PASSWORD_TITLE": "Ripristina la tua password", "AUTH_TOTP_INSTRUCTIONS": "Inserisci il codice fornito dalla tua app di autenticazione", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Disinstalla alcune app per ridurre il carico sulla CPU", "DASHBOARD_CPU_TITLE": "Carico CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilizzati su un totale di {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilizzati su un totale di {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Spazio su disco", "DASHBOARD_MEMORY_TITLE": "Memoria usata", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Locale non valido", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Non consentito in modalità demo", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Non consentito in modalità sviluppo", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Già aggiornato", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versione corrente: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versione corrente: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Azioni comuni da eseguire sulla tua istanza", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Manutenzione", - "SETTINGS_ACTIONS_NEW_VERSION": "Una nuova versione ({version}) di Tipi è disponibile", + "SETTINGS_ACTIONS_NEW_VERSION": "Una nuova versione ({{version}}) di Tipi è disponibile", "SETTINGS_ACTIONS_RESTART": "Riavvia", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Rimani aggiornato all'ultima versione di Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Azioni", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/ja-JP.json b/packages/backend/src/modules/i18n/translations/ja-JP.json similarity index 77% rename from src/client/messages/ja-JP.json rename to packages/backend/src/modules/i18n/translations/ja-JP.json index 38dc9e36ac..016ca2d71d 100644 --- a/src/client/messages/ja-JP.json +++ b/packages/backend/src/modules/i18n/translations/ja-JP.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "バージョン", "APP_DETAILS_WEBSITE": "ウェブサイト", - "APP_ERROR_APP_FAILED_TO_INSTALL": "アプリ {id} のインストールに失敗しました。詳細はログを参照してください。", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "アプリ {id} の起動に失敗しました。詳細はログを参照してください。", - "APP_ERROR_APP_FAILED_TO_STOP": "アプリ {id} の停止に失敗しました。詳細はログを参照してください。", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "アプリ {id} のアンインストールに失敗しました。詳細はログを参照してください。", - "APP_ERROR_APP_FAILED_TO_UPDATE": "アプリ {id} の更新に失敗しました。詳細はログを参照してください。", - "APP_ERROR_APP_FORCE_EXPOSED": "アプリ {id} は、公開されたドメインでのみ動作します", - "APP_ERROR_APP_NOT_EXPOSABLE": "アプリ {id} は公開できません", - "APP_ERROR_APP_NOT_FOUND": "アプリ {id} が見つかりません", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "ドメイン {domain} はすでにアプリ {id} によって使用されています", - "APP_ERROR_DOMAIN_NOT_VALID": "{domain} は無効なドメインです", + "APP_ERROR_APP_FAILED_TO_INSTALL": "アプリ {{id}} のインストールに失敗しました。詳細はログを参照してください。", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "アプリ {{id}} の起動に失敗しました。詳細はログを参照してください。", + "APP_ERROR_APP_FAILED_TO_STOP": "アプリ {{id}} の停止に失敗しました。詳細はログを参照してください。", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "アプリ {{id}} のアンインストールに失敗しました。詳細はログを参照してください。", + "APP_ERROR_APP_FAILED_TO_UPDATE": "アプリ {{id}} の更新に失敗しました。詳細はログを参照してください。", + "APP_ERROR_APP_FORCE_EXPOSED": "アプリ {{id}} は、公開されたドメインでのみ動作します", + "APP_ERROR_APP_NOT_EXPOSABLE": "アプリ {{id}} は公開できません", + "APP_ERROR_APP_NOT_FOUND": "アプリ {{id}} が見つかりません", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "ドメイン {{domain}} はすでにアプリ {{id}} によって使用されています", + "APP_ERROR_DOMAIN_NOT_VALID": "{{domain}} は無効なドメインです", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "アプリが公開される場合はドメインが必要です", - "APP_ERROR_INVALID_CONFIG": "アプリ {id} は無効な config.json ファイルを持っている", + "APP_ERROR_INVALID_CONFIG": "アプリ {{id}} は無効な config.json ファイルを持っている", "APP_INSTALL_FORM_CHOOSE_OPTION": "オプションを選択...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "ドメイン名", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "このドメインのDNSには、正確にIPを指すAレコードが含まれていることを確認してください。", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} は {min} 文字以上 {max} 文字以下で入力してください", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} は有効なドメインでなければなりません", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} は有効なドメインまたは IP アドレスでなければなりません", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} は有効なメールアドレスでなければなりません", - "APP_INSTALL_FORM_ERROR_IP": "{label} は有効な IP アドレスでなければなりません", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} は {max} より小さくなければなりません", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} は少なくとも {min} 文字必要です", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} は数字でなければなりません", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} はパターン {pattern} に一致する必要があります", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} が必要です", - "APP_INSTALL_FORM_ERROR_URL": "{label} は有効な URL でなければなりません", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} は {{min}} 文字以上 {{max}} 文字以下で入力してください", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} は有効なドメインでなければなりません", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} は有効なドメインまたは IP アドレスでなければなりません", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} は有効なメールアドレスでなければなりません", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} は有効な IP アドレスでなければなりません", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} は {{max}} より小さくなければなりません", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} は少なくとも {{min}} 文字必要です", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} は数字でなければなりません", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} はパターン {{pattern}} に一致する必要があります", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} が必要です", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} は有効な URL でなければなりません", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "インストール", "APP_INSTALL_FORM_SUBMIT_UPDATE": "更新", - "APP_INSTALL_FORM_TITLE": "{name} をインストール", + "APP_INSTALL_FORM_TITLE": "{{name}} をインストール", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "このアプリのすべてのデータが失われます。", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "本当に実行しますか?この作業は元に戻せません。", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "停止", "APP_STOP_FORM_SUBTITLE": "すべてのデータが保持されます", - "APP_STOP_FORM_TITLE": "{name} を停止しますか?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "{{name}} を停止しますか?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "カテゴリーを選択する", "APP_STORE_NO_RESULTS": "アプリが見つかりません", "APP_STORE_NO_RESULTS_SUBTITLE": "検索条件を絞り込んでください", "APP_STORE_SEARCH_PLACEHOLDER": "アプリを検索", "APP_STORE_TITLE": "アプリストア", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "アンインストール", "APP_UNINSTALL_FORM_SUBTITLE": "このアプリのすべてのデータが失われます。", - "APP_UNINSTALL_FORM_TITLE": "{name} をアンインストール?", + "APP_UNINSTALL_FORM_TITLE": "{{name}} をアンインストール?", "APP_UNINSTALL_FORM_WARNING": "本当に実行しますか?この作業は元に戻せません。", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "アプリの設定が正常に更新されました。変更を適用するにはアプリを再起動してください", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "更新", "APP_UPDATE_FORM_SUBTITLE_1": "アプリを最新のバージョンに更新:", "APP_UPDATE_FORM_SUBTITLE_2": "カスタム構成をリセットします (例: docker-compose.yml の変更)", - "APP_UPDATE_FORM_TITLE": "{name} を更新しますか?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "{name} 設定を更新", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "{{name}} を更新しますか?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "{{name}} 設定を更新", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "すでに管理者ユーザーがいます。管理者パネルから新しいユーザーを作成するにはログインしてください。", "AUTH_ERROR_ERROR_CREATING_USER": "利用者登録時のエラー", "AUTH_ERROR_INVALID_CREDENTIALS": "無効なログイン情報", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "パスワード変更リクエストをキャンセルする", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "パスワードをリセット", - "AUTH_RESET_PASSWORD_SUCCESS": "パスワードがリセットされました。新しいパスワードでログインできるようになりました。そして、メールアドレス {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "パスワードがリセットされました。新しいパスワードでログインできるようになりました。そして、メールアドレス {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "パスワードをリセットする", "AUTH_RESET_PASSWORD_TITLE": "パスワードをリセット", "AUTH_TOTP_INSTRUCTIONS": "認証アプリからコードを入力してください", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "アプリをアンインストールして負荷を軽減する", "DASHBOARD_CPU_TITLE": "CPU負荷", - "DASHBOARD_DISK_SPACE_SUBTITLE": "使用済み、{total} GB のうち", + "DASHBOARD_DISK_SPACE_SUBTITLE": "使用済み、{{total}} GB のうち", "DASHBOARD_DISK_SPACE_TITLE": "ディスク領域", "DASHBOARD_MEMORY_TITLE": "使用済みメモリ", "DASHBOARD_TITLE": "ダッシュボード", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "無効なロケールです", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "デモモードでは使用できません", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "開発モードでは使用できません", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "既に最新です", - "SETTINGS_ACTIONS_CURRENT_VERSION": "現在のバージョン: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "現在のバージョン: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "あなたのインスタンスで実行する一般的なアクション", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "メンテナンス", - "SETTINGS_ACTIONS_NEW_VERSION": "Tipiの新しいバージョン({version})が利用可能です", + "SETTINGS_ACTIONS_NEW_VERSION": "Tipiの新しいバージョン({{version}})が利用可能です", "SETTINGS_ACTIONS_RESTART": "再起動", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Tipiのバージョンを最新に更新する", "SETTINGS_ACTIONS_TAB_TITLE": "行動", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/ko-KR.json b/packages/backend/src/modules/i18n/translations/ko-KR.json similarity index 52% rename from src/client/messages/ko-KR.json rename to packages/backend/src/modules/i18n/translations/ko-KR.json index 23417edfd9..1a7226136a 100644 --- a/src/client/messages/ko-KR.json +++ b/packages/backend/src/modules/i18n/translations/ko-KR.json @@ -1,153 +1,171 @@ { - "APP_ACTION_CANCEL": "Cancel", - "APP_ACTION_INSTALL": "Install", - "APP_ACTION_LOADING": "Loading", - "APP_ACTION_OPEN": "Open", - "APP_ACTION_REMOVE": "Remove", - "APP_ACTION_SETTINGS": "Settings", - "APP_ACTION_START": "Start", - "APP_ACTION_STOP": "Stop", - "APP_ACTION_RESTART": "Restart", - "APP_ACTION_UPDATE": "Update", + "APP_ACTION_CANCEL": "취소", + "APP_ACTION_INSTALL": "설치", + "APP_ACTION_LOADING": "로딩 중", + "APP_ACTION_OPEN": "열기", + "APP_ACTION_REMOVE": "제거", + "APP_ACTION_SETTINGS": "설정", + "APP_ACTION_START": "시작", + "APP_ACTION_STOP": "중지", + "APP_ACTION_RESTART": "재시작", + "APP_ACTION_UPDATE": "업데이트", "APP_CATEGORY_AI": "AI", - "APP_CATEGORY_AUTOMATION": "Automation", - "APP_CATEGORY_BOOKS": "Books", - "APP_CATEGORY_DATA": "Data", - "APP_CATEGORY_DEVELOPMENT": "Development", - "APP_CATEGORY_FEATURED": "Featured", - "APP_CATEGORY_FINANCE": "Finance", - "APP_CATEGORY_GAMING": "Gaming", - "APP_CATEGORY_MEDIA": "Media", - "APP_CATEGORY_MUSIC": "Music", - "APP_CATEGORY_NETWORK": "Network", - "APP_CATEGORY_PHOTOGRAPHY": "Photography", - "APP_CATEGORY_SECURITY": "Security", - "APP_CATEGORY_SOCIAL": "Social", - "APP_CATEGORY_UTILITIES": "Utilities", - "APP_DETAILS_AUTHOR": "Author", - "APP_DETAILS_BASE_INFO": "Base info", - "APP_DETAILS_CATEGORIES_TITLE": "Categories", - "APP_DETAILS_CHOOSE_OPEN_METHOD": "Choose open method", - "APP_DETAILS_DEPRECATED_ALERT_SUBTITLE": "A breaking change in this app prevents it from being updated automatically. You can still use this version and update it manually, but it is recommended to switch to a newer version and migrate your data. You can find an updated version in the app store under the same name.", - "APP_DETAILS_DEPRECATED_ALERT_TITLE": "This app is deprecated", - "APP_DETAILS_DESCRIPTION": "Description", - "APP_DETAILS_LINK": "Link", - "APP_DETAILS_PORT": "Port", - "APP_DETAILS_SOURCE_CODE": "Source code", - "APP_DETAILS_SUPPORTED_ARCH": "Supported architectures", - "APP_DETAILS_TITLE": "App details", - "APP_DETAILS_VERSION": "Version", - "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", - "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", - "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", - "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", - "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", - "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", - "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", - "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", - "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", - "APP_INSTALL_FORM_RESET": "Reset app", - "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", - "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", - "APP_INSTALL_FORM_GENERAL": "General", - "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", - "APP_LOGS_TAB_FOLLOW": "Follow logs", - "APP_LOGS_TAB_MAX_LINES": "Max lines:", - "APP_LOGS_TAB_TITLE": "Logs", - "APP_LOGS_TAB_WRAP_LINES": "Wrap lines", - "APP_NEW": "NEW", - "APP_RESET_FORM_SUBMIT": "Reset", - "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", - "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", - "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", - "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", - "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", - "APP_RESTORE_SUBMIT": "Restore", - "APP_BACKUPS_TAB_TITLE": "Backups", - "APP_SETTINGS_GENERAL_TITLE": "General", - "APP_SETTINGS_BACKUPS_TITLE": "Backups", - "APP_STATUS_INSTALLING": "Installing", - "APP_STATUS_MISSING": "Missing", - "APP_STATUS_RESETTING": "Resetting", - "APP_STATUS_RUNNING": "Running", - "APP_STATUS_STARTING": "Starting", - "APP_STATUS_STOPPED": "Stopped", - "APP_STATUS_STOPPING": "Stopping", - "APP_STATUS_RESTARTING": "Restarting", - "APP_STATUS_UNINSTALLING": "Uninstalling", - "APP_STATUS_UPDATING": "Updating", - "APP_STATUS_BACKING_UP": "Backing up", - "APP_STATUS_RESTORING": "Restoring", - "APP_STOP_FORM_SUBMIT": "Stop", - "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", - "APP_RESTART_FORM_SUBMIT": "Restart", - "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", - "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", - "APP_STORE_NO_RESULTS": "No app found", - "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", - "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", - "APP_STORE_TITLE": "App Store", - "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", - "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", - "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", - "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", - "APP_UPDATE_FORM_SUBMIT": "Update", - "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", - "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", - "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", - "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", - "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", - "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", - "AUTH_ERROR_INVALID_PASSWORD": "Invalid password", + "APP_CATEGORY_AUTOMATION": "자동화", + "APP_CATEGORY_BOOKS": "도서", + "APP_CATEGORY_DATA": "데이터", + "APP_CATEGORY_DEVELOPMENT": "개발", + "APP_CATEGORY_FEATURED": "추천", + "APP_CATEGORY_FINANCE": "금융", + "APP_CATEGORY_GAMING": "게임", + "APP_CATEGORY_MEDIA": "미디어", + "APP_CATEGORY_MUSIC": "음악", + "APP_CATEGORY_NETWORK": "네트워크", + "APP_CATEGORY_PHOTOGRAPHY": "사진", + "APP_CATEGORY_SECURITY": "보안", + "APP_CATEGORY_SOCIAL": "소셜", + "APP_CATEGORY_UTILITIES": "유틸리티", + "APP_DETAILS_AUTHOR": "작성자", + "APP_DETAILS_BASE_INFO": "기본 정보", + "APP_DETAILS_CATEGORIES_TITLE": "카테고리", + "APP_DETAILS_CHOOSE_OPEN_METHOD": "열기 방법 선택", + "APP_DETAILS_DEPRECATED_ALERT_SUBTITLE": "이 앱의 호환성이 깨지는 변경으로 인해 자동 업데이트가 불가능합니다. 이 버전을 계속 사용하고 수동으로 업데이트할 수 있지만, 새로운 버전으로 전환하고 데이터를 마이그레이션하는 것이 좋습니다. 앱 스토어에서 동일한 이름으로 업데이트된 버전을 찾을 수 있습니다.", + "APP_DETAILS_DEPRECATED_ALERT_TITLE": "이 앱은 더 이상 지원되지 않습니다", + "APP_DETAILS_DESCRIPTION": "설명", + "APP_DETAILS_LINK": "링크", + "APP_DETAILS_PORT": "포트", + "APP_DETAILS_SOURCE_CODE": "소스 코드", + "APP_DETAILS_SUPPORTED_ARCH": "지원되는 아키텍처", + "APP_DETAILS_TITLE": "앱 상세정보", + "APP_DETAILS_VERSION": "버전", + "APP_DETAILS_WEBSITE": "웹사이트", + "APP_ERROR_APP_FAILED_TO_INSTALL": "앱 {{id}} 설치 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_RESET": "앱 {{id}} 초기화 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_START": "앱 {{id}} 시작 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_STOP": "앱 {{id}} 중지 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_RESTART": "앱 {{id}} 재시작 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "앱 {{id}} 제거 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FAILED_TO_UPDATE": "앱 {{id}} 업데이트 실패, 자세한 내용은 로그를 참조하세요", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", + "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "앱이 노출되는 경우 도메인이 필요합니다", + "APP_ERROR_INVALID_CONFIG": "앱 {{id}}의 config.json 파일이 유효하지 않습니다", + "APP_INSTALL_FORM_CHOOSE_OPTION": "옵션을 선택하세요...", + "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "게스트 대시보드에 표시", + "APP_INSTALL_FORM_DOMAIN_NAME": "도메인 이름", + "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "이 도메인에 귀하의 IP를 가리키는 A 레코드가 포함되어 있는지 확인하세요.", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} 은(는) 패턴 {{pattern}}과(와) 일치해야 합니다", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", + "APP_INSTALL_FORM_EXPOSE_APP": "인터넷에서 앱 공개", + "APP_INSTALL_FORM_OPEN_PORT": "포트 열기", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", + "APP_INSTALL_FORM_EXPOSE_LOCAL": "로컬 네트워크에서 앱 공개", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_RESET": "앱 초기화", + "APP_INSTALL_FORM_SUBMIT_INSTALL": "설치", + "APP_INSTALL_FORM_SUBMIT_UPDATE": "업데이트", + "APP_INSTALL_FORM_TITLE": "{{name}} 설치", + "APP_INSTALL_FORM_GENERAL": "일반", + "APP_INSTALL_FORM_REVERSE_PROXY": "리버스 프록시", + "APP_INSTALL_SUCCESS": "앱 {{id}} 설치 완료", + "APP_LOGS_TAB_FOLLOW": "로그 추적", + "APP_LOGS_TAB_MAX_LINES": "최대 줄 수:", + "APP_LOGS_TAB_TITLE": "로그", + "APP_LOGS_TAB_WRAP_LINES": "줄 바꿈", + "APP_NEW": "신규", + "APP_RESET_FORM_SUBMIT": "초기화", + "APP_RESET_FORM_SUBTITLE": "이 앱의 모든 데이터가 삭제됩니다.", + "APP_RESET_FORM_TITLE": "{{name}} 초기화하시겠습니까?", + "APP_RESET_FORM_WARNING": "정말 실행하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "APP_RESET_SUCCESS": "앱 {{id}} 초기화 완료", + "APP_START_SUCCESS": "앱 {{id}} 시작 완료", + "APP_BACKUP_TITLE": "{{name}} 백업", + "APP_BACKUP_SUBTITLE": "앱 데이터를 저장하기 위해 백업 폴더에 tar 아카이브가 생성됩니다.", + "APP_BACKUP_SUBMIT": "백업", + "APP_RESTORE_TITLE": "{{name}} 백업 복원하기", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", + "APP_RESTORE_SUBTITLE": "앱의 현재 모든 데이터가 삭제되고 백업의 데이터로 교체됩니다. 복원하기 전에 앱을 백업하는 것이 좋습니다.", + "APP_RESTORE_SUBMIT": "복원", + "APP_BACKUPS_TAB_TITLE": "백업", + "APP_SETTINGS_GENERAL_TITLE": "일반", + "APP_SETTINGS_BACKUPS_TITLE": "백업", + "APP_STATUS_INSTALLING": "설치 중", + "APP_STATUS_MISSING": "누락됨", + "APP_STATUS_RESETTING": "재설정 중", + "APP_STATUS_RUNNING": "실행 중", + "APP_STATUS_STARTING": "시작 중", + "APP_STATUS_STOPPED": "중지됨", + "APP_STATUS_STOPPING": "중지 중", + "APP_STATUS_RESTARTING": "재시작 중", + "APP_STATUS_UNINSTALLING": "제거 중", + "APP_STATUS_UPDATING": "업데이트 중", + "APP_STATUS_BACKING_UP": "백업 중", + "APP_STATUS_RESTORING": "복원 중", + "APP_STOP_FORM_SUBMIT": "중지", + "APP_STOP_FORM_SUBTITLE": "모든 데이터가 유지됩니다", + "APP_STOP_FORM_TITLE": "{{name}} 중지하시겠습니까?", + "APP_STOP_SUCCESS": "앱 {{id}} 중지 완료", + "APP_RESTART_FORM_SUBMIT": "재시작", + "APP_RESTART_FORM_SUBTITLE": "모든 데이터가 유지됩니다", + "APP_RESTART_FORM_TITLE": "{{name}} 재시작하시겠습니까?", + "APP_RESTART_SUCCESS": "앱 {{id}} 재시작 완료", + "APP_STORE_CATEGORY_PLACEHOLDER": "카테고리 선택", + "APP_STORE_NO_RESULTS": "앱을 찾을 수 없습니다", + "APP_STORE_NO_RESULTS_SUBTITLE": "검색어를 다시 설정해보세요", + "APP_STORE_SEARCH_PLACEHOLDER": "앱 검색", + "APP_STORE_TITLE": "앱스토어", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "백업 삭제", + "APP_UNINSTALL_FORM_SUBMIT": "제거", + "APP_UNINSTALL_FORM_SUBTITLE": "이 앱의 모든 데이터가 삭제됩니다.", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", + "APP_UNINSTALL_FORM_WARNING": "확실하신가요? 이 작업은 되돌릴 수 없습니다.", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", + "APP_UPDATE_CONFIG_SUCCESS": "앱 설정이 성공적으로 업데이트되었습니다. 변경사항을 적용하려면 앱을 다시 시작하세요", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "앱 {{id}} 업데이트에는 Tipi 버전 {{minVersion}} 이상이 필요합니다. 인스턴스를 업데이트하세요.", + "APP_UPDATE_FORM_SUBMIT": "업데이트", + "APP_UPDATE_FORM_SUBTITLE_1": "앱을 최신 버전으로 업데이트:", + "APP_UPDATE_FORM_SUBTITLE_2": "앱의 릴리스 노트를 읽고 앱 데이터를 백업했는지 확인하세요.", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "{{name}} 설정 업데이트", + "APP_UPDATE_SUCCESS": "앱 {{id}} 업데이트 성공", + "APP_UPDATE_FORM_BACKUP": "업데이트 전 앱 백업", + "APP_BACKUP_SUCCESS": "앱 {{id}} 백업 성공", + "APP_BACKUP_ERROR": "{{id}} 백업 실패, 자세한 내용은 로그를 확인하세요", + "APP_RESTORE_SUCCESS": "앱 {{id}} 복원 성공", + "APP_RESTORE_ERROR": "{{id}} 복원 실패, 자세한 내용은 로그를 확인하세요", + "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "관리자 계정이 이미 존재합니다. 관리자 패널에서 새 사용자를 생성하려면 로그인하세요.", + "AUTH_ERROR_ERROR_CREATING_USER": "사용자 생성 오류", + "AUTH_ERROR_INVALID_CREDENTIALS": "잘못된 인증 정보", + "AUTH_ERROR_INVALID_PASSWORD": "잘못된 비밀번호", "AUTH_ERROR_INVALID_PASSWORD_LENGTH": "Password must be at least 8 characters long", "AUTH_ERROR_INVALID_USERNAME": "Invalid username", "AUTH_ERROR_MISSING_EMAIL_OR_PASSWORD": "Missing email or password", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/nl-NL.json b/packages/backend/src/modules/i18n/translations/nl-NL.json similarity index 76% rename from src/client/messages/nl-NL.json rename to packages/backend/src/modules/i18n/translations/nl-NL.json index 3aaecd2f93..c453069c62 100644 --- a/src/client/messages/nl-NL.json +++ b/packages/backend/src/modules/i18n/translations/nl-NL.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App-details", "APP_DETAILS_VERSION": "Versie", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Installatie van app {id} mislukt, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_RESET": "Kon app {id} niet resetten, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_START": "Starten van app {id} is mislukt, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_STOP": "Stoppen van app {id} is mislukt, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Kon app {id} niet herstarten, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Installatie van app {id} mislukt, zie logs voor meer details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Bijwerken van app {id} is mislukt, zie logs voor meer informatie", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} werkt alleen met een openbaar domein", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} kan niet openbaar worden gemaakt", - "APP_ERROR_APP_NOT_FOUND": "App {id} is niet gevonden", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Uw architectuur {arch} wordt niet ondersteund door app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domein {domain} is al in gebruik door app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domein {domain} is geen geldig domein", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Installatie van app {{id}} mislukt, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_RESET": "Kon app {{id}} niet resetten, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_START": "Starten van app {{id}} is mislukt, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_STOP": "Stoppen van app {{id}} is mislukt, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Kon app {{id}} niet herstarten, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Installatie van app {{id}} mislukt, zie logs voor meer details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Bijwerken van app {{id}} is mislukt, zie logs voor meer informatie", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} werkt alleen met een openbaar domein", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} kan niet openbaar worden gemaakt", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} is niet gevonden", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Uw architectuur {{arch}} wordt niet ondersteund door app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domein {{domain}} is al in gebruik door app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domein {{domain}} is geen geldig domein", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domein is vereist als de app openbaar is", - "APP_ERROR_INVALID_CONFIG": "App {id} heeft een ongeldig config.json bestand", + "APP_ERROR_INVALID_CONFIG": "App {{id}} heeft een ongeldig config.json bestand", "APP_INSTALL_FORM_CHOOSE_OPTION": "Kies een optie...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Weergeven op gastdashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domeinnaam", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Zorg ervoor dat dit exacte domein een A-record bevat dat naar uw IP verwijst.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} moet tussen {min} en {max} karakters zijn", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} moet een geldige URL zijn", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} moet een geldig domein of IP-adres zijn", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} moet een geldig e-mailadres zijn", - "APP_INSTALL_FORM_ERROR_IP": "{label} moet een geldig IP-adres zijn", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} moet minder dan {max} tekens bevatten", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} moet minstens {min} tekens bevatten", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} moet een nummer zijn", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} moet overeenkomen met het patroon {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is verplicht", - "APP_INSTALL_FORM_ERROR_URL": "{label} moet een geldige URL zijn", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} moet tussen {{min}} en {{max}} karakters zijn", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} moet een geldige URL zijn", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} moet een geldig domein of IP-adres zijn", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} moet een geldig e-mailadres zijn", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} moet een geldig IP-adres zijn", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} moet minder dan {{max}} tekens bevatten", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} moet minstens {{min}} tekens bevatten", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} moet een nummer zijn", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} moet overeenkomen met het patroon {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is verplicht", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} moet een geldige URL zijn", "APP_INSTALL_FORM_EXPOSE_APP": "Applicatie openbaar maken", "APP_INSTALL_FORM_OPEN_PORT": "Open poort", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Een poort openen op de host? Deze app zal toegankelijk zijn op {internalIp}:{port}. (Eenvoudiger maar minder veilig)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Een poort openen op de host? Deze app zal toegankelijk zijn op {{internalIp}}:{{port}}. (Eenvoudiger maar minder veilig)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "App openbaar maken voor het lokaal netwerk", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "De app openbaar maken voor het lokale netwerk? Deze app is bereikbaar op {appId}.{domain}. (Ga naar de instellingenpagina om uw lokale domein in te stellen)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "De app openbaar maken voor het lokale netwerk? Deze app is bereikbaar op {{appId}}.{{domain}}. (Ga naar de instellingenpagina om uw lokale domein in te stellen)", "APP_INSTALL_FORM_RESET": "App resetten", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Installeren", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Updaten", - "APP_INSTALL_FORM_TITLE": "Installeer {name}", + "APP_INSTALL_FORM_TITLE": "Installeer {{name}}", "APP_INSTALL_FORM_GENERAL": "Algemeen", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} met succes geïnstalleerd", + "APP_INSTALL_SUCCESS": "App {{id}} met succes geïnstalleerd", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max regels:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NIEUW", "APP_RESET_FORM_SUBMIT": "Resetten", "APP_RESET_FORM_SUBTITLE": "Alle gegevens voor deze app zullen verloren gaan.", - "APP_RESET_FORM_TITLE": "Reset {name}?", + "APP_RESET_FORM_TITLE": "Reset {{name}}?", "APP_RESET_FORM_WARNING": "Weet u het zeker? Deze actie kan niet ongedaan worden gemaakt.", - "APP_RESET_SUCCESS": "App {id} met succes gereset", - "APP_START_SUCCESS": "App {id} succesvol gestart", - "APP_BACKUP_TITLE": "Back-up {name}", + "APP_RESET_SUCCESS": "App {{id}} met succes gereset", + "APP_START_SUCCESS": "App {{id}} succesvol gestart", + "APP_BACKUP_TITLE": "Back-up {{name}}", "APP_BACKUP_SUBTITLE": "Er zal een tar-bestand aangemaakt worden in de back-up map met de gegevens van je app.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Kies een categorie", "APP_STORE_NO_RESULTS": "Geen app gevonden", "APP_STORE_NO_RESULTS_SUBTITLE": "Probeer uw zoekopdracht te verfijnen", "APP_STORE_SEARCH_PLACEHOLDER": "Apps zoeken", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Deïnstalleren", "APP_UNINSTALL_FORM_SUBTITLE": "Alle gegevens voor deze app zullen verloren gaan.", - "APP_UNINSTALL_FORM_TITLE": "Deïnstalleer {name}?", + "APP_UNINSTALL_FORM_TITLE": "Deïnstalleer {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Weet u het zeker? Deze actie kan niet ongedaan worden gemaakt.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Bijwerken", "APP_UPDATE_FORM_SUBTITLE_1": "Bijwerken naar de laatste versie:", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/no-NO.json b/packages/backend/src/modules/i18n/translations/no-NO.json similarity index 77% rename from src/client/messages/no-NO.json rename to packages/backend/src/modules/i18n/translations/no-NO.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/no-NO.json +++ b/packages/backend/src/modules/i18n/translations/no-NO.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/pl-PL.json b/packages/backend/src/modules/i18n/translations/pl-PL.json similarity index 78% rename from src/client/messages/pl-PL.json rename to packages/backend/src/modules/i18n/translations/pl-PL.json index def7deed15..7cc8a41be2 100644 --- a/src/client/messages/pl-PL.json +++ b/packages/backend/src/modules/i18n/translations/pl-PL.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Szczegóły aplikacji", "APP_DETAILS_VERSION": "Wersja", "APP_DETAILS_WEBSITE": "Strona", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Nie udało się zainstalować aplikacji {id}, zobacz logi po więcej informacji", - "APP_ERROR_APP_FAILED_TO_RESET": "Nie udało się zresetować aplikacji {id}, zobacz logi, po więcej informacji", - "APP_ERROR_APP_FAILED_TO_START": "Nie udało się uruchomić aplikacji {id}, zobacz logi po więcej informacji", - "APP_ERROR_APP_FAILED_TO_STOP": "Nie udało się zatrzymać aplikacji {id}, zobacz logi po więcej informacji", - "APP_ERROR_APP_FAILED_TO_RESTART": "Nie udało się zrestartować aplikacji {id}, zobacz logi, po więcej informacji", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Nie udało się odinstalować aplikacji {id}, zobacz logi po więcej informacji", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Nie udało się zaktualizować aplikacji {id}, zobacz logi po więcej informacji", - "APP_ERROR_APP_FORCE_EXPOSED": "Aplikacja {id} działa tylko z udostępnioną domeną", - "APP_ERROR_APP_NOT_EXPOSABLE": "Aplikacji {id} nie można udostępnić", - "APP_ERROR_APP_NOT_FOUND": "Aplikacja {id} nie została znaleziona", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Twoja architektura {arch} nie jest obsługiwana przez aplikację {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domena {domain} jest już w użyciu przez aplikację {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domena {domain} nie jest poprawną domeną", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Nie udało się zainstalować aplikacji {{id}}, zobacz logi po więcej informacji", + "APP_ERROR_APP_FAILED_TO_RESET": "Nie udało się zresetować aplikacji {{id}}, zobacz logi, po więcej informacji", + "APP_ERROR_APP_FAILED_TO_START": "Nie udało się uruchomić aplikacji {{id}}, zobacz logi po więcej informacji", + "APP_ERROR_APP_FAILED_TO_STOP": "Nie udało się zatrzymać aplikacji {{id}}, zobacz logi po więcej informacji", + "APP_ERROR_APP_FAILED_TO_RESTART": "Nie udało się zrestartować aplikacji {{id}}, zobacz logi, po więcej informacji", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Nie udało się odinstalować aplikacji {{id}}, zobacz logi po więcej informacji", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Nie udało się zaktualizować aplikacji {{id}}, zobacz logi po więcej informacji", + "APP_ERROR_APP_FORCE_EXPOSED": "Aplikacja {{id}} działa tylko z udostępnioną domeną", + "APP_ERROR_APP_NOT_EXPOSABLE": "Aplikacji {{id}} nie można udostępnić", + "APP_ERROR_APP_NOT_FOUND": "Aplikacja {{id}} nie została znaleziona", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Twoja architektura {{arch}} nie jest obsługiwana przez aplikację {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domena {{domain}} jest już w użyciu przez aplikację {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domena {{domain}} nie jest poprawną domeną", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domena jest wymagana, jeśli aplikacja jest udostępniona", - "APP_ERROR_INVALID_CONFIG": "Aplikacja {id} ma nieprawidłowy plik config.json", + "APP_ERROR_INVALID_CONFIG": "Aplikacja {{id}} ma nieprawidłowy plik config.json", "APP_INSTALL_FORM_CHOOSE_OPTION": "Wybierz opcję...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Wyświetlaj w panelu gości", "APP_INSTALL_FORM_DOMAIN_NAME": "Nazwa domeny", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Upewnij się, że dokładnie ta domena zawiera rekord A wskazujący na twój adres IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} musi zawierać od {min} do {max} znaków", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} musi być prawidłową domeną", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} musi być prawidłową domeną lub adresem IP", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} musi być prawidłowym adresem e-mail", - "APP_INSTALL_FORM_ERROR_IP": "{label} musi być prawidłowym adresem IP", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} musi być krótsze niż {max} znaków", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} musi zawierać co najmniej {min} znaków", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} musi być liczbą", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} musi być zgodne ze wzorem {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} jest wymagane", - "APP_INSTALL_FORM_ERROR_URL": "{label} musi być prawidłowym adresem URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} musi zawierać od {{min}} do {{max}} znaków", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} musi być prawidłową domeną", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} musi być prawidłową domeną lub adresem IP", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} musi być prawidłowym adresem e-mail", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} musi być prawidłowym adresem IP", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} musi być krótsze niż {{max}} znaków", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} musi zawierać co najmniej {{min}} znaków", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} musi być liczbą", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} musi być zgodne ze wzorem {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} jest wymagane", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} musi być prawidłowym adresem URL", "APP_INSTALL_FORM_EXPOSE_APP": "Udostępnij aplikację w Internecie", "APP_INSTALL_FORM_OPEN_PORT": "Otwórz port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Otwórz port na serwerze? Ta aplikacja będzie dostępna na {internalIp}:{port}. (Najstarsza, ale mniej bezpieczna)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Otwórz port na serwerze? Ta aplikacja będzie dostępna na {{internalIp}}:{{port}}. (Najstarsza, ale mniej bezpieczna)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Udostępnij aplikację w sieci lokalnej", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Udostępnić aplikację w sieci lokalnej? Ta aplikacja będzie dostępna w {appId}.{domain}. (Odwiedź ustawienia, aby skonfigurować swoją lokalną domenę)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Udostępnić aplikację w sieci lokalnej? Ta aplikacja będzie dostępna w {{appId}}.{{domain}}. (Odwiedź ustawienia, aby skonfigurować swoją lokalną domenę)", "APP_INSTALL_FORM_RESET": "Zresetuj aplikację", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Zainstaluj", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Aktualizuj", - "APP_INSTALL_FORM_TITLE": "Zainstaluj {name}", + "APP_INSTALL_FORM_TITLE": "Zainstaluj {{name}}", "APP_INSTALL_FORM_GENERAL": "Ogólne", "APP_INSTALL_FORM_REVERSE_PROXY": "Odwrotne proxy", - "APP_INSTALL_SUCCESS": "Aplikacja {id} zainstalowana pomyślnie", + "APP_INSTALL_SUCCESS": "Aplikacja {{id}} zainstalowana pomyślnie", "APP_LOGS_TAB_FOLLOW": "Obserwuj logi", "APP_LOGS_TAB_MAX_LINES": "Maksymalna ilość linii:", "APP_LOGS_TAB_TITLE": "Logi", @@ -87,15 +87,15 @@ "APP_NEW": "NOWY", "APP_RESET_FORM_SUBMIT": "Resetuj", "APP_RESET_FORM_SUBTITLE": "Wszystkie dane tej aplikacji zostaną utracone.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Jesteś pewien? Tej akcji nie można cofnąć.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Zatrzymaj", "APP_STOP_FORM_SUBTITLE": "Wszystkie dane zostaną zachowane", - "APP_STOP_FORM_TITLE": "Zatrzymać {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Zatrzymać {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Wybierz kategorię", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Szukaj aplikacji", "APP_STORE_TITLE": "Sklep z aplikacjami", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Odinstaluj", "APP_UNINSTALL_FORM_SUBTITLE": "Wszystkie dane tej aplikacji zostaną utracone.", - "APP_UNINSTALL_FORM_TITLE": "Odinstalować {name}?", + "APP_UNINSTALL_FORM_TITLE": "Odinstalować {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Jesteś pewien? Tej akcji nie można cofnąć.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "Konfiguracja aplikacji została zaktualizowana. Uruchom aplikację ponownie, aby zastosować zmiany", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Aktualizuj", "APP_UPDATE_FORM_SUBTITLE_1": "Zaktualizuj aplikację do najnowszej wersji:", "APP_UPDATE_FORM_SUBTITLE_2": "Spowoduje to zresetowanie twojej konfiguracji (np. zmiany w docker-compose.yml)", - "APP_UPDATE_FORM_TITLE": "Zaktualizować {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Zaktualizuj konfigurację {name}", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Zaktualizować {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Zaktualizuj konfigurację {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Istnieje już użytkownik administratora. Zaloguj się, aby stworzyć nowego użytkownika z panelu administratora.", "AUTH_ERROR_ERROR_CREATING_USER": "Błąd podczas tworzenia użytkownika", "AUTH_ERROR_INVALID_CREDENTIALS": "Nieprawidłowe dane logowania", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Zresetuj hasło", - "AUTH_RESET_PASSWORD_SUCCESS": "Twoje hasło zostało zresetowane. Możesz teraz zalogować się przy użyciu nowego hasła. I e-mail {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Twoje hasło zostało zresetowane. Możesz teraz zalogować się przy użyciu nowego hasła. I e-mail {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Resetowanie hasła", "AUTH_RESET_PASSWORD_TITLE": "Zresetuj swoje hasło", "AUTH_TOTP_INSTRUCTIONS": "Wprowadź kod z aplikacji uwierzytelniającej", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Odinstaluj aplikacje aby zmniejszyć obciążenie", "DASHBOARD_CPU_TITLE": "Obciążenie CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Użyto z {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Użyto z {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Miejsce na dysku", "DASHBOARD_MEMORY_TITLE": "Użyta pamięć", "DASHBOARD_TITLE": "Panel nawigacyjny", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Niepoprawne ustawienia regionalne", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Niedozwolone w trybie demo", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Niedozwolone w trybie dev", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Już aktualne", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Aktualna wersja: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Aktualna wersja: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Wspólne działania do wykonania na twojej instancji", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Przerwa techniczna", - "SETTINGS_ACTIONS_NEW_VERSION": "Dostępna jest nowa wersja ({version}) Tipi", + "SETTINGS_ACTIONS_NEW_VERSION": "Dostępna jest nowa wersja ({{version}}) Tipi", "SETTINGS_ACTIONS_RESTART": "Uruchom ponownie", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Bądź na bieżąco z najnowszą wersją Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Akcje", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/pt-BR.json b/packages/backend/src/modules/i18n/translations/pt-BR.json similarity index 77% rename from src/client/messages/pt-BR.json rename to packages/backend/src/modules/i18n/translations/pt-BR.json index 038ae0d7c9..3e2ef99d53 100644 --- a/src/client/messages/pt-BR.json +++ b/packages/backend/src/modules/i18n/translations/pt-BR.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Detalhes do aplicativo", "APP_DETAILS_VERSION": "Versão", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Falha ao instalar o aplicativo {id}. Consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_RESET": "Falha ao redefinir os dados do aplicativo {id}.", - "APP_ERROR_APP_FAILED_TO_START": "Falha ao iniciar o aplicativo {id}. Consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_STOP": "Falha ao parar o aplicativo {id}. Consulte os logs para mais detalhes", - "APP_ERROR_APP_FAILED_TO_RESTART": "Falha ao reiniciar o aplicativo {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Falha ao desinstalar o aplicativo {id}. Consulte os logs para mais detalhes", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Falha ao atualizar do aplicativo {id}. Consulte os logs para mais detalhes", - "APP_ERROR_APP_FORCE_EXPOSED": "O Aplicativo {id} funciona apenas com um domínio público", - "APP_ERROR_APP_NOT_EXPOSABLE": "O aplicativo {id} não pode ser exposto", - "APP_ERROR_APP_NOT_FOUND": "Aplicativo {id} não encontrado", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Sua arquitetura {arch} não é compatível com o aplicativo {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "O domínio {domain} já está em uso pelo aplicativo {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "O domínio {domain} não é um domínio válido", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Falha ao instalar o aplicativo {{id}}. Consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_RESET": "Falha ao redefinir os dados do aplicativo {{id}}.", + "APP_ERROR_APP_FAILED_TO_START": "Falha ao iniciar o aplicativo {{id}}. Consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_STOP": "Falha ao parar o aplicativo {{id}}. Consulte os logs para mais detalhes", + "APP_ERROR_APP_FAILED_TO_RESTART": "Falha ao reiniciar o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Falha ao desinstalar o aplicativo {{id}}. Consulte os logs para mais detalhes", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Falha ao atualizar do aplicativo {{id}}. Consulte os logs para mais detalhes", + "APP_ERROR_APP_FORCE_EXPOSED": "O Aplicativo {{id}} funciona apenas com um domínio público", + "APP_ERROR_APP_NOT_EXPOSABLE": "O aplicativo {{id}} não pode ser exposto", + "APP_ERROR_APP_NOT_FOUND": "Aplicativo {{id}} não encontrado", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Sua arquitetura {{arch}} não é compatível com o aplicativo {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "O domínio {{domain}} já está em uso pelo aplicativo {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "O domínio {{domain}} não é um domínio válido", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "O Domínio é necessário se o aplicativo estiver exposto", - "APP_ERROR_INVALID_CONFIG": "O arquivo de configuração do aplicativo {id} contém erros de sintaxe ", + "APP_ERROR_INVALID_CONFIG": "O arquivo de configuração do aplicativo {{id}} contém erros de sintaxe ", "APP_INSTALL_FORM_CHOOSE_OPTION": "Selecione uma opção...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Exibir no painel do convidado", "APP_INSTALL_FORM_DOMAIN_NAME": "Nome do domínio", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Certifique-se de que este domínio exato contenha um registro A apontando para o seu IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} Deve ter entre {min} e {max} caracteres", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} Deve ser um domínio válido", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} Deve ser um domínio ou endereço IP válido", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} Deve ser um endereço de e-mail válido", - "APP_INSTALL_FORM_ERROR_IP": "{label} O endereço IP não é válido", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} Deve ter entre {min} e {max} caracteres", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} Deve ter pelo menos {min} caracteres", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} Deve ser um número", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} Deve corresponder ao padrão {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} É obrigatório", - "APP_INSTALL_FORM_ERROR_URL": "{label} Deve ser um endereço válido", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} Deve ter entre {{min}} e {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} Deve ser um domínio válido", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} Deve ser um domínio ou endereço IP válido", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} Deve ser um endereço de e-mail válido", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} O endereço IP não é válido", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} Deve ter entre {{min}} e {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} Deve ter pelo menos {{min}} caracteres", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} Deve ser um número", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} Deve corresponder ao padrão {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} É obrigatório", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} Deve ser um endereço válido", "APP_INSTALL_FORM_EXPOSE_APP": "Tornar aplicação pública", "APP_INSTALL_FORM_OPEN_PORT": "Abrir porta", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Abrir uma porta nesse endereço? Este aplicativo será acessível em {internalIp}:{port}. (Mais fácil, mas menos seguro)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Abrir uma porta nesse endereço? Este aplicativo será acessível em {{internalIp}}:{{port}}. (Mais fácil, mas menos seguro)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Tornar acessível apenas na rede local", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expor o aplicativo na rede local? Este aplicativo estará acessível em {appId}.{domain}. (Visite a página de configurações para configurar seu domínio local)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expor o aplicativo na rede local? Este aplicativo estará acessível em {{appId}}.{{domain}}. (Visite a página de configurações para configurar seu domínio local)", "APP_INSTALL_FORM_RESET": "Redefinir o aplicativo", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Instalar", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Atualizar", - "APP_INSTALL_FORM_TITLE": "Instalar {name}", + "APP_INSTALL_FORM_TITLE": "Instalar {{name}}", "APP_INSTALL_FORM_GENERAL": "Geral", "APP_INSTALL_FORM_REVERSE_PROXY": "Proxy Reverso", - "APP_INSTALL_SUCCESS": "Aplicativo {id} instalado com sucesso", + "APP_INSTALL_SUCCESS": "Aplicativo {{id}} instalado com sucesso", "APP_LOGS_TAB_FOLLOW": "Seguir registros", "APP_LOGS_TAB_MAX_LINES": "Máximo de linhas", "APP_LOGS_TAB_TITLE": "Registros", @@ -87,15 +87,15 @@ "APP_NEW": "NOVO", "APP_RESET_FORM_SUBMIT": "Redefinir", "APP_RESET_FORM_SUBTITLE": "Todos os dados deste aplicativo serão perdidos.", - "APP_RESET_FORM_TITLE": "Redefinir {name} ?", + "APP_RESET_FORM_TITLE": "Redefinir {{name}} ?", "APP_RESET_FORM_WARNING": "Confirma? Essa ação não poderá ser desfeita.", - "APP_RESET_SUCCESS": "App {id} redefinido com sucesso", - "APP_START_SUCCESS": "App {id} iniciado com sucesso", - "APP_BACKUP_TITLE": "Nome do backup {name}", + "APP_RESET_SUCCESS": "App {{id}} redefinido com sucesso", + "APP_START_SUCCESS": "App {{id}} iniciado com sucesso", + "APP_BACKUP_TITLE": "Nome do backup {{name}}", "APP_BACKUP_SUBTITLE": "Um arquivo tar será criado na pasta de backups para armazenar os dados do seu aplicativo.", - "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restaurar backup {name}", - "APP_RESTORE_WARNING": "Tem certeza de que deseja excluir o backup {id} feito em {date}?", + "APP_BACKUP_SUBMIT": "Fazer backup", + "APP_RESTORE_TITLE": "Restaurar backup {{name}}", + "APP_RESTORE_WARNING": "Tem certeza de que deseja excluir o backup {{id}} feito em {{date}}?", "APP_RESTORE_SUBTITLE": "Todos os dados atuais do aplicativo serão apagados e substituídos pelos dados do backup. É recomendado fazer um backup do seu aplicativo antes de restaurá-lo.", "APP_RESTORE_SUBMIT": "Restaurar", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restaurando", "APP_STOP_FORM_SUBMIT": "Parar", "APP_STOP_FORM_SUBTITLE": "Todos os dados serão removidos", - "APP_STOP_FORM_TITLE": "Interromper {name}?", - "APP_STOP_SUCCESS": "O aplicativo {id} foi interrompido com sucesso", + "APP_STOP_FORM_TITLE": "Interromper {{name}}?", + "APP_STOP_SUCCESS": "O aplicativo {{id}} foi interrompido com sucesso", "APP_RESTART_FORM_SUBMIT": "Reiniciar", "APP_RESTART_FORM_SUBTITLE": "Todos os dados serão apagados", - "APP_RESTART_FORM_TITLE": "Reiniciar {name} ?", - "APP_RESTART_SUCCESS": "O aplicativo {id} foi reiniciado com sucesso", + "APP_RESTART_FORM_TITLE": "Reiniciar {{name}} ?", + "APP_RESTART_SUCCESS": "O aplicativo {{id}} foi reiniciado com sucesso", "APP_STORE_CATEGORY_PLACEHOLDER": "Selecione uma categoria", "APP_STORE_NO_RESULTS": "Nenhum aplicativo encontrado", "APP_STORE_NO_RESULTS_SUBTITLE": "Tente aperfeiçoar a sua pesquisa", "APP_STORE_SEARCH_PLACEHOLDER": "Pesquisar aplicativos", - "APP_STORE_TITLE": "App Store", + "APP_STORE_TITLE": "Loja de aplicativos", + "APP_STORE_TABLE_EDIT": "Editar", + "APP_STORE_TABLE_DELETE": "Deletar", + "APP_STORE_EDIT_DIALOG_TITLE": "Editar loja de aplicativos", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Salvar", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Loja de aplicativos editada com sucesso", + "APP_STORE_EDIT_DIALOG_ENABLED": "Habilitado", + "APP_STORE_DELETE_DIALOG_TITLE": "Excluir loja de aplicativos", + "APP_STORE_DELETE_DIALOG_WARNING": "Tem certeza de que deseja excluir a loja de aplicativos {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Excluir", + "APP_STORE_DELETE_SUCCESS": "Loja de aplicativos excluída com sucesso", + "APP_STORE_DELETE_ERROR_LAST_STORE": "Não é possível excluir a última loja de aplicativos", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "Existem aplicativos instalados que pertencem a esta loja de aplicativos. Por favor, desinstale-os primeiro", + "APP_STORE_ADD_DIALOG_TITLE": "Adicionar loja de aplicativos", + "APP_STORE_ADD_FORM_NAME": "Nome da loja de aplicativos", + "APP_STORE_ADD_FORM_URL": "URL da loja de aplicativos", + "APP_STORE_ADD_FORM_SUBMIT": "Criar nova loja de aplicativos", + "APP_STORE_ADD_SUCCESS": "Loja de aplicativos adicionada com sucesso", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remover Backup", "APP_UNINSTALL_FORM_SUBMIT": "Desinstalar", "APP_UNINSTALL_FORM_SUBTITLE": "Todos os dados deste aplicativo serão perdidos.", - "APP_UNINSTALL_FORM_TITLE": "Desinstalar {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Desinstalar {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Tem certeza? Esta ação não pode ser desfeita.", - "APP_UNINSTALL_SUCCESS": "App {id} desinstalado com sucesso", + "APP_UNINSTALL_SUCCESS": "App {{id}} desinstalado com sucesso", "APP_UPDATE_CONFIG_SUCCESS": "Configuração do aplicativo atualizada com sucesso. Reinicie o aplicativo para aplicar as alterações", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "A atualização do aplicativo {id} requer a versão do Tipi {minVersion} ou superior. Por favor, atualize sua instância.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "A atualização do aplicativo {{id}} requer a versão do Tipi {{minVersion}} ou superior. Por favor, atualize sua instância.", "APP_UPDATE_FORM_SUBMIT": "Atualizar", "APP_UPDATE_FORM_SUBTITLE_1": "Atualize o aplicativo para a última versão :", "APP_UPDATE_FORM_SUBTITLE_2": "Certifique-se de ter lido as notas de lançamento do app e de ter feito backup dos dados do seu aplicativo.", - "APP_UPDATE_FORM_TITLE": "Atualizar {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Atualizar configuração {name}", - "APP_UPDATE_SUCCESS": "App {id} atualizado com sucesso", + "APP_UPDATE_FORM_TITLE": "Atualizar {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Atualizar configuração {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} atualizado com sucesso", "APP_UPDATE_FORM_BACKUP": "Backup do app antes de atualizar", - "APP_BACKUP_SUCCESS": "App {id} iniciado com sucesso", - "APP_BACKUP_ERROR": "\nFalha ao fazer backup de {id}. Consulte os registros para obter mais detalhes", - "APP_RESTORE_SUCCESS": "\nRestauração do aplicativo {id} realizada com sucesso", - "APP_RESTORE_ERROR": "Falha ao redefinir os dados do aplicativo {id}. ", + "APP_BACKUP_SUCCESS": "App {{id}} iniciado com sucesso", + "APP_BACKUP_ERROR": "\nFalha ao fazer backup de {{id}}. Consulte os registros para obter mais detalhes", + "APP_RESTORE_SUCCESS": "\nRestauração do aplicativo {{id}} realizada com sucesso", + "APP_RESTORE_ERROR": "Falha ao redefinir os dados do aplicativo {{id}}. ", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Já existe um usuário administrador. Faça login para criar um novo usuário no painel de administração.", "AUTH_ERROR_ERROR_CREATING_USER": "Erro ao criar usuário", "AUTH_ERROR_INVALID_CREDENTIALS": "Credenciais inválidas", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancelar solicitação de alteração de senha", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Para começar, execute este comando em seu servidor e depois atualize esta página. Se você já fez isso anteriormente, a solicitação de redefinição de senha pode ter expirado. Nesse caso, por favor, tente novamente", "AUTH_RESET_PASSWORD_SUBMIT": "Redefinir senha", - "AUTH_RESET_PASSWORD_SUCCESS": "Sua senha foi atualizada com sucesso, Agora você pode utilizar sua nova senha e seu e-mail {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Sua senha foi atualizada com sucesso, Agora você pode utilizar sua nova senha e seu e-mail {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Senha atualizada", "AUTH_RESET_PASSWORD_TITLE": "Redefinir minha senha", "AUTH_TOTP_INSTRUCTIONS": "Insira o código de autenticação", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Tamanho", "BACKUPS_LIST_DELETE_SUCCESS": "Backup excluído com sucesso", "COMMON_CLOSE": "Finalizar", + "COMMON_WARNING": "Aviso", "DASHBOARD_CPU_SUBTITLE": "Desinstale aplicativos para reduzir a carga", "DASHBOARD_CPU_TITLE": "Utilização do Processador", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Espaço disponível {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Espaço disponível {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Espaço em disco", "DASHBOARD_MEMORY_TITLE": "Memória utilizada", "DASHBOARD_TITLE": "Painel de controle", "DASHBOARD_IP_WARNING_TITLE": "Configuração insegura", - "DASHBOARD_IP_WARNING": "Atenção, você pode estar em risco! Parece que você está acessando sua instância através de um endereço IP público. Isso torna seu painel e todos os aplicativos que você instala vulneráveis ​​a ataques", + "DASHBOARD_IP_WARNING": "Atenção, você pode estar em risco! Parece que você está acessando sua instância através de um endereço IP público. Isso torna seu painel e todos os aplicativos que você instala vulneráveis ��a ataques", "DELETE_BACKUP_MODAL_TITLE": "Excluir backup", - "DELETE_BACKUP_MODAL_WARNING": "Tem certeza de que deseja excluir o backup {id} feito em {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Tem certeza de que deseja excluir o backup {{id}} feito em {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "Esta ação não pode ser desfeita", "DELETE_BACKUP_MODAL_SUBMIT": "Deletar", "GUEST_DASHBOARD": "Painel de controle para visitantes", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Idioma inválido", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Não é permitido no modo demonstração", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Não permitido no modo de desenvolvimento", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "Já existe uma loja de aplicativos com o mesmo endereço", + "APP_STORE_CLONE_ERROR": "Erro ao clonar a loja de aplicativos em {{url}}. É um repositório git válido?", + "APP_STORE_CHOOSE_CATEGORY": "Selecione uma categoria", + "APP_STORE_CHOOSE_STORE": "Selecione uma Loja de aplicativos", "SETTINGS_ACTIONS_ALREADY_LATEST": "Você já está na última versão", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versão atual: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versão atual: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Ações comuns a serem realizadas em sua instância", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Manutenção", - "SETTINGS_ACTIONS_NEW_VERSION": "Uma nova versão ({version}) do Tipi está disponível", + "SETTINGS_ACTIONS_NEW_VERSION": "Uma nova versão ({{version}}) do Tipi está disponível", "SETTINGS_ACTIONS_RESTART": "Reiniciar", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Mantenha-se atualizado com a última versão do Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Ações", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Isso irá redefinir seu repositório e puxar as últimas alterações do GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Atualizar", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Repositório da Appstore atualizado com sucesso", + "SETTINGS_APPSTORES_TITLE": "Lojas de aplicativos", + "SETTINGS_APPSTORES_SUBTITLE": "Adicionar ou remover loja de aplicativos", + "SETTINGS_APPSTORES_TAB_TITLE": "Lojas de aplicativos", + "SETTINGS_APPSTORES_WARNING": "Certifique-se de confiar nas lojas de aplicativos que você adicionar!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Permitir temas automáticos", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Seja surpreendido com temas alterados automaticamente, com base na época do ano.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Permitir monitoramento de erro anônimo", diff --git a/src/client/messages/pt-PT.json b/packages/backend/src/modules/i18n/translations/pt-PT.json similarity index 76% rename from src/client/messages/pt-PT.json rename to packages/backend/src/modules/i18n/translations/pt-PT.json index 715f1b3a76..5c5842f358 100644 --- a/src/client/messages/pt-PT.json +++ b/packages/backend/src/modules/i18n/translations/pt-PT.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Versão", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Falha ao instalar o aplicativo {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Falha ao iniciar a aplicação {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_STOP": "Falha ao interromper o aplicativo {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Falha ao desinstalar o aplicativo {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Falha ao atualizar o aplicativo {id}, consulte os logs para obter mais detalhes", - "APP_ERROR_APP_FORCE_EXPOSED": "O App {id} só funciona com domínio exposto", - "APP_ERROR_APP_NOT_EXPOSABLE": "O aplicativo {id} não pode ser exposto", - "APP_ERROR_APP_NOT_FOUND": "Aplicativo {id} não encontrado", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "O domínio {domain} já está em uso pela aplicação {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domínio {domain} não é um domínio válido", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Falha ao instalar o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_RESET": "Falha ao redefinir o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_START": "Falha ao iniciar a aplicação {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_STOP": "Falha ao interromper o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_RESTART": "Falha ao reiniciar o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Falha ao desinstalar o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Falha ao atualizar o aplicativo {{id}}, consulte os logs para obter mais detalhes", + "APP_ERROR_APP_FORCE_EXPOSED": "O App {{id}} só funciona com domínio exposto", + "APP_ERROR_APP_NOT_EXPOSABLE": "O aplicativo {{id}} não pode ser exposto", + "APP_ERROR_APP_NOT_FOUND": "Aplicativo {{id}} não encontrado", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "O domínio {{domain}} já está em uso pela aplicação {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domínio {{domain}} não é um domínio válido", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "O domínio é necessário se o aplicativo estiver exposto", - "APP_ERROR_INVALID_CONFIG": "O aplicativo {id} tem um arquivo config.json inválido", + "APP_ERROR_INVALID_CONFIG": "O aplicativo {{id}} tem um arquivo config.json inválido", "APP_INSTALL_FORM_CHOOSE_OPTION": "Escolher uma opção...", - "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", + "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Mostrar no painel do convidado", "APP_INSTALL_FORM_DOMAIN_NAME": "Nome de domínio", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Certifique-se de que este domínio exato contém um registro que aponta para seu IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} deve estar entre {min} e {max} caracteres", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} deve ser um domínio válido", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} deve ser um domínio válido ou endereço IP", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} tem de ser um endereço de email válido", - "APP_INSTALL_FORM_ERROR_IP": "{label} deve ser um endereço IP válido", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} deve ser menor que {max} caracteres", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} deve ter no mínimo {min} caracteres", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} deve ser um número", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} deve corresponder ao padrão {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} é obrigatório", - "APP_INSTALL_FORM_ERROR_URL": "{label} deve ser um domínio válido", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} deve estar entre {{min}} e {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} deve ser um domínio válido", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} deve ser um domínio válido ou endereço IP", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} tem de ser um endereço de email válido", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} deve ser um endereço IP válido", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} deve ser menor que {{max}} caracteres", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} deve ter no mínimo {{min}} caracteres", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} deve ser um número", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} deve corresponder ao padrão {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} é obrigatório", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} deve ser um domínio válido", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Instalar", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Atualizar", - "APP_INSTALL_FORM_TITLE": "Instalar {name}", + "APP_INSTALL_FORM_TITLE": "Instalar {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "Todos os dados deste aplicativo serão perdidos.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Tem a certeza? Esta ação não pode ser revertida.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Interromper", "APP_STOP_FORM_SUBTITLE": "Todos os dados serão retidos", - "APP_STOP_FORM_TITLE": "Interromper {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Interromper {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Selecione uma categoria", "APP_STORE_NO_RESULTS": "Nenhum aplicativo encontrado", "APP_STORE_NO_RESULTS_SUBTITLE": "Tente aperfeiçoar a sua pesquisa", "APP_STORE_SEARCH_PLACEHOLDER": "Pesquisar aplicações", "APP_STORE_TITLE": "Loja de Aplicações", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Desinstalar", "APP_UNINSTALL_FORM_SUBTITLE": "Todos os dados deste aplicativo serão perdidos.", - "APP_UNINSTALL_FORM_TITLE": "Desinstalar {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Desinstalar {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Tem a certeza? Esta ação não pode ser revertida.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "Configuração do aplicativo atualizada com sucesso. Reinicie o aplicativo para aplicar as mudanças", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Atualizar", "APP_UPDATE_FORM_SUBTITLE_1": "Atualizar o aplicativo para a última versão :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Atualizar {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Atualizar configuração do {name}", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Atualizar {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Atualizar configuração do {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "Backup do App {{id}} realizado com sucesso", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Já existe um administrador. Faça login para criar um utilizador no painel de administração.", "AUTH_ERROR_ERROR_CREATING_USER": "Erro ao criar utilizador", "AUTH_ERROR_INVALID_CREDENTIALS": "Credenciais inválidas", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancelar solicitação de alteração de senha", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Repor palavra-passe", - "AUTH_RESET_PASSWORD_SUCCESS": "Sua senha foi redefinida. Agora você pode acessar com a sua nova senha. E seu e-mail {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Sua senha foi redefinida. Agora você pode acessar com a sua nova senha. E seu e-mail {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Redefinir palavra-passe", "AUTH_RESET_PASSWORD_TITLE": "Redefinir a sua palavra-passe", "AUTH_TOTP_INSTRUCTIONS": "Introduza o código da sua aplicação de autenticação", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Desinstalar aplicativos para reduzir a carga", "DASHBOARD_CPU_TITLE": "Carga da CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Usado de {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Usado de {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Localidade inválida", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Não permitido em modo de demonstração", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Não permitido no modo de desenvolvimento", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Já é atualizado", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versão atual: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versão atual: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Ações comuns a serem executadas na sua instância", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Manutenção", - "SETTINGS_ACTIONS_NEW_VERSION": "Está disponível uma nova versão ({version}) do Tipi", + "SETTINGS_ACTIONS_NEW_VERSION": "Está disponível uma nova versão ({{version}}) do Tipi", "SETTINGS_ACTIONS_RESTART": "Reiniciar", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Mantenha-se atualizado com a versão mais recente do Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Ações", @@ -271,11 +294,15 @@ "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", - "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Repositório da Appstore atualizado com sucesso", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", - "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", + "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Seja surpreendido com temas que são alterados automaticamente com base na hora do ano.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", - "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING_HINT": "Error monitoring is used to track errors and improve Tipi. Keep this option enabled to help us improve Tipi.", + "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING_HINT": "O monitoramento de erros é utilizado para rastrear erros e melhorar o Tipi. Mantenha esta opção ativada para nos ajudar a melhorar o Tipi.", "SETTINGS_GENERAL_APPS_REPO": "URL do repositório dos aplicativos", "SETTINGS_GENERAL_APPS_REPO_HINT": "URL para o repositório de aplicativos.", "SETTINGS_GENERAL_DNS_IP": "IP DNS", diff --git a/src/client/messages/ro-RO.json b/packages/backend/src/modules/i18n/translations/ro-RO.json similarity index 77% rename from src/client/messages/ro-RO.json rename to packages/backend/src/modules/i18n/translations/ro-RO.json index 777c015442..65e9001014 100644 --- a/src/client/messages/ro-RO.json +++ b/packages/backend/src/modules/i18n/translations/ro-RO.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Detalii despre aplicație", "APP_DETAILS_VERSION": "Versiune", "APP_DETAILS_WEBSITE": "Site web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Instalarea aplicației {id} a eșuat, vezi jurnalele pentru mai multe detalii", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Nu s-a putut porni aplicația {id}, vezi jurnalele pentru mai multe detalii", - "APP_ERROR_APP_FAILED_TO_STOP": "Nu s-a reușit oprirea aplicației {id}, vezi jurnalele pentru mai multe detalii", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Dezinstalarea aplicației {id} a eșuat, a se vedea jurnalele pentru mai multe detalii", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Nu s-a reușit actualizarea aplicației {id}, vezi jurnalele pentru mai multe detalii", - "APP_ERROR_APP_FORCE_EXPOSED": "Aplicația {id} funcționează numai cu domeniul expus", - "APP_ERROR_APP_NOT_EXPOSABLE": "Aplicația {id} nu poate fi expusă", - "APP_ERROR_APP_NOT_FOUND": "Aplicația {id} nu a fost găsită", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domeniul {domain} este deja utilizat de către aplicația {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domeniul {domain} nu este un domeniu valid", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Instalarea aplicației {{id}} a eșuat, vezi jurnalele pentru mai multe detalii", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Nu s-a putut porni aplicația {{id}}, vezi jurnalele pentru mai multe detalii", + "APP_ERROR_APP_FAILED_TO_STOP": "Nu s-a reușit oprirea aplicației {{id}}, vezi jurnalele pentru mai multe detalii", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Dezinstalarea aplicației {{id}} a eșuat, a se vedea jurnalele pentru mai multe detalii", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Nu s-a reușit actualizarea aplicației {{id}}, vezi jurnalele pentru mai multe detalii", + "APP_ERROR_APP_FORCE_EXPOSED": "Aplicația {{id}} funcționează numai cu domeniul expus", + "APP_ERROR_APP_NOT_EXPOSABLE": "Aplicația {{id}} nu poate fi expusă", + "APP_ERROR_APP_NOT_FOUND": "Aplicația {{id}} nu a fost găsită", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domeniul {{domain}} este deja utilizat de către aplicația {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domeniul {{domain}} nu este un domeniu valid", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domeniul este necesar în cazul în care aplicația este expusă", - "APP_ERROR_INVALID_CONFIG": "Aplicația {id} are un fișier config.json nevalid", + "APP_ERROR_INVALID_CONFIG": "Aplicația {{id}} are un fișier config.json nevalid", "APP_INSTALL_FORM_CHOOSE_OPTION": "Alege o opţiune...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Nume domeniu", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Asigurați-vă că acest domeniu exact conține un A record care indică IP-ul dvs.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} trebuie să fie între {min} și {max} caractere", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} trebuie să fie un domeniu valid", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} trebuie să fie un domeniu sau o adresă IP validă", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} trebuie să fie o adresă de e-mail validă", - "APP_INSTALL_FORM_ERROR_IP": "{label} trebuie să fie o adresă IP validă", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} trebuie să aibă mai puțin de {max} caractere", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} trebuie să aibă cel puțin {min} caractere", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} trebuie să fie un număr", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} trebuie să corespundă modelului {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} este necesar", - "APP_INSTALL_FORM_ERROR_URL": "{label} trebuie să fie un URL valid", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} trebuie să fie între {{min}} și {{max}} caractere", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} trebuie să fie un domeniu valid", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} trebuie să fie un domeniu sau o adresă IP validă", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} trebuie să fie o adresă de e-mail validă", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} trebuie să fie o adresă IP validă", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} trebuie să aibă mai puțin de {{max}} caractere", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} trebuie să aibă cel puțin {{min}} caractere", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} trebuie să fie un număr", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} trebuie să corespundă modelului {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} este necesar", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} trebuie să fie un URL valid", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Instalare", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Actualizare", - "APP_INSTALL_FORM_TITLE": "Instalează {name}", + "APP_INSTALL_FORM_TITLE": "Instalează {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "Toate datele pentru această aplicație vor fi pierdute.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Sunteți sigur? Această acțiune nu poate fi anulată.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Oprire", "APP_STOP_FORM_SUBTITLE": "Toate datele vor fi păstrate", - "APP_STOP_FORM_TITLE": "Opriți {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Opriți {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Selectează o categorie", "APP_STORE_NO_RESULTS": "Nicio aplicație găsită", "APP_STORE_NO_RESULTS_SUBTITLE": "Încercați să perfecționați căutarea", "APP_STORE_SEARCH_PLACEHOLDER": "Căutare aplicații", "APP_STORE_TITLE": "Magazin Aplicații", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Dezinstalare", "APP_UNINSTALL_FORM_SUBTITLE": "Toate datele pentru această aplicație vor fi pierdute.", - "APP_UNINSTALL_FORM_TITLE": "Dezinstalați {name}?", + "APP_UNINSTALL_FORM_TITLE": "Dezinstalați {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Sunteți sigur? Această acțiune nu poate fi anulată.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "Configurarea aplicației a fost actualizată. Reporniți aplicația pentru a aplica modificările", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Actualizare", "APP_UPDATE_FORM_SUBTITLE_1": "Actualizează aplicația la cea mai recentă versiune:", "APP_UPDATE_FORM_SUBTITLE_2": "Aceasta va reseta configuraţia personalizată (de ex. modificări în docker-compose.yml)", - "APP_UPDATE_FORM_TITLE": "Actualizați {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualizare configurație {name}", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Actualizați {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Actualizare configurație {{name}}", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Există deja un utilizator administrator. Vă rugăm să vă autentificați pentru a crea un utilizator nou din panoul de administrare.", "AUTH_ERROR_ERROR_CREATING_USER": "Eroare la crearea utilizatorului", "AUTH_ERROR_INVALID_CREDENTIALS": "Datele de autentificare sunt invalide", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Anulează cererea de modificare a parolei", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Resetare parola", - "AUTH_RESET_PASSWORD_SUCCESS": "Parola ta a fost resetată. Acum te poți conecta cu noua ta parolă. Și adresa de e-mail {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Parola ta a fost resetată. Acum te poți conecta cu noua ta parolă. Și adresa de e-mail {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Parolă resetată", "AUTH_RESET_PASSWORD_TITLE": "Resetează-ţi parola", "AUTH_TOTP_INSTRUCTIONS": "Introdu codul din aplicația de autentificare", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Dezinstalează aplicații pentru a reduce încărcarea", "DASHBOARD_CPU_TITLE": "Încărcătura procesorului", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilizat din {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Utilizat din {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Spatiu pe disc", "DASHBOARD_MEMORY_TITLE": "Memorie utilizată", "DASHBOARD_TITLE": "Tablou de bord", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Localizare invalidă", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Nu este permis în modul demonstrativ", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Nu este permis în modul de dezvoltator", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Actualizat deja", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Versiunea curentă: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Versiunea curentă: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Acțiuni comune de efectuat în instanța ta", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Mentenanță", - "SETTINGS_ACTIONS_NEW_VERSION": "O nouă versiune ({version}) de Tipi este disponibilă", + "SETTINGS_ACTIONS_NEW_VERSION": "O nouă versiune ({{version}}) de Tipi este disponibilă", "SETTINGS_ACTIONS_RESTART": "Repornire", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Rămâi la curent cu ultima versiune de Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Acțiuni", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/ru-RU.json b/packages/backend/src/modules/i18n/translations/ru-RU.json similarity index 80% rename from src/client/messages/ru-RU.json rename to packages/backend/src/modules/i18n/translations/ru-RU.json index e7ec53f0b2..f64edf39b1 100644 --- a/src/client/messages/ru-RU.json +++ b/packages/backend/src/modules/i18n/translations/ru-RU.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Описание приложения", "APP_DETAILS_VERSION": "Версия", "APP_DETAILS_WEBSITE": "Сайт", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Не удалось установить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FAILED_TO_RESET": "Не удалось сбросить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FAILED_TO_START": "Не удалось запустить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FAILED_TO_STOP": "Не удалось остановить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FAILED_TO_RESTART": "Не удалось перезапустить приложение {id}, см. подробности в логах", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Не удалось удалить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Не удалось обновить приложение {id}. См. подробности в логах", - "APP_ERROR_APP_FORCE_EXPOSED": "Доступ к приложению {id} возможен только по доменному имени", - "APP_ERROR_APP_NOT_EXPOSABLE": "К приложению {id} невозможно получить доступ по доменному имени", - "APP_ERROR_APP_NOT_FOUND": "Приложение {id} не найдено", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Ваша архитектура {arch} не поддерживается приложением {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Домен {domain} уже используется приложением {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Домен {domain} недопустим", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Не удалось установить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FAILED_TO_RESET": "Не удалось сбросить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FAILED_TO_START": "Не удалось запустить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FAILED_TO_STOP": "Не удалось остановить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FAILED_TO_RESTART": "Не удалось перезапустить приложение {{id}}, см. подробности в логах", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Не удалось удалить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Не удалось обновить приложение {{id}}. См. подробности в логах", + "APP_ERROR_APP_FORCE_EXPOSED": "Доступ к приложению {{id}} возможен только по доменному имени", + "APP_ERROR_APP_NOT_EXPOSABLE": "К приложению {{id}} невозможно получить доступ по доменному имени", + "APP_ERROR_APP_NOT_FOUND": "Приложение {{id}} не найдено", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Ваша архитектура {{arch}} не поддерживается приложением {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Домен {{domain}} уже используется приложением {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Домен {{domain}} недопустим", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Доменное имя обязательно, если указан внешний доступ", - "APP_ERROR_INVALID_CONFIG": "Неправильный файл config.json для приложения {id}", + "APP_ERROR_INVALID_CONFIG": "Неправильный файл config.json для приложения {{id}}", "APP_INSTALL_FORM_CHOOSE_OPTION": "Выберите вариант...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Показывать на гостевой панели", "APP_INSTALL_FORM_DOMAIN_NAME": "Доменное имя", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Убедитесь, что этот конкретный домен содержит запись A, указывающую на ваш IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "Поле {label} должно быть от {min} до {max} символов", - "APP_INSTALL_FORM_ERROR_FQDN": "Поле {label} должно быть корректным доменом", - "APP_INSTALL_FORM_ERROR_FQDNIP": "Поле {label} должно быть корректным доменом или IP адресом", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "Поле {label} должно быть корректным адресом электронной почты", - "APP_INSTALL_FORM_ERROR_IP": "Поле {label} должно быть корректным IP адресом", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "Поле {label} должно быть короче, чем {max} символов", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "Поле {label} должно быть длиннее, чем {min} символов", - "APP_INSTALL_FORM_ERROR_NUMBER": "Поле {label} должно быть числом", - "APP_INSTALL_FORM_ERROR_REGEX": "Поле {label} должно совпадать с шаблоном {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "Требуется {label}", - "APP_INSTALL_FORM_ERROR_URL": "Поле {label} должно быть корректным URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "Поле {{label}} должно быть от {{min}} до {{max}} символов", + "APP_INSTALL_FORM_ERROR_FQDN": "Поле {{label}} должно быть корректным доменом", + "APP_INSTALL_FORM_ERROR_FQDNIP": "Поле {{label}} должно быть корректным доменом или IP адресом", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "Поле {{label}} должно быть корректным адресом электронной почты", + "APP_INSTALL_FORM_ERROR_IP": "Поле {{label}} должно быть корректным IP адресом", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "Поле {{label}} должно быть короче, чем {{max}} символов", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "Поле {{label}} должно быть длиннее, чем {{min}} символов", + "APP_INSTALL_FORM_ERROR_NUMBER": "Поле {{label}} должно быть числом", + "APP_INSTALL_FORM_ERROR_REGEX": "Поле {{label}} должно совпадать с шаблоном {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "Требуется {{label}}", + "APP_INSTALL_FORM_ERROR_URL": "Поле {{label}} должно быть корректным URL", "APP_INSTALL_FORM_EXPOSE_APP": "Открыть доступ из интернета", "APP_INSTALL_FORM_OPEN_PORT": "Открыть порт", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Открыть порт на хосте? Это приложение будет доступно по адресу {internalIp}:{port}. (Самый простой, но менее безопасный способ)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Открыть порт на хосте? Это приложение будет доступно по адресу {{internalIp}}:{{port}}. (Самый простой, но менее безопасный способ)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Открыть доступ из локальной сети", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Разрешить доступ к приложению в локальной сети? Это приложение будет доступно по адресу {appId}.{domain}. (Посетите страницу настроек, чтобы настроить ваш локальный домен)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Разрешить доступ к приложению в локальной сети? Это приложение будет доступно по адресу {{appId}}.{{domain}}. (Посетите страницу настроек, чтобы настроить ваш локальный домен)", "APP_INSTALL_FORM_RESET": "Сбросить приложение", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Установить", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Обновить", - "APP_INSTALL_FORM_TITLE": "Установить {name}", + "APP_INSTALL_FORM_TITLE": "Установить {{name}}", "APP_INSTALL_FORM_GENERAL": "Общее", "APP_INSTALL_FORM_REVERSE_PROXY": "Обратный прокси", - "APP_INSTALL_SUCCESS": "Приложение {id} успешно установлено", + "APP_INSTALL_SUCCESS": "Приложение {{id}} успешно установлено", "APP_LOGS_TAB_FOLLOW": "Следить за логами", "APP_LOGS_TAB_MAX_LINES": "Максимум строк:", "APP_LOGS_TAB_TITLE": "Логи", @@ -87,15 +87,15 @@ "APP_NEW": "НОВОЕ", "APP_RESET_FORM_SUBMIT": "Сбросить", "APP_RESET_FORM_SUBTITLE": "Все данные этого приложения будут потеряны.", - "APP_RESET_FORM_TITLE": "Сбросить {name}?", + "APP_RESET_FORM_TITLE": "Сбросить {{name}}?", "APP_RESET_FORM_WARNING": "Вы уверены? Это действие нельзя отменить.", - "APP_RESET_SUCCESS": "Приложение {id} успешно сброшено", - "APP_START_SUCCESS": "Приложение {id} успешно запущено", - "APP_BACKUP_TITLE": "Резервная копия {name}", + "APP_RESET_SUCCESS": "Приложение {{id}} успешно сброшено", + "APP_START_SUCCESS": "Приложение {{id}} успешно запущено", + "APP_BACKUP_TITLE": "Резервная копия {{name}}", "APP_BACKUP_SUBTITLE": "Архив tar будет создан в папке резервных копий для хранения данных приложения.", "APP_BACKUP_SUBMIT": "Резервное копирование", - "APP_RESTORE_TITLE": "Восстановить резервную копию {name}", - "APP_RESTORE_WARNING": "Вы действительно хотите восстановить резервную копию {id}, созданную {date}?", + "APP_RESTORE_TITLE": "Восстановить резервную копию {{name}}", + "APP_RESTORE_WARNING": "Вы действительно хотите восстановить резервную копию {{id}}, созданную {{date}}?", "APP_RESTORE_SUBTITLE": "Все текущие данные приложения будут удалены и заменены данными из резервной копии. Рекомендуется сделать резервную копию вашего приложения перед восстановлением.", "APP_RESTORE_SUBMIT": "Восстановить", "APP_BACKUPS_TAB_TITLE": "Резервные копии", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Восстановление", "APP_STOP_FORM_SUBMIT": "Остановить", "APP_STOP_FORM_SUBTITLE": "Все данные будут сохранены", - "APP_STOP_FORM_TITLE": "Остановить {name}?", - "APP_STOP_SUCCESS": "Приложение {id} успешно остановлено", + "APP_STOP_FORM_TITLE": "Остановить {{name}}?", + "APP_STOP_SUCCESS": "Приложение {{id}} успешно остановлено", "APP_RESTART_FORM_SUBMIT": "Перезапустить", "APP_RESTART_FORM_SUBTITLE": "Все данные будут сохранены", - "APP_RESTART_FORM_TITLE": "Перезапустить {name}?", - "APP_RESTART_SUCCESS": "Приложение {id} успешно перезапущено", + "APP_RESTART_FORM_TITLE": "Перезапустить {{name}}?", + "APP_RESTART_SUCCESS": "Приложение {{id}} успешно перезапущено", "APP_STORE_CATEGORY_PLACEHOLDER": "Выбрать категорию", "APP_STORE_NO_RESULTS": "Приложение не найдено", "APP_STORE_NO_RESULTS_SUBTITLE": "Попробуйте уточнить поиск", "APP_STORE_SEARCH_PLACEHOLDER": "Поиск приложений", "APP_STORE_TITLE": "Магазин приложений", + "APP_STORE_TABLE_EDIT": "Изменить", + "APP_STORE_TABLE_DELETE": "Удалить", + "APP_STORE_EDIT_DIALOG_TITLE": "Изменить appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Сохранить", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore успешно изменен", + "APP_STORE_EDIT_DIALOG_ENABLED": "Включен", + "APP_STORE_DELETE_DIALOG_TITLE": "Удалить appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Вы действительно хотите удалить appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Удалить", + "APP_STORE_DELETE_SUCCESS": "Appstore успешно удален", + "APP_STORE_DELETE_ERROR_LAST_STORE": "Нельзя удалить последний appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "Есть установленные приложения из этого appstore. Пожалуйста, сначала удалите их", + "APP_STORE_ADD_DIALOG_TITLE": "Добавить appstore", + "APP_STORE_ADD_FORM_NAME": "Название appstore", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Создать новый appstore", + "APP_STORE_ADD_SUCCESS": "Appstore успешно добавлен", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Удалить резервные копии", "APP_UNINSTALL_FORM_SUBMIT": "Удалить", "APP_UNINSTALL_FORM_SUBTITLE": "Все данные этого приложения будут потеряны.", - "APP_UNINSTALL_FORM_TITLE": "Удалить {name}?", + "APP_UNINSTALL_FORM_TITLE": "Удалить {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Вы уверены? Это действие нельзя отменить.", - "APP_UNINSTALL_SUCCESS": "Приложение {id} успешно удалено", + "APP_UNINSTALL_SUCCESS": "Приложение {{id}} успешно удалено", "APP_UPDATE_CONFIG_SUCCESS": "Настройки приложения успешно обновлены. Перезапустите приложение, чтобы применить изменения", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Обновление приложения {id} требует версии Tipi {minVersion} или выше. Пожалуйста, обновите ваш экземпляр.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Обновление приложения {{id}} требует версии Tipi {{minVersion}} или выше. Пожалуйста, обновите ваш экземпляр.", "APP_UPDATE_FORM_SUBMIT": "Обновить", "APP_UPDATE_FORM_SUBTITLE_1": "Обновить приложение до последней версии:", "APP_UPDATE_FORM_SUBTITLE_2": "Это сбросит ваши настройки (например, изменения в docker-compose.yml)", - "APP_UPDATE_FORM_TITLE": "Обновить {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Обновить настройки {name}", - "APP_UPDATE_SUCCESS": "Приложение {id} успешно обновлено", + "APP_UPDATE_FORM_TITLE": "Обновить {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Обновить настройки {{name}}", + "APP_UPDATE_SUCCESS": "Приложение {{id}} успешно обновлено", "APP_UPDATE_FORM_BACKUP": "Резервное копирование перед обновлением", - "APP_BACKUP_SUCCESS": "Приложение {id} успешно сохранено", - "APP_BACKUP_ERROR": "Не удалось создать резервную копию {id}. См. подробности в логах", - "APP_RESTORE_SUCCESS": "Приложение {id} успешно восстановлено", - "APP_RESTORE_ERROR": "Не удалось восстановить приложение {id}. См. подробности в логах", + "APP_BACKUP_SUCCESS": "Приложение {{id}} успешно сохранено", + "APP_BACKUP_ERROR": "Не удалось создать резервную копию {{id}}. См. подробности в логах", + "APP_RESTORE_SUCCESS": "Приложение {{id}} успешно восстановлено", + "APP_RESTORE_ERROR": "Не удалось восстановить приложение {{id}}. См. подробности в логах", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Администратор уже существует. Пожалуйста, войдите в систему для создания нового пользователя из панели администратора.", "AUTH_ERROR_ERROR_CREATING_USER": "Ошибка при создании пользователя", "AUTH_ERROR_INVALID_CREDENTIALS": "Неверные учетные данные", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Отменить запрос на смену пароля", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Чтобы начать, запустите команду на вашем сервере и обновите эту страницу. Если вы уже сделали это, возможно, запрос на сброс пароля истёк. В этом случае повторите попытку", "AUTH_RESET_PASSWORD_SUBMIT": "Сбросить пароль", - "AUTH_RESET_PASSWORD_SUCCESS": "Ваш пароль был сброшен. Теперь вы можете войти с новым паролем. Ваш адрес электронной почты {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Ваш пароль был сброшен. Теперь вы можете войти с новым паролем. Ваш адрес электронной почты {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Пароль сброшен", "AUTH_RESET_PASSWORD_TITLE": "Сбросить ваш пароль", "AUTH_TOTP_INSTRUCTIONS": "Введите код из приложения для аутентификации", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Размер", "BACKUPS_LIST_DELETE_SUCCESS": "Резервная копия успешно удалена", "COMMON_CLOSE": "Закрыть", + "COMMON_WARNING": "Внимание", "DASHBOARD_CPU_SUBTITLE": "Удалите приложения для уменьшения нагрузки", "DASHBOARD_CPU_TITLE": "Загрузка ЦП", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Используется из {total} ГБ", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Используется из {{total}} ГБ", "DASHBOARD_DISK_SPACE_TITLE": "Место на диске", "DASHBOARD_MEMORY_TITLE": "Использовано памяти", "DASHBOARD_TITLE": "Обзор", "DASHBOARD_IP_WARNING_TITLE": "Небезопасная конфигурация", "DASHBOARD_IP_WARNING": "Внимание! Похоже, что вы получаете доступ к своему экземпляру через публичный IP-адрес. Это делает вашу панель и все установленные вами приложения уязвимыми для атакующих", "DELETE_BACKUP_MODAL_TITLE": "Удалить резервную копию", - "DELETE_BACKUP_MODAL_WARNING": "Вы действительно хотите удалить резервную копию {id}, созданную {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Вы действительно хотите удалить резервную копию {{id}}, созданную {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "Это действие нельзя будет отменить", "DELETE_BACKUP_MODAL_SUBMIT": "Удалить", "GUEST_DASHBOARD": "Гостевая панель", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Недопустимый язык", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Запрещено в демо-режиме", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Запрещено в режиме разработки", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "Appstore с таким URL уже существует", + "APP_STORE_CLONE_ERROR": "Ошибка клонирования appstore по адресу {{url}}. Это действительный git репозиторий?", + "APP_STORE_CHOOSE_CATEGORY": "Выберите категорию", + "APP_STORE_CHOOSE_STORE": "Выберите appstore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Уже обновлено", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Текущая версия: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Текущая версия: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Общие действия для вашего экземпляра", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Обслуживание", - "SETTINGS_ACTIONS_NEW_VERSION": "Доступна новая версия Tipi ({version})", + "SETTINGS_ACTIONS_NEW_VERSION": "Доступна новая версия Tipi ({{version}})", "SETTINGS_ACTIONS_RESTART": "Перезапустить", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Будьте в курсе последней версии Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Действия", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Это сбросит ваш репозиторий и загрузит последние изменения с GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Обновить", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore успешно обновлен", + "SETTINGS_APPSTORES_TITLE": "Магазины приложений", + "SETTINGS_APPSTORES_SUBTITLE": "Добавить или удалить магазины приложений", + "SETTINGS_APPSTORES_TAB_TITLE": "Магазины приложений", + "SETTINGS_APPSTORES_WARNING": "Убедитесь, что вы доверяете добавляемым источникам!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Разрешить автоматическую смену тем", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Будьте удивлены темами, которые меняются автоматически по времени года.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Разрешить анонимный сбор ошибок", diff --git a/src/client/messages/sr-SP.json b/packages/backend/src/modules/i18n/translations/sr-SP.json similarity index 77% rename from src/client/messages/sr-SP.json rename to packages/backend/src/modules/i18n/translations/sr-SP.json index 23417edfd9..0e88ad1986 100644 --- a/src/client/messages/sr-SP.json +++ b/packages/backend/src/modules/i18n/translations/sr-SP.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "App details", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Website", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable", - "APP_ERROR_APP_NOT_FOUND": "App {id} not found", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Failed to start app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {{id}}, see logs for more details", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} works only with exposed domain", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} is not exposable", + "APP_ERROR_APP_NOT_FOUND": "App {{id}} not found", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {{domain}} is already in use by app {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domain {{domain}} is not a valid domain", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed", - "APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file", + "APP_ERROR_INVALID_CONFIG": "App {{id}} has an invalid config.json file", "APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard", "APP_INSTALL_FORM_DOMAIN_NAME": "Domain name", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address", - "APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required", - "APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} must be between {{min}} and {{max}} characters", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} must be a valid domain", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} must be a valid domain or IP address", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} must be a valid email address", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} must be a valid IP address", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} must be less than {{max}} characters", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} must be at least {{min}} characters", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} must be a number", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "Reset app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Install", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Update", - "APP_INSTALL_FORM_TITLE": "Install {name}", + "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "App {id} installed successfully", + "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "Reset", "APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_RESET_FORM_TITLE": "Reset {name} ?", + "APP_RESET_FORM_TITLE": "Reset {{name}} ?", "APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Stop", "APP_STOP_FORM_SUBTITLE": "All data will be retained", - "APP_STOP_FORM_TITLE": "Stop {name} ?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "Stop {{name}} ?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "Select a category", "APP_STORE_NO_RESULTS": "No app found", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", - "APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "Update", "APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :", "APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.", - "APP_UPDATE_FORM_TITLE": "Update {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "Update {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Update {{name}} config", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.", "AUTH_ERROR_ERROR_CREATING_USER": "Error creating user", "AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "Reset password", - "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset", "AUTH_RESET_PASSWORD_TITLE": "Reset your password", "AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load", "DASHBOARD_CPU_TITLE": "CPU load", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk space", "DASHBOARD_MEMORY_TITLE": "Memory used", "DASHBOARD_TITLE": "Dashboard", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Invalid locale", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance", - "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available", + "SETTINGS_ACTIONS_NEW_VERSION": "A new version ({{version}}) of Tipi is available", "SETTINGS_ACTIONS_RESTART": "Restart", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Actions", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/sv-SE.json b/packages/backend/src/modules/i18n/translations/sv-SE.json similarity index 76% rename from src/client/messages/sv-SE.json rename to packages/backend/src/modules/i18n/translations/sv-SE.json index edbdf26d97..45a9b3dc4f 100644 --- a/src/client/messages/sv-SE.json +++ b/packages/backend/src/modules/i18n/translations/sv-SE.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Appdetaljer", "APP_DETAILS_VERSION": "Version", "APP_DETAILS_WEBSITE": "Webbsida", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Det gick inte att installera app {id}, se loggar för mer information", - "APP_ERROR_APP_FAILED_TO_RESET": "Det gick inte att återställa appen {id}, se loggar för mer information", - "APP_ERROR_APP_FAILED_TO_START": "Det gick inte att starta app {id}, se loggar för mer information", - "APP_ERROR_APP_FAILED_TO_STOP": "Det gick inte att stoppa app {id}, se loggar för mer information", - "APP_ERROR_APP_FAILED_TO_RESTART": "Misslyckades med att starta om appen{id}. Se loggen för mer detaljer", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Det gick inte att avinstallera app {id}, se loggar för mer information", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Det gick inte att uppdatera app {id}, se loggar för mer information", - "APP_ERROR_APP_FORCE_EXPOSED": "Appen {id} fungerar endast med exponerad domän", - "APP_ERROR_APP_NOT_EXPOSABLE": "Appen {id} kan inte exponeras", - "APP_ERROR_APP_NOT_FOUND": "Appen {id} hittades inte", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Din arkitektur {arch} stöds inte av appen {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domän {domain} används redan av appen {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Domän {domain} är inte en giltig domän", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Det gick inte att installera app {{id}}, se loggar för mer information", + "APP_ERROR_APP_FAILED_TO_RESET": "Det gick inte att återställa appen {{id}}, se loggar för mer information", + "APP_ERROR_APP_FAILED_TO_START": "Det gick inte att starta app {{id}}, se loggar för mer information", + "APP_ERROR_APP_FAILED_TO_STOP": "Det gick inte att stoppa app {{id}}, se loggar för mer information", + "APP_ERROR_APP_FAILED_TO_RESTART": "Misslyckades med att starta om appen{{id}}. Se loggen för mer detaljer", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Det gick inte att avinstallera app {{id}}, se loggar för mer information", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Det gick inte att uppdatera app {{id}}, se loggar för mer information", + "APP_ERROR_APP_FORCE_EXPOSED": "Appen {{id}} fungerar endast med exponerad domän", + "APP_ERROR_APP_NOT_EXPOSABLE": "Appen {{id}} kan inte exponeras", + "APP_ERROR_APP_NOT_FOUND": "Appen {{id}} hittades inte", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Din arkitektur {{arch}} stöds inte av appen {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domän {{domain}} används redan av appen {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Domän {{domain}} är inte en giltig domän", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domän krävs om appen är exponerad", - "APP_ERROR_INVALID_CONFIG": "Appen {id} har en ogiltig config.json-fil", + "APP_ERROR_INVALID_CONFIG": "Appen {{id}} har en ogiltig config.json-fil", "APP_INSTALL_FORM_CHOOSE_OPTION": "Välj ett alternativ...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Visa på gästkontrollpanelen", "APP_INSTALL_FORM_DOMAIN_NAME": "Domännamn", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Se till att denna exakta domän innehåller en A-rekord som pekar mot din IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} måste vara mellan {min} och {max} tecken", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} måste vara en giltig domän", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} måste vara en giltig domän eller IP-adress", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} måste vara en giltig e-postadress", - "APP_INSTALL_FORM_ERROR_IP": "{label} måste vara en giltig IP-adress", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} måste vara mindre än {max} tecken", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} måste vara minst {min} tecken", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} måste vara ett nummer", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} måste matcha mönstret {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} krävs", - "APP_INSTALL_FORM_ERROR_URL": "{label} måste vara en giltig URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} måste vara mellan {{min}} och {{max}} tecken", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} måste vara en giltig domän", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} måste vara en giltig domän eller IP-adress", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} måste vara en giltig e-postadress", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} måste vara en giltig IP-adress", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} måste vara mindre än {{max}} tecken", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} måste vara minst {{min}} tecken", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} måste vara ett nummer", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} måste matcha mönstret {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} krävs", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} måste vara en giltig URL", "APP_INSTALL_FORM_EXPOSE_APP": "Exponera appen mot internet", "APP_INSTALL_FORM_OPEN_PORT": "Öppna en port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Öppna en port på värden? Denna app kommer att vara tillgänglig på {internalIp}:{port}. (Enklast men mindre säkert)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Öppna en port på värden? Denna app kommer att vara tillgänglig på {{internalIp}}:{{port}}. (Enklast men mindre säkert)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Exponera app på lokala nätverk", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Vill du exponera appen i det lokala nätverket? Den här appen kommer att vara åtkomlig vid {appId}.{domain}. (Besök inställningssidan för att ställa in din lokala domän)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Vill du exponera appen i det lokala nätverket? Den här appen kommer att vara åtkomlig vid {{appId}}.{{domain}}. (Besök inställningssidan för att ställa in din lokala domän)", "APP_INSTALL_FORM_RESET": "Återställ app", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Installera", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Uppdatera", - "APP_INSTALL_FORM_TITLE": "Installera {name}", + "APP_INSTALL_FORM_TITLE": "Installera {{name}}", "APP_INSTALL_FORM_GENERAL": "Allmänt", "APP_INSTALL_FORM_REVERSE_PROXY": "Omvänd proxy", - "APP_INSTALL_SUCCESS": "Appen {id} installerades", + "APP_INSTALL_SUCCESS": "Appen {{id}} installerades", "APP_LOGS_TAB_FOLLOW": "Följ loggar", "APP_LOGS_TAB_MAX_LINES": "Max antal rader:", "APP_LOGS_TAB_TITLE": "Loggar", @@ -87,15 +87,15 @@ "APP_NEW": "NYTT", "APP_RESET_FORM_SUBMIT": "Återställ", "APP_RESET_FORM_SUBTITLE": "Alla data för denna app kommer att försvinna.", - "APP_RESET_FORM_TITLE": "Återställ {name} ?", + "APP_RESET_FORM_TITLE": "Återställ {{name}} ?", "APP_RESET_FORM_WARNING": "Är du säker? Den här åtgärden kan inte ångras.", - "APP_RESET_SUCCESS": "Appen {id} har återställts", - "APP_START_SUCCESS": "Appen {id} startades", - "APP_BACKUP_TITLE": "Säkerhetskopiera {name}", + "APP_RESET_SUCCESS": "Appen {{id}} har återställts", + "APP_START_SUCCESS": "Appen {{id}} startades", + "APP_BACKUP_TITLE": "Säkerhetskopiera {{name}}", "APP_BACKUP_SUBTITLE": "En tararkiv kommer att skapas i mapparna för att lagra din apps data.", "APP_BACKUP_SUBMIT": "Säkerhetskopia", - "APP_RESTORE_TITLE": "Återställ {name} säkerhetskopia", - "APP_RESTORE_WARNING": "Vill du verkligen återställa backup {id} gjord den {date}?", + "APP_RESTORE_TITLE": "Återställ {{name}} säkerhetskopia", + "APP_RESTORE_WARNING": "Vill du verkligen återställa backup {{id}} gjord den {{date}}?", "APP_RESTORE_SUBTITLE": "Alla aktuella data i appen kommer att raderas och ersättas med data från säkerhetskopian. Det rekommenderas att säkerhetskopiera din app innan du återställer den.", "APP_RESTORE_SUBMIT": "Återställ", "APP_BACKUPS_TAB_TITLE": "Säkerhetskopior", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Återställer", "APP_STOP_FORM_SUBMIT": "Stoppa", "APP_STOP_FORM_SUBTITLE": "All data sparas", - "APP_STOP_FORM_TITLE": "Stoppa {name}?", - "APP_STOP_SUCCESS": "Appen {id} stoppades", + "APP_STOP_FORM_TITLE": "Stoppa {{name}}?", + "APP_STOP_SUCCESS": "Appen {{id}} stoppades", "APP_RESTART_FORM_SUBMIT": "Starta om", "APP_RESTART_FORM_SUBTITLE": "All data sparas", - "APP_RESTART_FORM_TITLE": "Starta om {name}?", - "APP_RESTART_SUCCESS": "Appen {id} startades om", + "APP_RESTART_FORM_TITLE": "Starta om {{name}}?", + "APP_RESTART_SUCCESS": "Appen {{id}} startades om", "APP_STORE_CATEGORY_PLACEHOLDER": "Välj en kategori", "APP_STORE_NO_RESULTS": "Ingen app hittades", "APP_STORE_NO_RESULTS_SUBTITLE": "Försök att förfina din sökning", "APP_STORE_SEARCH_PLACEHOLDER": "Sök appar", "APP_STORE_TITLE": "App Store", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Ta bort säkerhetskopior", "APP_UNINSTALL_FORM_SUBMIT": "Avinstallera", "APP_UNINSTALL_FORM_SUBTITLE": "Alla data för denna app kommer att försvinna.", - "APP_UNINSTALL_FORM_TITLE": "Avinstallera {name}?", + "APP_UNINSTALL_FORM_TITLE": "Avinstallera {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Är du säker? Den här åtgärden kan inte ångras.", - "APP_UNINSTALL_SUCCESS": "Appen {id} avinstallerades", + "APP_UNINSTALL_SUCCESS": "Appen {{id}} avinstallerades", "APP_UPDATE_CONFIG_SUCCESS": "Appkonfiguration har uppdaterats. Starta om appen för att tillämpa ändringarna", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} uppdatering kräver Tipi version {minVersion} eller högre. Vänligen uppdatera din instans.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} uppdatering kräver Tipi version {{minVersion}} eller högre. Vänligen uppdatera din instans.", "APP_UPDATE_FORM_SUBMIT": "Uppdatera", "APP_UPDATE_FORM_SUBTITLE_1": "Uppdatera appen till senaste version :", "APP_UPDATE_FORM_SUBTITLE_2": "Detta kommer att återställa din anpassade konfiguration (t.ex. ändringar i docker-compose.yml)", - "APP_UPDATE_FORM_TITLE": "Uppdatera {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Uppdatera {name} konfiguration", - "APP_UPDATE_SUCCESS": "Appen {id} uppdaterades", + "APP_UPDATE_FORM_TITLE": "Uppdatera {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Uppdatera {{name}} konfiguration", + "APP_UPDATE_SUCCESS": "Appen {{id}} uppdaterades", "APP_UPDATE_FORM_BACKUP": "Säkerhetskopiera appen innan du uppdaterar", - "APP_BACKUP_SUCCESS": "Appen {id} säkerhetskopierades framgångsrikt", - "APP_BACKUP_ERROR": "Det gick inte att säkerhetskopiera {id}, se loggar för mer information", - "APP_RESTORE_SUCCESS": "Appen {id} har återställts", - "APP_RESTORE_ERROR": "Det gick inte att återställa appen {id}, se loggar för mer information", + "APP_BACKUP_SUCCESS": "Appen {{id}} säkerhetskopierades framgångsrikt", + "APP_BACKUP_ERROR": "Det gick inte att säkerhetskopiera {{id}}, se loggar för mer information", + "APP_RESTORE_SUCCESS": "Appen {{id}} har återställts", + "APP_RESTORE_ERROR": "Det gick inte att återställa appen {{id}}, se loggar för mer information", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Det finns redan en administratörsanvändare. Logga in för att skapa en ny användare från adminpanelen.", "AUTH_ERROR_ERROR_CREATING_USER": "Fel vid skapande av användare", "AUTH_ERROR_INVALID_CREDENTIALS": "Ogiltiga inloggningsuppgifter", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Avbryta begäran om ändring av lösenord", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "För att komma igång, kör detta kommando på din server och uppdatera sedan den här sidan. Om du tidigare har gjort det, kan lösenordsåterställningsbegäran ha gått ut. I så fall, försök igen", "AUTH_RESET_PASSWORD_SUBMIT": "Återställ lösenord", - "AUTH_RESET_PASSWORD_SUCCESS": "Ditt lösenord har återställts. Du kan nu logga in med ditt nya lösenord. Och din e-postadress {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Ditt lösenord har återställts. Du kan nu logga in med ditt nya lösenord. Och din e-postadress {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Återställ lösenord", "AUTH_RESET_PASSWORD_TITLE": "Återställ ditt lösenord", "AUTH_TOTP_INSTRUCTIONS": "Ange koden från din autentiseringsapp", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Storlek", "BACKUPS_LIST_DELETE_SUCCESS": "Säkerhetskopiering har tagits bort", "COMMON_CLOSE": "Stäng", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Avinstallera appar för att minska belastning", "DASHBOARD_CPU_TITLE": "CPU Belastning", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Används av {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Används av {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Disk utrymme", "DASHBOARD_MEMORY_TITLE": "Använt minne", "DASHBOARD_TITLE": "Kontrollpanel", "DASHBOARD_IP_WARNING_TITLE": "Osäkra inställningar", "DASHBOARD_IP_WARNING": "Varning! Du kan vara i fara! Det verkar som att du har tillgång till ditt instans via en offentlig IP-adress. Detta gör din instrumentpanel och alla appar som du installerar sårbara för angripare", "DELETE_BACKUP_MODAL_TITLE": "Ta bort säkerhetskopia", - "DELETE_BACKUP_MODAL_WARNING": "Vill du verkligen återställa backup {id} gjord den {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Vill du verkligen återställa backup {{id}} gjord den {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "Denna åtgärd kan inte ångras", "DELETE_BACKUP_MODAL_SUBMIT": "Ta bort", "GUEST_DASHBOARD": "Gästkontrollpanelen", @@ -222,7 +241,7 @@ "HEADER_LOGOUT": "Logga ut", "HEADER_SETTINGS": "Inställningar", "HEADER_SOURCE_CODE": "Källkod", - "HEADER_SPONSOR": "Sponsor", + "HEADER_SPONSOR": "Sponsra", "HEADER_UPDATE_AVAILABLE": "Ny version tillgänglig", "INTERNAL_SERVER_ERROR": "Internt serverfel", "LINKS_ADD_SUBMIT": "Skicka in", @@ -258,20 +277,28 @@ "SERVER_ERROR_INVALID_LOCALE": "Ogiltig språk", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Inte tillåtet i demoläge", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Ej tillåtet i dev-läge", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Redan uppdaterad", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Nuvarande version: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Nuvarande version: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Vanliga åtgärder att utföra på din instans", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Underhåll", - "SETTINGS_ACTIONS_NEW_VERSION": "En ny version ({version}) av Tipi är tillgänglig", + "SETTINGS_ACTIONS_NEW_VERSION": "En ny version ({{version}}) av Tipi är tillgänglig", "SETTINGS_ACTIONS_RESTART": "Starta om", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Håll dig uppdaterad med den senaste versionen av Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Handlingar", "SETTINGS_ACTIONS_TITLE": "Handlingar", - "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Update Repository", - "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Use this button to update your appstore", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", - "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", - "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_ACTIONS_UPDATE_REPO_TITLE": "Uppdatera utvecklingskatalog", + "SETTINGS_ACTIONS_UPDATE_REPO_SUBTITLE": "Använd denna knapp för att uppdatera din appstore", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Detta kommer att återställa ditt utvecklingskatalog och dra de senaste ändringarna från GitHub", + "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Uppdatera", + "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore utvecklingskatalog har uppdaterats", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Tillåt automatiskt teman", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Bli förvånad över teman som förändras automatiskt baserat på tiden på året.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Tillåt anonym felövervakning", diff --git a/src/client/messages/tr-TR.json b/packages/backend/src/modules/i18n/translations/tr-TR.json similarity index 76% rename from src/client/messages/tr-TR.json rename to packages/backend/src/modules/i18n/translations/tr-TR.json index 4823c1debe..c3776de14a 100644 --- a/src/client/messages/tr-TR.json +++ b/packages/backend/src/modules/i18n/translations/tr-TR.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Uygulama detayları", "APP_DETAILS_VERSION": "Sürüm", "APP_DETAILS_WEBSITE": "Web sitesi", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Uygulama {id} yüklenemedi, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_RESET": "Uygulama {id} sıfırlanamadı, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_START": "Uygulama {id} başlatılamadı, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_STOP": "Uygulama {id} durdurulamadı, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_RESTART": "Uygulama {id} yeniden başlatılamadı, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Uygulama {id} kaldırılamadı, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Uygulama {id} güncellenemedi, daha fazla bilgi için günlüklere bakın", - "APP_ERROR_APP_FORCE_EXPOSED": "Uygulama {id} yalnızca açığa çıkarılan bir alan adıyla çalışır", - "APP_ERROR_APP_NOT_EXPOSABLE": "Uygulama {id} açığa çıkarılamaz", - "APP_ERROR_APP_NOT_FOUND": "Uygulama {id} bulunamadı", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Mimari {arch} uygulama {id} tarafından desteklenmiyor", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Alan adı {domain} zaten uygulama {id} tarafından kullanılıyor", - "APP_ERROR_DOMAIN_NOT_VALID": "Alan adı {domain} geçerli bir alan adı değil", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Uygulama {{id}} yüklenemedi, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_RESET": "Uygulama {{id}} sıfırlanamadı, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_START": "Uygulama {{id}} başlatılamadı, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_STOP": "Uygulama {{id}} durdurulamadı, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_RESTART": "Uygulama {{id}} yeniden başlatılamadı, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Uygulama {{id}} kaldırılamadı, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Uygulama {{id}} güncellenemedi, daha fazla bilgi için günlüklere bakın", + "APP_ERROR_APP_FORCE_EXPOSED": "Uygulama {{id}} yalnızca açığa çıkarılan bir alan adıyla çalışır", + "APP_ERROR_APP_NOT_EXPOSABLE": "Uygulama {{id}} açığa çıkarılamaz", + "APP_ERROR_APP_NOT_FOUND": "Uygulama {{id}} bulunamadı", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Mimari {{arch}} uygulama {{id}} tarafından desteklenmiyor", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Alan adı {{domain}} zaten uygulama {{id}} tarafından kullanılıyor", + "APP_ERROR_DOMAIN_NOT_VALID": "Alan adı {{domain}} geçerli bir alan adı değil", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Uygulama açığa çıkarıldığında bir alan adı gereklidir", - "APP_ERROR_INVALID_CONFIG": "Uygulama {id} geçersiz bir config.json dosyasına sahip", + "APP_ERROR_INVALID_CONFIG": "Uygulama {{id}} geçersiz bir config.json dosyasına sahip", "APP_INSTALL_FORM_CHOOSE_OPTION": "Bir seçenek seçin...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Misafir kontrol panelinde göster", "APP_INSTALL_FORM_DOMAIN_NAME": "Alan adı", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Bu tam alan adının IP'nize işaret eden bir A kaydına sahip olduğundan emin olun.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} {min} ile {max} karakter arasında olmalıdır", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} geçerli bir alan adı olmalıdır", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} geçerli bir alan adı veya IP adresi olmalıdır", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} geçerli bir e-posta adresi olmalıdır", - "APP_INSTALL_FORM_ERROR_IP": "{label} geçerli bir IP adresi olmalıdır", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} {max} karakterden kısa olmalıdır", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} en az {min} karakter olmalıdır", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} bir sayı olmalıdır", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} {pattern} kalıbına uymalıdır", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} gerekli", - "APP_INSTALL_FORM_ERROR_URL": "{label} geçerli bir URL olmalıdır", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} {{min}} ile {{max}} karakter arasında olmalıdır", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} geçerli bir alan adı olmalıdır", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} geçerli bir alan adı veya IP adresi olmalıdır", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} geçerli bir e-posta adresi olmalıdır", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} geçerli bir IP adresi olmalıdır", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} {{max}} karakterden kısa olmalıdır", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} en az {{min}} karakter olmalıdır", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} bir sayı olmalıdır", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} {{pattern}} kalıbına uymalıdır", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} gerekli", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} geçerli bir URL olmalıdır", "APP_INSTALL_FORM_EXPOSE_APP": "Uygulamayı internette açığa çıkar", "APP_INSTALL_FORM_OPEN_PORT": "Portu aç", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ana bilgisayarda bir port aç? Bu uygulamaya {internalIp}:{port} adresinden erişilebilecek. (En kolay ama daha az güvenli)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Ana bilgisayarda bir port aç? Bu uygulamaya {{internalIp}}:{{port}} adresinden erişilebilecek. (En kolay ama daha az güvenli)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Uygulamayı yerel ağda açığa çıkar", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Uygulamayı yerel ağda açığa çıkar? Bu uygulamaya {appId}.{domain} adresinden erişilebilecek. (Yerel alan adınızı ayarlamak için ayarlar sayfasını ziyaret edin)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Uygulamayı yerel ağda açığa çıkar? Bu uygulamaya {{appId}}.{{domain}} adresinden erişilebilecek. (Yerel alan adınızı ayarlamak için ayarlar sayfasını ziyaret edin)", "APP_INSTALL_FORM_RESET": "Uygulamayı sıfırla", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Yükle", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Güncelle", - "APP_INSTALL_FORM_TITLE": "{name} Yükle", + "APP_INSTALL_FORM_TITLE": "{{name}} Yükle", "APP_INSTALL_FORM_GENERAL": "Genel", "APP_INSTALL_FORM_REVERSE_PROXY": "Ters vekil sunucu", - "APP_INSTALL_SUCCESS": "Uygulama {id} başarıyla yüklendi", + "APP_INSTALL_SUCCESS": "Uygulama {{id}} başarıyla yüklendi", "APP_LOGS_TAB_FOLLOW": "Günlükleri takip et", "APP_LOGS_TAB_MAX_LINES": "Maksimum satır sayısı:", "APP_LOGS_TAB_TITLE": "Günlükler", @@ -87,15 +87,15 @@ "APP_NEW": "YENİ", "APP_RESET_FORM_SUBMIT": "Sıfırla", "APP_RESET_FORM_SUBTITLE": "Bu uygulamanın tüm verileri kaybolacak.", - "APP_RESET_FORM_TITLE": "{name} Sıfırla?", + "APP_RESET_FORM_TITLE": "{{name}} Sıfırla?", "APP_RESET_FORM_WARNING": "Emin misiniz? Bu işlem geri alınamaz.", - "APP_RESET_SUCCESS": "Uygulama {id} başarıyla sıfırlandı", - "APP_START_SUCCESS": "Uygulama {id} başarıyla başlatıldı", - "APP_BACKUP_TITLE": "{name} Yedekle", + "APP_RESET_SUCCESS": "Uygulama {{id}} başarıyla sıfırlandı", + "APP_START_SUCCESS": "Uygulama {{id}} başarıyla başlatıldı", + "APP_BACKUP_TITLE": "{{name}} Yedekle", "APP_BACKUP_SUBTITLE": "Bir tar arşivi, uygulamanızın verilerini depolamak için yedekler klasöründe oluşturulacak.", "APP_BACKUP_SUBMIT": "Yedekle", - "APP_RESTORE_TITLE": "{name} Yedeklemesini Geri Yükle", - "APP_RESTORE_WARNING": "{date} tarihinde yapılan {id} yedeklemesini gerçekten geri yüklemek istiyor musunuz?", + "APP_RESTORE_TITLE": "{{name}} Yedeklemesini Geri Yükle", + "APP_RESTORE_WARNING": "{{date}} tarihinde yapılan {{id}} yedeklemesini gerçekten geri yüklemek istiyor musunuz?", "APP_RESTORE_SUBTITLE": "Uygulamanın mevcut tüm verileri silinecek ve yedekten alınan verilerle değiştirilecek. Geri yüklemeden önce uygulamanızın yedeğini almanız önerilir.", "APP_RESTORE_SUBMIT": "Geri Yükle", "APP_BACKUPS_TAB_TITLE": "Yedeklemeler", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Geri Yükleniyor", "APP_STOP_FORM_SUBMIT": "Durdur", "APP_STOP_FORM_SUBTITLE": "Tüm veriler korunacaktır", - "APP_STOP_FORM_TITLE": "{name} Durdurulsun mu?", - "APP_STOP_SUCCESS": "{id} uygulaması başarıyla durduruldu", + "APP_STOP_FORM_TITLE": "{{name}} Durdurulsun mu?", + "APP_STOP_SUCCESS": "{{id}} uygulaması başarıyla durduruldu", "APP_RESTART_FORM_SUBMIT": "Yeniden Başlat", "APP_RESTART_FORM_SUBTITLE": "Tüm veriler korunacaktır", - "APP_RESTART_FORM_TITLE": "{name} Yeniden Başlatılsın mı?", - "APP_RESTART_SUCCESS": "{id} uygulaması başarıyla yeniden başlatıldı", + "APP_RESTART_FORM_TITLE": "{{name}} Yeniden Başlatılsın mı?", + "APP_RESTART_SUCCESS": "{{id}} uygulaması başarıyla yeniden başlatıldı", "APP_STORE_CATEGORY_PLACEHOLDER": "Bir kategori seçin", "APP_STORE_NO_RESULTS": "Uygulama bulunamadı", "APP_STORE_NO_RESULTS_SUBTITLE": "Aramanızı daraltmayı deneyin", "APP_STORE_SEARCH_PLACEHOLDER": "Uygulama ara", "APP_STORE_TITLE": "Uygulama Mağazası", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Kaldır", "APP_UNINSTALL_FORM_SUBTITLE": "Bu uygulamaya ait tüm veriler silinecektir.", - "APP_UNINSTALL_FORM_TITLE": "{name} Kaldırılsın mı?", + "APP_UNINSTALL_FORM_TITLE": "{{name}} Kaldırılsın mı?", "APP_UNINSTALL_FORM_WARNING": "Emin misiniz? Bu işlem geri alınamaz.", - "APP_UNINSTALL_SUCCESS": "{id} uygulaması başarıyla kaldırıldı", + "APP_UNINSTALL_SUCCESS": "{{id}} uygulaması başarıyla kaldırıldı", "APP_UPDATE_CONFIG_SUCCESS": "Uygulama yapılandırması başarıyla güncellendi. Değişikliklerin uygulanması için uygulamayı yeniden başlatın", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "{id} uygulaması güncellemesi Tipi sürümü {minVersion} veya daha üstünü gerektirir. Lütfen sürümünüzü güncelleyin.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "{{id}} uygulaması güncellemesi Tipi sürümü {{minVersion}} veya daha üstünü gerektirir. Lütfen sürümünüzü güncelleyin.", "APP_UPDATE_FORM_SUBMIT": "Güncelle", "APP_UPDATE_FORM_SUBTITLE_1": "Uygulamayı en son sürüme güncelle:", "APP_UPDATE_FORM_SUBTITLE_2": "Uygulamanın sürüm notlarını okuduğunuzdan ve verilerinizi yedeklediğinizden emin olun.", - "APP_UPDATE_FORM_TITLE": "{name} Güncellensin mi?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "{name} Yapılandırmasını Güncelle", - "APP_UPDATE_SUCCESS": "{id} uygulaması başarıyla güncellendi", + "APP_UPDATE_FORM_TITLE": "{{name}} Güncellensin mi?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "{{name}} Yapılandırmasını Güncelle", + "APP_UPDATE_SUCCESS": "{{id}} uygulaması başarıyla güncellendi", "APP_UPDATE_FORM_BACKUP": "Güncellemeden önce uygulamayı yedekleyin", - "APP_BACKUP_SUCCESS": "{id} uygulaması başarıyla yedeklendi", - "APP_BACKUP_ERROR": "{id} yedeklenemedi, daha fazla ayrıntı için loglara bakın", - "APP_RESTORE_SUCCESS": "{id} uygulaması başarıyla geri yüklendi", - "APP_RESTORE_ERROR": "{id} geri yüklenemedi, daha fazla ayrıntı için loglara bakın", + "APP_BACKUP_SUCCESS": "{{id}} uygulaması başarıyla yedeklendi", + "APP_BACKUP_ERROR": "{{id}} yedeklenemedi, daha fazla ayrıntı için loglara bakın", + "APP_RESTORE_SUCCESS": "{{id}} uygulaması başarıyla geri yüklendi", + "APP_RESTORE_ERROR": "{{id}} geri yüklenemedi, daha fazla ayrıntı için loglara bakın", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Zaten bir yönetici kullanıcısı var. Yeni bir kullanıcı oluşturmak için yönetici paneline giriş yapın.", "AUTH_ERROR_ERROR_CREATING_USER": "Kullanıcı oluşturma hatası", "AUTH_ERROR_INVALID_CREDENTIALS": "Geçersiz kimlik bilgileri", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Şifre değiştirme isteğini iptal et", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Başlamak için sunucunuzda bu komutu çalıştırın ve ardından sayfayı yenileyin. Daha önce yaptıysanız, şifre sıfırlama isteği süresi dolmuş olabilir. Bu durumda lütfen tekrar deneyin.", "AUTH_RESET_PASSWORD_SUBMIT": "Şifreyi Sıfırla", - "AUTH_RESET_PASSWORD_SUCCESS": "Şifreniz sıfırlandı. Yeni şifrenizle giriş yapabilirsiniz. E-posta adresiniz {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Şifreniz sıfırlandı. Yeni şifrenizle giriş yapabilirsiniz. E-posta adresiniz {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Şifre sıfırlandı", "AUTH_RESET_PASSWORD_TITLE": "Şifrenizi sıfırlayın", "AUTH_TOTP_INSTRUCTIONS": "Doğrulayıcı uygulamanızdaki kodu girin", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Boyut", "BACKUPS_LIST_DELETE_SUCCESS": "Yedek başarıyla silindi", "COMMON_CLOSE": "Kapat", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Yükü azaltmak için uygulamaları kaldırın", "DASHBOARD_CPU_TITLE": "CPU yükü", - "DASHBOARD_DISK_SPACE_SUBTITLE": "{total} GB'den kullanılan", + "DASHBOARD_DISK_SPACE_SUBTITLE": "{{total}} GB'den kullanılan", "DASHBOARD_DISK_SPACE_TITLE": "Disk alanı", "DASHBOARD_MEMORY_TITLE": "Kullanılan bellek", "DASHBOARD_TITLE": "Gösterge Paneli", "DASHBOARD_IP_WARNING_TITLE": "Güvensiz yapılandırma", "DASHBOARD_IP_WARNING": "Uyarı, risk altında olabilirsiniz! Görünüşe göre instance'ınıza genel bir IP adresi üzerinden erişiyorsunuz. Bu, gösterge panelinizi ve kurduğunuz tüm uygulamaları saldırganlara karşı savunmasız hale getirir.", "DELETE_BACKUP_MODAL_TITLE": "Yedeklemeyi Sil", - "DELETE_BACKUP_MODAL_WARNING": "{date} tarihinde oluşturulan {id} yedeklemesini silmek istediğinizden emin misiniz?", + "DELETE_BACKUP_MODAL_WARNING": "{{date}} tarihinde oluşturulan {{id}} yedeklemesini silmek istediğinizden emin misiniz?", "DELETE_BACKUP_MODAL_SUBTITLE": "Bu işlem geri alınamaz", "DELETE_BACKUP_MODAL_SUBMIT": "Sil", "GUEST_DASHBOARD": "Misafir Panosu", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Geçersiz yerel ayar", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Demo modunda izin verilmiyor", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Geliştirici modunda izin verilmiyor", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Zaten en güncel", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Mevcut sürüm: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Mevcut sürüm: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Sisteminizde yapılabilecek genel bakım işlemleri", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Bakım", - "SETTINGS_ACTIONS_NEW_VERSION": "Tipi'nin yeni bir sürümü ({version}) mevcut", + "SETTINGS_ACTIONS_NEW_VERSION": "Tipi'nin yeni bir sürümü ({{version}}) mevcut", "SETTINGS_ACTIONS_RESTART": "Yeniden Başlat", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Tipi'nin en son sürümüyle güncel kalın", "SETTINGS_ACTIONS_TAB_TITLE": "İşlemler", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Bu işlem, deponuzu sıfırlayacak ve GitHub'dan en son değişiklikleri çekecektir", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Güncelle", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Uygulama mağazası deposu başarıyla güncellendi", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Otomatik temalara izin ver", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Yılın zamanına göre otomatik olarak değişen temalarla sürpriz yaşayın.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Anonim hata izlemeye izin ver", diff --git a/src/client/messages/uk-UA.json b/packages/backend/src/modules/i18n/translations/uk-UA.json similarity index 78% rename from src/client/messages/uk-UA.json rename to packages/backend/src/modules/i18n/translations/uk-UA.json index 993563d327..91f6505818 100644 --- a/src/client/messages/uk-UA.json +++ b/packages/backend/src/modules/i18n/translations/uk-UA.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Про додаток", "APP_DETAILS_VERSION": "Версія", "APP_DETAILS_WEBSITE": "Сайт", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Не вдалося встановити додаток {id}, див. журнали для отримання додаткових відомостей", - "APP_ERROR_APP_FAILED_TO_RESET": "Не вдалося скинути додаток {id}, перегляньте журнали для деталей", - "APP_ERROR_APP_FAILED_TO_START": "Не вдалося запустити додаток {id}, дивіться журнали для отримання додаткової інформації", - "APP_ERROR_APP_FAILED_TO_STOP": "Не вдалося зупинити додаток {id}, дивіться журнали для отримання додаткової інформації", - "APP_ERROR_APP_FAILED_TO_RESTART": "Не вдалося перезапустити додаток {id}, дивіться журнали для отримання додаткової інформації", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Не вдалося видалити додаток {id}, дивіться журнали для отримання додаткової інформації", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Не вдалося оновити додаток {id}, дивіться журнали для отримання додаткової інформації", - "APP_ERROR_APP_FORCE_EXPOSED": "Додаток {id} працює лише з відкритим доменом", - "APP_ERROR_APP_NOT_EXPOSABLE": "Додаток {id} не можна відкрити для доступу", - "APP_ERROR_APP_NOT_FOUND": "Додаток {id} не знайдено", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Ваша архітектура {arch} не підтримується додатком {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Домен {domain} вже використовується додатком {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Домен {domain} не є дійсним доменом", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Не вдалося встановити додаток {{id}}, див. журнали для отримання додаткових відомостей", + "APP_ERROR_APP_FAILED_TO_RESET": "Не вдалося скинути додаток {{id}}, перегляньте журнали для деталей", + "APP_ERROR_APP_FAILED_TO_START": "Не вдалося запустити додаток {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_ERROR_APP_FAILED_TO_STOP": "Не вдалося зупинити додаток {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_ERROR_APP_FAILED_TO_RESTART": "Не вдалося перезапустити додаток {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Не вдалося видалити додаток {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Не вдалося оновити додаток {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_ERROR_APP_FORCE_EXPOSED": "Додаток {{id}} працює лише з відкритим доменом", + "APP_ERROR_APP_NOT_EXPOSABLE": "Додаток {{id}} не можна відкрити для доступу", + "APP_ERROR_APP_NOT_FOUND": "Додаток {{id}} не знайдено", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Ваша архітектура {{arch}} не підтримується додатком {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Домен {{domain}} вже використовується додатком {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Домен {{domain}} не є дійсним доменом", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Домен є обов'язковим, якщо додаток відкритий для доступу", - "APP_ERROR_INVALID_CONFIG": "Додаток {id} має неприпустимий файл config.json", + "APP_ERROR_INVALID_CONFIG": "Додаток {{id}} має неприпустимий файл config.json", "APP_INSTALL_FORM_CHOOSE_OPTION": "Оберіть варіант...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Показувати на гостьовій панелі", "APP_INSTALL_FORM_DOMAIN_NAME": "Доменне ім'я", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Переконайтеся, що даний домен містить запис A, що вказує на ваш IP.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} повинно бути від {min} до {max} символів", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} має бути дійсним доменом", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} має бути дійсним доменом або IP-адресою", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} має бути дійсною адресою ел. поштою", - "APP_INSTALL_FORM_ERROR_IP": "{label} має бути дійсною IP-адресою", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} має бути менше ніж {max} символів", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} має складатися мінімум з {min} символів", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} має бути числом", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} має відповідати шаблону {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} є обов'язковим", - "APP_INSTALL_FORM_ERROR_URL": "{label} має бути коректним URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} повинно бути від {{min}} до {{max}} символів", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} має бути дійсним доменом", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} має бути дійсним доменом або IP-адресою", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} має бути дійсною адресою ел. поштою", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} має бути дійсною IP-адресою", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} має бути менше ніж {{max}} символів", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} має складатися мінімум з {{min}} символів", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} має бути числом", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} має відповідати шаблону {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} є обов'язковим", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} має бути коректним URL", "APP_INSTALL_FORM_EXPOSE_APP": "Відкрити доступ до додатка в інтернеті", "APP_INSTALL_FORM_OPEN_PORT": "Відкрити порт", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Відкрити порт на хості? Цей додаток буде доступний в режимі {internalIp}:{port}. (Найбезпечніше, але менш захищене)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Відкрити порт на хості? Цей додаток буде доступний в режимі {{internalIp}}:{{port}}. (Найбезпечніше, але менш захищене)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Відкрити доступ до додатка у локальній мережі", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Дозволити доступ до додатку в локальній мережі? Цей додаток буде доступний за адресою {appId}.{domain}. (Відвідайте сторінку налаштувань, щоб налаштувати свій локальний домен)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Дозволити доступ до додатку в локальній мережі? Цей додаток буде доступний за адресою {{appId}}.{{domain}}. (Відвідайте сторінку налаштувань, щоб налаштувати свій локальний домен)", "APP_INSTALL_FORM_RESET": "Скинути додаток", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Встановити", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Оновити", - "APP_INSTALL_FORM_TITLE": "Встановити {name}", + "APP_INSTALL_FORM_TITLE": "Встановити {{name}}", "APP_INSTALL_FORM_GENERAL": "Загальні", "APP_INSTALL_FORM_REVERSE_PROXY": "Реверс-проксі", - "APP_INSTALL_SUCCESS": "Додаток {id} встановлено успішно", + "APP_INSTALL_SUCCESS": "Додаток {{id}} встановлено успішно", "APP_LOGS_TAB_FOLLOW": "Відстежувати журнали", "APP_LOGS_TAB_MAX_LINES": "Макс. рядків:", "APP_LOGS_TAB_TITLE": "Журнали", @@ -87,15 +87,15 @@ "APP_NEW": "НОВИНКА", "APP_RESET_FORM_SUBMIT": "Скинути", "APP_RESET_FORM_SUBTITLE": "Всі дані для цього додатка будуть втрачені.", - "APP_RESET_FORM_TITLE": "Скинути {name} ?", + "APP_RESET_FORM_TITLE": "Скинути {{name}} ?", "APP_RESET_FORM_WARNING": "Ви впевнені? Цю дію неможливо скасувати.", - "APP_RESET_SUCCESS": "Додаток {id} успішно скинуто", - "APP_START_SUCCESS": "Додаток {id} успішно запущено", - "APP_BACKUP_TITLE": "Резервна копія {name}", + "APP_RESET_SUCCESS": "Додаток {{id}} успішно скинуто", + "APP_START_SUCCESS": "Додаток {{id}} успішно запущено", + "APP_BACKUP_TITLE": "Резервна копія {{name}}", "APP_BACKUP_SUBTITLE": "У папці резервних копій буде створено архів tar для зберігання даних вашого додатка.", "APP_BACKUP_SUBMIT": "Резервна копія", - "APP_RESTORE_TITLE": "Відновити резервну копію {name}", - "APP_RESTORE_WARNING": "Ви дійсно хочете відновити резервну копію {id}, зроблену {date}?", + "APP_RESTORE_TITLE": "Відновити резервну копію {{name}}", + "APP_RESTORE_WARNING": "Ви дійсно хочете відновити резервну копію {{id}}, зроблену {{date}}?", "APP_RESTORE_SUBTITLE": "Всі поточні дані додатка будуть стерті та замінені даними з резервної копії. Рекомендується створити резервну копію вашого додатка перед відновленням.", "APP_RESTORE_SUBMIT": "Відновити", "APP_BACKUPS_TAB_TITLE": "Резервні копії", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Відновлення", "APP_STOP_FORM_SUBMIT": "Зупинити", "APP_STOP_FORM_SUBTITLE": "Всі дані будуть збережені", - "APP_STOP_FORM_TITLE": "Зупинити {name} ?", - "APP_STOP_SUCCESS": "Додаток {id} успішно зупинено", + "APP_STOP_FORM_TITLE": "Зупинити {{name}} ?", + "APP_STOP_SUCCESS": "Додаток {{id}} успішно зупинено", "APP_RESTART_FORM_SUBMIT": "Перезавантажити", "APP_RESTART_FORM_SUBTITLE": "Всі дані будуть збережені", - "APP_RESTART_FORM_TITLE": "Перезавантажити {name}?", - "APP_RESTART_SUCCESS": "Додаток {id} успішно перезавантажено", + "APP_RESTART_FORM_TITLE": "Перезавантажити {{name}}?", + "APP_RESTART_SUCCESS": "Додаток {{id}} успішно перезавантажено", "APP_STORE_CATEGORY_PLACEHOLDER": "Вибрати категорію", "APP_STORE_NO_RESULTS": "Додаток не знайдено", "APP_STORE_NO_RESULTS_SUBTITLE": "Спробуйте уточнити свій пошук", "APP_STORE_SEARCH_PLACEHOLDER": "Пошук додатків", "APP_STORE_TITLE": "Магазин додатків", + "APP_STORE_TABLE_EDIT": "Редагувати", + "APP_STORE_TABLE_DELETE": "Видалити", + "APP_STORE_EDIT_DIALOG_TITLE": "Редагувати магазин додатків", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Зберегти", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Магазин додатків успішно відредаговано", + "APP_STORE_EDIT_DIALOG_ENABLED": "Увімкнено", + "APP_STORE_DELETE_DIALOG_TITLE": "Видалити магазин додатків", + "APP_STORE_DELETE_DIALOG_WARNING": "Ви впевнені, що бажаєте видалити магазин додатків{{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Видалити", + "APP_STORE_DELETE_SUCCESS": "Магазин додатків успішно видалено", + "APP_STORE_DELETE_ERROR_LAST_STORE": "Ви не можете видалити останній магазин додатків", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "Встановлено застосунки, що належать до цього магазину додатків. Спочатку видаліть їх", + "APP_STORE_ADD_DIALOG_TITLE": "Додати магазин додатків", + "APP_STORE_ADD_FORM_NAME": "Назва магазину додатків", + "APP_STORE_ADD_FORM_URL": "URL магазину додатків", + "APP_STORE_ADD_FORM_SUBMIT": "Створити новий магазин додатків", + "APP_STORE_ADD_SUCCESS": "Магазин додатків успішно додано", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Вилучити резервні копії", "APP_UNINSTALL_FORM_SUBMIT": "Видалити", "APP_UNINSTALL_FORM_SUBTITLE": "Всі дані для цього додатка будуть втрачені.", - "APP_UNINSTALL_FORM_TITLE": "Видалити {name} ?", + "APP_UNINSTALL_FORM_TITLE": "Видалити {{name}} ?", "APP_UNINSTALL_FORM_WARNING": "Ви впевнені? Цю дію неможливо скасувати.", - "APP_UNINSTALL_SUCCESS": "Додаток {id} успішно видалено", + "APP_UNINSTALL_SUCCESS": "Додаток {{id}} успішно видалено", "APP_UPDATE_CONFIG_SUCCESS": "Конфігурацію додатка успішно оновлено. Перезапустіть додаток, щоб застосувати зміни", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Оновлення додатку {id} потребує версії Tipi {minVersion} або вище. Будь ласка, оновіть ваш екземпляр.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Оновлення додатку {{id}} потребує версії Tipi {{minVersion}} або вище. Будь ласка, оновіть ваш екземпляр.", "APP_UPDATE_FORM_SUBMIT": "Оновити", "APP_UPDATE_FORM_SUBTITLE_1": "Оновіть додаток до останньої версії :", "APP_UPDATE_FORM_SUBTITLE_2": "Переконайтеся, що ви прочитали примітки щодо випуску додатка і створили резервну копію даних вашого додатка.", - "APP_UPDATE_FORM_TITLE": "Оновити {name} ?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Оновити {name} конфігурацію", - "APP_UPDATE_SUCCESS": "Додаток {id} успішно оновлено", + "APP_UPDATE_FORM_TITLE": "Оновити {{name}} ?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Оновити {{name}} конфігурацію", + "APP_UPDATE_SUCCESS": "Додаток {{id}} успішно оновлено", "APP_UPDATE_FORM_BACKUP": "Резервна копія додатку перед оновленням", - "APP_BACKUP_SUCCESS": "Додаток {id} успішно збережено в резервну копію", - "APP_BACKUP_ERROR": "Не вдалося створити резервну копію {id}, дивіться журнали для отримання додаткової інформації", - "APP_RESTORE_SUCCESS": "Додаток {id} відновлено успішно", - "APP_RESTORE_ERROR": "Не вдалося відновити {id}, дивіться журнали для отримання додаткової інформації", + "APP_BACKUP_SUCCESS": "Додаток {{id}} успішно збережено в резервну копію", + "APP_BACKUP_ERROR": "Не вдалося створити резервну копію {{id}}, дивіться журнали для отримання додаткової інформації", + "APP_RESTORE_SUCCESS": "Додаток {{id}} відновлено успішно", + "APP_RESTORE_ERROR": "Не вдалося відновити {{id}}, дивіться журнали для отримання додаткової інформації", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Вже існує користувач з правами адміністратора. Будь ласка, увійдіть, щоб створити нового користувача з панелі адміністратора.", "AUTH_ERROR_ERROR_CREATING_USER": "Помилка при створенні користувача", "AUTH_ERROR_INVALID_CREDENTIALS": "Невірні облікові дані", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Скасувати запит на зміну пароля", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Щоб почати, запустіть цю команду на своєму сервері, а потім оновіть цю сторінку. Якщо ви раніше це вже робили, запит на скидання пароля може бути недійсним. У цьому випадку спробуйте ще раз", "AUTH_RESET_PASSWORD_SUBMIT": "Скинути пароль", - "AUTH_RESET_PASSWORD_SUCCESS": "Ваш пароль було скинуто. Тепер ви можете увійти за допомогою нового паролю. І ваша ел. пошта {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Ваш пароль було скинуто. Тепер ви можете увійти за допомогою нового паролю. І ваша ел. пошта {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Скинути пароль", "AUTH_RESET_PASSWORD_TITLE": "Скинути пароль", "AUTH_TOTP_INSTRUCTIONS": "Введіть код з вашого додатка для автентифікації", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Розмір", "BACKUPS_LIST_DELETE_SUCCESS": "Резервну копію видалено", "COMMON_CLOSE": "Закрити", + "COMMON_WARNING": "Увага", "DASHBOARD_CPU_SUBTITLE": "Видаліть додатки для зниження навантаження", "DASHBOARD_CPU_TITLE": "Завантаження ЦП", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Використано з {total} ГБ", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Використано з {{total}} ГБ", "DASHBOARD_DISK_SPACE_TITLE": "Місце на диску", "DASHBOARD_MEMORY_TITLE": "Використано пам'яті", "DASHBOARD_TITLE": "Огляд", "DASHBOARD_IP_WARNING_TITLE": "Небезпечна конфігурація", "DASHBOARD_IP_WARNING": "Попередження, ви можете опинитися під загрозою! Схоже, що ви отримуєте доступ до свого екземпляра за допомогою публічної IP-адреси. Це робить вашу головну панель та всі додатки, які ви встановлюєте вразливими до зловмисників", "DELETE_BACKUP_MODAL_TITLE": "Видалити резервну копію", - "DELETE_BACKUP_MODAL_WARNING": "Ви впевнені, що бажаєте видалити резервну копію {id} зроблену {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Ви впевнені, що бажаєте видалити резервну копію {{id}} зроблену {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "Цю дію не можна буде скасувати", "DELETE_BACKUP_MODAL_SUBMIT": "Видалити", "GUEST_DASHBOARD": "Гостьова панель", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Недійсна локаль", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Заборонено в демо-режимі", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Заборонено в режимі розробника", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "Магазин додатків з такою url-адресою вже існує", + "APP_STORE_CLONE_ERROR": "Помилка клонування магазину додатків за посиланням {{url}}. Це дійсний git репозиторій?", + "APP_STORE_CHOOSE_CATEGORY": "Виберіть категорію", + "APP_STORE_CHOOSE_STORE": "Оберіть магазин додатків", "SETTINGS_ACTIONS_ALREADY_LATEST": "Уже найновіше", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Поточна версія: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Поточна версія: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Загальні дії, які слід виконати на вашому екземплярі", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Підтримка", - "SETTINGS_ACTIONS_NEW_VERSION": "Доступна нова версія ({version}) Tipi", + "SETTINGS_ACTIONS_NEW_VERSION": "Доступна нова версія ({{version}}) Tipi", "SETTINGS_ACTIONS_RESTART": "Перезавантажити", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Залишайтеся в курсі останньої версії Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Дії", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "Це призведе до скидання вашого репозиторію та витягне останні зміни з GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Оновити", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Репозиторій магазину додатків успішно оновлено", + "SETTINGS_APPSTORES_TITLE": "Магазини додатків", + "SETTINGS_APPSTORES_SUBTITLE": "Додати або видалити магазини додатків", + "SETTINGS_APPSTORES_TAB_TITLE": "Магазини додатків", + "SETTINGS_APPSTORES_WARNING": "Переконайтесь, що довіряєте магазинам додатків, які додаєте!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Дозволити автоматичне змінення тем", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Будьте приємно здивовані темами, які автоматично змінюються залежно від часу року.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Дозволяти анонімний моніторинг помилок", diff --git a/src/client/messages/vi-VN.json b/packages/backend/src/modules/i18n/translations/vi-VN.json similarity index 79% rename from src/client/messages/vi-VN.json rename to packages/backend/src/modules/i18n/translations/vi-VN.json index 5d8910639b..d1f9ab851a 100644 --- a/src/client/messages/vi-VN.json +++ b/packages/backend/src/modules/i18n/translations/vi-VN.json @@ -1,5 +1,5 @@ { - "APP_ACTION_CANCEL": "Hủy", + "APP_ACTION_CANCEL": "Huy", "APP_ACTION_INSTALL": "Cài đặt", "APP_ACTION_LOADING": "Đang tải", "APP_ACTION_OPEN": "Mở", @@ -14,7 +14,7 @@ "APP_CATEGORY_BOOKS": "Sách", "APP_CATEGORY_DATA": "Dữ liệu", "APP_CATEGORY_DEVELOPMENT": "Phát triển", - "APP_CATEGORY_FEATURED": "Nổi bật", + "APP_CATEGORY_FEATURED": "Nôi bât", "APP_CATEGORY_FINANCE": "Tài chính", "APP_CATEGORY_GAMING": "Trò chơi", "APP_CATEGORY_MEDIA": "Phương tiện truyền thông", @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "Chi tiết ứng dụng", "APP_DETAILS_VERSION": "Phiên bản", "APP_DETAILS_WEBSITE": "Trang web", - "APP_ERROR_APP_FAILED_TO_INSTALL": "Cài đặt ứng dụng {id} thất bại, xem nhật kí để biết chi tiết", - "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_START": "Khởi động ứng dụng {id} thất bại, vui lòng xem log để biết chi tiết", - "APP_ERROR_APP_FAILED_TO_STOP": "Dừng ứng dụng {id} thất bại, vui lòng xem log để biết chi tiết", - "APP_ERROR_APP_FAILED_TO_RESTART": "Khởi động lại ứng dụng {id} thất bại, xem nhật kí biết thêm", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Gỡ cài đặt ứng dụng {id} thất bại, vui lòng xem log để biết chi tiết", - "APP_ERROR_APP_FAILED_TO_UPDATE": "Cập nhật ứng dụng {id} thất bại, vui lòng xem log để biết chi tiết", - "APP_ERROR_APP_FORCE_EXPOSED": "Ứng dụng {id} chỉ hoạt động với tên miền công khai", - "APP_ERROR_APP_NOT_EXPOSABLE": "Ứng dụng {id} không được mở công khai", - "APP_ERROR_APP_NOT_FOUND": "Không tìm thấy ứng dụng {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Tên miền {domain} đã được sử dụng bởi ứng dụng {id}", - "APP_ERROR_DOMAIN_NOT_VALID": "Tên miên {domain} không hợp lệ", + "APP_ERROR_APP_FAILED_TO_INSTALL": "Cài đặt ứng dụng {{id}} thất bại, xem nhật kí để biết chi tiết", + "APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_START": "Khởi động ứng dụng {{id}} thất bại, vui lòng xem log để biết chi tiết", + "APP_ERROR_APP_FAILED_TO_STOP": "Dừng ứng dụng {{id}} thất bại, vui lòng xem log để biết chi tiết", + "APP_ERROR_APP_FAILED_TO_RESTART": "Khởi động lại ứng dụng {{id}} thất bại, xem nhật kí biết thêm", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "Gỡ cài đặt ứng dụng {{id}} thất bại, vui lòng xem log để biết chi tiết", + "APP_ERROR_APP_FAILED_TO_UPDATE": "Cập nhật ứng dụng {{id}} thất bại, vui lòng xem log để biết chi tiết", + "APP_ERROR_APP_FORCE_EXPOSED": "Ứng dụng {{id}} chỉ hoạt động với tên miền công khai", + "APP_ERROR_APP_NOT_EXPOSABLE": "Ứng dụng {{id}} không được mở công khai", + "APP_ERROR_APP_NOT_FOUND": "Không tìm thấy ứng dụng {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "Tên miền {{domain}} đã được sử dụng bởi ứng dụng {{id}}", + "APP_ERROR_DOMAIN_NOT_VALID": "Tên miên {{domain}} không hợp lệ", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Tên miền là bắt buộc nếu ứng dụng được mở công khai", - "APP_ERROR_INVALID_CONFIG": "File config.json của ứng dụng {id} không hợp lệ", + "APP_ERROR_INVALID_CONFIG": "File config.json của ứng dụng {{id}} không hợp lệ", "APP_INSTALL_FORM_CHOOSE_OPTION": "Chọn một tùy chọn...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Hiển thị trong bảng điều khiển", "APP_INSTALL_FORM_DOMAIN_NAME": "Tên miền", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Hãy chắc chắn rằng tên miền này có chứa bản ghi A trỏ đến địa chỉ IP của bạn.", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} phải nằm giữa {min} và {max} ký tự", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} phải là một tên miền hợp lệ", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} phải là một tên miền hoặc địa chỉ IP hợp lệ", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} phải là một địa chỉ email hợp lệ", - "APP_INSTALL_FORM_ERROR_IP": "{label} phải là một địa chỉ IP hợp lệ", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} phải ít hơn {max} ký tự", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} phải nhiều hơn {min} ký tự", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} phải là số", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} phải theo mẫu {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} là bắt buộc", - "APP_INSTALL_FORM_ERROR_URL": "{label} phải là một đường dẫn", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} phải nằm giữa {{min}} và {{max}} ký tự", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} phải là một tên miền hợp lệ", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} phải là một tên miền hoặc địa chỉ IP hợp lệ", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} phải là một địa chỉ email hợp lệ", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} phải là một địa chỉ IP hợp lệ", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} phải ít hơn {{max}} ký tự", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} phải nhiều hơn {{min}} ký tự", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} phải là số", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} phải theo mẫu {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} là bắt buộc", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} phải là một đường dẫn", "APP_INSTALL_FORM_EXPOSE_APP": "Mở ứng dụng để truy cập được từ internet", "APP_INSTALL_FORM_OPEN_PORT": "Mở cổng", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Mở một cổng trên máy chủ? Ứng dụng này có thể truy cập tại {internalIp}:{port}. (Dễ nhất và kém an toàn nhất)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Mở một cổng trên máy chủ? Ứng dụng này có thể truy cập tại {{internalIp}}:{{port}}. (Dễ nhất và kém an toàn nhất)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Mở ứng dụng trên mạng cục bộ", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Phơi bày ứng dụng trên mạng nội bộ? Ứng dụng này sẽ có thể truy cập tại {appId}.{domain}. (Truy cập trang cài đặt để thiết lập tên miền nội bộ của bạn)", - "APP_INSTALL_FORM_RESET": "Thiết lập lại ứng dụng", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Phơi bày ứng dụng trên mạng nội bộ? Ứng dụng này sẽ có thể truy cập tại {{appId}}.{{domain}}. (Truy cập trang cài đặt để thiết lập tên miền nội bộ của bạn)", + "APP_INSTALL_FORM_RESET": "Thiêt lâp lai ứng dụng", "APP_INSTALL_FORM_SUBMIT_INSTALL": "Cài đặt", "APP_INSTALL_FORM_SUBMIT_UPDATE": "Cập nhật", - "APP_INSTALL_FORM_TITLE": "Cài đặt {name}", + "APP_INSTALL_FORM_TITLE": "Cài đặt {{name}}", "APP_INSTALL_FORM_GENERAL": "Chung", "APP_INSTALL_FORM_REVERSE_PROXY": "Proxy ngược", - "APP_INSTALL_SUCCESS": "Ứng dụng {id} đã được cài đặt thành công", + "APP_INSTALL_SUCCESS": "Ứng dụng {{id}} đã được cài đặt thành công", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "MỚI", "APP_RESET_FORM_SUBMIT": "Thiết lập lại", "APP_RESET_FORM_SUBTITLE": "Tất cả dữ liệu của ứng dụng này sẽ bị mất.", - "APP_RESET_FORM_TITLE": "Đặt lại {name}?", + "APP_RESET_FORM_TITLE": "Đặt lại {{name}}?", "APP_RESET_FORM_WARNING": "Bạn chắc chứ? Hành động này không thể hoàn tác.", - "APP_RESET_SUCCESS": "Ứng dụng {id} đã được thiết lập lại thành công", - "APP_START_SUCCESS": "Ứng dụng {id} Bắt đầu thành công", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "Ứng dụng {{id}} đã được thiết lập lại thành công", + "APP_START_SUCCESS": "Ứng dụng {{id}} Bắt đầu thành công", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "Dừng", "APP_STOP_FORM_SUBTITLE": "Tất cả dữ liệu sẽ được giữ lại", - "APP_STOP_FORM_TITLE": "Dừng {name}?", - "APP_STOP_SUCCESS": "Ứng dụng {id} đã dừng thành công", + "APP_STOP_FORM_TITLE": "Dừng {{name}}?", + "APP_STOP_SUCCESS": "Ứng dụng {{id}} đã dừng thành công", "APP_RESTART_FORM_SUBMIT": "Khởi động lại", "APP_RESTART_FORM_SUBTITLE": "Tất cả dữ liệu sẽ được giữ lại", - "APP_RESTART_FORM_TITLE": "Khởi động lại {name} ?", - "APP_RESTART_SUCCESS": "Ứng dụng {id} đã khởi động lại thành công", + "APP_RESTART_FORM_TITLE": "Khởi động lại {{name}} ?", + "APP_RESTART_SUCCESS": "Ứng dụng {{id}} đã khởi động lại thành công", "APP_STORE_CATEGORY_PLACEHOLDER": "Chọn một danh mục", "APP_STORE_NO_RESULTS": "Không tìm thấy ứng dụng", "APP_STORE_NO_RESULTS_SUBTITLE": "Hãy thử tìm kiếm lại", "APP_STORE_SEARCH_PLACEHOLDER": "Tìm kiếm ứng dụng", "APP_STORE_TITLE": "Cửa hàng ứng dụng", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Gỡ cài đặt", "APP_UNINSTALL_FORM_SUBTITLE": "Tất cả dữ liệu của ứng dụng này sẽ bị mất.", - "APP_UNINSTALL_FORM_TITLE": "Gỡ cài đặt {name}?", + "APP_UNINSTALL_FORM_TITLE": "Gỡ cài đặt {{name}}?", "APP_UNINSTALL_FORM_WARNING": "Bạn chắc chứ? Hành động này không thể hoàn tác.", - "APP_UNINSTALL_SUCCESS": "Ứng dụng {id} đã được gỡ cài đặt thành công", + "APP_UNINSTALL_SUCCESS": "Ứng dụng {{id}} đã được gỡ cài đặt thành công", "APP_UPDATE_CONFIG_SUCCESS": "Cài đặt của ứng dụng đã được cập nhật. Khởi động lại ứng dụng để áp dụng thay đổi", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Ứng dụng {id} cần cập nhật phiên bản Tipi {minVersion} hoặc cao hơn. Vui lòng cập nhật phiên bản của bạn.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "Ứng dụng {{id}} cần cập nhật phiên bản Tipi {{minVersion}} hoặc cao hơn. Vui lòng cập nhật phiên bản của bạn.", "APP_UPDATE_FORM_SUBMIT": "Cập nhật", "APP_UPDATE_FORM_SUBTITLE_1": "Cập nhật ứng dụng lên phiên bản mới nhất:", "APP_UPDATE_FORM_SUBTITLE_2": "Việc này sẽ thay đổi cấu hình tùy chọn của bạn (ví dụ: các thay đổi trong file docker-compose.yml).", - "APP_UPDATE_FORM_TITLE": "Cập nhật {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "Cập nhật cấu hình {name}", - "APP_UPDATE_SUCCESS": "Ứng dụng {id} cập nhật thành công", + "APP_UPDATE_FORM_TITLE": "Cập nhật {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "Cập nhật cấu hình {{name}}", + "APP_UPDATE_SUCCESS": "Ứng dụng {{id}} cập nhật thành công", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "Người dùng với quyền quản trị đã tồn tại. Vui lòng đăng nhập để tạo người dùng mới từ Trang quản trị.", "AUTH_ERROR_ERROR_CREATING_USER": "Đã có lỗi khi tạo người dùng", "AUTH_ERROR_INVALID_CREDENTIALS": "Thông tin đăng nhập không hợp lệ", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "Hủy yêu cầu thay đổi mật khẩu", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "Để bắt đầu, chạy lệnh này trên máy chủ của bạn và sau đó làm mới trang này. Nếu bạn đã thực hiện điều này trước đó, yêu cầu đặt lại mật khẩu có thể đã hết hạn. Trong trường hợp đó, vui lòng thử lại", "AUTH_RESET_PASSWORD_SUBMIT": "Đặt lại mật khẩu", - "AUTH_RESET_PASSWORD_SUCCESS": "Mật khẩu của bạn đã được khôi phục. Bạn có thể đăng nhập với mật khẩu mới và email {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "Mật khẩu của bạn đã được khôi phục. Bạn có thể đăng nhập với mật khẩu mới và email {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Khôi phục mật khẩu", "AUTH_RESET_PASSWORD_TITLE": "Đặt lại mật khẩu của bạn", "AUTH_TOTP_INSTRUCTIONS": "Nhập mã từ ứng dụng xác thực của bạn", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Đóng", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "Gỡ cài đặt ứng dụng để giảm thiểu việc quá tải", "DASHBOARD_CPU_TITLE": "Tải CPU", - "DASHBOARD_DISK_SPACE_SUBTITLE": "Đã dùng trên tổng số {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "Đã dùng trên tổng số {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "Dung lượng đĩa", "DASHBOARD_MEMORY_TITLE": "Đã sử dụng bộ nhớ", "DASHBOARD_TITLE": "Bảng điều khiển", "DASHBOARD_IP_WARNING_TITLE": "Cấu hình không an toàn", "DASHBOARD_IP_WARNING": "Cảnh báo, bạn có thể gặp nguy hiểm! Có vẻ bạn đang truy cập vào phiên của mình thông qua IP công cộng. Điều này khiến bảng điều khiển của bạn và các ứng dụng bạn cài đặt có thể bị tấn công", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Bảng điều khiển Khách", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "Địa phương không hợp lệ", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Không được phép trong chế độ DEMO", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Không được phép trong chế độ DEV", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "Đã cập nhật", - "SETTINGS_ACTIONS_CURRENT_VERSION": "Phiên bản hiện tại: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "Phiên bản hiện tại: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Những thao tác thường sử dụng trên máy chủ của bạn", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Bảo trì", - "SETTINGS_ACTIONS_NEW_VERSION": "Phiên bản mới của Tipi đã có sẵn. Phiên bản ({version})", + "SETTINGS_ACTIONS_NEW_VERSION": "Phiên bản mới của Tipi đã có sẵn. Phiên bản ({{version}})", "SETTINGS_ACTIONS_RESTART": "Khởi động lại", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Luôn được cập nhật phiên bản mới nhất của Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "Thao tác", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Cho phép chủ đề tự động", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Hãy ngạc nhiên bởi các chủ đề thay đổi tự động dựa trên thời gian trong năm.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Cho phép theo dõi lỗi ẩn danh", diff --git a/src/client/messages/zh-CN.json b/packages/backend/src/modules/i18n/translations/zh-CN.json similarity index 76% rename from src/client/messages/zh-CN.json rename to packages/backend/src/modules/i18n/translations/zh-CN.json index f5a63cc504..75b7cbf786 100644 --- a/src/client/messages/zh-CN.json +++ b/packages/backend/src/modules/i18n/translations/zh-CN.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "应用详情", "APP_DETAILS_VERSION": "版本", "APP_DETAILS_WEBSITE": "网址", - "APP_ERROR_APP_FAILED_TO_INSTALL": "安装应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FAILED_TO_RESET": "重置应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FAILED_TO_START": "启动应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FAILED_TO_STOP": "停止应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {id}, see logs for more details", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "卸载应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FAILED_TO_UPDATE": "更新应用程序 {id} 失败,查看日志获取更多详细信息", - "APP_ERROR_APP_FORCE_EXPOSED": "App {id} 只能使用公开域名", - "APP_ERROR_APP_NOT_EXPOSABLE": "App {id} 不可发布", - "APP_ERROR_APP_NOT_FOUND": "找不到应用程序 {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {arch} is not supported by app {id}", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "域名 {domain} 已被应用程序 {id} 使用", - "APP_ERROR_DOMAIN_NOT_VALID": "{domain} 不是有效域名", + "APP_ERROR_APP_FAILED_TO_INSTALL": "安装应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FAILED_TO_RESET": "重置应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FAILED_TO_START": "启动应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FAILED_TO_STOP": "停止应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FAILED_TO_RESTART": "Failed to restart app {{id}}, see logs for more details", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "卸载应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FAILED_TO_UPDATE": "更新应用程序 {{id}} 失败,查看日志获取更多详细信息", + "APP_ERROR_APP_FORCE_EXPOSED": "App {{id}} 只能使用公开域名", + "APP_ERROR_APP_NOT_EXPOSABLE": "App {{id}} 不可发布", + "APP_ERROR_APP_NOT_FOUND": "找不到应用程序 {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "Your architecture {{arch}} is not supported by app {{id}}", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "域名 {{domain}} 已被应用程序 {{id}} 使用", + "APP_ERROR_DOMAIN_NOT_VALID": "{{domain}} 不是有效域名", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "如果应用程序被暴露给公网则需要域名", - "APP_ERROR_INVALID_CONFIG": "App {id} 有无效的 config.json 文件", + "APP_ERROR_INVALID_CONFIG": "App {{id}} 有无效的 config.json 文件", "APP_INSTALL_FORM_CHOOSE_OPTION": "选择一个选项...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "在访客面板上显示", "APP_INSTALL_FORM_DOMAIN_NAME": "域名", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "请确保此域名包含一个指向您的 IP 的记录。", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} 必须在 {min} 和 {max} 字符之间", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} 必须是一个有效的域", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} 必须是一个有效的域或IP地址", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} 必须是一个有效的电子邮件地址", - "APP_INSTALL_FORM_ERROR_IP": "{label} 必须是一个有效的 IP 地址", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} 必须小于 {max} 个字符", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} 必须至少 {min} 个字符", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} 必须是一个数字", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} 必须匹配模式 {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} 是必须的", - "APP_INSTALL_FORM_ERROR_URL": "{label} 必须是一个有效的 URL", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} 必须在 {{min}} 和 {{max}} 字符之间", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} 必须是一个有效的域", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} 必须是一个有效的域或IP地址", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} 必须是一个有效的电子邮件地址", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} 必须是一个有效的 IP 地址", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} 必须小于 {{max}} 个字符", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} 必须至少 {{min}} 个字符", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} 必须是一个数字", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} 必须匹配模式 {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} 是必须的", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} 必须是一个有效的 URL", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {internalIp}:{port}. (Easiest but less secure)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "Expose app on local network", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {appId}.{domain}. (Visit settings page to setup your local domain)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "Expose the app on the local network? This app will be accessible at {{appId}}.{{domain}}. (Visit settings page to setup your local domain)", "APP_INSTALL_FORM_RESET": "重置应用", "APP_INSTALL_FORM_SUBMIT_INSTALL": "安装", "APP_INSTALL_FORM_SUBMIT_UPDATE": "更新", - "APP_INSTALL_FORM_TITLE": "安装 {name}", + "APP_INSTALL_FORM_TITLE": "安装 {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", - "APP_INSTALL_SUCCESS": "应用 {id} 安装成功", + "APP_INSTALL_SUCCESS": "应用 {{id}} 安装成功", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", "APP_LOGS_TAB_TITLE": "Logs", @@ -87,15 +87,15 @@ "APP_NEW": "NEW", "APP_RESET_FORM_SUBMIT": "重置", "APP_RESET_FORM_SUBTITLE": "此应用的所有数据将丢失。", - "APP_RESET_FORM_TITLE": "确认重置 {name}?", + "APP_RESET_FORM_TITLE": "确认重置 {{name}}?", "APP_RESET_FORM_WARNING": "您确定吗?这个操作无法撤销。", - "APP_RESET_SUCCESS": "App {id} reset successfully", - "APP_START_SUCCESS": "App {id} started successfully", - "APP_BACKUP_TITLE": "Backup {name}", + "APP_RESET_SUCCESS": "App {{id}} reset successfully", + "APP_START_SUCCESS": "App {{id}} started successfully", + "APP_BACKUP_TITLE": "Backup {{name}}", "APP_BACKUP_SUBTITLE": "A tar archive will be created in the backups folder to store your app's data.", "APP_BACKUP_SUBMIT": "Backup", - "APP_RESTORE_TITLE": "Restore {name} backup", - "APP_RESTORE_WARNING": "Do you really want to restore backup {id} made on {date}?", + "APP_RESTORE_TITLE": "Restore {{name}} backup", + "APP_RESTORE_WARNING": "Do you really want to restore backup {{id}} made on {{date}}?", "APP_RESTORE_SUBTITLE": "All the current data of the app will be erased and replaced with the data from the backup. It is recommended to backup your app before restoring.", "APP_RESTORE_SUBMIT": "Restore", "APP_BACKUPS_TAB_TITLE": "Backups", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "Restoring", "APP_STOP_FORM_SUBMIT": "停止", "APP_STOP_FORM_SUBTITLE": "所有数据将被保留", - "APP_STOP_FORM_TITLE": "停止 {name}?", - "APP_STOP_SUCCESS": "App {id} stopped successfully", + "APP_STOP_FORM_TITLE": "停止 {{name}}?", + "APP_STOP_SUCCESS": "App {{id}} stopped successfully", "APP_RESTART_FORM_SUBMIT": "Restart", "APP_RESTART_FORM_SUBTITLE": "All data will be retained", - "APP_RESTART_FORM_TITLE": "Restart {name} ?", - "APP_RESTART_SUCCESS": "App {id} restarted successfully", + "APP_RESTART_FORM_TITLE": "Restart {{name}} ?", + "APP_RESTART_SUCCESS": "App {{id}} restarted successfully", "APP_STORE_CATEGORY_PLACEHOLDER": "选择类别", "APP_STORE_NO_RESULTS": "未找到应用", "APP_STORE_NO_RESULTS_SUBTITLE": "尝试完善您的搜索", "APP_STORE_SEARCH_PLACEHOLDER": "搜索应用", "APP_STORE_TITLE": "应用市场", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "卸载", "APP_UNINSTALL_FORM_SUBTITLE": "此应用的所有数据将丢失。", - "APP_UNINSTALL_FORM_TITLE": "卸载 {name} 吗?", + "APP_UNINSTALL_FORM_TITLE": "卸载 {{name}} 吗?", "APP_UNINSTALL_FORM_WARNING": "您确定吗?这个操作无法撤销。", - "APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully", + "APP_UNINSTALL_SUCCESS": "App {{id}} uninstalled successfully", "APP_UPDATE_CONFIG_SUCCESS": "应用程序配置更新成功。重启应用以应用更改", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {id} update requires Tipi version {minVersion} or higher. Please update your instance.", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "App {{id}} update requires Tipi version {{minVersion}} or higher. Please update your instance.", "APP_UPDATE_FORM_SUBMIT": "更新", "APP_UPDATE_FORM_SUBTITLE_1": "更新应用到最新版本:", "APP_UPDATE_FORM_SUBTITLE_2": "这将重置您的自定义配置(如:docker-compose. yml中的更改)", - "APP_UPDATE_FORM_TITLE": "更新 {name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "更新 {name} 配置", - "APP_UPDATE_SUCCESS": "App {id} updated successfully", + "APP_UPDATE_FORM_TITLE": "更新 {{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "更新 {{name}} 配置", + "APP_UPDATE_SUCCESS": "App {{id}} updated successfully", "APP_UPDATE_FORM_BACKUP": "Backup app before updating", - "APP_BACKUP_SUCCESS": "App {id} backed up successfully", - "APP_BACKUP_ERROR": "Failed to backup {id}, see logs for more details", - "APP_RESTORE_SUCCESS": "App {id} restored successfully", - "APP_RESTORE_ERROR": "Failed to restore {id}, see logs for more details", + "APP_BACKUP_SUCCESS": "App {{id}} backed up successfully", + "APP_BACKUP_ERROR": "Failed to backup {{id}}, see logs for more details", + "APP_RESTORE_SUCCESS": "App {{id}} restored successfully", + "APP_RESTORE_ERROR": "Failed to restore {{id}}, see logs for more details", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "已经有一个管理员用户。请登录并从管理面板创建一个新用户。", "AUTH_ERROR_ERROR_CREATING_USER": "创建新用户错误", "AUTH_ERROR_INVALID_CREDENTIALS": "凭证无效", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "取消密码更改请求", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "To get started, run this command on your server and then refresh this page. If you have previously done so, the password reset request may have expired. In that case please retry", "AUTH_RESET_PASSWORD_SUBMIT": "重置密码", - "AUTH_RESET_PASSWORD_SUCCESS": "您的密码已重置。您现在可以使用您的新密码登录。您的电子邮件 {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "您的密码已重置。您现在可以使用您的新密码登录。您的电子邮件 {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "密码重置", "AUTH_RESET_PASSWORD_TITLE": "重置您的密码", "AUTH_TOTP_INSTRUCTIONS": "请输入您的双重验证应用中显示的验证码", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "Size", "BACKUPS_LIST_DELETE_SUCCESS": "Backup deleted successfully", "COMMON_CLOSE": "Close", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "卸载应用以减少负载", "DASHBOARD_CPU_TITLE": "CPU 负载", - "DASHBOARD_DISK_SPACE_SUBTITLE": "已使用 {total} GB", + "DASHBOARD_DISK_SPACE_SUBTITLE": "已使用 {{total}} GB", "DASHBOARD_DISK_SPACE_TITLE": "磁盘空间", "DASHBOARD_MEMORY_TITLE": "已用内存", "DASHBOARD_TITLE": "控制面板", "DASHBOARD_IP_WARNING_TITLE": "Insecure configuration", "DASHBOARD_IP_WARNING": "Warning, you might be at risk! it looks like you are accessing your instance through a public IP address. This makes your dashboard and all apps that you install vulnerable to attackers", "DELETE_BACKUP_MODAL_TITLE": "Delete backup", - "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {id} made on {date}?", + "DELETE_BACKUP_MODAL_WARNING": "Are you sure you want to delete backup {{id}} made on {{date}}?", "DELETE_BACKUP_MODAL_SUBTITLE": "This action cannot be undone", "DELETE_BACKUP_MODAL_SUBMIT": "Delete", "GUEST_DASHBOARD": "Guest dashboard", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "无效的区域设置", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "在演示模式下不允许", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "在开发模式下不允许", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "已是最新版本", - "SETTINGS_ACTIONS_CURRENT_VERSION": "当前版本: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "当前版本: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "在您的实例中执行的常见操作", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "维护", - "SETTINGS_ACTIONS_NEW_VERSION": "Tipi 的新版本 ({version}) 可用", + "SETTINGS_ACTIONS_NEW_VERSION": "Tipi 的新版本 ({{version}}) 可用", "SETTINGS_ACTIONS_RESTART": "重启", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "保持最新版本的 Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "操作", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "This will reset your repository and pull the latest changes from GitHub", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "Update", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "Appstore repository updated successfully", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring", diff --git a/src/client/messages/zh-TW.json b/packages/backend/src/modules/i18n/translations/zh-TW.json similarity index 75% rename from src/client/messages/zh-TW.json rename to packages/backend/src/modules/i18n/translations/zh-TW.json index b53d8c8db1..bc6fc58221 100644 --- a/src/client/messages/zh-TW.json +++ b/packages/backend/src/modules/i18n/translations/zh-TW.json @@ -38,48 +38,48 @@ "APP_DETAILS_TITLE": "應用程式詳細資訊", "APP_DETAILS_VERSION": "版本", "APP_DETAILS_WEBSITE": "網址", - "APP_ERROR_APP_FAILED_TO_INSTALL": "安裝應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_RESET": "重置應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_START": "啟動應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_STOP": "停止應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_RESTART": "重新啟用應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_UNINSTALL": "解除安裝應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FAILED_TO_UPDATE": "更新應用程式 {id} 失敗,請參閱日誌以瞭解更多詳細資訊", - "APP_ERROR_APP_FORCE_EXPOSED": "應用程式 {id} 僅適用於公開網域", - "APP_ERROR_APP_NOT_EXPOSABLE": "應用程式 {id} 不可公開", - "APP_ERROR_APP_NOT_FOUND": "未找到應用程式 {id}", - "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "應用程式 {id} 不支援你的 {arch} 架構", - "APP_ERROR_DOMAIN_ALREADY_IN_USE": "網域 {domain} 已被應用程式 {id} 使用", - "APP_ERROR_DOMAIN_NOT_VALID": "{domain} 不是有效網域", + "APP_ERROR_APP_FAILED_TO_INSTALL": "安裝應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_RESET": "重置應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_START": "啟動應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_STOP": "停止應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_RESTART": "重新啟用應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_UNINSTALL": "解除安裝應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FAILED_TO_UPDATE": "更新應用程式 {{id}} 失敗,請參閱日誌以瞭解更多詳細資訊", + "APP_ERROR_APP_FORCE_EXPOSED": "應用程式 {{id}} 僅適用於公開網域", + "APP_ERROR_APP_NOT_EXPOSABLE": "應用程式 {{id}} 不可公開", + "APP_ERROR_APP_NOT_FOUND": "未找到應用程式 {{id}}", + "APP_ERROR_ARCHITECTURE_NOT_SUPPORTED": "應用程式 {{id}} 不支援你的 {{arch}} 架構", + "APP_ERROR_DOMAIN_ALREADY_IN_USE": "網域 {{domain}} 已被應用程式 {{id}} 使用", + "APP_ERROR_DOMAIN_NOT_VALID": "{{domain}} 不是有效網域", "APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "公開應用程式需要網域", - "APP_ERROR_INVALID_CONFIG": "應用程式 {id} 的 config.json 文件無效", + "APP_ERROR_INVALID_CONFIG": "應用程式 {{id}} 的 config.json 文件無效", "APP_INSTALL_FORM_CHOOSE_OPTION": "選擇一個選項...", "APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "顯示在訪客儀錶板上", "APP_INSTALL_FORM_DOMAIN_NAME": "網域", "APP_INSTALL_FORM_DOMAIN_NAME_HINT": "請確保此網域含有指向你的 IP 的 A 記錄。", - "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} 必須介於 {min} 和 {max} 個字符之間", - "APP_INSTALL_FORM_ERROR_FQDN": "{label} 必須是有效網域", - "APP_INSTALL_FORM_ERROR_FQDNIP": "{label} 必須是有效的網域或 IP 地址", - "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} 必須是有效的電子郵件地址", - "APP_INSTALL_FORM_ERROR_IP": "{label} 必須是有效的 IP 地址", - "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} 必須少於 {max} 個字符", - "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} 必須至少為 {min} 個字符", - "APP_INSTALL_FORM_ERROR_NUMBER": "{label} 必須是數字", - "APP_INSTALL_FORM_ERROR_REGEX": "{label} 必須符合模式 {pattern}", - "APP_INSTALL_FORM_ERROR_REQUIRED": "{label} 是必填欄位", - "APP_INSTALL_FORM_ERROR_URL": "{label} 必須是有效的網址", + "APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{{label}} 必須介於 {{min}} 和 {{max}} 個字符之間", + "APP_INSTALL_FORM_ERROR_FQDN": "{{label}} 必須是有效網域", + "APP_INSTALL_FORM_ERROR_FQDNIP": "{{label}} 必須是有效的網域或 IP 地址", + "APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{{label}} 必須是有效的電子郵件地址", + "APP_INSTALL_FORM_ERROR_IP": "{{label}} 必須是有效的 IP 地址", + "APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{{label}} 必須少於 {{max}} 個字符", + "APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{{label}} 必須至少為 {{min}} 個字符", + "APP_INSTALL_FORM_ERROR_NUMBER": "{{label}} 必須是數字", + "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} 必須符合模式 {{pattern}}", + "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} 是必填欄位", + "APP_INSTALL_FORM_ERROR_URL": "{{label}} 必須是有效的網址", "APP_INSTALL_FORM_EXPOSE_APP": "在網路上公開應用程式", "APP_INSTALL_FORM_OPEN_PORT": "開放端口", - "APP_INSTALL_FORM_OPEN_PORT_HINT": "在伺服器上開放端口? 此軟體將能從 {internalIp}:{port} 進入(簡單但較不安全)", + "APP_INSTALL_FORM_OPEN_PORT_HINT": "在伺服器上開放端口? 此軟體將能從 {{internalIp}}:{{port}} 進入(簡單但較不安全)", "APP_INSTALL_FORM_EXPOSE_LOCAL": "在區域網路中開放應用程式", - "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "將軟體開放在區域網路? 此軟體將能從 {appId}.{domain} 進入(在設定頁面裡設定內部網域)", + "APP_INSTALL_FORM_EXPOSE_LOCAL_HINT": "將軟體開放在區域網路? 此軟體將能從 {{appId}}.{{domain}} 進入(在設定頁面裡設定內部網域)", "APP_INSTALL_FORM_RESET": "重置應用程式", "APP_INSTALL_FORM_SUBMIT_INSTALL": "安裝", "APP_INSTALL_FORM_SUBMIT_UPDATE": "更新", - "APP_INSTALL_FORM_TITLE": "安裝 {name}", + "APP_INSTALL_FORM_TITLE": "安裝 {{name}}", "APP_INSTALL_FORM_GENERAL": "一般", "APP_INSTALL_FORM_REVERSE_PROXY": "反向代理", - "APP_INSTALL_SUCCESS": "應用程式 {id} 已成功安裝", + "APP_INSTALL_SUCCESS": "應用程式 {{id}} 已成功安裝", "APP_LOGS_TAB_FOLLOW": "追蹤日誌", "APP_LOGS_TAB_MAX_LINES": "最大行數:", "APP_LOGS_TAB_TITLE": "日誌", @@ -87,15 +87,15 @@ "APP_NEW": "新", "APP_RESET_FORM_SUBMIT": "重置", "APP_RESET_FORM_SUBTITLE": "此應用程式的所有資料都將遺失。", - "APP_RESET_FORM_TITLE": "重置 {name}?", + "APP_RESET_FORM_TITLE": "重置 {{name}}?", "APP_RESET_FORM_WARNING": "你確定嗎?這個動作無法被復原!", - "APP_RESET_SUCCESS": "應用程式 {id} 已成功重置", - "APP_START_SUCCESS": "應用程式 {id} 已成功啟動", - "APP_BACKUP_TITLE": "備份 {name}", + "APP_RESET_SUCCESS": "應用程式 {{id}} 已成功重置", + "APP_START_SUCCESS": "應用程式 {{id}} 已成功啟動", + "APP_BACKUP_TITLE": "備份 {{name}}", "APP_BACKUP_SUBTITLE": "你的應用程式資料將會儲存在備份資料夾中的一個 .tar 檔案中", "APP_BACKUP_SUBMIT": "備份", - "APP_RESTORE_TITLE": "復原 {name} 備份", - "APP_RESTORE_WARNING": "你真的要復原於 {date} 製作的 {id} 備份嗎?", + "APP_RESTORE_TITLE": "復原 {{name}} 備份", + "APP_RESTORE_WARNING": "你真的要復原於 {{date}} 製作的 {{id}} 備份嗎?", "APP_RESTORE_SUBTITLE": "目前應用程式所有資料將會被刪除,並以備份資料取代。 建議在復原前先備份您的應用程式。", "APP_RESTORE_SUBMIT": "復原", "APP_BACKUPS_TAB_TITLE": "備份", @@ -115,35 +115,53 @@ "APP_STATUS_RESTORING": "復原中", "APP_STOP_FORM_SUBMIT": "停止", "APP_STOP_FORM_SUBTITLE": "所有資料將被保留", - "APP_STOP_FORM_TITLE": "停止{name}?", - "APP_STOP_SUCCESS": "應用程式 {id} 已成功停止", + "APP_STOP_FORM_TITLE": "停止{{name}}?", + "APP_STOP_SUCCESS": "應用程式 {{id}} 已成功停止", "APP_RESTART_FORM_SUBMIT": "重新啟動", "APP_RESTART_FORM_SUBTITLE": "所有資料將會保留", - "APP_RESTART_FORM_TITLE": "重新啟動 {name}?", - "APP_RESTART_SUCCESS": "應用程式 {id} 已成功重新啟動", + "APP_RESTART_FORM_TITLE": "重新啟動 {{name}}?", + "APP_RESTART_SUCCESS": "應用程式 {{id}} 已成功重新啟動", "APP_STORE_CATEGORY_PLACEHOLDER": "選擇一個類別", "APP_STORE_NO_RESULTS": "未找到任何應用程式", "APP_STORE_NO_RESULTS_SUBTITLE": "請嘗試更精確地搜尋", "APP_STORE_SEARCH_PLACEHOLDER": "搜尋應用程式", "APP_STORE_TITLE": "應用程式商店", + "APP_STORE_TABLE_EDIT": "Edit", + "APP_STORE_TABLE_DELETE": "Delete", + "APP_STORE_EDIT_DIALOG_TITLE": "Edit appstore", + "APP_STORE_EDIT_DIALOG_SUBMIT": "Save", + "APP_STORE_EDIT_DIALOG_SUCCESS": "Appstore edited successfully", + "APP_STORE_EDIT_DIALOG_ENABLED": "Enabled", + "APP_STORE_DELETE_DIALOG_TITLE": "Delete appstore", + "APP_STORE_DELETE_DIALOG_WARNING": "Are you sure you want to delete appstore {{name}}?", + "APP_STORE_DELETE_DIALOG_SUBMIT": "Delete", + "APP_STORE_DELETE_SUCCESS": "Appstore deleted successfully", + "APP_STORE_DELETE_ERROR_LAST_STORE": "You cannot delete the last appstore", + "APP_STORE_DELETE_ERROR_APPS_EXIST": "There are installed apps that belong to this appstore. Please uninstall them first", + "APP_STORE_ADD_DIALOG_TITLE": "Add appstore", + "APP_STORE_ADD_FORM_NAME": "Appstore name", + "APP_STORE_ADD_FORM_URL": "Appstore URL", + "APP_STORE_ADD_FORM_SUBMIT": "Create new appstore", + "APP_STORE_ADD_SUCCESS": "Appstore added successfully", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "卸載", "APP_UNINSTALL_FORM_SUBTITLE": "此應用程式的所有資料將會遺失。", - "APP_UNINSTALL_FORM_TITLE": "卸載{name}?", + "APP_UNINSTALL_FORM_TITLE": "卸載{{name}}?", "APP_UNINSTALL_FORM_WARNING": "您確定嗎?這個動作無法被復原!", - "APP_UNINSTALL_SUCCESS": "應用程式 {id} 已成功解除安裝", + "APP_UNINSTALL_SUCCESS": "應用程式 {{id}} 已成功解除安裝", "APP_UPDATE_CONFIG_SUCCESS": "應用程式設定已成功更新。請重新啟動應用程式以應用變更。", - "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "應用程式 {id} 更新需要 Tipi 版本 {minVersion} 或更高的版本,請更新你的系統。", + "APP_UPDATE_ERROR_MIN_TIPI_VERSION": "應用程式 {{id}} 更新需要 Tipi 版本 {{minVersion}} 或更高的版本,請更新你的系統。", "APP_UPDATE_FORM_SUBMIT": "更新", "APP_UPDATE_FORM_SUBTITLE_1": "更新到最新版本:", "APP_UPDATE_FORM_SUBTITLE_2": "這將重置您的自定義配置 (例如 docker-compose. yml 中的更改)", - "APP_UPDATE_FORM_TITLE": "更新{name}?", - "APP_UPDATE_SETTINGS_FORM_TITLE": "更新 {name} 設定", - "APP_UPDATE_SUCCESS": "應用程式 {id} 已成功更新", + "APP_UPDATE_FORM_TITLE": "更新{{name}}?", + "APP_UPDATE_SETTINGS_FORM_TITLE": "更新 {{name}} 設定", + "APP_UPDATE_SUCCESS": "應用程式 {{id}} 已成功更新", "APP_UPDATE_FORM_BACKUP": "更新前 備份應用程式", - "APP_BACKUP_SUCCESS": "應用程式 {id} 備份成功", - "APP_BACKUP_ERROR": "備份 {id} 失敗 請見日誌以獲取更多詳情", - "APP_RESTORE_SUCCESS": "應用程式 {id} 復原成功", - "APP_RESTORE_ERROR": "恢復 {id} 失敗 請見日誌以獲取更多詳情", + "APP_BACKUP_SUCCESS": "應用程式 {{id}} 備份成功", + "APP_BACKUP_ERROR": "備份 {{id}} 失敗 請見日誌以獲取更多詳情", + "APP_RESTORE_SUCCESS": "應用程式 {{id}} 復原成功", + "APP_RESTORE_ERROR": "恢復 {{id}} 失敗 請見日誌以獲取更多詳情", "AUTH_ERROR_ADMIN_ALREADY_EXISTS": "已經有一個管理使用者。請登入管理面板以建立新的使用者。", "AUTH_ERROR_ERROR_CREATING_USER": "創建使用者時發生錯誤", "AUTH_ERROR_INVALID_CREDENTIALS": "無效的登入憑證", @@ -184,7 +202,7 @@ "AUTH_RESET_PASSWORD_CANCEL": "取消密碼更改請求", "AUTH_RESET_PASSWORD_INSTRUCTIONS": "首先,請在你的伺服器上執行此命令,然後刷新此頁面。如果你之前已完成操作,則密碼重設請求可能已過期,請重新嘗試。", "AUTH_RESET_PASSWORD_SUBMIT": "密碼重置", - "AUTH_RESET_PASSWORD_SUCCESS": "你的密碼已重置。 你現在可以使用新密碼登錄。 還有你的電子郵件 {email}", + "AUTH_RESET_PASSWORD_SUCCESS": "你的密碼已重置。 你現在可以使用新密碼登錄。 還有你的電子郵件 {{email}}", "AUTH_RESET_PASSWORD_SUCCESS_TITLE": "重置密碼", "AUTH_RESET_PASSWORD_TITLE": "重置你的密碼", "AUTH_TOTP_INSTRUCTIONS": "輸入驗證應用程式中的驗證代碼", @@ -198,16 +216,17 @@ "BACKUPS_LIST_ROW_TITLE_SIZE": "大小", "BACKUPS_LIST_DELETE_SUCCESS": "成功刪除備份", "COMMON_CLOSE": "關閉", + "COMMON_WARNING": "Warning", "DASHBOARD_CPU_SUBTITLE": "解除安裝應用程式以減少資源負載", "DASHBOARD_CPU_TITLE": "CPU 承載", - "DASHBOARD_DISK_SPACE_SUBTITLE": "在 {total} GB 中已使用", + "DASHBOARD_DISK_SPACE_SUBTITLE": "在 {{total}} GB 中已使用", "DASHBOARD_DISK_SPACE_TITLE": "硬碟空間", "DASHBOARD_MEMORY_TITLE": "已使用記憶體", "DASHBOARD_TITLE": "儀表板", "DASHBOARD_IP_WARNING_TITLE": "不安全的設定", "DASHBOARD_IP_WARNING": "注意,你的安全性可能會受到威脅!看起來你正透過公共 IP 位址存取系統。這使得你的儀表板和你安裝的所有應用程式容易受到攻擊。", "DELETE_BACKUP_MODAL_TITLE": "刪除備份", - "DELETE_BACKUP_MODAL_WARNING": "你確定要刪除建立於 {date} 的 {id} 備份嗎?", + "DELETE_BACKUP_MODAL_WARNING": "你確定要刪除建立於 {{date}} 的 {{id}} 備份嗎?", "DELETE_BACKUP_MODAL_SUBTITLE": "此動作無法復原", "DELETE_BACKUP_MODAL_SUBMIT": "刪除", "GUEST_DASHBOARD": "訪客儀錶板", @@ -258,11 +277,15 @@ "SERVER_ERROR_INVALID_LOCALE": "語言環境無效", "SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Demo模式下禁止此動作", "SERVER_ERROR_NOT_ALLOWED_IN_DEV": "開發模式下禁止此動作", + "SERVER_ERROR_APP_STORE_ALREADY_EXISTS": "An app store with the same url already exists", + "APP_STORE_CLONE_ERROR": "Error cloning app store at {{url}}. Is it a valid git repository?", + "APP_STORE_CHOOSE_CATEGORY": "Choose a category", + "APP_STORE_CHOOSE_STORE": "Choose an AppStore", "SETTINGS_ACTIONS_ALREADY_LATEST": "已是最新版本", - "SETTINGS_ACTIONS_CURRENT_VERSION": "目前版本: {version}", + "SETTINGS_ACTIONS_CURRENT_VERSION": "目前版本: {{version}}", "SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "你可以在系統上執行的常用操作", "SETTINGS_ACTIONS_MAINTENANCE_TITLE": "維護", - "SETTINGS_ACTIONS_NEW_VERSION": "Tipi 的新版本 ({version}) 可用", + "SETTINGS_ACTIONS_NEW_VERSION": "Tipi 的新版本 ({{version}}) 可用", "SETTINGS_ACTIONS_RESTART": "重新啟動", "SETTINGS_ACTIONS_STAY_UP_TO_DATE": "即時了解最新版本的 Tipi", "SETTINGS_ACTIONS_TAB_TITLE": "操作", @@ -272,6 +295,10 @@ "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_SUBTITLE": "這將會重設你的 repository 並從 GitHub下載最新變動", "SETTINGS_ACTIONS_UPDATE_REPO_MODAL_BUTTON": "更新", "SETTINGS_ACTIONS_UPDATE_REPO_SUCCESS": "應用商店 repository 更新成功", + "SETTINGS_APPSTORES_TITLE": "App Stores", + "SETTINGS_APPSTORES_SUBTITLE": "Add or remove app stores", + "SETTINGS_APPSTORES_TAB_TITLE": "App Stores", + "SETTINGS_APPSTORES_WARNING": "Make sure you trust the appstores you add!", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "允許自動主題", "SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "主題將會隨著年度的時間變化", "SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "允許匿名錯誤監控", diff --git a/packages/backend/src/modules/links/dto/links.dto.ts b/packages/backend/src/modules/links/dto/links.dto.ts new file mode 100644 index 0000000000..04e88a50ba --- /dev/null +++ b/packages/backend/src/modules/links/dto/links.dto.ts @@ -0,0 +1,41 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const linkSchema = z.object({ + id: z.number(), + title: z.string().min(1).max(20), + description: z.string().min(0).max(50).nullable(), + url: z.string().url(), + iconUrl: z.string().url().or(z.string().max(0)).nullable(), + userId: z.number(), +}); + +export class LinkBodyDto extends createZodDto( + linkSchema + .omit({ + id: true, + userId: true, + description: true, + iconUrl: true, + }) + .extend({ + description: z.string().min(0).max(50).optional(), + iconUrl: z.string().url().or(z.string().max(0)).optional(), + }), +) {} + +export class EditLinkBodyDto extends createZodDto( + linkSchema + .omit({ + id: true, + userId: true, + description: true, + iconUrl: true, + }) + .extend({ + description: z.string().min(0).max(50).optional(), + iconUrl: z.string().url().or(z.string().max(0)).optional(), + }), +) {} + +export class LinksDto extends createZodDto(z.object({ links: z.array(linkSchema) })) {} diff --git a/packages/backend/src/modules/links/links.controller.ts b/packages/backend/src/modules/links/links.controller.ts new file mode 100644 index 0000000000..af1f9fb62e --- /dev/null +++ b/packages/backend/src/modules/links/links.controller.ts @@ -0,0 +1,49 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { Body, Controller, Delete, Get, Injectable, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AuthGuard } from '../auth/auth.guard'; +import { EditLinkBodyDto, LinkBodyDto, LinksDto } from './dto/links.dto'; +import { LinksService } from './links.service'; + +@Injectable() +@Controller('links') +@UseGuards(AuthGuard) +export class LinksController { + constructor(private readonly linksService: LinksService) {} + + @Get() + @ZodSerializerDto(LinksDto) + async getLinks(@Req() req: Request): Promise { + const links = await this.linksService.getLinks(req.user?.id); + + return { links }; + } + + @Post() + async createLink(@Body() body: LinkBodyDto, @Req() req: Request) { + if (!req.user) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + return this.linksService.add(body, req.user.id); + } + + @Patch(':id') + async editLink(@Param('id') id: number, @Body() body: EditLinkBodyDto, @Req() req: Request) { + if (!req.user) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + return this.linksService.edit(id, body, req.user.id); + } + + @Delete(':id') + async deleteLink(@Param('id') id: number, @Req() req: Request) { + if (!req.user) { + throw new TranslatableError('SYSTEM_ERROR_YOU_MUST_BE_LOGGED_IN'); + } + + return this.linksService.delete(id, req.user.id); + } +} diff --git a/packages/backend/src/modules/links/links.module.ts b/packages/backend/src/modules/links/links.module.ts new file mode 100644 index 0000000000..8a2ecfee04 --- /dev/null +++ b/packages/backend/src/modules/links/links.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LinksController } from './links.controller'; +import { LinksRepository } from './links.repository'; +import { LinksService } from './links.service'; + +@Module({ + imports: [], + controllers: [LinksController], + providers: [LinksService, LinksRepository], +}) +export class LinksModule {} diff --git a/packages/backend/src/modules/links/links.repository.ts b/packages/backend/src/modules/links/links.repository.ts new file mode 100644 index 0000000000..567bf8e19b --- /dev/null +++ b/packages/backend/src/modules/links/links.repository.ts @@ -0,0 +1,56 @@ +import { DatabaseService } from '@/core/database/database.service'; +import { link as linkTable } from '@/core/database/drizzle/schema'; +import { Injectable } from '@nestjs/common'; +import { and, eq } from 'drizzle-orm'; +import type { EditLinkBodyDto, LinkBodyDto } from './dto/links.dto'; + +@Injectable() +export class LinksRepository { + constructor(private databaseService: DatabaseService) {} + + /** + * Adds a new link to the database. + * @param {LinkInfo} link - The link information to be added. + * @returns The newly added link. + */ + public async addLink(link: LinkBodyDto, userId: number) { + const { title, description, url, iconUrl } = link; + const newLinks = await this.databaseService.db.insert(linkTable).values({ title, description, url, iconUrl, userId }).returning(); + return newLinks[0]; + } + + /** + * Edits an existing link in the database. + * @param {LinkInfo} link - The updated link information. + * @returns The updated link. + * @throws Error if no id is provided. + */ + public async editLink(linkId: number, link: EditLinkBodyDto, userId: number) { + const { title, description, url, iconUrl } = link; + + const updatedLinks = await this.databaseService.db + .update(linkTable) + .set({ title, description, url, iconUrl, updatedAt: new Date().toISOString() }) + .where(and(eq(linkTable.id, linkId), eq(linkTable.userId, userId))) + .returning(); + + return updatedLinks[0]; + } + + /** + * Deletes a link from the database. + * @param {number} linkId - The id of the link to be deleted. + */ + public async deleteLink(linkId: number, userId: number) { + await this.databaseService.db.delete(linkTable).where(and(eq(linkTable.id, linkId), eq(linkTable.userId, userId))); + } + + /** + * Retrieves all links for a given user from the database. + * @param {number} userId - The id of the user. + * @returns An array of links belonging to the user. + */ + public async getLinks(userId: number) { + return this.databaseService.db.select().from(linkTable).where(eq(linkTable.userId, userId)).orderBy(linkTable.id); + } +} diff --git a/packages/backend/src/modules/links/links.service.ts b/packages/backend/src/modules/links/links.service.ts new file mode 100644 index 0000000000..6bf6d7b540 --- /dev/null +++ b/packages/backend/src/modules/links/links.service.ts @@ -0,0 +1,35 @@ +import { TranslatableError } from '@/common/error/translatable-error'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { Injectable } from '@nestjs/common'; +import type { EditLinkBodyDto, LinkBodyDto } from './dto/links.dto'; +import { LinksRepository } from './links.repository'; + +@Injectable() +export class LinksService { + constructor( + private readonly config: ConfigurationService, + private readonly linksRepository: LinksRepository, + ) {} + + public add = async (link: LinkBodyDto, userId: number) => { + if (this.config.get('demoMode')) { + throw new TranslatableError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO'); + } + + return this.linksRepository.addLink(link, userId); + }; + + public edit = async (linkId: number, link: EditLinkBodyDto, userId: number) => { + return this.linksRepository.editLink(linkId, link, userId); + }; + + public delete = async (linkId: number, userId: number) => { + return this.linksRepository.deleteLink(linkId, userId); + }; + + public async getLinks(userId: number | undefined) { + if (!userId) return []; + + return this.linksRepository.getLinks(userId); + } +} diff --git a/packages/backend/src/modules/marketplace/dto/marketplace.dto.ts b/packages/backend/src/modules/marketplace/dto/marketplace.dto.ts new file mode 100644 index 0000000000..51ed1de402 --- /dev/null +++ b/packages/backend/src/modules/marketplace/dto/marketplace.dto.ts @@ -0,0 +1,181 @@ +import { ARCHITECTURES } from '@/common/constants'; +import type { AppUrn } from '@/types/app/app.types'; +import { createZodDto } from 'nestjs-zod'; +import { type ZodStringDef, z } from 'zod'; + +export const APP_CATEGORIES = [ + 'network', + 'media', + 'development', + 'automation', + 'social', + 'utilities', + 'photography', + 'security', + 'featured', + 'books', + 'data', + 'music', + 'finance', + 'gaming', + 'ai', +] as const; +export type AppCategory = (typeof APP_CATEGORIES)[number]; + +export const FIELD_TYPES = ['text', 'password', 'email', 'number', 'fqdn', 'ip', 'fqdnip', 'url', 'random', 'boolean'] as const; +export type FieldType = (typeof FIELD_TYPES)[number]; + +export const RANDOM_ENCODINGS = ['hex', 'base64'] as const; +export type RandomEncoding = (typeof RANDOM_ENCODINGS)[number]; + +export const formFieldSchema = z.object({ + type: z.enum(FIELD_TYPES), + label: z.string(), + placeholder: z.string().optional(), + max: z.number().optional(), + min: z.number().optional(), + hint: z.string().optional(), + options: z.object({ label: z.string(), value: z.string() }).array().optional(), + required: z.boolean().optional().default(false), + default: z.union([z.boolean(), z.string(), z.number()]).optional(), + regex: z.string().optional(), + pattern_error: z.string().optional(), + env_variable: z.string(), + encoding: z.enum(RANDOM_ENCODINGS).optional(), +}); + +export const appInfoSchema = z.object({ + id: z.string().refine((v) => v.split(':').length === 1), + urn: z.string().refine((v) => v.split(':').length === 2) as unknown as z.ZodType, + available: z.boolean(), + deprecated: z.boolean().optional().default(false), + port: z.number().min(1).max(65535), + name: z.string(), + description: z.string().optional().default(''), + version: z.string().optional().default('latest'), + tipi_version: z.number(), + short_desc: z.string(), + author: z.string(), + source: z.string(), + website: z.string().optional(), + force_expose: z.boolean().optional().default(false), + generate_vapid_keys: z.boolean().optional().default(false), + categories: z.enum(APP_CATEGORIES).array().default([]), + url_suffix: z.string().optional(), + form_fields: z.array(formFieldSchema).optional().default([]), + https: z.boolean().optional().default(false), + exposable: z.boolean().optional().default(false), + no_gui: z.boolean().optional().default(false), + supported_architectures: z.enum(ARCHITECTURES).array().optional(), + uid: z.number().optional(), + gid: z.number().optional(), + dynamic_config: z.boolean().optional().default(false), + min_tipi_version: z.string().optional(), + created_at: z + .number() + .int() + .min(0) + .refine((v) => v < Date.now()) + .optional() + .default(0), + updated_at: z + .number() + .int() + .min(0) + .refine((v) => v < Date.now()) + .optional() + .default(0), +}); + +// Derived types +export type AppInfoInput = z.input; +export type AppInfo = z.output; +export type FormField = z.output; + +// App info +export class AppInfoSimpleDto extends createZodDto( + appInfoSchema.pick({ + id: true, + urn: true, + name: true, + short_desc: true, + categories: true, + deprecated: true, + created_at: true, + supported_architectures: true, + available: true, + }), +) {} + +export class AppInfoDto extends createZodDto(appInfoSchema) {} + +export class MetadataDto extends createZodDto( + z.object({ + hasCustomConfig: z.boolean().optional(), + latestVersion: z.number(), + minTipiVersion: z.string().optional(), + latestDockerVersion: z.string().optional(), + }), +) {} + +// Search apps +export class SearchAppsQueryDto extends createZodDto( + z.object({ + search: z.string().optional(), + pageSize: z.coerce.number().optional(), + cursor: z.string().optional(), + category: z.enum(APP_CATEGORIES).optional(), + storeId: z.string().optional(), + }), +) {} + +export class SearchAppsDto extends createZodDto( + z.object({ + data: AppInfoSimpleDto.schema.array(), + nextCursor: z.string().optional(), + total: z.number(), + }), +) {} + +export class AppDetailsDto extends createZodDto( + z.object({ + info: AppInfoDto.schema, + metadata: MetadataDto.schema, + }), +) {} + +// Pull +export class PullDto extends createZodDto( + z.object({ + success: z.boolean(), + }), +) {} + +class AppStoreDto extends createZodDto( + z.object({ + slug: z.string(), + name: z.string(), + url: z.string(), + enabled: z.boolean(), + }), +) {} + +export class AllAppStoresDto extends createZodDto( + z.object({ + appStores: z.array(AppStoreDto.schema), + }), +) {} + +export class UpdateAppStoreBodyDto extends createZodDto( + z.object({ + name: z.string(), + enabled: z.boolean(), + }), +) {} + +export class CreateAppStoreBodyDto extends createZodDto( + z.object({ + name: z.string().min(1).max(16), + url: z.string().trim().toLowerCase(), + }), +) {} diff --git a/packages/backend/src/modules/marketplace/marketplace.controller.ts b/packages/backend/src/modules/marketplace/marketplace.controller.ts new file mode 100644 index 0000000000..4847712af2 --- /dev/null +++ b/packages/backend/src/modules/marketplace/marketplace.controller.ts @@ -0,0 +1,109 @@ +import { castAppUrn } from '@/common/helpers/app-helpers'; +import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AppStoreService } from '../app-stores/app-store.service'; +import { AuthGuard } from '../auth/auth.guard'; +import { + APP_CATEGORIES, + AllAppStoresDto, + CreateAppStoreBodyDto, + PullDto, + SearchAppsDto, + SearchAppsQueryDto, + UpdateAppStoreBodyDto, +} from './dto/marketplace.dto'; +import { MarketplaceService } from './marketplace.service'; + +@Controller('marketplace') +export class MarketplaceController { + constructor( + private readonly marketplaceService: MarketplaceService, + private readonly appStoreService: AppStoreService, + ) {} + + @Get('apps/search') + @UseGuards(AuthGuard) + @ZodSerializerDto(SearchAppsDto) + @ApiQuery({ name: 'search', type: String, required: false }) + @ApiQuery({ name: 'pageSize', type: Number, required: false }) + @ApiQuery({ name: 'cursor', type: String, required: false }) + @ApiQuery({ name: 'category', required: false, enum: APP_CATEGORIES }) + @ApiQuery({ name: 'storeId', type: String, required: false }) + async searchApps(@Query() query: SearchAppsQueryDto): Promise { + const { search, pageSize, cursor, category, storeId } = query; + + const size = pageSize ? Number(pageSize) : 24; + if (Number.isNaN(size) || size <= 0) { + throw new BadRequestException('Invalid pageSize'); + } + const res = await this.marketplaceService.searchApps({ search, pageSize: size, cursor, category, storeId }); + + return res; + } + + @Get('apps/:urn/image') + async getImage(@Param('urn') urn: string, @Res() res: Response) { + const image = await this.marketplaceService.getAppImage(castAppUrn(urn)); + + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }); + + return res.send(image); + } + + @Post('pull') + @UseGuards(AuthGuard) + @ZodSerializerDto(PullDto) + async pullAppStore(): Promise { + return this.appStoreService.pullRepositories(); + } + + @Post('create') + @UseGuards(AuthGuard) + async createAppStore(@Body() body: CreateAppStoreBodyDto) { + const appStore = await this.appStoreService.createAppStore(body); + await this.marketplaceService.initialize(); + + return { appStore }; + } + + @Get('all') + @UseGuards(AuthGuard) + @ZodSerializerDto(AllAppStoresDto) + async getAllAppStores(): Promise { + const appStores = await this.appStoreService.getAllAppStores(); + + return { appStores }; + } + + @Get('enabled') + @UseGuards(AuthGuard) + @ZodSerializerDto(AllAppStoresDto) + async getEnabledAppStores(): Promise { + const appStores = await this.appStoreService.getEnabledAppStores(); + + return { appStores }; + } + + @Patch(':id') + @UseGuards(AuthGuard) + async updateAppStore(@Param('id') id: string, @Body() body: UpdateAppStoreBodyDto) { + await this.appStoreService.updateAppStore(id, body); + await this.marketplaceService.initialize(); + + return { success: true }; + } + + @Delete(':id') + @UseGuards(AuthGuard) + async deleteAppStore(@Param('id') id: string) { + await this.appStoreService.deleteAppStore(id); + await this.marketplaceService.initialize(); + + return { success: true }; + } +} diff --git a/packages/backend/src/modules/marketplace/marketplace.module.ts b/packages/backend/src/modules/marketplace/marketplace.module.ts new file mode 100644 index 0000000000..5f11e67d86 --- /dev/null +++ b/packages/backend/src/modules/marketplace/marketplace.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AppStoreModule } from '../app-stores/app-store.module'; +import { MarketplaceController } from './marketplace.controller'; +import { MarketplaceService } from './marketplace.service'; + +@Module({ + imports: [AppStoreModule], + controllers: [MarketplaceController], + providers: [MarketplaceService], + exports: [MarketplaceService], +}) +export class MarketplaceModule {} diff --git a/packages/backend/src/modules/marketplace/marketplace.service.ts b/packages/backend/src/modules/marketplace/marketplace.service.ts new file mode 100644 index 0000000000..6171ea56b4 --- /dev/null +++ b/packages/backend/src/modules/marketplace/marketplace.service.ts @@ -0,0 +1,217 @@ +import type { Architecture } from '@/common/constants'; +import { extractAppUrn } from '@/common/helpers/app-helpers'; +import { notEmpty, pLimit } from '@/common/helpers/file-helpers'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import MiniSearch from 'minisearch'; +import { AppStoreFilesManager } from '../app-stores/app-store-files-manager'; +import { AppStoreService } from '../app-stores/app-store.service'; + +type AppList = Awaited['getAllAppFromStores']>>; + +const sortApps = (a: AppList[number], b: AppList[number]) => a.urn.localeCompare(b.urn); +const filterApp = + (architecture: Architecture) => + (app: AppList[number]): boolean => { + if (app.deprecated) { + return false; + } + + if (!app.supported_architectures) { + return true; + } + + return app.supported_architectures.includes(architecture); + }; + +@Injectable() +export class MarketplaceService { + private stores: Map = new Map(); + private appsAvailable: AppList | null = null; + private miniSearch: MiniSearch | null = null; + private cacheTimeout = 1000 * 60 * 15; // 15 minutes + private cacheLastUpdated = 0; + + constructor( + private readonly configuration: ConfigurationService, + private readonly filesystem: FilesystemService, + private readonly logger: LoggerService, + private readonly appStoreService: AppStoreService, + ) {} + + async initialize() { + this.stores.clear(); + + const stores = await this.appStoreService.getEnabledAppStores(); + + for (const config of stores) { + const store = new AppStoreFilesManager(this.configuration, this.filesystem, this.logger, config.slug); + this.stores.set(config.slug, store); + } + + await this.appStoreService.pullRepositories(); + this.invalidateCache(); + + this.logger.debug('Marketplace service initialized with stores', Array.from(this.stores.keys()).join(', ')); + } + + private getStoreFromUrn(appUrn: AppUrn) { + const { appStoreId } = extractAppUrn(appUrn); + + const store = this.stores.get(appStoreId); + if (!store) { + throw new Error(`Store ${appStoreId} not found`); + } + + return { store }; + } + + async getAppInfoFromAppStore(appUrn: AppUrn) { + const { store } = this.getStoreFromUrn(appUrn); + + return store.getAppInfoFromAppStore(appUrn); + } + + async getAvailableAppUrns(): Promise { + const allUrns: AppUrn[] = []; + for (const store of this.stores.values()) { + const urns = await store.getAvailableAppUrns(); + allUrns.push(...urns); + } + return allUrns.sort((a, b) => a.localeCompare(b)); + } + + /** + * Get all available apps from the catalog + * @returns All available apps + */ + private async getAllAppFromStores() { + const appUrns = await this.getAvailableAppUrns(); + + const limit = pLimit(10); + const apps = await Promise.all( + appUrns.map(async (appUrn) => { + return limit(() => { + const { store } = this.getStoreFromUrn(appUrn); + return store.getAppInfoFromAppStore(appUrn); + }); + }), + ); + + return apps.filter(notEmpty); + } + + /** + * Filter the apps based on the architecture + * @param apps - The apps to filter + * @returns The filtered apps + */ + private filterApps(apps: AppList): AppList { + const { architecture } = this.configuration.getConfig(); + return apps.sort(sortApps).filter(filterApp(architecture)); + } + + /** + * Invalidate the cache + */ + private invalidateCache() { + this.appsAvailable = null; + if (this.miniSearch) { + this.miniSearch.removeAll(); + } + } + + /** + * Get all available apps from all stores + * @returns All available apps + */ + public async getAvailableApps(): Promise { + if (this.cacheLastUpdated && Date.now() - this.cacheLastUpdated > this.cacheTimeout) { + this.invalidateCache(); + } + + if (!this.appsAvailable) { + const apps = await this.getAllAppFromStores(); + + this.appsAvailable = this.filterApps(apps); + + this.miniSearch = new MiniSearch<(typeof this.appsAvailable)[number]>({ + fields: ['name', 'short_desc', 'categories'], + storeFields: ['urn'], + idField: 'urn', + searchOptions: { + boost: { name: 2 }, + fuzzy: 0.2, + prefix: true, + }, + }); + this.miniSearch.addAll(this.appsAvailable); + + this.cacheLastUpdated = Date.now(); + } + + return this.appsAvailable; + } + + /** + * Search for apps in the catalog + * @param params - The search parameters + * @returns The search results + */ + public async searchApps(params: { search?: string | null; category?: string | null; pageSize?: number; cursor?: string | null; storeId?: string }) { + const { search, category, pageSize, cursor, storeId } = params; + + let filteredApps = await this.getAvailableApps(); + + if (storeId) { + filteredApps = filteredApps.filter((app) => { + const { appStoreId } = extractAppUrn(app.urn); + return appStoreId === storeId; + }); + } + + if (category) { + filteredApps = filteredApps.filter((app) => app.categories.some((c) => c === category)); + } + + if (search && this.miniSearch) { + const result = this.miniSearch.search(search); + const searchIds = result.map((app) => app.id); + filteredApps = filteredApps.filter((app) => searchIds.includes(app.urn)).sort((a, b) => searchIds.indexOf(a.urn) - searchIds.indexOf(b.urn)); + } + + const start = cursor ? filteredApps.findIndex((app) => app.urn === cursor) : 0; + const end = start + (pageSize ?? 24); + const data = filteredApps.slice(start, end); + + return { data, total: filteredApps.length, nextCursor: filteredApps[end]?.urn }; + } + + /** + * Get the image of an app + * @param appUrn - The ID of the app + * @returns The image of the app + */ + public async getAppImage(appUrn: AppUrn) { + const { store } = this.getStoreFromUrn(appUrn); + return store.getAppImage(appUrn); + } + + public async getAppUpdateInfo(appUrn: AppUrn) { + const { store } = this.getStoreFromUrn(appUrn); + return store.getAppUpdateInfo(appUrn); + } + + public async copyAppFromRepoToInstalled(appUrn: AppUrn) { + const { store } = this.getStoreFromUrn(appUrn); + return store.copyAppFromRepoToInstalled(appUrn); + } + + public async copyDataDir(appUrn: AppUrn, envMap: Map) { + const { store } = this.getStoreFromUrn(appUrn); + return store.copyDataDir(appUrn, envMap); + } +} diff --git a/packages/backend/src/modules/queue/entities/app-events.ts b/packages/backend/src/modules/queue/entities/app-events.ts new file mode 100644 index 0000000000..c600d5c080 --- /dev/null +++ b/packages/backend/src/modules/queue/entities/app-events.ts @@ -0,0 +1,48 @@ +import { appFormSchema } from '@/modules/app-lifecycle/dto/app-lifecycle.dto'; +import type { AppUrn } from '@/types/app/app.types'; +import { Injectable } from '@nestjs/common'; +import { type ZodStringDef, z } from 'zod'; +import { Queue } from '../queue.entity'; + +const commonAppCommandSchema = z.object({ + command: z.union([ + z.literal('start'), + z.literal('stop'), + z.literal('install'), + z.literal('uninstall'), + z.literal('reset'), + z.literal('restart'), + z.literal('generate_env'), + z.literal('backup'), + ]), + appUrn: z.string().refine((v) => v.split(':').length === 2) as unknown as z.ZodType, + skipEnv: z.boolean().optional().default(false), + form: appFormSchema, +}); + +const restoreAppCommandSchema = z.object({ + command: z.literal('restore'), + appUrn: z.string().refine((v) => v.split(':').length === 2) as unknown as z.ZodType, + filename: z.string(), + form: appFormSchema, +}); + +const updateAppCommandSchema = z.object({ + command: z.literal('update'), + appUrn: z.string().refine((v) => v.split(':').length === 2) as unknown as z.ZodType, + form: appFormSchema, + performBackup: z.boolean().optional().default(true), +}); + +export const appEventSchema = commonAppCommandSchema.or(restoreAppCommandSchema).or(updateAppCommandSchema); + +export const appEventResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); + +export type AppEventFormInput = z.input['form']; +export type AppEventForm = z.output['form']; + +@Injectable() +export class AppEventsQueue extends Queue {} diff --git a/packages/backend/src/modules/queue/entities/repo-events.ts b/packages/backend/src/modules/queue/entities/repo-events.ts new file mode 100644 index 0000000000..3b6ea9ed67 --- /dev/null +++ b/packages/backend/src/modules/queue/entities/repo-events.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; +import { Queue } from '../queue.entity'; + +const singleRepoCommandSchema = z.object({ + command: z.union([z.literal('clone'), z.literal('update')]), + id: z.string(), + url: z.string().url(), +}); + +const allReposCommandSchema = z.object({ + command: z.union([z.literal('update_all'), z.literal('clone_all')]), +}); + +export const repoCommandSchema = singleRepoCommandSchema.or(allReposCommandSchema); + +export const repoCommandResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); + +@Injectable() +export class RepoEventsQueue extends Queue {} diff --git a/packages/backend/src/modules/queue/queue.entity.ts b/packages/backend/src/modules/queue/queue.entity.ts new file mode 100644 index 0000000000..a08fc702ea --- /dev/null +++ b/packages/backend/src/modules/queue/queue.entity.ts @@ -0,0 +1,70 @@ +import type { LoggerService } from '@/core/logger/logger.service'; +import * as Sentry from '@sentry/nestjs'; +import cron from 'node-cron'; +import type { Connection, RPCClient } from 'rabbitmq-client'; +import { type ZodSchema, z } from 'zod'; + +export class Queue> { + constructor( + private rabbit: Connection, + private rpcClient: RPCClient, + private queueName: string, + private workers: number, + private eventSchema: T, + private resultSchema: R, + private logger: LoggerService, + ) {} + + public onEvent(callback: (data: z.output & { eventId: string }, reply: (response: z.input) => Promise) => Promise) { + this.rabbit.createConsumer({ queue: this.queueName, concurrency: this.workers }, async (req, reply) => { + try { + await callback(req.body, reply); + } catch (error) { + this.logger.error('Error in consumer callback:', error); + await reply({ success: false, message: (error as Error)?.message }); + } + }); + } + + async publish(event: z.input): Promise> { + try { + const eventData = this.eventSchema.safeParse(event); + + if (!eventData.success) { + throw new Error('Invalid event data'); + } + + const res = await this.rpcClient.send(this.queueName, eventData.data); + const response = this.resultSchema.safeParse(res.body); + + if (response.success) { + return response.data; + } + + throw new Error('Invalid response schema'); + } catch (err) { + return { success: false, message: (err as Error)?.message }; + } + } + + public publishRepeatable(data: z.input, cronPattern: string) { + if (!cron.validate(cronPattern)) { + throw new Error('Invalid cron pattern'); + } + + const eventData = this.eventSchema.safeParse(data); + + if (!eventData.success) { + throw new Error('Invalid event data'); + } + + cron.schedule(cronPattern, async () => { + try { + await this.rpcClient.send(this.queueName, eventData.data); + } catch (e) { + Sentry.captureException(e, { tags: { queueName: this.queueName } }); + this.logger.error('Error in cron job:', e); + } + }); + } +} diff --git a/packages/backend/src/modules/queue/queue.factory.ts b/packages/backend/src/modules/queue/queue.factory.ts new file mode 100644 index 0000000000..6bc4c310d4 --- /dev/null +++ b/packages/backend/src/modules/queue/queue.factory.ts @@ -0,0 +1,42 @@ +import { LoggerService } from '@/core/logger/logger.service'; +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { Connection } from 'rabbitmq-client'; +import { type ZodSchema, z } from 'zod'; +import { Queue } from './queue.entity'; + +@Injectable() +export class QueueFactory { + private rabbit: Connection; + + public constructor(private readonly logger: LoggerService) { + this.initializeConnection(); + } + + private initializeConnection() { + this.rabbit = new Connection({ url: 'amqp://guest:guest@localhost:5672' }); + + this.rabbit.on('error', (error) => { + this.logger.error('RabbitMQ connection error:', error); + Sentry.captureException(error, { tags: { source: 'rabbitmq' } }); + }); + } + + public createQueue(params: { + queueName: string; + workers?: number; + eventSchema: T; + resultSchema?: R; + timeout?: number; + }) { + const { queueName, workers = 3, eventSchema, resultSchema = z.object({ success: z.boolean(), message: z.string() }), timeout } = params; + + const rpcClient = this.rabbit.createRPCClient({ + timeout, + confirm: true, + maxAttempts: 3, + }); + + return new Queue(this.rabbit, rpcClient, queueName, workers, eventSchema, resultSchema, this.logger); + } +} diff --git a/packages/backend/src/modules/queue/queue.health.ts b/packages/backend/src/modules/queue/queue.health.ts new file mode 100644 index 0000000000..3349f6564d --- /dev/null +++ b/packages/backend/src/modules/queue/queue.health.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; +import Connection from 'rabbitmq-client'; + +@Injectable() +export class QueueHealthIndicator extends HealthIndicator { + private connection = new Connection({ + url: 'amqp://guest:guest@localhost:5672', + connectionTimeout: 30000, + }); + + async isHealthy(key: string): Promise { + const isHealthy = this.connection.ready; + + const result = this.getStatus(key, isHealthy); + + if (isHealthy) { + return result; + } + throw new HealthCheckError('Queue healthcheck failed', result); + } +} diff --git a/packages/backend/src/modules/queue/queue.module.ts b/packages/backend/src/modules/queue/queue.module.ts new file mode 100644 index 0000000000..f911ea0fea --- /dev/null +++ b/packages/backend/src/modules/queue/queue.module.ts @@ -0,0 +1,46 @@ +import { ConfigurationService } from '@/core/config/configuration.service'; +import { Module } from '@nestjs/common'; +import { AppEventsQueue, appEventResultSchema, appEventSchema } from './entities/app-events'; +import { RepoEventsQueue, repoCommandResultSchema, repoCommandSchema } from './entities/repo-events'; +import { QueueFactory } from './queue.factory'; +import { QueueHealthIndicator } from './queue.health'; + +@Module({ + imports: [], + providers: [ + QueueHealthIndicator, + QueueFactory, + { + provide: AppEventsQueue, + useFactory: (queueFactory: QueueFactory, config: ConfigurationService) => { + const timeout = config.get('userSettings').eventsTimeout; + + return queueFactory.createQueue({ + queueName: 'app-events-queue', + workers: 1, + eventSchema: appEventSchema, + resultSchema: appEventResultSchema, + timeout: timeout, + }); + }, + inject: [QueueFactory, ConfigurationService], + }, + { + provide: RepoEventsQueue, + useFactory: (queueFactory: QueueFactory, config: ConfigurationService) => { + const timeout = config.get('userSettings').eventsTimeout; + + return queueFactory.createQueue({ + queueName: 'repo-queue', + workers: 3, + eventSchema: repoCommandSchema, + resultSchema: repoCommandResultSchema, + timeout: timeout, + }); + }, + inject: [QueueFactory, ConfigurationService], + }, + ], + exports: [AppEventsQueue, RepoEventsQueue, QueueHealthIndicator], +}) +export class QueueModule {} diff --git a/packages/backend/src/modules/system/dto/system.dto.ts b/packages/backend/src/modules/system/dto/system.dto.ts new file mode 100644 index 0000000000..052295ee7d --- /dev/null +++ b/packages/backend/src/modules/system/dto/system.dto.ts @@ -0,0 +1,14 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +// Load +export class LoadDto extends createZodDto( + z.object({ + diskUsed: z.number().nullish().default(0), + diskSize: z.number().nullish().default(0), + percentUsed: z.number().nullish().default(0), + cpuLoad: z.number().nullish().default(0), + memoryTotal: z.number().nullish().default(0), + percentUsedMemory: z.number().nullish().default(0), + }), +) {} diff --git a/packages/backend/src/modules/system/system.controller.ts b/packages/backend/src/modules/system/system.controller.ts new file mode 100644 index 0000000000..aa21b07b6e --- /dev/null +++ b/packages/backend/src/modules/system/system.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; +import { AuthGuard } from '../auth/auth.guard'; +import { LoadDto } from './dto/system.dto'; +import { SystemService } from './system.service'; + +@UseGuards(AuthGuard) +@Controller('system') +export class SystemController { + constructor(private readonly systemService: SystemService) {} + + @Get('/load') + @ZodSerializerDto(LoadDto) + async systemLoad(): Promise { + const res = await this.systemService.getSystemLoad(); + return res; + } + + @Get('/certificate') + async downloadLocalCertificate(@Res() res: Response) { + const cert = await this.systemService.getLocalCertificate(); + + res.set({ + 'Content-Type': 'application/x-pem-file', + 'Content-Disposition': 'attachment; filename=cert.pem', + }); + + return res.send(cert); + } +} diff --git a/packages/backend/src/modules/system/system.module.ts b/packages/backend/src/modules/system/system.module.ts new file mode 100644 index 0000000000..99bda2b6a6 --- /dev/null +++ b/packages/backend/src/modules/system/system.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SystemController } from './system.controller'; +import { SystemService } from './system.service'; + +@Module({ + imports: [], + controllers: [SystemController], + providers: [SystemService], + exports: [], +}) +export class SystemModule {} diff --git a/packages/backend/src/modules/system/system.service.ts b/packages/backend/src/modules/system/system.service.ts new file mode 100644 index 0000000000..1cd4c3c1fb --- /dev/null +++ b/packages/backend/src/modules/system/system.service.ts @@ -0,0 +1,61 @@ +import { ConfigurationService } from '@/core/config/configuration.service'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import { LoggerService } from '@/core/logger/logger.service'; +import { Injectable } from '@nestjs/common'; +import si from 'systeminformation'; + +@Injectable() +export class SystemService { + constructor( + private readonly logger: LoggerService, + private readonly config: ConfigurationService, + private readonly filesystem: FilesystemService, + ) {} + + public async getSystemLoad() { + const { currentLoad } = await si.currentLoad(); + + const memResult = { total: 0, used: 0, available: 0 }; + + try { + const memInfo = await this.filesystem.readTextFile('/host/proc/meminfo'); + + memResult.total = Number(memInfo?.toString().match(/MemTotal:\s+(\d+)/)?.[1] ?? 0) * 1024; + memResult.available = Number(memInfo?.toString().match(/MemAvailable:\s+(\d+)/)?.[1] ?? 0) * 1024; + memResult.used = memResult.total - memResult.available; + } catch (e) { + this.logger.error(`Unable to read /host/proc/meminfo: ${e}`); + } + + const [disk0] = await si.fsSize(); + + const disk = disk0 ?? { available: 0, size: 0 }; + const diskFree = Math.round(disk.available / 1024 / 1024 / 1024); + const diskSize = Math.round(disk.size / 1024 / 1024 / 1024); + const diskUsed = diskSize - diskFree; + const percentUsed = Math.round((diskUsed / diskSize) * 100); + + const memoryTotal = Math.round(Number(memResult.total) / 1024 / 1024 / 1024); + const memoryFree = Math.round(Number(memResult.available) / 1024 / 1024 / 1024); + const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100); + + return { + diskUsed: diskUsed || 0, + diskSize: diskSize || 0, + percentUsed: percentUsed || 0, + cpuLoad: currentLoad || 0, + memoryTotal: memoryTotal || 0, + percentUsedMemory: percentUsedMemory || 0, + }; + } + + public async getLocalCertificate() { + const { dataDir } = this.config.get('directories'); + const filePath = `${dataDir}/traefik/tls/cert.pem`; + + if (await this.filesystem.pathExists(filePath)) { + const file = await this.filesystem.readTextFile(filePath); + return file; + } + } +} diff --git a/packages/backend/src/modules/user/dto/user.dto.ts b/packages/backend/src/modules/user/dto/user.dto.ts new file mode 100644 index 0000000000..270fa36986 --- /dev/null +++ b/packages/backend/src/modules/user/dto/user.dto.ts @@ -0,0 +1,13 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const userSchema = z.object({ + id: z.number(), + username: z.string(), + totpEnabled: z.boolean(), + locale: z.string(), + operator: z.boolean(), + hasSeenWelcome: z.boolean(), +}); + +export class UserDto extends createZodDto(userSchema) {} diff --git a/packages/backend/src/modules/user/user.module.ts b/packages/backend/src/modules/user/user.module.ts new file mode 100644 index 0000000000..cca8ed4048 --- /dev/null +++ b/packages/backend/src/modules/user/user.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UserRepository } from './user.repository'; + +@Module({ + imports: [], + controllers: [], + providers: [UserRepository], + exports: [UserRepository], +}) +export class UserModule {} diff --git a/packages/backend/src/modules/user/user.repository.ts b/packages/backend/src/modules/user/user.repository.ts new file mode 100644 index 0000000000..ff0dff3f7e --- /dev/null +++ b/packages/backend/src/modules/user/user.repository.ts @@ -0,0 +1,80 @@ +import { DatabaseService } from '@/core/database/database.service'; +import { user } from '@/core/database/drizzle/schema'; +import type { NewUser } from '@/core/database/drizzle/types'; +import { Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm/sql'; + +@Injectable() +export class UserRepository { + constructor(private databaseService: DatabaseService) {} + + /** + * Given a username, return the user associated to it + * + * @param {string} username - The username of the user to return + */ + public async getUserByUsername(username: string) { + return this.databaseService.db.query.user.findFirst({ where: eq(user.username, username.trim().toLowerCase()) }); + } + + /** + * Given a userId, return the user associated to it + * + * @param {number} id - The id of the user to return + */ + public async getUserById(id: number) { + return this.databaseService.db.query.user.findFirst({ where: eq(user.id, Number(id)) }); + } + + /** + * Given a userId, return the user associated to it with only the id, username, and totpEnabled fields + * + * @param {number} id - The id of the user to return + */ + public async getUserDtoById(id: number) { + return this.databaseService.db.query.user.findFirst({ + where: eq(user.id, Number(id)), + columns: { id: true, username: true, totpEnabled: true, locale: true, operator: true, hasSeenWelcome: true }, + }); + } + + /** + * Given a userId, update the user with the given data + * + * @param {number} id - The id of the user to update + * @param {Partial} data - The data to update the user with + */ + public async updateUser(id: number, data: Partial) { + const updatedUsers = await this.databaseService.db + .update(user) + .set(data) + .where(eq(user.id, Number(id))) + .returning(); + + return updatedUsers[0]; + } + + /** + * Returns all operators registered in the system + */ + public async getOperators() { + return this.databaseService.db.select().from(user).where(eq(user.operator, true)); + } + + /** + * Returns the first operator found in the system + */ + public async getFirstOperator() { + return this.databaseService.db.query.user.findFirst({ where: eq(user.operator, true) }); + } + + /** + * Given user data, creates a new user + * + * @param {NewUser} data - The data to create the user with + */ + public async createUser(data: NewUser) { + const newUsers = await this.databaseService.db.insert(user).values(data).returning(); + return newUsers[0]; + } +} diff --git a/packages/backend/src/swagger.json b/packages/backend/src/swagger.json new file mode 100644 index 0000000000..1780743622 --- /dev/null +++ b/packages/backend/src/swagger.json @@ -0,0 +1,3237 @@ +{ + "openapi": "3.0.0", + "paths": { + "/api/user-context": { + "get": { + "operationId": "userContext", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserContextDto" + } + } + } + } + }, + "tags": [ + "App" + ] + } + }, + "/api/app-context": { + "get": { + "operationId": "appContext", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppContextDto" + } + } + } + } + }, + "tags": [ + "App" + ] + } + }, + "/api/user-settings": { + "patch": { + "operationId": "updateUserSettings", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartialUserSettingsDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "App" + ] + } + }, + "/api/acknowledge-welcome": { + "patch": { + "operationId": "acknowledgeWelcome", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcknowledgeWelcomeBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "App" + ] + } + }, + "/api/system/load": { + "get": { + "operationId": "systemLoad", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoadDto" + } + } + } + } + }, + "tags": [ + "System" + ] + } + }, + "/api/system/certificate": { + "get": { + "operationId": "downloadLocalCertificate", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "System" + ] + } + }, + "/api/i18n/locales/{ns}/{lng}.json": { + "get": { + "operationId": "getTranslation", + "parameters": [ + { + "name": "ns", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "lng", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "I18n" + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "login", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginBody" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/verify-totp": { + "post": { + "operationId": "verifyTotp", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyTotpBody" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/register": { + "post": { + "operationId": "register", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterBody" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/logout": { + "post": { + "operationId": "logout", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/username": { + "patch": { + "operationId": "changeUsername", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeUsernameBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/password": { + "patch": { + "operationId": "changePassword", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/totp/get-uri": { + "patch": { + "operationId": "getTotpUri", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTotpUriBody" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTotpUriDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/totp/setup": { + "patch": { + "operationId": "setupTotp", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupTotpBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/totp/disable": { + "patch": { + "operationId": "disableTotp", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisableTotpBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/auth/reset-password": { + "post": { + "operationId": "resetPassword", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordBody" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + }, + "delete": { + "operationId": "cancelResetPassword", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + }, + "get": { + "operationId": "checkResetPasswordRequest", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckResetPasswordRequestDto" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/api/apps/installed": { + "get": { + "operationId": "getInstalledApps", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyAppsDto" + } + } + } + } + }, + "tags": [ + "Apps" + ] + } + }, + "/api/apps/guest": { + "get": { + "operationId": "getGuestApps", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuestAppsDto" + } + } + } + } + }, + "tags": [ + "Apps" + ] + } + }, + "/api/apps/{urn}": { + "get": { + "operationId": "getApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAppDto" + } + } + } + } + }, + "tags": [ + "Apps" + ] + } + }, + "/api/marketplace/apps/search": { + "get": { + "operationId": "searchApps", + "parameters": [ + { + "name": "storeId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "required": false, + "in": "query", + "schema": { + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ], + "type": "string" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "search", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchAppsDto" + } + } + } + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/apps/{urn}/image": { + "get": { + "operationId": "getImage", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/pull": { + "post": { + "operationId": "pullAppStore", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullDto" + } + } + } + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/create": { + "post": { + "operationId": "createAppStore", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppStoreBodyDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/all": { + "get": { + "operationId": "getAllAppStores", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllAppStoresDto" + } + } + } + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/enabled": { + "get": { + "operationId": "getEnabledAppStores", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllAppStoresDto" + } + } + } + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/marketplace/{id}": { + "patch": { + "operationId": "updateAppStore", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAppStoreBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Marketplace" + ] + }, + "delete": { + "operationId": "deleteAppStore", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Marketplace" + ] + } + }, + "/api/app-lifecycle/{urn}/install": { + "post": { + "operationId": "installApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppFormBody" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/start": { + "post": { + "operationId": "startApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/stop": { + "post": { + "operationId": "stopApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/restart": { + "post": { + "operationId": "restartApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/uninstall": { + "delete": { + "operationId": "uninstallApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UninstallAppBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/reset": { + "post": { + "operationId": "resetApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/update": { + "patch": { + "operationId": "updateApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAppBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/{urn}/update-config": { + "patch": { + "operationId": "updateAppConfig", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppFormBody" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/app-lifecycle/update-all": { + "patch": { + "operationId": "updateAllApps", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "AppLifecycle" + ] + } + }, + "/api/backups/{urn}/backup": { + "post": { + "operationId": "backupApp", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Backups" + ] + } + }, + "/api/backups/{urn}/restore": { + "post": { + "operationId": "restoreAppBackup", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreAppBackupDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Backups" + ] + } + }, + "/api/backups/{urn}": { + "get": { + "operationId": "getAppBackups", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "pageSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAppBackupsDto" + } + } + } + } + }, + "tags": [ + "Backups" + ] + }, + "delete": { + "operationId": "deleteAppBackup", + "parameters": [ + { + "name": "urn", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAppBackupBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Backups" + ] + } + }, + "/api/links": { + "get": { + "operationId": "getLinks", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinksDto" + } + } + } + } + }, + "tags": [ + "Links" + ] + }, + "post": { + "operationId": "createLink", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkBodyDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Links" + ] + } + }, + "/api/links/{id}": { + "patch": { + "operationId": "editLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditLinkBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Links" + ] + }, + "delete": { + "operationId": "deleteLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Links" + ] + } + }, + "/api/health": { + "get": { + "operationId": "check", + "parameters": [], + "responses": { + "200": { + "description": "The Health Check is successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "info": { + "type": "object", + "example": { + "database": { + "status": "up" + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + }, + "nullable": true + }, + "error": { + "type": "object", + "example": {}, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + }, + "nullable": true + }, + "details": { + "type": "object", + "example": { + "database": { + "status": "up" + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + } + } + } + } + } + } + }, + "503": { + "description": "The Health Check is not successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "error" + }, + "info": { + "type": "object", + "example": { + "database": { + "status": "up" + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + }, + "nullable": true + }, + "error": { + "type": "object", + "example": { + "redis": { + "status": "down", + "message": "Could not connect" + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + }, + "nullable": true + }, + "details": { + "type": "object", + "example": { + "database": { + "status": "up" + }, + "redis": { + "status": "down", + "message": "Could not connect" + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + } + }, + "additionalProperties": true + } + } + } + } + } + } + } + }, + "tags": [ + "Health" + ] + } + } + }, + "info": { + "title": "Runtipi API", + "description": "API specs for Runtipi", + "version": "1.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "UserContextDto": { + "type": "object", + "properties": { + "version": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "latest": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "required": [ + "current", + "latest", + "body" + ] + }, + "isLoggedIn": { + "description": "Indicates if the user is logged in", + "type": "boolean" + }, + "isConfigured": { + "description": "Indicates if the app is already configured", + "type": "boolean" + }, + "isGuestDashboardEnabled": { + "description": "Indicates if the guest dashboard is enabled", + "type": "boolean" + }, + "allowAutoThemes": { + "description": "Indicates if the app allows auto themes", + "type": "boolean" + }, + "allowErrorMonitoring": { + "description": "Indicates if the app allows anonymous error monitoring", + "type": "boolean" + } + }, + "required": [ + "version", + "isLoggedIn", + "isConfigured", + "isGuestDashboardEnabled", + "allowAutoThemes", + "allowErrorMonitoring" + ] + }, + "AppContextDto": { + "type": "object", + "properties": { + "version": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "latest": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "required": [ + "current", + "latest", + "body" + ] + }, + "userSettings": { + "type": "object", + "properties": { + "dnsIp": { + "type": "string" + }, + "internalIp": { + "type": "string" + }, + "postgresPort": { + "type": "number" + }, + "appsRepoUrl": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + }, + "appDataPath": { + "type": "string" + }, + "localDomain": { + "type": "string" + }, + "demoMode": { + "type": "boolean" + }, + "guestDashboard": { + "type": "boolean" + }, + "allowAutoThemes": { + "type": "boolean" + }, + "allowErrorMonitoring": { + "type": "boolean" + }, + "persistTraefikConfig": { + "type": "boolean" + }, + "port": { + "type": "number" + }, + "sslPort": { + "type": "number" + }, + "listenIp": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "eventsTimeout": { + "type": "number" + } + }, + "required": [ + "dnsIp", + "internalIp", + "postgresPort", + "appsRepoUrl", + "domain", + "appDataPath", + "localDomain", + "demoMode", + "guestDashboard", + "allowAutoThemes", + "allowErrorMonitoring", + "persistTraefikConfig", + "port", + "sslPort", + "listenIp", + "timeZone", + "eventsTimeout" + ] + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "username": { + "type": "string" + }, + "totpEnabled": { + "type": "boolean" + }, + "locale": { + "type": "string" + }, + "operator": { + "type": "boolean" + }, + "hasSeenWelcome": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "totpEnabled", + "locale", + "operator", + "hasSeenWelcome" + ] + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "urn": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short_desc": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ] + }, + "default": [] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "created_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + }, + "supported_architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm64", + "amd64" + ] + } + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "id", + "urn", + "name", + "short_desc", + "available" + ] + } + }, + "updatesAvailable": { + "type": "number" + } + }, + "required": [ + "version", + "userSettings", + "user", + "apps", + "updatesAvailable" + ] + }, + "PartialUserSettingsDto": { + "type": "object", + "properties": { + "dnsIp": { + "type": "string" + }, + "internalIp": { + "type": "string" + }, + "postgresPort": { + "type": "number" + }, + "appsRepoUrl": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + }, + "appDataPath": { + "type": "string" + }, + "localDomain": { + "type": "string" + }, + "demoMode": { + "type": "boolean" + }, + "guestDashboard": { + "type": "boolean" + }, + "allowAutoThemes": { + "type": "boolean" + }, + "allowErrorMonitoring": { + "type": "boolean" + }, + "persistTraefikConfig": { + "type": "boolean" + }, + "port": { + "type": "number" + }, + "sslPort": { + "type": "number" + }, + "listenIp": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "eventsTimeout": { + "type": "number" + } + } + }, + "AcknowledgeWelcomeBody": { + "type": "object", + "properties": { + "allowErrorMonitoring": { + "type": "boolean" + } + }, + "required": [ + "allowErrorMonitoring" + ] + }, + "LoadDto": { + "type": "object", + "properties": { + "diskUsed": { + "type": "number", + "nullable": true, + "default": 0 + }, + "diskSize": { + "type": "number", + "nullable": true, + "default": 0 + }, + "percentUsed": { + "type": "number", + "nullable": true, + "default": 0 + }, + "cpuLoad": { + "type": "number", + "nullable": true, + "default": 0 + }, + "memoryTotal": { + "type": "number", + "nullable": true, + "default": 0 + }, + "percentUsedMemory": { + "type": "number", + "nullable": true, + "default": 0 + } + } + }, + "LoginBody": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "LoginDto": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "totpSessionId": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "VerifyTotpBody": { + "type": "object", + "properties": { + "totpCode": { + "type": "string" + }, + "totpSessionId": { + "type": "string" + } + }, + "required": [ + "totpCode", + "totpSessionId" + ] + }, + "RegisterBody": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "RegisterDto": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "ChangeUsernameBody": { + "type": "object", + "properties": { + "newUsername": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "newUsername", + "password" + ] + }, + "ChangePasswordBody": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "GetTotpUriBody": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ] + }, + "GetTotpUriDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "key", + "uri" + ] + }, + "SetupTotpBody": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "required": [ + "code" + ] + }, + "DisableTotpBody": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ] + }, + "ResetPasswordBody": { + "type": "object", + "properties": { + "newPassword": { + "type": "string" + } + }, + "required": [ + "newPassword" + ] + }, + "ResetPasswordDto": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "email": { + "type": "string" + } + }, + "required": [ + "success", + "email" + ] + }, + "CheckResetPasswordRequestDto": { + "type": "object", + "properties": { + "isRequestPending": { + "type": "boolean" + } + }, + "required": [ + "isRequestPending" + ] + }, + "MyAppsDto": { + "type": "object", + "properties": { + "installed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "app": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "port": { + "type": "number", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "number" + }, + "exposed": { + "type": "boolean" + }, + "openPort": { + "type": "boolean" + }, + "exposedLocal": { + "type": "boolean" + }, + "domain": { + "type": "string", + "nullable": true + }, + "isVisibleOnGuestDashboard": { + "type": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "port", + "status", + "version", + "exposed", + "openPort", + "exposedLocal", + "domain", + "isVisibleOnGuestDashboard" + ] + }, + "info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "urn": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short_desc": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ] + }, + "default": [] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "created_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + }, + "supported_architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm64", + "amd64" + ] + } + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "id", + "urn", + "name", + "short_desc", + "available" + ] + }, + "metadata": { + "type": "object", + "properties": { + "hasCustomConfig": { + "type": "boolean" + }, + "latestVersion": { + "type": "number" + }, + "minTipiVersion": { + "type": "string" + }, + "latestDockerVersion": { + "type": "string" + } + }, + "required": [ + "latestVersion" + ] + } + }, + "required": [ + "app", + "info", + "metadata" + ] + } + } + }, + "required": [ + "installed" + ] + }, + "GuestAppsDto": { + "type": "object", + "properties": { + "installed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "app": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "port": { + "type": "number", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "number" + }, + "exposed": { + "type": "boolean" + }, + "openPort": { + "type": "boolean" + }, + "exposedLocal": { + "type": "boolean" + }, + "domain": { + "type": "string", + "nullable": true + }, + "isVisibleOnGuestDashboard": { + "type": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "port", + "status", + "version", + "exposed", + "openPort", + "exposedLocal", + "domain", + "isVisibleOnGuestDashboard" + ] + }, + "info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "urn": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "port": { + "type": "number", + "minimum": 1, + "exclusiveMinimum": false, + "maximum": 65535, + "exclusiveMaximum": false + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "default": "" + }, + "version": { + "type": "string", + "default": "latest" + }, + "tipi_version": { + "type": "number" + }, + "short_desc": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source": { + "type": "string" + }, + "website": { + "type": "string" + }, + "force_expose": { + "type": "boolean", + "default": false + }, + "generate_vapid_keys": { + "type": "boolean", + "default": false + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ] + }, + "default": [] + }, + "url_suffix": { + "type": "string" + }, + "form_fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "password", + "email", + "number", + "fqdn", + "ip", + "fqdnip", + "url", + "random", + "boolean" + ] + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "hint": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + }, + "required": { + "type": "boolean", + "default": false + }, + "default": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "regex": { + "type": "string" + }, + "pattern_error": { + "type": "string" + }, + "env_variable": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + } + }, + "required": [ + "type", + "label", + "env_variable" + ] + }, + "default": [] + }, + "https": { + "type": "boolean", + "default": false + }, + "exposable": { + "type": "boolean", + "default": false + }, + "no_gui": { + "type": "boolean", + "default": false + }, + "supported_architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm64", + "amd64" + ] + } + }, + "uid": { + "type": "number" + }, + "gid": { + "type": "number" + }, + "dynamic_config": { + "type": "boolean", + "default": false + }, + "min_tipi_version": { + "type": "string" + }, + "created_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + } + }, + "required": [ + "id", + "urn", + "available", + "port", + "name", + "tipi_version", + "short_desc", + "author", + "source" + ] + } + }, + "required": [ + "app", + "info" + ] + } + } + }, + "required": [ + "installed" + ] + }, + "GetAppDto": { + "type": "object", + "properties": { + "app": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "port": { + "type": "number", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "number" + }, + "exposed": { + "type": "boolean" + }, + "openPort": { + "type": "boolean" + }, + "exposedLocal": { + "type": "boolean" + }, + "domain": { + "type": "string", + "nullable": true + }, + "isVisibleOnGuestDashboard": { + "type": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "port", + "status", + "version", + "exposed", + "openPort", + "exposedLocal", + "domain", + "isVisibleOnGuestDashboard" + ], + "nullable": true + }, + "info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "urn": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "port": { + "type": "number", + "minimum": 1, + "exclusiveMinimum": false, + "maximum": 65535, + "exclusiveMaximum": false + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "default": "" + }, + "version": { + "type": "string", + "default": "latest" + }, + "tipi_version": { + "type": "number" + }, + "short_desc": { + "type": "string" + }, + "author": { + "type": "string" + }, + "source": { + "type": "string" + }, + "website": { + "type": "string" + }, + "force_expose": { + "type": "boolean", + "default": false + }, + "generate_vapid_keys": { + "type": "boolean", + "default": false + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ] + }, + "default": [] + }, + "url_suffix": { + "type": "string" + }, + "form_fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "password", + "email", + "number", + "fqdn", + "ip", + "fqdnip", + "url", + "random", + "boolean" + ] + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "hint": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + }, + "required": { + "type": "boolean", + "default": false + }, + "default": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "regex": { + "type": "string" + }, + "pattern_error": { + "type": "string" + }, + "env_variable": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + } + }, + "required": [ + "type", + "label", + "env_variable" + ] + }, + "default": [] + }, + "https": { + "type": "boolean", + "default": false + }, + "exposable": { + "type": "boolean", + "default": false + }, + "no_gui": { + "type": "boolean", + "default": false + }, + "supported_architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm64", + "amd64" + ] + } + }, + "uid": { + "type": "number" + }, + "gid": { + "type": "number" + }, + "dynamic_config": { + "type": "boolean", + "default": false + }, + "min_tipi_version": { + "type": "string" + }, + "created_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + } + }, + "required": [ + "id", + "urn", + "available", + "port", + "name", + "tipi_version", + "short_desc", + "author", + "source" + ] + }, + "metadata": { + "type": "object", + "properties": { + "hasCustomConfig": { + "type": "boolean" + }, + "latestVersion": { + "type": "number" + }, + "minTipiVersion": { + "type": "string" + }, + "latestDockerVersion": { + "type": "string" + } + }, + "required": [ + "latestVersion" + ] + } + }, + "required": [ + "info", + "metadata" + ] + }, + "SearchAppsDto": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "urn": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short_desc": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "network", + "media", + "development", + "automation", + "social", + "utilities", + "photography", + "security", + "featured", + "books", + "data", + "music", + "finance", + "gaming", + "ai" + ] + }, + "default": [] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "created_at": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": false, + "default": 0 + }, + "supported_architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm64", + "amd64" + ] + } + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "id", + "urn", + "name", + "short_desc", + "available" + ] + } + }, + "nextCursor": { + "type": "string" + }, + "total": { + "type": "number" + } + }, + "required": [ + "data", + "total" + ] + }, + "PullDto": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "CreateAppStoreBodyDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 16 + }, + "url": { + "type": "string" + } + }, + "required": [ + "name", + "url" + ] + }, + "AllAppStoresDto": { + "type": "object", + "properties": { + "appStores": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "slug", + "name", + "url", + "enabled" + ] + } + } + }, + "required": [ + "appStores" + ] + }, + "UpdateAppStoreBodyDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "enabled" + ] + }, + "AppFormBody": { + "type": "object", + "properties": { + "port": { + "type": "number", + "minimum": 1024, + "exclusiveMinimum": false, + "maximum": 65535, + "exclusiveMaximum": false + }, + "exposed": { + "type": "boolean" + }, + "exposedLocal": { + "type": "boolean" + }, + "openPort": { + "type": "boolean", + "default": true + }, + "domain": { + "type": "string" + }, + "isVisibleOnGuestDashboard": { + "type": "boolean" + } + } + }, + "UninstallAppBody": { + "type": "object", + "properties": { + "removeBackups": { + "type": "boolean" + } + }, + "required": [ + "removeBackups" + ] + }, + "UpdateAppBody": { + "type": "object", + "properties": { + "performBackup": { + "type": "boolean" + } + }, + "required": [ + "performBackup" + ] + }, + "RestoreAppBackupDto": { + "type": "object", + "properties": { + "filename": { + "type": "string" + } + }, + "required": [ + "filename" + ] + }, + "GetAppBackupsDto": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "size": { + "type": "number" + }, + "date": { + "type": "number" + } + }, + "required": [ + "id", + "size", + "date" + ] + } + }, + "total": { + "type": "number" + }, + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + } + }, + "required": [ + "data", + "total", + "currentPage", + "lastPage" + ] + }, + "DeleteAppBackupBodyDto": { + "type": "object", + "properties": { + "filename": { + "type": "string" + } + }, + "required": [ + "filename" + ] + }, + "LinksDto": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 20 + }, + "description": { + "type": "string", + "minLength": 0, + "maxLength": 50, + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "iconUrl": { + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 + } + ], + "nullable": true + }, + "userId": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "description", + "url", + "iconUrl", + "userId" + ] + } + } + }, + "required": [ + "links" + ] + }, + "LinkBodyDto": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 20 + }, + "url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "minLength": 0, + "maxLength": 50 + }, + "iconUrl": { + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 + } + ] + } + }, + "required": [ + "title", + "url" + ] + }, + "EditLinkBodyDto": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 20 + }, + "url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "minLength": 0, + "maxLength": 50 + }, + "iconUrl": { + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 + } + ] + } + }, + "required": [ + "title", + "url" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/worker/tests/mocks/fs.ts b/packages/backend/src/tests/__mocks__/fs.ts similarity index 86% rename from packages/worker/tests/mocks/fs.ts rename to packages/backend/src/tests/__mocks__/fs.ts index 447557b734..1e3318f52f 100644 --- a/packages/worker/tests/mocks/fs.ts +++ b/packages/backend/src/tests/__mocks__/fs.ts @@ -47,6 +47,14 @@ export const fsMock = { // Create folder tree vol.fromJSON(newMockFiles, 'utf8'); }, - __printVol: () => console.log(vol.toTree()), + __printVol: () => { + // biome-ignore lint/suspicious/noConsoleLog: + console.log(vol.toTree()); + }, + tree: () => { + return vol.toTree(); + }, }, }; + +export type FsMock = typeof fsMock.default; diff --git a/packages/backend/src/tests/db.compose.yml b/packages/backend/src/tests/db.compose.yml new file mode 100644 index 0000000000..65f418aad0 --- /dev/null +++ b/packages/backend/src/tests/db.compose.yml @@ -0,0 +1,21 @@ +services: + db: + container_name: test-db + image: postgres:14 + restart: no + ports: + - 5433:5432 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + + rabbitmq: + container_name: test-rabbitmq + image: rabbitmq + restart: no + ports: + - 5672:5672 + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest diff --git a/packages/backend/src/tests/integration/__snapshots__/app-lifecycle.test.ts.snap b/packages/backend/src/tests/integration/__snapshots__/app-lifecycle.test.ts.snap new file mode 100644 index 0000000000..df37e2fb03 --- /dev/null +++ b/packages/backend/src/tests/integration/__snapshots__/app-lifecycle.test.ts.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App lifecycle > install app > should not delete an existing app-data folder even if the app is reinstalled 1`] = ` +"/ +├─ app-data/ +│ └─ test/ +│ └─ test2/ +│ ├─ app.env +│ └─ data/ +│ └─ test.txt +└─ data/ + ├─ .env + ├─ apps/ + │ └─ test/ + │ └─ test2/ + │ ├─ config.json + │ ├─ data/ + │ ├─ docker-compose.json + │ └─ docker-compose.yml + ├─ backups/ + ├─ repos/ + │ └─ test/ + │ └─ apps/ + │ └─ test2/ + │ ├─ config.json + │ ├─ data/ + │ └─ docker-compose.json + └─ state/ + └─ seed" +`; + +exports[`App lifecycle > install app > should successfully install app and create expected directory structure 1`] = ` +"/ +├─ app-data/ +│ └─ test/ +│ └─ test/ +│ ├─ app.env +│ └─ data/ +└─ data/ + ├─ .env + ├─ apps/ + │ └─ test/ + │ └─ test/ + │ ├─ config.json + │ ├─ data/ + │ ├─ docker-compose.json + │ └─ docker-compose.yml + ├─ backups/ + ├─ repos/ + │ └─ test/ + │ └─ apps/ + │ └─ test/ + │ ├─ config.json + │ ├─ data/ + │ └─ docker-compose.json + └─ state/ + └─ seed" +`; + +exports[`App lifecycle > install app > should successfully install app and create expected directory structure 2`] = ` +"services: + test_test: + image: test + container_name: test_test + restart: unless-stopped + networks: + - tipi_main_network + environment: + TEST: test + ports: + - \${APP_PORT}:80 + labels: + generated: true + traefik.enable: false + traefik.http.middlewares.test_test-web-redirect.redirectscheme.scheme: https + traefik.http.services.test_test.loadbalancer.server.port: "80" +networks: + tipi_main_network: + name: runtipi_tipi_main_network + external: true + +" +`; diff --git a/packages/backend/src/tests/integration/app-lifecycle.test.ts b/packages/backend/src/tests/integration/app-lifecycle.test.ts new file mode 100644 index 0000000000..f75e981a65 --- /dev/null +++ b/packages/backend/src/tests/integration/app-lifecycle.test.ts @@ -0,0 +1,144 @@ +import fs from 'node:fs'; +import { APP_DATA_DIR, APP_DIR, DATA_DIR } from '@/common/constants'; +import { ConfigurationService } from '@/core/config/configuration.service'; +import { DatabaseService } from '@/core/database/database.service'; +import { appStore } from '@/core/database/drizzle/schema'; +import { FilesystemService } from '@/core/filesystem/filesystem.service'; +import type { LoggerService } from '@/core/logger/logger.service'; +import { AppLifecycleCommandFactory } from '@/modules/app-lifecycle/app-lifecycle-command.factory'; +import { AppLifecycleService } from '@/modules/app-lifecycle/app-lifecycle.service'; +import { AppStoreRepository } from '@/modules/app-stores/app-store.repository'; +import { AppStoreService } from '@/modules/app-stores/app-store.service'; +import { AppFilesManager } from '@/modules/apps/app-files-manager'; +import { AppHelpers } from '@/modules/apps/app.helpers'; +import { AppsRepository } from '@/modules/apps/apps.repository'; +import { EnvUtils } from '@/modules/env/env.utils'; +import { MarketplaceService } from '@/modules/marketplace/marketplace.service'; +import { AppEventsQueue, appEventResultSchema, appEventSchema } from '@/modules/queue/entities/app-events'; +import { QueueFactory } from '@/modules/queue/queue.factory'; +import type { AppUrn } from '@/types/app/app.types'; +import { Test } from '@nestjs/testing'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import waitFor from 'wait-for-expect'; +import type { FsMock } from '../__mocks__/fs'; +import { createAppInStore } from '../utils/create-app-in-store'; +import { type TestDatabase, cleanTestData, createTestDatabase } from '../utils/create-test-database'; + +let db: TestDatabase; +const DB_NAME = 'applifecycletest'; + +describe('App lifecycle', () => { + let appLifecycleService: AppLifecycleService; + let marketplaceService: MarketplaceService; + let appsRepository: AppsRepository; + let configurationService = mock(); + let databaseService = mock(); + const loggerService = mock(); + const queueFactory = new QueueFactory(loggerService); + const appEventsQueue = queueFactory.createQueue({ + queueName: 'app-events-queue', + workers: 1, + eventSchema: appEventSchema, + resultSchema: appEventResultSchema, + }); + + beforeAll(async () => { + db = await createTestDatabase(DB_NAME); + }); + + beforeEach(async () => { + await cleanTestData(db); + + const moduleRef = await Test.createTestingModule({ + providers: [ + AppLifecycleService, + MarketplaceService, + AppStoreService, + AppStoreRepository, + FilesystemService, + QueueFactory, + AppLifecycleCommandFactory, + AppFilesManager, + AppsRepository, + EnvUtils, + AppHelpers, + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: AppEventsQueue, + useValue: appEventsQueue, + }, + ], + }) + .useMocker(mock) + .compile(); + + appLifecycleService = moduleRef.get(AppLifecycleService); + configurationService = moduleRef.get(ConfigurationService); + databaseService = moduleRef.get(DatabaseService); + marketplaceService = moduleRef.get(MarketplaceService); + appsRepository = moduleRef.get(AppsRepository); + + databaseService.db = db; + + configurationService.getConfig.mockReturnValue( + fromPartial({ + demoMode: false, + directories: { dataDir: DATA_DIR, appDir: APP_DIR, appDataDir: APP_DATA_DIR }, + internalIp: '127.0.0.1', + envFilePath: '/data/.env', + rootFolderHost: '/opt/runtipi', + userSettings: { + appDataPath: '/opt/runtipi', + }, + }), + ); + + await db.insert(appStore).values({ slug: 'test', url: 'https://appstore.example.com', hash: 'test', name: 'test', enabled: true }).execute(); + await marketplaceService.initialize(); + }); + + describe('install app', () => { + it('should successfully install app and create expected directory structure', async () => { + // arrange + const appInfo = await createAppInStore('test', { id: 'test' }); + const appUrn = `${appInfo.id}:test` as AppUrn; + + // act + await appLifecycleService.installApp({ appUrn, form: {} }); + + await waitFor(async () => { + const app = await appsRepository.getAppByUrn(appUrn); + expect(app?.status).toBe('running'); + }); + + // assert + expect((fs as unknown as FsMock).tree()).toMatchSnapshot(); + const yml = await fs.promises.readFile(`${DATA_DIR}/apps/test/${appInfo.id}/docker-compose.yml`, 'utf-8'); + expect(yml).toMatchSnapshot(); + }); + + it('should not delete an existing app-data folder even if the app is reinstalled', async () => { + // arrange + const appInfo = await createAppInStore('test', { id: 'test2' }); + const appUrn = `${appInfo.id}:test` as AppUrn; + + await fs.promises.mkdir(`${APP_DATA_DIR}/test/test2/data`, { recursive: true }); + await fs.promises.writeFile(`${APP_DATA_DIR}/test/test2/data/test.txt`, 'test'); + + await appLifecycleService.installApp({ appUrn, form: {} }); + + await waitFor(async () => { + const app = await appsRepository.getAppByUrn(appUrn); + expect(app?.status).toBe('running'); + }); + + // assert + expect((fs as unknown as FsMock).tree()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/backend/src/tests/utils/create-app-in-store.ts b/packages/backend/src/tests/utils/create-app-in-store.ts new file mode 100644 index 0000000000..62d834948d --- /dev/null +++ b/packages/backend/src/tests/utils/create-app-in-store.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DATA_DIR } from '@/common/constants'; +import type { AppInfo, AppInfoInput } from '@/modules/marketplace/dto/marketplace.dto'; +import type { AppUrn } from '@/types/app/app.types'; +import { faker } from '@faker-js/faker'; + +export const createAppInStore = async (storeId: string, app: Partial = {}) => { + const id = app.id ?? faker.string.uuid(); + + const appInfo: AppInfoInput = { + id: faker.string.uuid(), + urn: `${id}:${storeId}` as AppUrn, + name: faker.lorem.words(2), + port: faker.number.int({ min: 1000, max: 9999 }), + https: false, + author: faker.internet.username(), + no_gui: false, + available: true, + exposable: true, + dynamic_config: true, + source: faker.internet.url(), + version: faker.system.semver(), + categories: ['utilities'], + description: faker.lorem.sentence(), + short_desc: faker.lorem.sentence(), + website: faker.internet.url(), + supported_architectures: [], + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + deprecated: false, + tipi_version: 1, + force_expose: false, + generate_vapid_keys: false, + form_fields: [], + ...app, + }; + + const composeJson = { + services: [ + { + name: appInfo.id, + image: 'test', + isMain: true, + internalPort: 80, + environment: { + TEST: 'test', + }, + }, + ], + }; + + const appStorePath = `${DATA_DIR}/repos/${storeId}/apps/${appInfo.id}`; + + await fs.promises.mkdir(`${DATA_DIR}/repos/${storeId}/apps/${appInfo.id}/data`, { recursive: true }); + await fs.promises.writeFile(path.join(appStorePath, 'config.json'), JSON.stringify(appInfo, null, 2)); + await fs.promises.writeFile(path.join(appStorePath, 'docker-compose.json'), JSON.stringify(composeJson, null, 2)); + + return appInfo; +}; diff --git a/packages/backend/src/tests/utils/create-test-database.ts b/packages/backend/src/tests/utils/create-test-database.ts new file mode 100644 index 0000000000..3b48c43ce4 --- /dev/null +++ b/packages/backend/src/tests/utils/create-test-database.ts @@ -0,0 +1,55 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { Client } from 'pg'; +import * as schema from '../../core/database/drizzle/schema'; + +export type TestDatabase = Awaited>; + +const getClient = () => { + return new Client({ + user: 'postgres', + host: 'localhost', + database: 'postgres', + password: 'postgres', + port: 5433, + }); +}; + +export const createTestDatabase = async (testsuite: string) => { + const client = getClient(); + await client.connect(); + + await client.query(`DROP DATABASE IF EXISTS ${testsuite}`); + await client.query(`CREATE DATABASE ${testsuite}`); + + await client.end(); + + const connectionString = `postgresql://postgres:postgres@localhost:5433/${testsuite}?connect_timeout=300`; + const drizzleClient = drizzle(connectionString, { schema }); + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + + await migrate(drizzleClient, { migrationsFolder: path.join(__dirname, '..', '..', 'core', 'database', 'drizzle') }).catch((e) => { + console.error('Failed to run migrations', e); + }); + + return drizzleClient; +}; + +export const dropTestDatabase = async (testsuite: string) => { + const client = getClient(); + await client.connect(); + + await client.query(`DROP DATABASE IF EXISTS ${testsuite}`); + + await client.end(); +}; + +export const cleanTestData = async (db: TestDatabase) => { + await db.delete(schema.link); + await db.delete(schema.app); + await db.delete(schema.user); + await db.delete(schema.appStore); +}; diff --git a/packages/backend/src/tests/vite.setup.ts b/packages/backend/src/tests/vite.setup.ts new file mode 100644 index 0000000000..ed9d696446 --- /dev/null +++ b/packages/backend/src/tests/vite.setup.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DATA_DIR } from '@/common/constants'; +import { beforeEach, vi } from 'vitest'; +import type { FsMock } from './__mocks__/fs'; + +vi.mock('fs', async () => { + const { fsMock } = await import('./__mocks__/fs'); + return { + ...fsMock, + }; +}); + +beforeEach(async () => { + (fs as unknown as FsMock).__resetAllMocks(); + + const directories = [DATA_DIR, path.join(DATA_DIR, 'state'), path.join(DATA_DIR, 'backups')]; + + try { + await Promise.all( + directories.map(async (dir) => { + await fs.promises.mkdir(dir, { recursive: true }); + }), + ); + + await fs.promises.writeFile(path.join(DATA_DIR, 'state', 'seed'), 'seed'); + await fs.promises.writeFile(path.join(DATA_DIR, '.env'), 'ROOT_FOLDER_HOST=/opt/runtipi'); + } catch (err) { + console.error('Failed to setup test directories', err); + } +}); diff --git a/packages/backend/src/types/app/app.types.ts b/packages/backend/src/types/app/app.types.ts new file mode 100644 index 0000000000..a79207055c --- /dev/null +++ b/packages/backend/src/types/app/app.types.ts @@ -0,0 +1,4 @@ +export type AppUrn = `${string}:${string}` & { + readonly __type: 'urn'; + split: (separator: ':') => [string, string]; +}; diff --git a/packages/backend/src/types/express/index.d.ts b/packages/backend/src/types/express/index.d.ts new file mode 100644 index 0000000000..b6974e3089 --- /dev/null +++ b/packages/backend/src/types/express/index.d.ts @@ -0,0 +1,9 @@ +import { UserDto } from '@/modules/user/user.repository'; + +declare global { + namespace Express { + interface Request { + user?: UserDto; + } + } +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100644 index 0000000000..618df4d7f7 --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + /* Strictness */ + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "module": "preserve", + "outDir": "./dist", + "noEmit": false, + "lib": [ + "es2022" + ], + "typeRoots": [ + "./src/@types", + "./node_modules/@types" + ], + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + } +} diff --git a/packages/backend/vitest.config.mts b/packages/backend/vitest.config.mts new file mode 100644 index 0000000000..9d08f39955 --- /dev/null +++ b/packages/backend/vitest.config.mts @@ -0,0 +1,16 @@ +import swc from 'unplugin-swc'; +import viteTsconfigPaths from 'vite-tsconfig-paths'; +import { type Plugin, defineConfig } from 'vitest/config'; + +// biome-ignore lint/style/noDefaultExport: needed for vitest config +export default defineConfig({ + plugins: [swc.vite(), viteTsconfigPaths() as unknown] as Plugin[], + test: { + setupFiles: ['./src/tests/vite.setup.ts'], + include: ['src/**/*.test.ts'], + exclude: ['**/integration/**'], + coverage: { all: true, reporter: ['lcov', 'text-summary'] }, + reporters: ['default'], + }, + resolve: {}, +}); diff --git a/packages/backend/vitest.integration.config.mts b/packages/backend/vitest.integration.config.mts new file mode 100644 index 0000000000..f8557e8392 --- /dev/null +++ b/packages/backend/vitest.integration.config.mts @@ -0,0 +1,14 @@ +import swc from 'unplugin-swc'; +import viteTsconfigPaths from 'vite-tsconfig-paths'; +import { type Plugin, defineConfig } from 'vitest/config'; + +// biome-ignore lint/style/noDefaultExport: needed for vitest config +export default defineConfig({ + plugins: [swc.vite(), viteTsconfigPaths() as unknown] as Plugin[], + test: { + setupFiles: ['./src/tests/vite.setup.ts'], + include: ['src/**/integration/**/*.test.ts'], + reporters: ['default'], + }, + resolve: {}, +}); diff --git a/packages/cache/package.json b/packages/cache/package.json deleted file mode 100644 index 20ac6bb503..0000000000 --- a/packages/cache/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@runtipi/cache", - "version": "1.0.0", - "description": "", - "main": "./src/index.ts", - "module": "./src/index.ts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 0" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@runtipi/shared": "workspace:^", - "ioredis": "^5.4.1" - }, - "devDependencies": { - "vitest": "^2.1.3" - } -} diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts deleted file mode 100644 index ddbfd31e0b..0000000000 --- a/packages/cache/src/cache.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { ILogger } from '@runtipi/shared/node'; -import IORedis from 'ioredis'; - -const ONE_DAY_IN_SECONDS = 60 * 60 * 24; - -type IConfig = { - host: string; - port: number; - password: string; -}; - -export interface ICache { - set: (key: string, value: string, expiration?: number) => Promise; - get: (key: string) => Promise; - del: (key: string) => Promise; - getByPrefix: (prefix: string) => Promise>; - close: () => Promise; - ttl: (key: string) => Promise; - clear: () => Promise; - getClient: () => IORedis; -} - -export class Cache implements ICache { - private client: IORedis; - - constructor( - config: IConfig, - private logger: ILogger, - ) { - const { host, port, password } = config; - this.client = new IORedis({ host, port, password, maxRetriesPerRequest: null }); - - this.client.on('error', (error) => { - this.logger.error('cache error', error); - }); - - this.client.on('connect', () => { - this.logger.debug('connected to cache'); - }); - } - - public getClient() { - if (this.client.status === 'close') { - this.client.connect(); - } - return this.client; - } - - public async set(key: string, value: string, expiration = ONE_DAY_IN_SECONDS) { - return this.client.set(key, value, 'EX', expiration); - } - - public async get(key: string) { - return this.client.get(key); - } - - public async del(key: string) { - return this.client.del(key); - } - - public async getByPrefix(prefix: string) { - const keys = await this.client.keys(`${prefix}*`); - - const promises = keys.map(async (key) => { - const val = await this.client.get(key); - return { - key, - val, - }; - }); - - return Promise.all(promises); - } - - public async close() { - this.logger.info('Closing cache connection'); - return this.client.quit(); - } - - public async ttl(key: string) { - return this.client.ttl(key); - } - - public async clear() { - try { - const keys = await this.client.keys('*'); - return Promise.all(keys.map((key) => this.client.del(key))); - } catch (error) { - this.logger.error('Failed to clear cache', error); - throw error; - } - } -} diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts deleted file mode 100644 index 8744978d57..0000000000 --- a/packages/cache/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Cache, type ICache } from './cache'; diff --git a/packages/cache/src/mock.ts b/packages/cache/src/mock.ts deleted file mode 100644 index c1d9823c65..0000000000 --- a/packages/cache/src/mock.ts +++ /dev/null @@ -1,36 +0,0 @@ -import IORedis from 'ioredis'; -import { vi } from 'vitest'; -import type { ICache } from './cache'; - -export class CacheMock implements ICache { - private values = new Map(); - - public set = vi.fn(async (key: string, value: string) => { - this.values.set(key, value); - return value; - }); - - public get = vi.fn(async (key: string) => this.values.get(key) || null); - - public del = vi.fn(async (key: string) => { - this.values.delete(key); - return 1; - }); - - public getByPrefix = vi.fn(async (prefix: string) => { - const keys = Array.from(this.values.keys()).filter((key) => key.startsWith(prefix)); - return keys.map((key) => ({ key, val: this.values.get(key) || null })); - }); - - public close = vi.fn(async () => 'OK'); - - public ttl = vi.fn(async () => -1); - - public clear = vi.fn(async () => { - const keys = Array.from(this.values.keys()); - - return Promise.all(keys.map((key) => this.del(key))); - }); - - public getClient = () => new IORedis(); -} diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json deleted file mode 100644 index 8331467362..0000000000 --- a/packages/cache/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "esModuleInterop": true, - "skipLibCheck": true, - "target": "es2022", - "allowJs": true, - "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "verbatimModuleSyntax": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "module": "preserve", - "noEmit": true, - "lib": ["es2022"] - }, - "include": ["**/*.ts", "**/*.tsx", "**/*.mjs", "**/*.js", "**/*.jsx"], - "exclude": ["node_modules"] -} diff --git a/packages/db/assets/migrations/00000-create-migrations-table.sql b/packages/db/assets/migrations/00000-create-migrations-table.sql deleted file mode 100644 index 9a9d1c1ce6..0000000000 --- a/packages/db/assets/migrations/00000-create-migrations-table.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS migrations ( - id integer PRIMARY KEY, - name varchar(100) UNIQUE NOT NULL, - hash varchar(40) NOT NULL, -- sha1 hex encoded hash of the file name and contents, to ensure it hasn't been altered since applying the migration - executed_at timestamp DEFAULT CURRENT_TIMESTAMP -); diff --git a/packages/db/assets/migrations/00001-initial.sql b/packages/db/assets/migrations/00001-initial.sql deleted file mode 100644 index 27f69b33a5..0000000000 --- a/packages/db/assets/migrations/00001-initial.sql +++ /dev/null @@ -1,69 +0,0 @@ -DO $$ -BEGIN - -- check if enum update_status_enum exists - IF NOT EXISTS ( - SELECT - 1 - FROM - pg_type - WHERE - typname = 'update_status_enum') THEN - -- create enum - CREATE TYPE "public"."update_status_enum" AS ENUM ( - 'FAILED', - 'SUCCESS' -); -END IF; - -- check if enum app_status_enum exists - IF NOT EXISTS ( - SELECT - 1 - FROM - pg_type - WHERE - typname = 'app_status_enum') THEN - -- create enum - CREATE TYPE "public"."app_status_enum" AS ENUM ( - 'running', - 'stopped', - 'installing', - 'uninstalling', - 'stopping', - 'starting', - 'missing' -); -END IF; -END -$$; - -CREATE TABLE IF NOT EXISTS "update" ( - "id" serial NOT NULL, - "name" character varying NOT NULL, - "status" "public"."update_status_enum" NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "UQ_6e7d7ecccdc972caa0ad33cb014" UNIQUE ("name"), - CONSTRAINT "PK_575f77a0576d6293bc1cb752847" PRIMARY KEY ("id") -); - -CREATE TABLE IF NOT EXISTS "user" ( - "id" serial NOT NULL, - "username" character varying NOT NULL, - "password" character varying NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), - CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id") -); - -CREATE TABLE IF NOT EXISTS "app" ( - "id" character varying NOT NULL, - "status" "public"."app_status_enum" NOT NULL DEFAULT 'stopped', - "lastOpened" timestamp with time zone DEFAULT now(), - "numOpened" integer NOT NULL DEFAULT '0', - "config" jsonb NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "UQ_9478629fc093d229df09e560aea" UNIQUE ("id"), - CONSTRAINT "PK_9478629fc093d229df09e560aea" PRIMARY KEY ("id") -); diff --git a/packages/db/assets/migrations/00007-add-locale-user-col.sql b/packages/db/assets/migrations/00007-add-locale-user-col.sql deleted file mode 100644 index 65f5ef5380..0000000000 --- a/packages/db/assets/migrations/00007-add-locale-user-col.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Create locale field if it doesn't exist -ALTER TABLE "user" - ADD COLUMN IF NOT EXISTS "locale" character varying DEFAULT 'en'; - --- Set default locale to en -UPDATE - "user" -SET - "locale" = 'en' -WHERE - "locale" IS NULL; - --- Set locale column to not null constraint -ALTER TABLE "user" - ALTER COLUMN "locale" SET NOT NULL; diff --git a/packages/db/assets/migrations/00011-create-link-table.sql b/packages/db/assets/migrations/00011-create-link-table.sql deleted file mode 100644 index a55536f57f..0000000000 --- a/packages/db/assets/migrations/00011-create-link-table.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "link" ( - "id" serial NOT NULL, - "title" character varying(20) NOT NULL, - "url" character varying NOT NULL, - "icon_url" character varying, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now(), - "user_id" integer NOT NULL, - CONSTRAINT "PK_link" PRIMARY KEY ("id"), - CONSTRAINT "FK_link_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE -); diff --git a/packages/db/package.json b/packages/db/package.json deleted file mode 100644 index 81e7b4b71a..0000000000 --- a/packages/db/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@runtipi/db", - "version": "1.0.0", - "description": "", - "main": "./src/index.ts", - "module": "./src/index.ts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 0" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@runtipi/postgres-migrations": "^5.3.0", - "@runtipi/shared": "workspace:^", - "drizzle-orm": "^0.35.3", - "pg": "^8.13.1" - } -} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts deleted file mode 100644 index 1f57462571..0000000000 --- a/packages/db/src/client.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ILogger } from '@runtipi/shared/node'; -import { type NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres'; -import { Pool } from 'pg'; -import * as schema from './schema'; - -export type IDatabase = NodePgDatabase; - -type IConfig = { - host: string; - port: number; - username: string; - password: string; - database: string; -}; - -export interface IDbClient { - db: IDatabase; -} - -export class DbClient { - public db: IDatabase; - private logger: ILogger; - - constructor(config: IConfig, logger: ILogger) { - this.logger = logger; - const connectionString = `postgresql://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}?connect_timeout=300`; - - const pool = new Pool({ - connectionString, - }); - - pool.on('error', async (err) => { - this.logger.error('Unexpected error on idle client:', err); - }); - - pool.on('connect', () => { - this.logger.debug('Connected to the database successfully.'); - }); - - pool.on('remove', () => { - this.logger.debug('Client removed from the pool.'); - }); - - this.db = drizzle(pool, { schema }); - } -} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts deleted file mode 100644 index 8b0a013b33..0000000000 --- a/packages/db/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './schema'; -export { Migrator, type IMigrator } from './migrator'; -export { type IDbClient, type IDatabase as Database, DbClient } from './client'; diff --git a/packages/db/src/migrator.ts b/packages/db/src/migrator.ts deleted file mode 100644 index 4838c8ecbe..0000000000 --- a/packages/db/src/migrator.ts +++ /dev/null @@ -1,57 +0,0 @@ -import path from 'node:path'; -import { migrate } from '@runtipi/postgres-migrations'; -import type { ILogger } from '@runtipi/shared/node'; -import pg from 'pg'; - -type MigrationParams = { - host: string; - port: number; - username: string; - password: string; - database: string; - migrationsFolder: string; -}; - -export interface IMigrator { - runPostgresMigrations(params: MigrationParams): Promise; -} - -export class Migrator implements IMigrator { - constructor(private logger: ILogger) {} - - public runPostgresMigrations = async (params: MigrationParams) => { - const { database, host, username, password, port, migrationsFolder } = params; - - this.logger.info('Starting database migration'); - - this.logger.info(`Connecting to database ${database} on ${host} as ${username} on port ${port}`); - - const client = new pg.Client({ user: username, host, database, password, port: Number(port) }); - await client.connect(); - - this.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')) { - this.logger.info('Found legacy migration. Deleting table migrations'); - await client.query('DROP TABLE migrations'); - } - } catch (e) { - this.logger.info('Migrations table not found, creating it', e); - } - - this.logger.info('Running migrations'); - try { - await migrate({ client }, path.join(migrationsFolder, 'migrations'), { skipCreateMigrationTable: true }); - } catch (e) { - this.logger.error('Error running migrations. Dropping table migrations and trying again', e); - await client.query('DROP TABLE migrations'); - await migrate({ client }, path.join(migrationsFolder, 'migrations'), { skipCreateMigrationTable: true }); - } - - this.logger.info('Migration complete'); - await client.end(); - }; -} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts deleted file mode 100644 index ee0cb3ca91..0000000000 --- a/packages/db/src/schema.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { boolean, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; - -const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']); -const appStatusEnum = pgEnum('app_status_enum', [ - 'running', - 'stopped', - 'starting', - 'stopping', - 'updating', - 'missing', - 'installing', - 'uninstalling', - 'resetting', - 'restarting', - 'backing_up', - 'restoring', -]); - -const APP_STATUS = appStatusEnum.enumValues; -export type AppStatus = (typeof APP_STATUS)[number]; - -export const migrations = pgTable('migrations', { - id: integer('id').notNull(), - name: varchar('name', { length: 100 }).notNull(), - hash: varchar('hash', { length: 40 }).notNull(), - executedAt: timestamp('executed_at', { mode: 'string' }).defaultNow(), -}); - -export const userTable = pgTable('user', { - id: serial('id').notNull(), - username: varchar('username').notNull(), - password: varchar('password').notNull(), - createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(), - operator: boolean('operator').default(false).notNull(), - totpSecret: text('totp_secret'), - totpEnabled: boolean('totp_enabled').default(false).notNull(), - salt: text('salt'), - locale: varchar('locale').default('en').notNull(), -}); -export type User = InferSelectModel; -export type NewUser = InferInsertModel; - -export const update = pgTable('update', { - id: serial('id').notNull(), - name: varchar('name').notNull(), - status: updateStatusEnum('status').notNull(), - createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(), -}); - -export const appTable = pgTable('app', { - id: varchar('id').notNull(), - status: appStatusEnum('status').default('stopped').notNull(), - lastOpened: timestamp('lastOpened', { withTimezone: true, mode: 'string' }).defaultNow(), - numOpened: integer('numOpened').default(0).notNull(), - config: jsonb('config').notNull(), - createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(), - version: integer('version').default(1).notNull(), - exposed: boolean('exposed').notNull(), - exposedLocal: boolean('exposed_local').notNull(), - openPort: boolean('open_port').notNull(), - domain: varchar('domain'), - isVisibleOnGuestDashboard: boolean('is_visible_on_guest_dashboard').default(false).notNull(), -}); - -export const linkTable = pgTable('link', { - id: serial('id').notNull(), - title: varchar('title', { length: 20 }).notNull(), - description: varchar('description', { length: 50 }), - url: varchar('url').notNull(), - iconUrl: varchar('icon_url'), - createdAt: timestamp('createdAt').defaultNow().notNull(), - updatedAt: timestamp('updatedAt').defaultNow().notNull(), - userId: integer('user_id') - .notNull() - .references(() => userTable.id, { onDelete: 'cascade' }), -}); - -export type App = InferSelectModel; -export type NewApp = InferInsertModel; -export type Link = InferSelectModel; diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000000..0d018402ec --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +public/js/* +!public/js/.gitkeep + +# Sentry Config File +.env.sentry-build-plugin diff --git a/packages/frontend/README.md b/packages/frontend/README.md new file mode 100644 index 0000000000..74872fd4af --- /dev/null +++ b/packages/frontend/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/packages/frontend/index.html b/packages/frontend/index.html new file mode 100644 index 0000000000..b9a1017c53 --- /dev/null +++ b/packages/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + Runtipi + + + +
+ + + + diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000000..9f725be5ef --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,82 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "bundle": "vite build", + "preview": "vite preview", + "tsc": "tsc --noEmit", + "postinstall": "./scripts/postinstall.sh" + }, + "dependencies": { + "@hey-api/client-fetch": "^0.5.7", + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-context-menu": "^2.2.4", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.2", + "@sentry/react": "^8.47.0", + "@sentry/vite-plugin": "^2.22.7", + "@tabler/core": "1.0.0-beta21", + "@tabler/icons-react": "^3.26.0", + "@tanstack/react-query": "^5.62.8", + "@tanstack/react-query-devtools": "^5.62.8", + "@uidotdev/usehooks": "^2.4.1", + "backend": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.2.3", + "geist": "^1.3.1", + "i18next": "^24.2.0", + "i18next-browser-languagedetector": "^8.0.2", + "i18next-http-backend": "^3.0.1", + "immer": "^10.1.1", + "js-cookie": "^3.0.5", + "let-it-go": "^1.1.0", + "qrcode.react": "^4.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.54.1", + "react-hot-toast": "^2.4.1", + "react-i18next": "^15.2.0", + "react-markdown": "^9.0.1", + "react-router": "^7.0.2", + "react-timezone-select": "^3.2.8", + "react-tooltip": "^5.28.0", + "rehype-raw": "^7.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.0", + "semver": "^7.6.3", + "socket.io-client": "^4.8.1", + "validator": "^13.12.0", + "zod": "^3.24.1", + "zustand": "5.0.2" + }, + "devDependencies": { + "@faker-js/faker": "^9.3.0", + "@hey-api/openapi-ts": "^0.60.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/dompurify": "^3.2.0", + "@types/js-cookie": "^3.0.6", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@types/semver": "^7.5.8", + "@types/validator": "^13.12.2", + "@vitejs/plugin-react": "^4.3.4", + "globals": "^15.14.0", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^2.1.8" + } +} diff --git a/public/app-not-found.jpg b/packages/frontend/public/app-not-found.jpg similarity index 100% rename from public/app-not-found.jpg rename to packages/frontend/public/app-not-found.jpg diff --git a/public/empty.svg b/packages/frontend/public/empty.svg similarity index 100% rename from public/empty.svg rename to packages/frontend/public/empty.svg diff --git a/public/error.png b/packages/frontend/public/error.png similarity index 100% rename from public/error.png rename to packages/frontend/public/error.png diff --git a/packages/frontend/public/icons/apple-touch-icon.png b/packages/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000..981bf257fc Binary files /dev/null and b/packages/frontend/public/icons/apple-touch-icon.png differ diff --git a/packages/frontend/public/icons/favicon-96x96.png b/packages/frontend/public/icons/favicon-96x96.png new file mode 100644 index 0000000000..582f58f384 Binary files /dev/null and b/packages/frontend/public/icons/favicon-96x96.png differ diff --git a/packages/frontend/public/icons/favicon.ico b/packages/frontend/public/icons/favicon.ico new file mode 100644 index 0000000000..58a77e9313 Binary files /dev/null and b/packages/frontend/public/icons/favicon.ico differ diff --git a/packages/frontend/public/icons/favicon.svg b/packages/frontend/public/icons/favicon.svg new file mode 100644 index 0000000000..3ce294a3e4 --- /dev/null +++ b/packages/frontend/public/icons/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/frontend/public/icons/site.webmanifest b/packages/frontend/public/icons/site.webmanifest new file mode 100644 index 0000000000..f435806c0c --- /dev/null +++ b/packages/frontend/public/icons/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/icons/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/packages/frontend/public/icons/web-app-manifest-192x192.png b/packages/frontend/public/icons/web-app-manifest-192x192.png new file mode 100644 index 0000000000..f7caea8d9e Binary files /dev/null and b/packages/frontend/public/icons/web-app-manifest-192x192.png differ diff --git a/packages/frontend/public/icons/web-app-manifest-512x512.png b/packages/frontend/public/icons/web-app-manifest-512x512.png new file mode 100644 index 0000000000..3f41195444 Binary files /dev/null and b/packages/frontend/public/icons/web-app-manifest-512x512.png differ diff --git a/patches/.gitkeep b/packages/frontend/public/js/.gitkeep similarity index 100% rename from patches/.gitkeep rename to packages/frontend/public/js/.gitkeep diff --git a/public/mockServiceWorker.js b/packages/frontend/public/mockServiceWorker.js similarity index 99% rename from public/mockServiceWorker.js rename to packages/frontend/public/mockServiceWorker.js index a8262f093f..15751fa199 100644 --- a/public/mockServiceWorker.js +++ b/packages/frontend/public/mockServiceWorker.js @@ -8,7 +8,7 @@ * - Please do NOT serve this file on production. */ -const PACKAGE_VERSION = '2.4.9' +const PACKAGE_VERSION = '2.3.5' const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/public/placeholder.png b/packages/frontend/public/placeholder.png similarity index 100% rename from public/placeholder.png rename to packages/frontend/public/placeholder.png diff --git a/public/tipi-christmas.png b/packages/frontend/public/tipi-christmas.png similarity index 100% rename from public/tipi-christmas.png rename to packages/frontend/public/tipi-christmas.png diff --git a/public/tipi.png b/packages/frontend/public/tipi.png similarity index 100% rename from public/tipi.png rename to packages/frontend/public/tipi.png diff --git a/packages/frontend/public/vite.svg b/packages/frontend/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/packages/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/scripts/postinstall.sh b/packages/frontend/scripts/postinstall.sh new file mode 100755 index 0000000000..c3c850fe94 --- /dev/null +++ b/packages/frontend/scripts/postinstall.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +cp ./node_modules/@tabler/core/dist/js/tabler.min.js ./public/js/tabler.min.js diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css new file mode 100644 index 0000000000..0674f20ee7 --- /dev/null +++ b/packages/frontend/src/App.css @@ -0,0 +1,18 @@ +@import "@tabler/core/dist/css/tabler.min.css"; + +#root { + height: 100%; +} + +main { + height: 100%; +} + +body { + overflow-y: scroll; +} + +.tooltip { + max-width: 300px; + text-align: center; +} diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx new file mode 100644 index 0000000000..cbaf061ec9 --- /dev/null +++ b/packages/frontend/src/App.tsx @@ -0,0 +1,16 @@ +import { Navigate } from 'react-router'; +import { useUserContext } from './context/user-context'; + +export const App = () => { + const { isLoggedIn, isConfigured, isGuestDashboardEnabled } = useUserContext(); + + if (isLoggedIn || isGuestDashboardEnabled) { + return ; + } + + if (isConfigured) { + return ; + } + + return ; +}; diff --git a/packages/frontend/src/api-client/@tanstack/react-query.gen.ts b/packages/frontend/src/api-client/@tanstack/react-query.gen.ts new file mode 100644 index 0000000000..09c4434313 --- /dev/null +++ b/packages/frontend/src/api-client/@tanstack/react-query.gen.ts @@ -0,0 +1,1268 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { OptionsLegacyParser } from '@hey-api/client-fetch'; +import { queryOptions, type UseMutationOptions, infiniteQueryOptions, type InfiniteData } from '@tanstack/react-query'; +import { + client, + userContext, + appContext, + updateUserSettings, + acknowledgeWelcome, + systemLoad, + downloadLocalCertificate, + getTranslation, + login, + verifyTotp, + register, + logout, + changeUsername, + changePassword, + getTotpUri, + setupTotp, + disableTotp, + resetPassword, + cancelResetPassword, + checkResetPasswordRequest, + getInstalledApps, + getGuestApps, + getApp, + searchApps, + getImage, + pullAppStore, + createAppStore, + getAllAppStores, + getEnabledAppStores, + updateAppStore, + deleteAppStore, + installApp, + startApp, + stopApp, + restartApp, + uninstallApp, + resetApp, + updateApp, + updateAppConfig, + updateAllApps, + backupApp, + restoreAppBackup, + getAppBackups, + deleteAppBackup, + getLinks, + createLink, + editLink, + deleteLink, + check, +} from '../sdk.gen'; +import type { + UpdateUserSettingsData, + UpdateUserSettingsError, + UpdateUserSettingsResponse, + AcknowledgeWelcomeData, + AcknowledgeWelcomeError, + AcknowledgeWelcomeResponse, + GetTranslationData, + LoginData, + LoginError, + LoginResponse, + VerifyTotpData, + VerifyTotpError, + VerifyTotpResponse, + RegisterData, + RegisterError, + RegisterResponse, + LogoutError, + LogoutResponse, + ChangeUsernameData, + ChangeUsernameError, + ChangeUsernameResponse, + ChangePasswordData, + ChangePasswordError, + ChangePasswordResponse, + GetTotpUriData, + GetTotpUriError, + GetTotpUriResponse, + SetupTotpData, + SetupTotpError, + SetupTotpResponse, + DisableTotpData, + DisableTotpError, + DisableTotpResponse, + ResetPasswordData, + ResetPasswordError, + ResetPasswordResponse, + CancelResetPasswordError, + CancelResetPasswordResponse, + GetAppData, + SearchAppsData, + SearchAppsError, + SearchAppsResponse, + GetImageData, + PullAppStoreError, + PullAppStoreResponse, + CreateAppStoreData, + CreateAppStoreError, + CreateAppStoreResponse, + UpdateAppStoreData, + UpdateAppStoreError, + UpdateAppStoreResponse, + DeleteAppStoreData, + DeleteAppStoreError, + DeleteAppStoreResponse, + InstallAppData, + InstallAppError, + InstallAppResponse, + StartAppData, + StartAppError, + StartAppResponse, + StopAppData, + StopAppError, + StopAppResponse, + RestartAppData, + RestartAppError, + RestartAppResponse, + UninstallAppData, + UninstallAppError, + UninstallAppResponse, + ResetAppData, + ResetAppError, + ResetAppResponse, + UpdateAppData, + UpdateAppError, + UpdateAppResponse, + UpdateAppConfigData, + UpdateAppConfigError, + UpdateAppConfigResponse, + UpdateAllAppsError, + UpdateAllAppsResponse, + BackupAppData, + BackupAppError, + BackupAppResponse, + RestoreAppBackupData, + RestoreAppBackupError, + RestoreAppBackupResponse, + GetAppBackupsData, + GetAppBackupsError, + GetAppBackupsResponse, + DeleteAppBackupData, + DeleteAppBackupError, + DeleteAppBackupResponse, + CreateLinkData, + CreateLinkError, + CreateLinkResponse, + EditLinkData, + EditLinkError, + EditLinkResponse, + DeleteLinkData, + DeleteLinkError, + DeleteLinkResponse, +} from '../types.gen'; + +type QueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + }, +]; + +const createQueryKey = (id: string, options?: TOptions, infinite?: boolean): QueryKey[0] => { + const params: QueryKey[0] = { _id: id, baseUrl: (options?.client ?? client).getConfig().baseUrl } as QueryKey[0]; + if (infinite) { + params._infinite = infinite; + } + if (options?.body) { + params.body = options.body; + } + if (options?.headers) { + params.headers = options.headers; + } + if (options?.path) { + params.path = options.path; + } + if (options?.query) { + params.query = options.query; + } + return params; +}; + +export const userContextQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('userContext', options)]; + +export const userContextOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await userContext({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: userContextQueryKey(options), + }); +}; + +export const appContextQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('appContext', options)]; + +export const appContextOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await appContext({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: appContextQueryKey(options), + }); +}; + +export const updateUserSettingsMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateUserSettings({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const acknowledgeWelcomeMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await acknowledgeWelcome({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const systemLoadQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('systemLoad', options)]; + +export const systemLoadOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await systemLoad({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: systemLoadQueryKey(options), + }); +}; + +export const downloadLocalCertificateQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('downloadLocalCertificate', options)]; + +export const downloadLocalCertificateOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await downloadLocalCertificate({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: downloadLocalCertificateQueryKey(options), + }); +}; + +export const getTranslationQueryKey = (options: OptionsLegacyParser) => [createQueryKey('getTranslation', options)]; + +export const getTranslationOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getTranslation({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getTranslationQueryKey(options), + }); +}; + +export const loginQueryKey = (options: OptionsLegacyParser) => [createQueryKey('login', options)]; + +export const loginOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await login({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: loginQueryKey(options), + }); +}; + +export const loginMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await login({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const verifyTotpQueryKey = (options: OptionsLegacyParser) => [createQueryKey('verifyTotp', options)]; + +export const verifyTotpOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await verifyTotp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: verifyTotpQueryKey(options), + }); +}; + +export const verifyTotpMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await verifyTotp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const registerQueryKey = (options: OptionsLegacyParser) => [createQueryKey('register', options)]; + +export const registerOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await register({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: registerQueryKey(options), + }); +}; + +export const registerMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await register({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const logoutQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('logout', options)]; + +export const logoutOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await logout({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: logoutQueryKey(options), + }); +}; + +export const logoutMutation = (options?: Partial) => { + const mutationOptions: UseMutationOptions = { + mutationFn: async (localOptions) => { + const { data } = await logout({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const changeUsernameMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await changeUsername({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const changePasswordMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await changePassword({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getTotpUriMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await getTotpUri({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const setupTotpMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await setupTotp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const disableTotpMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await disableTotp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const resetPasswordQueryKey = (options: OptionsLegacyParser) => [createQueryKey('resetPassword', options)]; + +export const resetPasswordOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await resetPassword({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: resetPasswordQueryKey(options), + }); +}; + +export const resetPasswordMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await resetPassword({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const cancelResetPasswordMutation = (options?: Partial) => { + const mutationOptions: UseMutationOptions = { + mutationFn: async (localOptions) => { + const { data } = await cancelResetPassword({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const checkResetPasswordRequestQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('checkResetPasswordRequest', options)]; + +export const checkResetPasswordRequestOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await checkResetPasswordRequest({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: checkResetPasswordRequestQueryKey(options), + }); +}; + +export const getInstalledAppsQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('getInstalledApps', options)]; + +export const getInstalledAppsOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getInstalledApps({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getInstalledAppsQueryKey(options), + }); +}; + +export const getGuestAppsQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('getGuestApps', options)]; + +export const getGuestAppsOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGuestApps({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGuestAppsQueryKey(options), + }); +}; + +export const getAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('getApp', options)]; + +export const getAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAppQueryKey(options), + }); +}; + +export const searchAppsQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('searchApps', options)]; + +export const searchAppsOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await searchApps({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: searchAppsQueryKey(options), + }); +}; + +const createInfiniteParams = [0], 'body' | 'headers' | 'path' | 'query'>>( + queryKey: QueryKey, + page: K, +) => { + const params = queryKey[0]; + if (page.body) { + params.body = { + ...(queryKey[0].body as any), + ...(page.body as any), + }; + } + if (page.headers) { + params.headers = { + ...queryKey[0].headers, + ...page.headers, + }; + } + if (page.path) { + params.path = { + ...queryKey[0].path, + ...page.path, + }; + } + if (page.query) { + params.query = { + ...queryKey[0].query, + ...page.query, + }; + } + return params as unknown as typeof page; +}; + +export const searchAppsInfiniteQueryKey = (options?: OptionsLegacyParser): QueryKey> => [ + createQueryKey('searchApps', options, true), +]; + +export const searchAppsInfiniteOptions = (options?: OptionsLegacyParser) => { + return infiniteQueryOptions< + SearchAppsResponse, + SearchAppsError, + InfiniteData, + QueryKey>, + string | Pick>[0], 'body' | 'headers' | 'path' | 'query'> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = + typeof pageParam === 'object' + ? pageParam + : { + query: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await searchApps({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: searchAppsInfiniteQueryKey(options), + }, + ); +}; + +export const getImageQueryKey = (options: OptionsLegacyParser) => [createQueryKey('getImage', options)]; + +export const getImageOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getImage({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getImageQueryKey(options), + }); +}; + +export const pullAppStoreQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('pullAppStore', options)]; + +export const pullAppStoreOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await pullAppStore({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: pullAppStoreQueryKey(options), + }); +}; + +export const pullAppStoreMutation = (options?: Partial) => { + const mutationOptions: UseMutationOptions = { + mutationFn: async (localOptions) => { + const { data } = await pullAppStore({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const createAppStoreQueryKey = (options: OptionsLegacyParser) => [createQueryKey('createAppStore', options)]; + +export const createAppStoreOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createAppStore({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createAppStoreQueryKey(options), + }); +}; + +export const createAppStoreMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await createAppStore({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getAllAppStoresQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('getAllAppStores', options)]; + +export const getAllAppStoresOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAllAppStores({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAllAppStoresQueryKey(options), + }); +}; + +export const getEnabledAppStoresQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('getEnabledAppStores', options)]; + +export const getEnabledAppStoresOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEnabledAppStores({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEnabledAppStoresQueryKey(options), + }); +}; + +export const updateAppStoreMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateAppStore({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteAppStoreMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await deleteAppStore({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const installAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('installApp', options)]; + +export const installAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await installApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: installAppQueryKey(options), + }); +}; + +export const installAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await installApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const startAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('startApp', options)]; + +export const startAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await startApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: startAppQueryKey(options), + }); +}; + +export const startAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await startApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const stopAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('stopApp', options)]; + +export const stopAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await stopApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: stopAppQueryKey(options), + }); +}; + +export const stopAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await stopApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const restartAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('restartApp', options)]; + +export const restartAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await restartApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: restartAppQueryKey(options), + }); +}; + +export const restartAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await restartApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const uninstallAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await uninstallApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const resetAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('resetApp', options)]; + +export const resetAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await resetApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: resetAppQueryKey(options), + }); +}; + +export const resetAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await resetApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const updateAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const updateAppConfigMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateAppConfig({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const updateAllAppsMutation = (options?: Partial) => { + const mutationOptions: UseMutationOptions = { + mutationFn: async (localOptions) => { + const { data } = await updateAllApps({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const backupAppQueryKey = (options: OptionsLegacyParser) => [createQueryKey('backupApp', options)]; + +export const backupAppOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await backupApp({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: backupAppQueryKey(options), + }); +}; + +export const backupAppMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await backupApp({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const restoreAppBackupQueryKey = (options: OptionsLegacyParser) => [createQueryKey('restoreAppBackup', options)]; + +export const restoreAppBackupOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await restoreAppBackup({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: restoreAppBackupQueryKey(options), + }); +}; + +export const restoreAppBackupMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await restoreAppBackup({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getAppBackupsQueryKey = (options: OptionsLegacyParser) => [createQueryKey('getAppBackups', options)]; + +export const getAppBackupsOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAppBackups({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAppBackupsQueryKey(options), + }); +}; + +export const getAppBackupsInfiniteQueryKey = (options: OptionsLegacyParser): QueryKey> => [ + createQueryKey('getAppBackups', options, true), +]; + +export const getAppBackupsInfiniteOptions = (options: OptionsLegacyParser) => { + return infiniteQueryOptions< + GetAppBackupsResponse, + GetAppBackupsError, + InfiniteData, + QueryKey>, + number | Pick>[0], 'body' | 'headers' | 'path' | 'query'> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = + typeof pageParam === 'object' + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getAppBackups({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAppBackupsInfiniteQueryKey(options), + }, + ); +}; + +export const deleteAppBackupMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await deleteAppBackup({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getLinksQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('getLinks', options)]; + +export const getLinksOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getLinks({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getLinksQueryKey(options), + }); +}; + +export const createLinkQueryKey = (options: OptionsLegacyParser) => [createQueryKey('createLink', options)]; + +export const createLinkOptions = (options: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createLink({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createLinkQueryKey(options), + }); +}; + +export const createLinkMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await createLink({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const editLinkMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await editLink({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteLinkMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await deleteLink({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const checkQueryKey = (options?: OptionsLegacyParser) => [createQueryKey('check', options)]; + +export const checkOptions = (options?: OptionsLegacyParser) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await check({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: checkQueryKey(options), + }); +}; diff --git a/packages/frontend/src/api-client/index.ts b/packages/frontend/src/api-client/index.ts new file mode 100644 index 0000000000..688e3c9124 --- /dev/null +++ b/packages/frontend/src/api-client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './sdk.gen'; +export * from './types.gen'; diff --git a/packages/frontend/src/api-client/sdk.gen.ts b/packages/frontend/src/api-client/sdk.gen.ts new file mode 100644 index 0000000000..d68341f7a5 --- /dev/null +++ b/packages/frontend/src/api-client/sdk.gen.ts @@ -0,0 +1,472 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type OptionsLegacyParser } from '@hey-api/client-fetch'; +import type { + UserContextError, + UserContextResponse, + AppContextError, + AppContextResponse, + UpdateUserSettingsData, + UpdateUserSettingsError, + UpdateUserSettingsResponse, + AcknowledgeWelcomeData, + AcknowledgeWelcomeError, + AcknowledgeWelcomeResponse, + SystemLoadError, + SystemLoadResponse, + DownloadLocalCertificateError, + DownloadLocalCertificateResponse, + GetTranslationData, + GetTranslationError, + GetTranslationResponse, + LoginData, + LoginError, + LoginResponse, + VerifyTotpData, + VerifyTotpError, + VerifyTotpResponse, + RegisterData, + RegisterError, + RegisterResponse, + LogoutError, + LogoutResponse, + ChangeUsernameData, + ChangeUsernameError, + ChangeUsernameResponse, + ChangePasswordData, + ChangePasswordError, + ChangePasswordResponse, + GetTotpUriData, + GetTotpUriError, + GetTotpUriResponse, + SetupTotpData, + SetupTotpError, + SetupTotpResponse, + DisableTotpData, + DisableTotpError, + DisableTotpResponse, + ResetPasswordData, + ResetPasswordError, + ResetPasswordResponse, + CancelResetPasswordError, + CancelResetPasswordResponse, + CheckResetPasswordRequestError, + CheckResetPasswordRequestResponse, + GetInstalledAppsError, + GetInstalledAppsResponse, + GetGuestAppsError, + GetGuestAppsResponse, + GetAppData, + GetAppError, + GetAppResponse, + SearchAppsData, + SearchAppsError, + SearchAppsResponse, + GetImageData, + GetImageError, + GetImageResponse, + PullAppStoreError, + PullAppStoreResponse, + CreateAppStoreData, + CreateAppStoreError, + CreateAppStoreResponse, + GetAllAppStoresError, + GetAllAppStoresResponse, + GetEnabledAppStoresError, + GetEnabledAppStoresResponse, + UpdateAppStoreData, + UpdateAppStoreError, + UpdateAppStoreResponse, + DeleteAppStoreData, + DeleteAppStoreError, + DeleteAppStoreResponse, + InstallAppData, + InstallAppError, + InstallAppResponse, + StartAppData, + StartAppError, + StartAppResponse, + StopAppData, + StopAppError, + StopAppResponse, + RestartAppData, + RestartAppError, + RestartAppResponse, + UninstallAppData, + UninstallAppError, + UninstallAppResponse, + ResetAppData, + ResetAppError, + ResetAppResponse, + UpdateAppData, + UpdateAppError, + UpdateAppResponse, + UpdateAppConfigData, + UpdateAppConfigError, + UpdateAppConfigResponse, + UpdateAllAppsError, + UpdateAllAppsResponse, + BackupAppData, + BackupAppError, + BackupAppResponse, + RestoreAppBackupData, + RestoreAppBackupError, + RestoreAppBackupResponse, + GetAppBackupsData, + GetAppBackupsError, + GetAppBackupsResponse, + DeleteAppBackupData, + DeleteAppBackupError, + DeleteAppBackupResponse, + GetLinksError, + GetLinksResponse, + CreateLinkData, + CreateLinkError, + CreateLinkResponse, + EditLinkData, + EditLinkError, + EditLinkResponse, + DeleteLinkData, + DeleteLinkError, + DeleteLinkResponse, + CheckError, + CheckResponse, +} from './types.gen'; + +export const client = createClient(createConfig()); + +export const userContext = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/user-context', + }); +}; + +export const appContext = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/app-context', + }); +}; + +export const updateUserSettings = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/user-settings', + }); +}; + +export const acknowledgeWelcome = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/acknowledge-welcome', + }); +}; + +export const systemLoad = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/system/load', + }); +}; + +export const downloadLocalCertificate = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/system/certificate', + }); +}; + +export const getTranslation = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/i18n/locales/{ns}/{lng}.json', + }); +}; + +export const login = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/auth/login', + }); +}; + +export const verifyTotp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/auth/verify-totp', + }); +}; + +export const register = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/auth/register', + }); +}; + +export const logout = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/auth/logout', + }); +}; + +export const changeUsername = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/auth/username', + }); +}; + +export const changePassword = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/auth/password', + }); +}; + +export const getTotpUri = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/auth/totp/get-uri', + }); +}; + +export const setupTotp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/auth/totp/setup', + }); +}; + +export const disableTotp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/auth/totp/disable', + }); +}; + +export const resetPassword = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/auth/reset-password', + }); +}; + +export const cancelResetPassword = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/auth/reset-password', + }); +}; + +export const checkResetPasswordRequest = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/auth/reset-password', + }); +}; + +export const getInstalledApps = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/apps/installed', + }); +}; + +export const getGuestApps = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/apps/guest', + }); +}; + +export const getApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/apps/{urn}', + }); +}; + +export const searchApps = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/marketplace/apps/search', + }); +}; + +export const getImage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/marketplace/apps/{urn}/image', + }); +}; + +export const pullAppStore = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/marketplace/pull', + }); +}; + +export const createAppStore = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/marketplace/create', + }); +}; + +export const getAllAppStores = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/marketplace/all', + }); +}; + +export const getEnabledAppStores = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/marketplace/enabled', + }); +}; + +export const updateAppStore = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/marketplace/{id}', + }); +}; + +export const deleteAppStore = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/marketplace/{id}', + }); +}; + +export const installApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/app-lifecycle/{urn}/install', + }); +}; + +export const startApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/app-lifecycle/{urn}/start', + }); +}; + +export const stopApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/app-lifecycle/{urn}/stop', + }); +}; + +export const restartApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/app-lifecycle/{urn}/restart', + }); +}; + +export const uninstallApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/app-lifecycle/{urn}/uninstall', + }); +}; + +export const resetApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/app-lifecycle/{urn}/reset', + }); +}; + +export const updateApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/app-lifecycle/{urn}/update', + }); +}; + +export const updateAppConfig = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/app-lifecycle/{urn}/update-config', + }); +}; + +export const updateAllApps = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/app-lifecycle/update-all', + }); +}; + +export const backupApp = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/backups/{urn}/backup', + }); +}; + +export const restoreAppBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/backups/{urn}/restore', + }); +}; + +export const getAppBackups = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/backups/{urn}', + }); +}; + +export const deleteAppBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/backups/{urn}', + }); +}; + +export const getLinks = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/links', + }); +}; + +export const createLink = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/links', + }); +}; + +export const editLink = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/links/{id}', + }); +}; + +export const deleteLink = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/links/{id}', + }); +}; + +export const check = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/health', + }); +}; diff --git a/packages/frontend/src/api-client/types.gen.ts b/packages/frontend/src/api-client/types.gen.ts new file mode 100644 index 0000000000..4af274c873 --- /dev/null +++ b/packages/frontend/src/api-client/types.gen.ts @@ -0,0 +1,1019 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AcknowledgeWelcomeBody = { + allowErrorMonitoring: boolean; +}; + +export type AllAppStoresDto = { + appStores: Array<{ + slug: string; + name: string; + url: string; + enabled: boolean; + }>; +}; + +export type AppContextDto = { + version: { + current: string; + latest: string; + body: string; + }; + userSettings: { + dnsIp: string; + internalIp: string; + postgresPort: number; + appsRepoUrl: string; + domain: string; + appDataPath: string; + localDomain: string; + demoMode: boolean; + guestDashboard: boolean; + allowAutoThemes: boolean; + allowErrorMonitoring: boolean; + persistTraefikConfig: boolean; + port: number; + sslPort: number; + listenIp: string; + timeZone: string; + eventsTimeout: number; + }; + user: { + id: number; + username: string; + totpEnabled: boolean; + locale: string; + operator: boolean; + hasSeenWelcome: boolean; + }; + apps: Array<{ + id: string; + urn: string; + name: string; + short_desc: string; + categories?: Array< + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai' + >; + deprecated?: boolean; + created_at?: number; + supported_architectures?: Array<'arm64' | 'amd64'>; + available: boolean; + }>; + updatesAvailable: number; +}; + +export type AppFormBody = { + port?: number; + exposed?: boolean; + exposedLocal?: boolean; + openPort?: boolean; + domain?: string; + isVisibleOnGuestDashboard?: boolean; +}; + +export type ChangePasswordBody = { + currentPassword: string; + newPassword: string; +}; + +export type ChangeUsernameBody = { + newUsername: string; + password: string; +}; + +export type CheckResetPasswordRequestDto = { + isRequestPending: boolean; +}; + +export type CreateAppStoreBodyDto = { + name: string; + url: string; +}; + +export type DeleteAppBackupBodyDto = { + filename: string; +}; + +export type DisableTotpBody = { + password: string; +}; + +export type EditLinkBodyDto = { + title: string; + url: string; + description?: string; + iconUrl?: string; +}; + +export type GetAppBackupsDto = { + data: Array<{ + id: string; + size: number; + date: number; + }>; + total: number; + currentPage: number; + lastPage: number; +}; + +export type GetAppDto = { + app?: { + id: number; + port: number | null; + status: + | 'running' + | 'stopped' + | 'installing' + | 'uninstalling' + | 'stopping' + | 'starting' + | 'missing' + | 'updating' + | 'resetting' + | 'restarting' + | 'backing_up' + | 'restoring'; + createdAt?: string; + updatedAt?: string; + version: number; + exposed: boolean; + openPort: boolean; + exposedLocal: boolean; + domain: string | null; + isVisibleOnGuestDashboard: boolean; + config?: { + [key: string]: unknown; + }; + } | null; + info: { + id: string; + urn: string; + available: boolean; + deprecated?: boolean; + port: number; + name: string; + description?: string; + version?: string; + tipi_version: number; + short_desc: string; + author: string; + source: string; + website?: string; + force_expose?: boolean; + generate_vapid_keys?: boolean; + categories?: Array< + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai' + >; + url_suffix?: string; + form_fields?: Array<{ + type: 'text' | 'password' | 'email' | 'number' | 'fqdn' | 'ip' | 'fqdnip' | 'url' | 'random' | 'boolean'; + label: string; + placeholder?: string; + max?: number; + min?: number; + hint?: string; + options?: Array<{ + label: string; + value: string; + }>; + required?: boolean; + default?: boolean | string | number; + regex?: string; + pattern_error?: string; + env_variable: string; + encoding?: 'hex' | 'base64'; + }>; + https?: boolean; + exposable?: boolean; + no_gui?: boolean; + supported_architectures?: Array<'arm64' | 'amd64'>; + uid?: number; + gid?: number; + dynamic_config?: boolean; + min_tipi_version?: string; + created_at?: number; + updated_at?: number; + }; + metadata: { + hasCustomConfig?: boolean; + latestVersion: number; + minTipiVersion?: string; + latestDockerVersion?: string; + }; +}; + +export type status = + | 'running' + | 'stopped' + | 'installing' + | 'uninstalling' + | 'stopping' + | 'starting' + | 'missing' + | 'updating' + | 'resetting' + | 'restarting' + | 'backing_up' + | 'restoring'; + +export type GetTotpUriBody = { + password: string; +}; + +export type GetTotpUriDto = { + key: string; + uri: string; +}; + +export type GuestAppsDto = { + installed: Array<{ + app: { + id: number; + port: number | null; + status: + | 'running' + | 'stopped' + | 'installing' + | 'uninstalling' + | 'stopping' + | 'starting' + | 'missing' + | 'updating' + | 'resetting' + | 'restarting' + | 'backing_up' + | 'restoring'; + createdAt?: string; + updatedAt?: string; + version: number; + exposed: boolean; + openPort: boolean; + exposedLocal: boolean; + domain: string | null; + isVisibleOnGuestDashboard: boolean; + config?: { + [key: string]: unknown; + }; + }; + info: { + id: string; + urn: string; + available: boolean; + deprecated?: boolean; + port: number; + name: string; + description?: string; + version?: string; + tipi_version: number; + short_desc: string; + author: string; + source: string; + website?: string; + force_expose?: boolean; + generate_vapid_keys?: boolean; + categories?: Array< + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai' + >; + url_suffix?: string; + form_fields?: Array<{ + type: 'text' | 'password' | 'email' | 'number' | 'fqdn' | 'ip' | 'fqdnip' | 'url' | 'random' | 'boolean'; + label: string; + placeholder?: string; + max?: number; + min?: number; + hint?: string; + options?: Array<{ + label: string; + value: string; + }>; + required?: boolean; + default?: boolean | string | number; + regex?: string; + pattern_error?: string; + env_variable: string; + encoding?: 'hex' | 'base64'; + }>; + https?: boolean; + exposable?: boolean; + no_gui?: boolean; + supported_architectures?: Array<'arm64' | 'amd64'>; + uid?: number; + gid?: number; + dynamic_config?: boolean; + min_tipi_version?: string; + created_at?: number; + updated_at?: number; + }; + }>; +}; + +export type LinkBodyDto = { + title: string; + url: string; + description?: string; + iconUrl?: string; +}; + +export type LinksDto = { + links: Array<{ + id: number; + title: string; + description: string | null; + url: string; + iconUrl: string | null; + userId: number; + }>; +}; + +export type LoadDto = { + diskUsed?: number | null; + diskSize?: number | null; + percentUsed?: number | null; + cpuLoad?: number | null; + memoryTotal?: number | null; + percentUsedMemory?: number | null; +}; + +export type LoginBody = { + username: string; + password: string; +}; + +export type LoginDto = { + success: boolean; + totpSessionId?: string; +}; + +export type MyAppsDto = { + installed: Array<{ + app: { + id: number; + port: number | null; + status: + | 'running' + | 'stopped' + | 'installing' + | 'uninstalling' + | 'stopping' + | 'starting' + | 'missing' + | 'updating' + | 'resetting' + | 'restarting' + | 'backing_up' + | 'restoring'; + createdAt?: string; + updatedAt?: string; + version: number; + exposed: boolean; + openPort: boolean; + exposedLocal: boolean; + domain: string | null; + isVisibleOnGuestDashboard: boolean; + config?: { + [key: string]: unknown; + }; + }; + info: { + id: string; + urn: string; + name: string; + short_desc: string; + categories?: Array< + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai' + >; + deprecated?: boolean; + created_at?: number; + supported_architectures?: Array<'arm64' | 'amd64'>; + available: boolean; + }; + metadata: { + hasCustomConfig?: boolean; + latestVersion: number; + minTipiVersion?: string; + latestDockerVersion?: string; + }; + }>; +}; + +export type PartialUserSettingsDto = { + dnsIp?: string; + internalIp?: string; + postgresPort?: number; + appsRepoUrl?: string; + domain?: string; + appDataPath?: string; + localDomain?: string; + demoMode?: boolean; + guestDashboard?: boolean; + allowAutoThemes?: boolean; + allowErrorMonitoring?: boolean; + persistTraefikConfig?: boolean; + port?: number; + sslPort?: number; + listenIp?: string; + timeZone?: string; + eventsTimeout?: number; +}; + +export type PullDto = { + success: boolean; +}; + +export type RegisterBody = { + username: string; + password: string; +}; + +export type RegisterDto = { + success: boolean; +}; + +export type ResetPasswordBody = { + newPassword: string; +}; + +export type ResetPasswordDto = { + success: boolean; + email: string; +}; + +export type RestoreAppBackupDto = { + filename: string; +}; + +export type SearchAppsDto = { + data: Array<{ + id: string; + urn: string; + name: string; + short_desc: string; + categories?: Array< + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai' + >; + deprecated?: boolean; + created_at?: number; + supported_architectures?: Array<'arm64' | 'amd64'>; + available: boolean; + }>; + nextCursor?: string; + total: number; +}; + +export type SetupTotpBody = { + code: string; +}; + +export type UninstallAppBody = { + removeBackups: boolean; +}; + +export type UpdateAppBody = { + performBackup: boolean; +}; + +export type UpdateAppStoreBodyDto = { + name: string; + enabled: boolean; +}; + +export type UserContextDto = { + version: { + current: string; + latest: string; + body: string; + }; + /** + * Indicates if the user is logged in + */ + isLoggedIn: boolean; + /** + * Indicates if the app is already configured + */ + isConfigured: boolean; + /** + * Indicates if the guest dashboard is enabled + */ + isGuestDashboardEnabled: boolean; + /** + * Indicates if the app allows auto themes + */ + allowAutoThemes: boolean; + /** + * Indicates if the app allows anonymous error monitoring + */ + allowErrorMonitoring: boolean; +}; + +export type VerifyTotpBody = { + totpCode: string; + totpSessionId: string; +}; + +export type UserContextResponse = UserContextDto; + +export type UserContextError = unknown; + +export type AppContextResponse = AppContextDto; + +export type AppContextError = unknown; + +export type UpdateUserSettingsData = { + body: PartialUserSettingsDto; +}; + +export type UpdateUserSettingsResponse = unknown; + +export type UpdateUserSettingsError = unknown; + +export type AcknowledgeWelcomeData = { + body: AcknowledgeWelcomeBody; +}; + +export type AcknowledgeWelcomeResponse = unknown; + +export type AcknowledgeWelcomeError = unknown; + +export type SystemLoadResponse = LoadDto; + +export type SystemLoadError = unknown; + +export type DownloadLocalCertificateResponse = unknown; + +export type DownloadLocalCertificateError = unknown; + +export type GetTranslationData = { + path: { + lng: string; + ns: string; + }; +}; + +export type GetTranslationResponse = { + [key: string]: unknown; +}; + +export type GetTranslationError = unknown; + +export type LoginData = { + body: LoginBody; +}; + +export type LoginResponse = LoginDto; + +export type LoginError = unknown; + +export type VerifyTotpData = { + body: VerifyTotpBody; +}; + +export type VerifyTotpResponse = LoginDto; + +export type VerifyTotpError = unknown; + +export type RegisterData = { + body: RegisterBody; +}; + +export type RegisterResponse = RegisterDto; + +export type RegisterError = unknown; + +export type LogoutResponse = unknown; + +export type LogoutError = unknown; + +export type ChangeUsernameData = { + body: ChangeUsernameBody; +}; + +export type ChangeUsernameResponse = unknown; + +export type ChangeUsernameError = unknown; + +export type ChangePasswordData = { + body: ChangePasswordBody; +}; + +export type ChangePasswordResponse = unknown; + +export type ChangePasswordError = unknown; + +export type GetTotpUriData = { + body: GetTotpUriBody; +}; + +export type GetTotpUriResponse = GetTotpUriDto; + +export type GetTotpUriError = unknown; + +export type SetupTotpData = { + body: SetupTotpBody; +}; + +export type SetupTotpResponse = unknown; + +export type SetupTotpError = unknown; + +export type DisableTotpData = { + body: DisableTotpBody; +}; + +export type DisableTotpResponse = unknown; + +export type DisableTotpError = unknown; + +export type ResetPasswordData = { + body: ResetPasswordBody; +}; + +export type ResetPasswordResponse = ResetPasswordDto; + +export type ResetPasswordError = unknown; + +export type CancelResetPasswordResponse = unknown; + +export type CancelResetPasswordError = unknown; + +export type CheckResetPasswordRequestResponse = CheckResetPasswordRequestDto; + +export type CheckResetPasswordRequestError = unknown; + +export type GetInstalledAppsResponse = MyAppsDto; + +export type GetInstalledAppsError = unknown; + +export type GetGuestAppsResponse = GuestAppsDto; + +export type GetGuestAppsError = unknown; + +export type GetAppData = { + path: { + urn: string; + }; +}; + +export type GetAppResponse = GetAppDto; + +export type GetAppError = unknown; + +export type SearchAppsData = { + query?: { + category?: + | 'network' + | 'media' + | 'development' + | 'automation' + | 'social' + | 'utilities' + | 'photography' + | 'security' + | 'featured' + | 'books' + | 'data' + | 'music' + | 'finance' + | 'gaming' + | 'ai'; + cursor?: string; + pageSize?: number; + search?: string; + storeId?: string; + }; +}; + +export type SearchAppsResponse = SearchAppsDto; + +export type SearchAppsError = unknown; + +export type GetImageData = { + path: { + urn: string; + }; +}; + +export type GetImageResponse = unknown; + +export type GetImageError = unknown; + +export type PullAppStoreResponse = PullDto; + +export type PullAppStoreError = unknown; + +export type CreateAppStoreData = { + body: CreateAppStoreBodyDto; +}; + +export type CreateAppStoreResponse = unknown; + +export type CreateAppStoreError = unknown; + +export type GetAllAppStoresResponse = AllAppStoresDto; + +export type GetAllAppStoresError = unknown; + +export type GetEnabledAppStoresResponse = AllAppStoresDto; + +export type GetEnabledAppStoresError = unknown; + +export type UpdateAppStoreData = { + body: UpdateAppStoreBodyDto; + path: { + id: string; + }; +}; + +export type UpdateAppStoreResponse = unknown; + +export type UpdateAppStoreError = unknown; + +export type DeleteAppStoreData = { + path: { + id: string; + }; +}; + +export type DeleteAppStoreResponse = unknown; + +export type DeleteAppStoreError = unknown; + +export type InstallAppData = { + body: AppFormBody; + path: { + urn: string; + }; +}; + +export type InstallAppResponse = unknown; + +export type InstallAppError = unknown; + +export type StartAppData = { + path: { + urn: string; + }; +}; + +export type StartAppResponse = unknown; + +export type StartAppError = unknown; + +export type StopAppData = { + path: { + urn: string; + }; +}; + +export type StopAppResponse = unknown; + +export type StopAppError = unknown; + +export type RestartAppData = { + path: { + urn: string; + }; +}; + +export type RestartAppResponse = unknown; + +export type RestartAppError = unknown; + +export type UninstallAppData = { + body: UninstallAppBody; + path: { + urn: string; + }; +}; + +export type UninstallAppResponse = unknown; + +export type UninstallAppError = unknown; + +export type ResetAppData = { + path: { + urn: string; + }; +}; + +export type ResetAppResponse = unknown; + +export type ResetAppError = unknown; + +export type UpdateAppData = { + body: UpdateAppBody; + path: { + urn: string; + }; +}; + +export type UpdateAppResponse = unknown; + +export type UpdateAppError = unknown; + +export type UpdateAppConfigData = { + body: AppFormBody; + path: { + urn: string; + }; +}; + +export type UpdateAppConfigResponse = unknown; + +export type UpdateAppConfigError = unknown; + +export type UpdateAllAppsResponse = unknown; + +export type UpdateAllAppsError = unknown; + +export type BackupAppData = { + path: { + urn: string; + }; +}; + +export type BackupAppResponse = unknown; + +export type BackupAppError = unknown; + +export type RestoreAppBackupData = { + body: RestoreAppBackupDto; + path: { + urn: string; + }; +}; + +export type RestoreAppBackupResponse = unknown; + +export type RestoreAppBackupError = unknown; + +export type GetAppBackupsData = { + path: { + urn: string; + }; + query?: { + page?: number; + pageSize?: number; + }; +}; + +export type GetAppBackupsResponse = GetAppBackupsDto; + +export type GetAppBackupsError = unknown; + +export type DeleteAppBackupData = { + body: DeleteAppBackupBodyDto; + path: { + urn: string; + }; +}; + +export type DeleteAppBackupResponse = unknown; + +export type DeleteAppBackupError = unknown; + +export type GetLinksResponse = LinksDto; + +export type GetLinksError = unknown; + +export type CreateLinkData = { + body: LinkBodyDto; +}; + +export type CreateLinkResponse = unknown; + +export type CreateLinkError = unknown; + +export type EditLinkData = { + body: EditLinkBodyDto; + path: { + id: number; + }; +}; + +export type EditLinkResponse = unknown; + +export type EditLinkError = unknown; + +export type DeleteLinkData = { + path: { + id: number; + }; +}; + +export type DeleteLinkResponse = unknown; + +export type DeleteLinkError = unknown; + +export type CheckResponse = { + status?: string; + info?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + } | null; + error?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + } | null; + details?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + }; +}; + +export type CheckError = { + status?: string; + info?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + } | null; + error?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + } | null; + details?: { + [key: string]: { + status: string; + [key: string]: unknown | string; + }; + }; +}; diff --git a/src/client/components/AppLogo/AppLogo.module.scss b/packages/frontend/src/components/app-logo/app-logo.css similarity index 86% rename from src/client/components/AppLogo/AppLogo.module.scss rename to packages/frontend/src/components/app-logo/app-logo.css index 423413466a..b1f4318905 100644 --- a/src/client/components/AppLogo/AppLogo.module.scss +++ b/packages/frontend/src/components/app-logo/app-logo.css @@ -1,3 +1,3 @@ -.dropShadow { +.drop-shadow { filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06)); } diff --git a/packages/frontend/src/components/app-logo/app-logo.tsx b/packages/frontend/src/components/app-logo/app-logo.tsx new file mode 100644 index 0000000000..551b7cf3ec --- /dev/null +++ b/packages/frontend/src/components/app-logo/app-logo.tsx @@ -0,0 +1,26 @@ +import clsx from 'clsx'; +import type React from 'react'; +import './app-logo.css'; + +export const AppLogo: React.FC<{ + urn?: string; + url?: string; + size?: number; + className?: string; + alt?: string; + placeholder?: boolean; +}> = ({ urn, url, size = 80, className = '', alt = '' }) => { + const logoUrl = urn ? `/api/marketplace/apps/${urn}/image` : '/app-not-found.jpg'; + + return ( +
+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: Svg has no alt attibute */} + + + + + + +
+ ); +}; diff --git a/src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.module.scss b/packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.css similarity index 81% rename from src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.module.scss rename to packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.css index 116051057b..907901a179 100644 --- a/src/app/(dashboard)/app-store/components/AppStoreTableActions/AppStoreTableActions.module.scss +++ b/packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.css @@ -1,4 +1,4 @@ -.selector { +.search-input { @media screen and (min-width: 768px) { max-width: 300px; } diff --git a/packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.tsx b/packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.tsx new file mode 100644 index 0000000000..fc82754f5f --- /dev/null +++ b/packages/frontend/src/components/app-store-layout-actions/app-store-layout-actions.tsx @@ -0,0 +1,65 @@ +import { Input } from '@/components/ui/Input'; +import { useAppStoreState } from '@/stores/app-store'; +import clsx from 'clsx'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import './app-store-layout-actions.css'; +import { getEnabledAppStoresOptions } from '@/api-client/@tanstack/react-query.gen'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router'; +import { CategorySelector } from '../category-selector/category-selector'; +import { StoreSelector } from '../store-selector/store-selector'; + +export const AppStoreLayoutActions = () => { + const { setCategory, category, setStoreId, search: initialSearch, setSearch } = useAppStoreState(); + const [search, setLocalSearch] = useState(initialSearch); + const { t } = useTranslation(); + + const { data } = useSuspenseQuery({ + ...getEnabledAppStoresOptions(), + }); + + const onSearch = (e: React.ChangeEvent) => { + setLocalSearch(e.target.value); + setSearch(e.target.value); + }; + + useEffect(() => { + if (selectedStore) { + setStoreId(selectedStore); + } + }, [setStoreId]); + + const [params, setParams] = useSearchParams(); + const selectedStore = params.get('store') ?? undefined; + + const onSelectStore = (value?: string) => { + if (value) { + setParams({ store: value }); + } else { + setParams({}); + } + setStoreId(value); + }; + + return ( +
+ + {data.appStores.length > 1 && ( + + )} + +
+ ); +}; diff --git a/src/app/(dashboard)/app-store/components/CategorySelector/CategorySelector.tsx b/packages/frontend/src/components/category-selector/category-selector.tsx similarity index 55% rename from src/app/(dashboard)/app-store/components/CategorySelector/CategorySelector.tsx rename to packages/frontend/src/components/category-selector/category-selector.tsx index 1c5eca867a..4c3c17abcf 100644 --- a/src/app/(dashboard)/app-store/components/CategorySelector/CategorySelector.tsx +++ b/packages/frontend/src/components/category-selector/category-selector.tsx @@ -1,8 +1,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'; -import type { AppCategory } from '@runtipi/shared'; -import { useTranslations } from 'next-intl'; +import { iconForCategory } from '@/modules/app/helpers/table-helpers'; +import type { AppCategory } from '@/types/app.types'; import { useState } from 'react'; -import { iconForCategory } from '../../helpers/table.helpers'; +import { useTranslation } from 'react-i18next'; interface Props { onSelect: (value?: AppCategory) => void; @@ -11,24 +11,32 @@ interface Props { } export const CategorySelector = ({ onSelect, className, initialValue }: Props) => { - const t = useTranslations(); + const { t } = useTranslation(); + const [resetCounter, setResetCounter] = useState(0); + const options = iconForCategory.map((category) => ({ value: category.id, - label: t(`APP_CATEGORY_${category.id.toUpperCase() as Uppercase}`), + label: t(`APP_CATEGORY_${category.id.toUpperCase()}`), icon: category.icon, })); const [value, setValue] = useState(initialValue); - const handleChange = (option: AppCategory) => { + const handleChange = (option?: AppCategory) => { setValue(option); onSelect(option); }; + const handleReset = () => { + setValue(undefined); + onSelect(undefined); + setResetCounter((prev) => prev + 1); + }; + return ( - handleChange(o)}> + + {options?.map(({ value: category, icon: CategoryIcon, label }) => ( diff --git a/src/client/components/DateFormat/DateFormat.tsx b/packages/frontend/src/components/date-format/date-format.tsx similarity index 58% rename from src/client/components/DateFormat/DateFormat.tsx rename to packages/frontend/src/components/date-format/date-format.tsx index 78ee4f426f..b05164f377 100644 --- a/src/client/components/DateFormat/DateFormat.tsx +++ b/packages/frontend/src/components/date-format/date-format.tsx @@ -1,15 +1,15 @@ -import { useClientSettings } from '@/hooks/useClientSettings'; -import { useCookies } from 'next-client-cookies'; +import { useAppContext } from '@/context/app-context'; +import { getCurrentLocale } from '@/lib/i18n/locales'; type IProps = { date: Date | string; }; export const useDateFormat = () => { - const cookies = useCookies(); - const { timeZone } = useClientSettings(); + const { userSettings } = useAppContext(); + const { timeZone } = userSettings; - const locale = cookies.get('tipi-locale') || 'en-US'; + const locale = getCurrentLocale(); const formatDate = (date?: Date | string) => { if (!date) return 'Invalid date'; @@ -24,10 +24,10 @@ export const useDateFormat = () => { }; export const DateFormat = ({ date }: IProps) => { - const cookies = useCookies(); - const { timeZone } = useClientSettings(); + const { userSettings } = useAppContext(); + const { timeZone } = userSettings; - const locale = cookies.get('tipi-locale') || 'en-US'; + const locale = getCurrentLocale(); const formattedDate = new Date(date).toLocaleString(locale, { timeZone }); diff --git a/src/client/components/ui/EmptyPage/EmptyPage.module.scss b/packages/frontend/src/components/empty-page/empty-page.css similarity index 68% rename from src/client/components/ui/EmptyPage/EmptyPage.module.scss rename to packages/frontend/src/components/empty-page/empty-page.css index 91ffe8032d..c05f521c26 100644 --- a/src/client/components/ui/EmptyPage/EmptyPage.module.scss +++ b/packages/frontend/src/components/empty-page/empty-page.css @@ -1,4 +1,4 @@ -.emptyImage { +.empty-image { height: 80px; width: 80px; } diff --git a/src/app/components/EmptyPage/EmptyPage.tsx b/packages/frontend/src/components/empty-page/empty-page.tsx similarity index 60% rename from src/app/components/EmptyPage/EmptyPage.tsx rename to packages/frontend/src/components/empty-page/empty-page.tsx index 9eaac70011..60f917cccc 100644 --- a/src/app/components/EmptyPage/EmptyPage.tsx +++ b/packages/frontend/src/components/empty-page/empty-page.tsx @@ -1,34 +1,28 @@ -'use client'; - import { Button } from '@/components/ui/Button'; -import type { MessageKey } from '@/server/utils/errors'; -import clsx from 'clsx'; -import { useTranslations } from 'next-intl'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; import type React from 'react'; -import styles from './EmptyPage.module.scss'; +import './empty-page.css'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; interface IProps { - title: MessageKey; - subtitle?: MessageKey; - actionLabel?: MessageKey; + title: string; + subtitle?: string; + actionLabel?: string; redirectPath?: string; } export const EmptyPage: React.FC = ({ title, subtitle, redirectPath, actionLabel }) => { - const t = useTranslations(); - const router = useRouter(); + const { t } = useTranslation(); + const navigate = useNavigate(); return (
- = ({ title, subtitle, redirectPath, act {subtitle &&

{t(subtitle)}

}
{redirectPath && actionLabel && ( - )} diff --git a/packages/frontend/src/components/error/error-page.tsx b/packages/frontend/src/components/error/error-page.tsx new file mode 100644 index 0000000000..cf7047e8fe --- /dev/null +++ b/packages/frontend/src/components/error/error-page.tsx @@ -0,0 +1,34 @@ +import { IconReload } from '@tabler/icons-react'; +import { Button } from '../ui/Button'; + +type ErrorPageProps = { + onReset: () => void; + error: Error; +}; + +export const ErrorPage = ({ error, onReset }: ErrorPageProps) => { + return ( +
+
+
+

Oops... An error occurred!

+

+ Try refreshing the page or click the button below to try again. If the problem persists, open an issue on GitHub with the error message + below. +

+
+ +
+
+            {error.message}
+            
+ Location: {location.pathname} +
+
+
+
+ ); +}; diff --git a/src/client/components/FileSize/FileSize.tsx b/packages/frontend/src/components/file-size/file-size.tsx similarity index 100% rename from src/client/components/FileSize/FileSize.tsx rename to packages/frontend/src/components/file-size/file-size.tsx diff --git a/packages/frontend/src/components/header/guest-header.tsx b/packages/frontend/src/components/header/guest-header.tsx new file mode 100644 index 0000000000..33b96ff213 --- /dev/null +++ b/packages/frontend/src/components/header/guest-header.tsx @@ -0,0 +1,88 @@ +import { getLogo } from '@/lib/theme/theme'; +import { useUIStore } from '@/stores/ui-store'; +import { IconBrandGithub, IconHeart, IconLogin, IconMoon, IconSun } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router'; +import { Tooltip } from 'react-tooltip'; + +export const GuestHeader = () => { + const setDarkMode = useUIStore((state) => state.setDarkMode); + + const { t } = useTranslation(); + + const navigate = useNavigate(); + + return ( +
+
+ +

+ Runtipi logo + Runtipi +

+ +
+ +
+ + {t('HEADER_DARK_MODE')} + + + + {t('HEADER_LIGHT_MODE')} + + + + {t('HEADER_LOGIN')} + + +
+
+
+
+ ); +}; diff --git a/src/app/(dashboard)/components/Header/Header.tsx b/packages/frontend/src/components/header/header.tsx similarity index 66% rename from src/app/(dashboard)/components/Header/Header.tsx rename to packages/frontend/src/components/header/header.tsx index ae3e77b889..d85a57bada 100644 --- a/src/app/(dashboard)/components/Header/Header.tsx +++ b/packages/frontend/src/components/header/header.tsx @@ -1,61 +1,46 @@ -'use client'; - +import { logoutMutation } from '@/api-client/@tanstack/react-query.gen'; +import { getLogo } from '@/lib/theme/theme'; +import { useUIStore } from '@/stores/ui-store'; import { IconBrandGithub, IconHeart, IconLogin, IconLogout, IconMoon, IconSun } from '@tabler/icons-react'; -import clsx from 'clsx'; -import Image from 'next/image'; -import Link from 'next/link'; -import type React from 'react'; - -import { logoutAction } from '@/actions/logout/logout-action'; -import { useUIStore } from '@/client/state/uiStore'; -import { useClientSettings } from '@/hooks/useClientSettings'; -import { getLogo } from '@/lib/themes'; -import { useTranslations } from 'next-intl'; -import { useAction } from 'next-safe-action/hooks'; -import { useRouter } from 'next/navigation'; -import Script from 'next/script'; +import { useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router'; import { Tooltip } from 'react-tooltip'; -import { NavBar } from '../NavBar'; +import { NavBar } from '../navbar/navbar'; + +type HeaderProps = { + isUpdateAvailable: boolean; + isLoggedIn: boolean; + allowAutoThemes: boolean; +}; + +export const Header = (props: HeaderProps) => { + const setDarkMode = useUIStore((state) => state.setDarkMode); -interface IProps { - isUpdateAvailable?: boolean; - authenticated?: boolean; -} + const { isUpdateAvailable, allowAutoThemes, isLoggedIn } = props; -export const Header: React.FC = ({ isUpdateAvailable, authenticated = true }) => { - const { setDarkMode } = useUIStore(); - const t = useTranslations(); - const { allowAutoThemes = false } = useClientSettings(); + const { t } = useTranslation(); - const router = useRouter(); + const navigate = useNavigate(); - const logoutMutation = useAction(logoutAction, { + const logout = useMutation({ + ...logoutMutation(), onSuccess: () => { - router.push('/'); + navigate('/', { replace: true }); }, }); - const logHandler = () => { - if (authenticated) { - logoutMutation.execute(); - } else { - router.push('/login'); - } - }; - return (
-
Nicolas Meienberger
Nicolas Meienberger

💻 🚇 ⚠️ 📖
ArneNaessens
ArneNaessens

💻 🤔 ⚠️
DrMxrcy
DrMxrcy

💻 🤔 ⚠️ 🖋 📣 💬 👀
Nicolas Meienberger
Nicolas Meienberger

💻 🚇 ! 📖
ArneNaessens
ArneNaessens

💻 🤔 !
DrMxrcy
DrMxrcy

💻 🤔 ! 🖋 📣 💬 👀
Cooper
Cooper

💻
JTruj1ll0923
JTruj1ll0923

💻
Stetsed
Stetsed

💻
Nghia Lele
Nghia Lele

🌍
amusingimpala75
amusingimpala75

💻
David
David

🌍
Stavros
Stavros

🌍 💻 ⚠️ 📖
Stavros
Stavros

🌍 💻 ! 📖
loxiry
loxiry

🌍
JigSaw
JigSaw

💻
Armand Gillot
Armand Gillot

💻
Jaffo73
Jaffo73

⚠️ 💻
Jaffo73
Jaffo73

! 💻
Jorge Montejo
Jorge Montejo

💻
Frédéric Cilia
Frédéric Cilia

💻
Agustín Carrasco
Agustín Carrasco

💻