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
- Nicolas Meienberger 💻 🚇 ⚠️ 📖 |
- ArneNaessens 💻 🤔 ⚠️ |
- DrMxrcy 💻 🤔 ⚠️ 🖋 📣 💬 👀 |
+ Nicolas Meienberger 💻 🚇 ! 📖 |
+ ArneNaessens 💻 🤔 ! |
+ DrMxrcy 💻 🤔 ! 🖋 📣 💬 👀 |
Cooper 💻 |
JTruj1ll0923 💻 |
Stetsed 💻 |
@@ -131,7 +126,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Nghia Lele 🌍 |
amusingimpala75 💻 |
David 🌍 |
- Stavros 🌍 💻 ⚠️ 📖 |
+ Stavros 🌍 💻 ! 📖 |
loxiry 🌍 |
JigSaw 💻 |
@@ -146,7 +141,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Armand Gillot 💻 |
- Jaffo73 ⚠️ 💻 |
+ Jaffo73 ! 💻 |
Jorge Montejo 💻 |
Frédéric Cilia 💻 |
Agustín Carrasco 💻 |
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