Merge remote-tracking branch 'origin/main' into keynav_timeline

This commit is contained in:
Min Idzelis 2025-05-10 12:17:26 +00:00
commit ee202624f7
108 changed files with 5600 additions and 1490 deletions

118
.github/actions/image-build/action.yml vendored Normal file
View File

@ -0,0 +1,118 @@
name: 'Single arch image build'
description: 'Build single-arch image on platform appropriate runner'
inputs:
image:
description: 'Name of the image to build'
required: true
ghcr-token:
description: 'GitHub Container Registry token'
required: true
platform:
description: 'Platform to build for'
required: true
artifact-key-base:
description: 'Base key for artifact name'
required: true
context:
description: 'Path to build context'
required: true
dockerfile:
description: 'Path to Dockerfile'
required: true
build-args:
description: 'Docker build arguments'
required: false
runs:
using: 'composite'
steps:
- name: Prepare
id: prepare
shell: bash
env:
PLATFORM: ${{ inputs.platform }}
run: |
echo "platform-pair=${PLATFORM//\//-}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ inputs.ghcr-token }}
- name: Generate cache key suffix
id: cache-key-suffix
shell: bash
env:
REF: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "cache-key-suffix=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT
else
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "suffix=${SUFFIX}" >> $GITHUB_OUTPUT
fi
- name: Generate cache target
id: cache-target
shell: bash
env:
BUILD_ARGS: ${{ inputs.build-args }}
IMAGE: ${{ inputs.image }}
SUFFIX: ${{ steps.cache-key-suffix.outputs.suffix }}
PLATFORM_PAIR: ${{ steps.prepare.outputs.platform-pair }}
run: |
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
CACHE_KEY="${PLATFORM_PAIR}-${HASH}"
echo "cache-key-base=${CACHE_KEY}" >> $GITHUB_OUTPUT
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
# Essentially just ignore the cache output (forks can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,ref=${IMAGE}-build-cache:${CACHE_KEY}-${SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
- name: Build and push image
id: build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
cache-from: |
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-${{ steps.cache-key-suffix.outputs.suffix }}
type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-main
outputs: type=image,"name=${{ inputs.image }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: |
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.meta.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
${{ inputs.build-args }}
- name: Export digest
shell: bash
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ inputs.artifact-key-base }}-${{ steps.cache-target.outputs.cache-key-base }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1

View File

@ -40,6 +40,8 @@ jobs:
- 'machine-learning/**' - 'machine-learning/**'
workflow: workflow:
- '.github/workflows/docker.yml' - '.github/workflows/docker.yml'
- '.github/workflows/multi-runner-build.yml'
- '.github/actions/image-build'
- name: Check if we should force jobs to run - name: Check if we should force jobs to run
id: should_force id: should_force
@ -103,429 +105,74 @@ jobs:
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
build_and_push_ml: machine-learning:
name: Build and Push ML name: Build and Push ML
needs: pre-job needs: pre-job
permissions:
contents: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
runs-on: ${{ matrix.runner }}
env:
image: immich-machine-learning
context: machine-learning
file: machine-learning/Dockerfile
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
strategy: strategy:
# Prevent a failure in one image from stopping the other builds
fail-fast: false fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
device: cpu
- platform: linux/arm64
runner: ubuntu-24.04-arm
device: cpu
- platform: linux/amd64
runner: ubuntu-latest
device: cuda
suffix: -cuda
- platform: linux/amd64
runner: mich
device: rocm
suffix: -rocm
- platform: linux/amd64
runner: ubuntu-latest
device: openvino
suffix: -openvino
- platform: linux/arm64
runner: ubuntu-24.04-arm
device: armnn
suffix: -armnn
- platform: linux/arm64
runner: ubuntu-24.04-arm
device: rknn
suffix: -rknn
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix
env:
REF: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
else
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
fi
- name: Generate cache target
id: cache-target
run: |
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
# Essentially just ignore the cache output (forks can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${{ matrix.device }}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
- name: Build and push image
id: build
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: ${{ env.context }}
file: ${{ env.file }}
platforms: ${{ matrix.platforms }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
cache-from: |
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }}
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-main
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: |
DEVICE=${{ matrix.device }}
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
- name: Export digest
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ml-digests-${{ matrix.device }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge_ml:
name: Merge & Push ML
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
packages: write
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }}
env:
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
DOCKER_REPO: altran1502/immich-machine-learning
strategy:
matrix: matrix:
include: include:
- device: cpu - device: cpu
tag-suffix: ''
- device: cuda - device: cuda
suffix: -cuda tag-suffix: '-cuda'
- device: rocm platforms: linux/amd64
suffix: -rocm
- device: openvino - device: openvino
suffix: -openvino tag-suffix: '-openvino'
platforms: linux/amd64
- device: armnn - device: armnn
suffix: -armnn tag-suffix: '-armnn'
platforms: linux/arm64
- device: rknn - device: rknn
suffix: -rknn tag-suffix: '-rknn'
needs: platforms: linux/arm64
- build_and_push_ml - device: rocm
steps: tag-suffix: '-rocm'
- name: Download digests platforms: linux/amd64
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 runner-mapping: '{"linux/amd64": "mich"}'
with: uses: ./.github/workflows/multi-runner-build.yml
path: ${{ runner.temp }}/digests
pattern: ml-digests-${{ matrix.device }}-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ github.event_name == 'release' }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
flavor: |
# Disable latest tag
latest=false
suffix=${{ matrix.suffix }}
images: |
name=${{ env.GHCR_REPO }}
name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with pr-number
type=ref,event=pr
# Tag with long commit sha hash
type=sha,format=long,prefix=commit-
# Tag with git tag on release
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
# Process annotations
declare -a ANNOTATIONS=()
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
while IFS= read -r annotation; do
# Extract key and value by removing the manifest: prefix
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Use array to properly handle arguments with spaces
ANNOTATIONS+=(--annotation "index:$key=$value")
fi
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
build_and_push_server:
name: Build and Push Server
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
env:
image: immich-server
context: .
file: server/Dockerfile
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache key suffix
env:
REF: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
else
SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
fi
- name: Generate cache target
id: cache-target
run: |
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
# Essentially just ignore the cache output (forks can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
fi
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
- name: Build and push image
id: build
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: ${{ env.context }}
file: ${{ env.file }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
cache-from: |
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }}
type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-main
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
build-args: |
DEVICE=cpu
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}
- name: Export digest
run: | # zizmor: ignore[template-injection]
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge_server:
name: Merge & Push Server
runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
actions: read actions: read
packages: write packages: write
if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }} secrets:
env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKER_REPO: altran1502/immich-server with:
needs: image: immich-machine-learning
- build_and_push_server context: machine-learning
steps: dockerfile: machine-learning/Dockerfile
- name: Download digests platforms: ${{ matrix.platforms }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 runner-mapping: ${{ matrix.runner-mapping }}
with: tag-suffix: ${{ matrix.tag-suffix }}
path: ${{ runner.temp }}/digests dockerhub-push: ${{ github.event_name == 'release' }}
pattern: server-digests-* build-args: |
merge-multiple: true DEVICE=${{ matrix.device }}
- name: Login to Docker Hub server:
if: ${{ github.event_name == 'release' }} name: Build and Push Server
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 needs: pre-job
with: if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
username: ${{ secrets.DOCKERHUB_USERNAME }} uses: ./.github/workflows/multi-runner-build.yml
password: ${{ secrets.DOCKERHUB_TOKEN }} permissions:
contents: read
- name: Login to GHCR actions: read
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 packages: write
with: secrets:
registry: ghcr.io DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
username: ${{ github.repository_owner }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }} with:
image: immich-server
- name: Set up Docker Buildx context: .
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 dockerfile: server/Dockerfile
dockerhub-push: ${{ github.event_name == 'release' }}
- name: Generate docker image tags build-args: |
id: meta DEVICE=cpu
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
flavor: |
# Disable latest tag
latest=false
suffix=${{ matrix.suffix }}
images: |
name=${{ env.GHCR_REPO }}
name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with pr-number
type=ref,event=pr
# Tag with long commit sha hash
type=sha,format=long,prefix=commit-
# Tag with git tag on release
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
# Process annotations
declare -a ANNOTATIONS=()
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
while IFS= read -r annotation; do
# Extract key and value by removing the manifest: prefix
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Use array to properly handle arguments with spaces
ANNOTATIONS+=(--annotation "index:$key=$value")
fi
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
success-check-server: success-check-server:
name: Docker Build & Push Server Success name: Docker Build & Push Server Success
needs: [merge_server, retag_server] needs: [server, retag_server]
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
@ -540,7 +187,7 @@ jobs:
success-check-ml: success-check-ml:
name: Docker Build & Push ML Success name: Docker Build & Push ML Success
needs: [merge_ml, retag_ml] needs: [machine-learning, retag_ml]
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()

185
.github/workflows/multi-runner-build.yml vendored Normal file
View File

@ -0,0 +1,185 @@
name: 'Multi-runner container image build'
on:
workflow_call:
inputs:
image:
description: 'Name of the image'
type: string
required: true
context:
description: 'Path to build context'
type: string
required: true
dockerfile:
description: 'Path to Dockerfile'
type: string
required: true
tag-suffix:
description: 'Suffix to append to the image tag'
type: string
default: ''
dockerhub-push:
description: 'Push to Docker Hub'
type: boolean
default: false
build-args:
description: 'Docker build arguments'
type: string
required: false
platforms:
description: 'Platforms to build for'
type: string
runner-mapping:
description: 'Mapping from platforms to runners'
type: string
secrets:
DOCKERHUB_USERNAME:
required: false
DOCKERHUB_TOKEN:
required: false
env:
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}
DOCKERHUB_IMAGE: altran1502/${{ inputs.image }}
jobs:
matrix:
name: 'Generate matrix'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
key: ${{ steps.artifact-key.outputs.base }}
steps:
- name: Generate build matrix
id: matrix
shell: bash
env:
PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }}
RUNNER_MAPPING: ${{ inputs.runner-mapping || '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' }}
run: |
matrix=$(jq -R -c \
--argjson runner_mapping "${RUNNER_MAPPING}" \
'split(",") | map({platform: ., runner: $runner_mapping[.]})' \
<<< "${PLATFORMS}")
echo "${matrix}"
echo "matrix=${matrix}" >> $GITHUB_OUTPUT
- name: Determine artifact key
id: artifact-key
shell: bash
env:
IMAGE: ${{ inputs.image }}
SUFFIX: ${{ inputs.tag-suffix }}
run: |
if [[ -n "${SUFFIX}" ]]; then
base="${IMAGE}${SUFFIX}-digests"
else
base="${IMAGE}-digests"
fi
echo "${base}"
echo "base=${base}" >> $GITHUB_OUTPUT
build:
needs: matrix
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- uses: ./.github/actions/image-build
with:
context: ${{ inputs.context }}
dockerfile: ${{ inputs.dockerfile }}
image: ${{ env.GHCR_IMAGE }}
ghcr-token: ${{ secrets.GITHUB_TOKEN }}
platform: ${{ matrix.platform }}
artifact-key-base: ${{ needs.matrix.outputs.key }}
build-args: ${{ inputs.build-args }}
merge:
needs: [matrix, build]
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }}
permissions:
contents: read
actions: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
with:
path: ${{ runner.temp }}/digests
pattern: ${{ needs.matrix.outputs.key }}-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ inputs.dockerhub-push }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Generate docker image tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
flavor: |
# Disable latest tag
latest=false
suffix=${{ inputs.tag-suffix }}
images: |
name=${{ env.GHCR_IMAGE }}
name=${{ env.DOCKERHUB_IMAGE }},enable=${{ inputs.dockerhub-push }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with pr-number
type=ref,event=pr
# Tag with long commit sha hash
type=sha,format=long,prefix=commit-
# Tag with git tag on release
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
# Process annotations
declare -a ANNOTATIONS=()
if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
while IFS= read -r annotation; do
# Extract key and value by removing the manifest: prefix
if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Use array to properly handle arguments with spaces
ANNOTATIONS+=(--annotation "index:$key=$value")
fi
done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
fi
TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
SOURCE_ARGS=$(printf "${GHCR_IMAGE}@sha256:%s " *)
docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS

986
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -72,7 +72,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
### Nightly job ### Nightly job
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page. There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
## Usage ## Usage

View File

@ -92,7 +92,7 @@ Memory and execution time estimates were obtained without acceleration on a 7800
**Execution Time (ms)**: After warming up the model with one pass, the mean execution time of 100 passes with the same input. **Execution Time (ms)**: After warming up the model with one pass, the mean execution time of 100 passes with the same input.
**Memory (MiB)**: The peak RSS usage of the process afer performing the above timing benchmark. Does not include image decoding, concurrent processing, the web server, etc., which are relatively constant factors. **Memory (MiB)**: The peak RSS usage of the process after performing the above timing benchmark. Does not include image decoding, concurrent processing, the web server, etc., which are relatively constant factors.
**Recall (%)**: Evaluated on Crossmodal-3600, the average of the recall@1, recall@5 and recall@10 results for zeroshot image retrieval. Chinese (Simplified), English, French, German, Italian, Japanese, Korean, Polish, Russian, Spanish and Turkish are additionally tested on XTD-10. Chinese (Simplified) and English are additionally tested on Flickr30k. The recall metrics are the average across all tested datasets. **Recall (%)**: Evaluated on Crossmodal-3600, the average of the recall@1, recall@5 and recall@10 results for zeroshot image retrieval. Chinese (Simplified), English, French, German, Italian, Japanese, Korean, Polish, Russian, Spanish and Turkish are additionally tested on XTD-10. Chinese (Simplified) and English are additionally tested on Flickr30k. The recall metrics are the average across all tested datasets.

View File

@ -2,53 +2,13 @@
sidebar_position: 30 sidebar_position: 30
--- ---
import CodeBlock from '@theme/CodeBlock';
import ExampleEnv from '!!raw-loader!../../../docker/example.env';
# Docker Compose [Recommended] # Docker Compose [Recommended]
Docker Compose is the recommended method to run Immich in production. Below are the steps to deploy Immich with Docker Compose. Docker Compose is the recommended method to run Immich in production. Below are the steps to deploy Immich with Docker Compose.
## Step 1 - Download the required files import DockerComposeSteps from '/docs/partials/_docker-compose-install-steps.mdx';
Create a directory of your choice (e.g. `./immich-app`) to hold the `docker-compose.yml` and `.env` files. <DockerComposeSteps />
```bash title="Move to the directory you created"
mkdir ./immich-app
cd ./immich-app
```
Download [`docker-compose.yml`][compose-file] and [`example.env`][env-file] by running the following commands:
```bash title="Get docker-compose.yml file"
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
```
```bash title="Get .env file"
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
```
You can alternatively download these two files from your browser and move them to the directory that you created, in which case ensure that you rename `example.env` to `.env`.
## Step 2 - Populate the .env file with custom values
<CodeBlock language="bash" title="Default environmental variable content">
{ExampleEnv}
</CodeBlock>
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space.
- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication.
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this.
- Set your timezone by uncommenting the `TZ=` line.
- Populate custom database information if necessary.
## Step 3 - Start the containers
From the directory you created in Step 1 (which should now contain your customized `docker-compose.yml` and `.env` files), run the following command to start Immich as a background service:
```bash title="Start the containers"
docker compose up -d
```
:::info Docker version :::info Docker version
If you get an error such as `unknown shorthand flag: 'd' in -d` or `open <location of your .env file>: permission denied`, you are probably running the wrong Docker version. (This happens, for example, with the docker.io package in Ubuntu 22.04.3 LTS.) You can correct the problem by following the complete [Docker Engine install](https://docs.docker.com/engine/install/) procedure for your distribution, crucially the "Uninstall old versions" and "Install using the apt/rpm repository" sections. These replace the distro's Docker packages with Docker's official ones. If you get an error such as `unknown shorthand flag: 'd' in -d` or `open <location of your .env file>: permission denied`, you are probably running the wrong Docker version. (This happens, for example, with the docker.io package in Ubuntu 22.04.3 LTS.) You can correct the problem by following the complete [Docker Engine install](https://docs.docker.com/engine/install/) procedure for your distribution, crucially the "Uninstall old versions" and "Install using the apt/rpm repository" sections. These replace the distro's Docker packages with Docker's official ones.
@ -70,6 +30,3 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc
## Next Steps ## Next Steps
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md). Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env

View File

@ -2,7 +2,7 @@
sidebar_position: 80 sidebar_position: 80
--- ---
# TrueNAS SCALE [Community] # TrueNAS [Community]
:::note :::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience. This is a community contribution and not officially supported by the Immich team, but included here for convenience.
@ -12,17 +12,17 @@ Community support can be found in the dedicated channel on the [Discord Server](
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** **Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
::: :::
Immich can easily be installed on TrueNAS SCALE via the **Community** train application. Immich can easily be installed on TrueNAS Community Edition via the **Community** train application.
Consider reviewing the TrueNAS [Apps tutorial](https://www.truenas.com/docs/scale/scaletutorials/apps/) if you have not previously configured applications on your system. Consider reviewing the TrueNAS [Apps resources](https://apps.truenas.com/getting-started/) if you have not previously configured applications on your system.
TrueNAS SCALE makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries. TrueNAS Community Edition makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries.
## First Steps ## First Steps
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal. The Immich app in TrueNAS Community Edition installs, completes the initial configuration, then starts the Immich web portal.
When updates become available, SCALE alerts and provides easy updates. When updates become available, TrueNAS alerts and provides easy updates.
Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation. Before installing the Immich app in TrueNAS, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
You may also configure environment variables at any time after deploying the application. You may also configure environment variables at any time after deploying the application.
### Setting up Storage Datasets ### Setting up Storage Datasets
@ -126,9 +126,9 @@ className="border rounded-xl"
Accept the default port `30041` in **WebUI Port** or enter a custom port number. Accept the default port `30041` in **WebUI Port** or enter a custom port number.
:::info Allowed Port Numbers :::info Allowed Port Numbers
Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel. Only numbers within the range 9000-65535 may be used on TrueNAS versions below TrueNAS Community Edition 24.10 Electric Eel.
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/). Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/solutions/optimizations/security/#truenas-default-ports).
::: :::
### Storage Configuration ### Storage Configuration
@ -173,7 +173,7 @@ className="border rounded-xl"
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**. You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich. The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located. The **Host Path** is the location on the TrueNAS Community Edition server where your external library is located.
<!-- A section for Labels would go here but I don't know what they do. --> <!-- A section for Labels would go here but I don't know what they do. -->
@ -188,17 +188,17 @@ className="border rounded-xl"
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core). Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB. Specify the **Memory** limit in MB of RAM. Immich recommends at least 6000 MB (6GB). If you selected **Enable Machine Learning** in **Immich Configuration**, you should probably set this above 8000 MB.
:::info Older SCALE Versions :::info Older TrueNAS Versions
Before TrueNAS SCALE version 24.10 Electric Eel: Before TrueNAS Community Edition version 24.10 Electric Eel:
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads. The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000` The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
::: :::
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://apps.truenas.com/managing-apps/installing-apps/#gpu-passthrough)
### Install ### Install
@ -240,7 +240,7 @@ className="border rounded-xl"
/> />
:::info :::info
Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings). Some Environment Variables are not available for the TrueNAS Community Edition app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`. Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
::: :::
@ -251,7 +251,7 @@ Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`
Make sure to read the general [upgrade instructions](/docs/install/upgrading.md). Make sure to read the general [upgrade instructions](/docs/install/upgrading.md).
::: :::
When updates become available, SCALE alerts and provides easy updates. When updates become available, TrueNAS alerts and provides easy updates.
To update the app to the latest version: To update the app to the latest version:
- Go to the **Installed Applications** screen and select Immich from the list of installed applications. - Go to the **Installed Applications** screen and select Immich from the list of installed applications.

View File

@ -1,5 +1,5 @@
--- ---
sidebar_position: 2 sidebar_position: 3
--- ---
# Comparison # Comparison

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

@ -1,5 +1,5 @@
--- ---
sidebar_position: 3 sidebar_position: 2
--- ---
# Quick start # Quick start
@ -10,11 +10,20 @@ to install and use it.
## Requirements ## Requirements
Check the [requirements page](/docs/install/requirements) to get started. - A system with at least 4GB of RAM and 2 CPU cores.
- [Docker](https://docs.docker.com/engine/install/)
> For a more detailed list of requirements, see the [requirements page](/docs/install/requirements).
---
## Set up the server ## Set up the server
Follow the [Docker Compose (Recommended)](/docs/install/docker-compose) instructions to install the server. import DockerComposeSteps from '/docs/partials/_docker-compose-install-steps.mdx';
<DockerComposeSteps />
---
## Try the web app ## Try the web app
@ -26,6 +35,8 @@ Try uploading a picture from your browser.
<img src={require('./img/upload-button.webp').default} title="Upload button" /> <img src={require('./img/upload-button.webp').default} title="Upload button" />
---
## Try the mobile app ## Try the mobile app
### Download the Mobile App ### Download the Mobile App
@ -56,6 +67,8 @@ You can select the **Jobs** tab to see Immich processing your photos.
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} /> <img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} />
---
## Review the database backup and restore process ## Review the database backup and restore process
Immich has built-in database backups. You can refer to the Immich has built-in database backups. You can refer to the
@ -65,6 +78,8 @@ Immich has built-in database backups. You can refer to the
The database only contains metadata and user information. You must setup manual backups of the images and videos stored in `UPLOAD_LOCATION`. The database only contains metadata and user information. You must setup manual backups of the images and videos stored in `UPLOAD_LOCATION`.
::: :::
---
## Where to go from here? ## Where to go from here?
You may decide you'd like to install the server a different way; the Install category on the left menu provides many options. You may decide you'd like to install the server a different way; the Install category on the left menu provides many options.

View File

@ -2,9 +2,13 @@
sidebar_position: 1 sidebar_position: 1
--- ---
# Introduction # Welcome to Immich
<img src={require('./img/feature-panel.webp').default} alt="Immich - Self-hosted photos and videos backup tool" /> <img
src={require('./img/social-preview-light.webp').default}
alt="Immich - Self-hosted photos and videos backup tool"
data-theme="light"
/>
## Welcome! ## Welcome!

View File

@ -0,0 +1,43 @@
import CodeBlock from '@theme/CodeBlock';
import ExampleEnv from '!!raw-loader!../../../docker/example.env';
### Step 1 - Download the required files
Create a directory of your choice (e.g. `./immich-app`) to hold the `docker-compose.yml` and `.env` files.
```bash title="Move to the directory you created"
mkdir ./immich-app
cd ./immich-app
```
Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) by running the following commands:
```bash title="Get docker-compose.yml file"
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
```
```bash title="Get .env file"
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
```
You can alternatively download these two files from your browser and move them to the directory that you created, in which case ensure that you rename `example.env` to `.env`.
### Step 2 - Populate the .env file with custom values
<CodeBlock language="bash" title="Default environmental variable content">
{ExampleEnv}
</CodeBlock>
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space.
- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication.
To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this.
- Set your timezone by uncommenting the `TZ=` line.
- Populate custom database information if necessary.
### Step 3 - Start the containers
From the directory you created in Step 1 (which should now contain your customized `docker-compose.yml` and `.env` files), run the following command to start Immich as a background service:
```bash title="Start the containers"
docker compose up -d
```

View File

@ -95,7 +95,7 @@ const config = {
position: 'right', position: 'right',
}, },
{ {
to: '/docs/overview/introduction', to: '/docs/overview/welcome',
position: 'right', position: 'right',
label: 'Docs', label: 'Docs',
}, },
@ -124,6 +124,12 @@ const config = {
label: 'Discord', label: 'Discord',
position: 'right', position: 'right',
}, },
{
type: 'html',
position: 'right',
value:
'<a href="https://buy.immich.app" target="_blank" class="no-underline hover:no-underline"><button class="buy-button bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-black rounded-xl">Buy Immich</button></a>',
},
], ],
}, },
footer: { footer: {
@ -134,7 +140,7 @@ const config = {
items: [ items: [
{ {
label: 'Welcome', label: 'Welcome',
to: '/docs/overview/introduction', to: '/docs/overview/welcome',
}, },
{ {
label: 'Installation', label: 'Installation',

View File

@ -7,14 +7,22 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); @font-face {
font-family: 'Overpass';
body { src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
font-family: 'Be Vietnam Pro', serif; font-weight: 1 999;
font-optical-sizing: auto; font-style: normal;
/* font-size: 1.125rem;
ascent-override: 106.25%; ascent-override: 106.25%;
size-adjust: 106.25%; */ size-adjust: 106.25%;
}
@font-face {
font-family: 'Overpass Mono';
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
font-weight: 1 999;
font-style: normal;
ascent-override: 106.25%;
size-adjust: 106.25%;
} }
.breadcrumbs__link { .breadcrumbs__link {
@ -29,6 +37,7 @@ img {
/* You can override the default Infima variables here. */ /* You can override the default Infima variables here. */
:root { :root {
font-family: 'Overpass', sans-serif;
--ifm-color-primary: #4250af; --ifm-color-primary: #4250af;
--ifm-color-primary-dark: #4250af; --ifm-color-primary-dark: #4250af;
--ifm-color-primary-darker: #4250af; --ifm-color-primary-darker: #4250af;
@ -59,14 +68,12 @@ div[class^='announcementBar_'] {
} }
.menu__link { .menu__link {
padding: 10px; padding: 10px 10px 10px 16px;
padding-left: 16px;
border-radius: 24px; border-radius: 24px;
margin-right: 16px; margin-right: 16px;
} }
.menu__list-item-collapsible { .menu__list-item-collapsible {
border-radius: 10px;
margin-right: 16px; margin-right: 16px;
border-radius: 24px; border-radius: 24px;
} }
@ -83,3 +90,12 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
code { code {
font-weight: 600; font-weight: 600;
} }
.buy-button {
padding: 8px 14px;
border: 1px solid transparent;
font-family: 'Overpass', sans-serif;
font-weight: 500;
cursor: pointer;
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
}

View File

@ -4,7 +4,7 @@ import Layout from '@theme/Layout';
import { discordPath, discordViewBox } from '@site/src/components/svg-paths'; import { discordPath, discordViewBox } from '@site/src/components/svg-paths';
import ThemedImage from '@theme/ThemedImage'; import ThemedImage from '@theme/ThemedImage';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiAndroid } from '@mdi/js';
function HomepageHeader() { function HomepageHeader() {
return ( return (
<header> <header>
@ -13,11 +13,14 @@ function HomepageHeader() {
<div className="w-full h-[120vh] absolute left-0 top-0 backdrop-blur-3xl bg-immich-bg/40 dark:bg-transparent"></div> <div className="w-full h-[120vh] absolute left-0 top-0 backdrop-blur-3xl bg-immich-bg/40 dark:bg-transparent"></div>
</div> </div>
<section className="text-center pt-12 sm:pt-24 bg-immich-bg/50 dark:bg-immich-dark-bg/80"> <section className="text-center pt-12 sm:pt-24 bg-immich-bg/50 dark:bg-immich-dark-bg/80">
<ThemedImage <a href="https://futo.org" target="_blank" rel="noopener noreferrer">
sources={{ dark: 'img/logomark-dark.svg', light: 'img/logomark-light.svg' }} <ThemedImage
className="h-[115px] w-[115px] mb-2 antialiased rounded-none" sources={{ dark: 'img/logomark-dark-with-futo.svg', light: 'img/logomark-light-with-futo.svg' }}
alt="Immich logo" className="h-[125px] w-[125px] mb-2 antialiased rounded-none"
/> alt="Immich logo"
/>
</a>
<div className="mt-8"> <div className="mt-8">
<p className="text-3xl md:text-5xl sm:leading-tight mb-1 font-extrabold text-black/90 dark:text-white px-4"> <p className="text-3xl md:text-5xl sm:leading-tight mb-1 font-extrabold text-black/90 dark:text-white px-4">
Self-hosted{' '} Self-hosted{' '}
@ -28,7 +31,7 @@ function HomepageHeader() {
solution<span className="block"></span> solution<span className="block"></span>
</p> </p>
<p className="max-w-1/4 m-auto mt-4 px-4"> <p className="max-w-1/4 m-auto mt-4 px-4 text-lg text-gray-700 dark:text-gray-100">
Easily back up, organize, and manage your photos on your own server. Immich helps you Easily back up, organize, and manage your photos on your own server. Immich helps you
<span className="sm:block"></span> browse, search and organize your photos and videos with ease, without <span className="sm:block"></span> browse, search and organize your photos and videos with ease, without
sacrificing your privacy. sacrificing your privacy.
@ -36,27 +39,21 @@ function HomepageHeader() {
</div> </div>
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 gap-4 "> <div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 gap-4 ">
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-xl no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase" className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-xl no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold"
to="docs/overview/introduction" to="docs/overview/quick-start"
> >
Get started Get Started
</Link> </Link>
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase" className="flex place-items-center place-content-center py-3 px-8 border bg-white/90 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold"
to="https://demo.immich.app/" to="https://demo.immich.app/"
> >
Demo Open Demo
</Link>
<Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
to="https://immich.store"
>
Buy Merch
</Link> </Link>
</div> </div>
<div className="my-12 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">
<div className="my-8 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">
<Icon <Icon
path={discordPath} path={discordPath}
viewBox={discordViewBox} /* viewBox may show an error in your IDE but it is normal. */ viewBox={discordViewBox} /* viewBox may show an error in your IDE but it is normal. */
@ -119,7 +116,7 @@ export default function Home(): JSX.Element {
<HomepageHeader /> <HomepageHeader />
<div className="flex flex-col place-items-center text-center place-content-center dark:bg-immich-dark-bg py-8"> <div className="flex flex-col place-items-center text-center place-content-center dark:bg-immich-dark-bg py-8">
<p>This project is available under GNU AGPL v3 license.</p> <p>This project is available under GNU AGPL v3 license.</p>
<p className="text-xs">Privacy should not be a luxury</p> <p className="text-sm">Privacy should not be a luxury</p>
</div> </div>
</Layout> </Layout>
); );

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
function HomepageHeader() { function HomepageHeader() {
return ( return (

View File

@ -30,3 +30,4 @@
/docs/guides/api-album-sync /docs/community-projects 307 /docs/guides/api-album-sync /docs/community-projects 307
/docs/guides/remove-offline-files /docs/community-projects 307 /docs/guides/remove-offline-files /docs/community-projects 307
/milestones /roadmap 307 /milestones /roadmap 307
/docs/overview/introduction /docs/overview/welcome 307

Binary file not shown.

BIN
docs/static/fonts/overpass/Overpass.ttf vendored Normal file

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 75 KiB

587
e2e/package-lock.json generated
View File

@ -736,9 +736,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.25.1", "version": "9.26.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz",
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -999,6 +999,28 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz",
"integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2207,6 +2229,75 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/body-parser/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/body-parser/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -2596,6 +2687,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cookiejar": { "node_modules/cookiejar": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
@ -2631,6 +2742,20 @@
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
} }
}, },
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3009,9 +3134,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.25.1", "version": "9.26.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz",
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3021,11 +3146,12 @@
"@eslint/config-helpers": "^0.2.1", "@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.13.0", "@eslint/core": "^0.13.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.25.1", "@eslint/js": "9.26.0",
"@eslint/plugin-kit": "^0.2.8", "@eslint/plugin-kit": "^0.2.8",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
"@modelcontextprotocol/sdk": "^1.8.0",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4", "ajv": "^6.12.4",
@ -3049,7 +3175,8 @@
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3",
"zod": "^3.24.2"
}, },
"bin": { "bin": {
"eslint": "bin/eslint.js" "eslint": "bin/eslint.js"
@ -3083,9 +3210,9 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.6", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.3.1.tgz",
"integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "integrity": "sha512-vad9VWgEm9xaVXRNmb4aeOt0PWDc61IAdzghkbYQ2wavgax148iKoX1rNJcgkBGCipzLzOnHYVgL7xudM9yccQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3277,6 +3404,39 @@
"url": "https://github.com/eta-dev/eta?sponsor=1" "url": "https://github.com/eta-dev/eta?sponsor=1"
} }
}, },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
"integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.8.0", "version": "28.8.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
@ -3327,6 +3487,170 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": "^4.11 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/express/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3428,6 +3752,34 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/finalhandler/node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -3537,6 +3889,16 @@
"url": "https://ko-fi.com/tunnckoCore/commissions" "url": "https://ko-fi.com/tunnckoCore/commissions"
} }
}, },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -4160,6 +4522,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-builtin-module": { "node_modules/is-builtin-module": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz",
@ -4238,6 +4610,13 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -4646,6 +5025,19 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5410,6 +5802,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pkce-challenge": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
@ -5616,6 +6018,20 @@
} }
} }
}, },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -5676,6 +6092,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
@ -5904,6 +6330,33 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -5987,6 +6440,98 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/serve-static/node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -7383,6 +7928,26 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
} }
} }
} }

View File

@ -1,4 +1,16 @@
{ {
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"current_pin_code": "Current PIN code",
"new_pin_code": "New PIN code",
"setup_pin_code": "Setup a PIN code",
"confirm_new_pin_code": "Confirm new PIN code",
"unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code",
"pin_code_changed_successfully": "Successfully changed PIN code",
"pin_code_setup_successfully": "Successfully setup a PIN code",
"pin_code_reset_successfully": "Successfully reset PIN code",
"reset_pin_code": "Reset PIN code",
"about": "About", "about": "About",
"account": "Account", "account": "Account",
"account_settings": "Account Settings", "account_settings": "Account Settings",
@ -53,6 +65,7 @@
"confirm_email_below": "To confirm, type \"{email}\" below", "confirm_email_below": "To confirm, type \"{email}\" below",
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
"create_job": "Create job", "create_job": "Create job",
"cron_expression": "Cron expression", "cron_expression": "Cron expression",
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>", "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@ -922,6 +935,7 @@
"unable_to_remove_reaction": "Unable to remove reaction", "unable_to_remove_reaction": "Unable to remove reaction",
"unable_to_repair_items": "Unable to repair items", "unable_to_repair_items": "Unable to repair items",
"unable_to_reset_password": "Unable to reset password", "unable_to_reset_password": "Unable to reset password",
"unable_to_reset_pin_code": "Unable to reset PIN code",
"unable_to_resolve_duplicate": "Unable to resolve duplicate", "unable_to_resolve_duplicate": "Unable to resolve duplicate",
"unable_to_restore_assets": "Unable to restore assets", "unable_to_restore_assets": "Unable to restore assets",
"unable_to_restore_trash": "Unable to restore trash", "unable_to_restore_trash": "Unable to restore trash",

View File

@ -53,7 +53,7 @@ def init_rknn(model_path: str) -> "RKNNLite":
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms. ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
if ret != 0: if ret != 0:
raise RuntimeError("Failed to inititalize RKNN runtime environment") raise RuntimeError("Failed to initialize RKNN runtime environment")
return rknn_lite return rknn_lite

View File

@ -0,0 +1,146 @@
package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
/**
* Android plugin for Dart `HttpSSLOptions`
*/
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, null)
km = keyManagerFactory.keyManagers
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
result.success(true)
}
else -> result.notImplemented()
}
} catch (e: Throwable) {
result.error("error", e.message, null)
}
}
@SuppressLint("CustomX509TrustManager")
class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() {
private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager()
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
defaultTrustManager.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
if (serverHost == null) return
defaultTrustManager.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) {
if (serverHost == null) return
val socketAddress = socket?.remoteSocketAddress
if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, socket)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) {
if (serverHost == null || engine?.peerHost == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, engine)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
}
}
class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier {
companion object {
private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
}
override fun verify(hostname: String?, session: SSLSession?): Boolean {
if (serverHost == null || hostname == serverHost) {
return true
} else {
return _defaultHostnameVerifier.verify(hostname, session)
}
}
}
}

View File

@ -8,6 +8,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
// No need to set up method channel here as it's now handled in the plugin // No need to set up method channel here as it's now handled in the plugin
} }
} }

View File

@ -6,8 +6,8 @@ const Map<String, Locale> locales = {
// Additional locales // Additional locales
'Arabic (ar)': Locale('ar'), 'Arabic (ar)': Locale('ar'),
'Catalan (ca)': Locale('ca'), 'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale('zh', 'SIMPLIFIED'), 'Chinese Simplified (zh_CN)': Locale('zh', 'CN'),
'Chinese Traditional (zh_TW)': Locale('zh', 'Hant'), 'Chinese Traditional (zh_TW)': Locale('zh', 'TW'),
'Czech (cs)': Locale('cs'), 'Czech (cs)': Locale('cs'),
'Danish (da)': Locale('da'), 'Danish (da)': Locale('da'),
'Dutch (nl)': Locale('nl'), 'Dutch (nl)': Locale('nl'),
@ -31,8 +31,10 @@ const Map<String, Locale> locales = {
'Portuguese (pt)': Locale('pt'), 'Portuguese (pt)': Locale('pt'),
'Romanian (ro)': Locale('ro'), 'Romanian (ro)': Locale('ro'),
'Russian (ru)': Locale('ru'), 'Russian (ru)': Locale('ru'),
'Serbian Cyrillic (sr_Cyrl)': Locale('sr', 'Cyrl'), 'Serbian Cyrillic (sr_Cyrl)':
'Serbian Latin (sr_Latn)': Locale('sr', 'Latn'), Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'),
'Serbian Latin (sr_Latn)':
Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
'Slovak (sk)': Locale('sk'), 'Slovak (sk)': Locale('sk'),
'Slovenian (sl)': Locale('sl'), 'Slovenian (sl)': Locale('sl'),
'Spanish (es)': Locale('es'), 'Spanish (es)': Locale('es'),

View File

@ -27,7 +27,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -42,7 +42,7 @@ void main() async {
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true); await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(db); await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride(); HttpSSLOptions.apply();
runApp( runApp(
ProviderScope( ProviderScope(

View File

@ -32,7 +32,7 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@ -359,7 +359,7 @@ class BackgroundService {
], ],
); );
HttpOverrides.global = HttpSSLCertOverride(); HttpSSLOptions.apply();
ref ref
.read(apiServiceProvider) .read(apiServiceProvider)
.setAccessToken(Store.get(StoreKey.accessToken)); .setAccessToken(Store.get(StoreKey.accessToken));

View File

@ -1,16 +1,20 @@
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides { class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride"); static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert; final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert; late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() { HttpSSLCertOverride(
this._allowSelfSignedSSLCert,
this._serverHost,
this._clientCert,
) {
if (_clientCert != null) { if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true); _ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) { if (_ctxWithCert != null) {
@ -47,28 +51,15 @@ class HttpSSLCertOverride extends HttpOverrides {
return super.createHttpClient(context) return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) { ..badCertificateCallback = (X509Certificate cert, String host, int port) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; if (_allowSelfSignedSSLCert) {
// Conduct server host checks if user is logged in to avoid making
// Check if user has allowed self signed SSL certificates. // insecure SSL connections to services that are not the immich server.
bool selfSignedCertsAllowed = if (_serverHost == null || _serverHost.contains(host)) {
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue); return true;
}
bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null;
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (isLoggedIn && selfSignedCertsAllowed) {
String serverHost =
Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
selfSignedCertsAllowed &= serverHost.contains(host);
} }
_log.severe("Invalid SSL certificate for $host:$port");
if (!selfSignedCertsAllowed) { return false;
_log.severe("Invalid SSL certificate for $host:$port");
}
return selfSignedCertsAllowed;
}; };
} }
} }

View File

@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class HttpSSLOptions {
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
static void apply() {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert =
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert);
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
}
static void _apply(bool allowSelfSignedSSLCert) {
String? serverHost;
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
HttpOverrides.global =
HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
if (Platform.isAndroid) {
_channel.invokeMethod("apply", [
allowSelfSignedSSLCert,
serverHost,
clientCert?.data,
clientCert?.password,
]).onError<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
}
}
}

View File

@ -10,7 +10,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@ -104,7 +104,7 @@ class AdvancedSettings extends HookConsumerWidget {
valueNotifier: allowSelfSignedSSLCert, valueNotifier: allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(), title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(), onChanged: HttpSSLOptions.applyFromSettings,
), ),
const CustomeProxyHeaderSettings(), const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
class SslClientCertSettings extends StatefulWidget { class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn}); const SslClientCertSettings({super.key, required this.isLoggedIn});
@ -103,7 +104,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
return; return;
} }
cert.save(); cert.save();
HttpOverrides.global = HttpSSLCertOverride(); HttpSSLOptions.apply();
setState( setState(
() => isCertExist = true, () => isCertExist = true,
); );
@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
void removeCert(BuildContext context) { void removeCert(BuildContext context) {
SSLClientCertStoreVal.delete(); SSLClientCertStoreVal.delete();
HttpOverrides.global = HttpSSLCertOverride(); HttpSSLOptions.apply();
setState( setState(
() => isCertExist = false, () => isCertExist = false,
); );

View File

@ -109,8 +109,12 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code |
*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
@ -304,6 +308,7 @@ Class | Method | HTTP request | Description
- [AssetTypeEnum](doc//AssetTypeEnum.md) - [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AssetVisibility](doc//AssetVisibility.md) - [AssetVisibility](doc//AssetVisibility.md)
- [AudioCodec](doc//AudioCodec.md) - [AudioCodec](doc//AudioCodec.md)
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
- [AvatarUpdate](doc//AvatarUpdate.md) - [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md) - [BulkIdsDto](doc//BulkIdsDto.md)
@ -383,6 +388,8 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [PinCodeChangeDto](doc//PinCodeChangeDto.md)
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md) - [PurchaseUpdate](doc//PurchaseUpdate.md)

View File

@ -108,6 +108,7 @@ part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart'; part 'model/asset_type_enum.dart';
part 'model/asset_visibility.dart'; part 'model/asset_visibility.dart';
part 'model/audio_codec.dart'; part 'model/audio_codec.dart';
part 'model/auth_status_response_dto.dart';
part 'model/avatar_update.dart'; part 'model/avatar_update.dart';
part 'model/bulk_id_response_dto.dart'; part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart'; part 'model/bulk_ids_dto.dart';
@ -187,6 +188,8 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart'; part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart'; part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart'; part 'model/person_with_faces_response_dto.dart';
part 'model/pin_code_change_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart'; part 'model/places_response_dto.dart';
part 'model/purchase_response.dart'; part 'model/purchase_response.dart';
part 'model/purchase_update.dart'; part 'model/purchase_update.dart';

View File

@ -63,6 +63,86 @@ class AuthenticationApi {
return null; return null;
} }
/// Performs an HTTP 'PUT /auth/pin-code' operation and returns the [Response].
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<Response> changePinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/pin-code';
// ignore: prefer_final_locals
Object? postBody = pinCodeChangeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<void> changePinCode(PinCodeChangeDto pinCodeChangeDto,) async {
final response = await changePinCodeWithHttpInfo(pinCodeChangeDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /auth/status' operation and returns the [Response].
Future<Response> getAuthStatusWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/status';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AuthStatusResponseDto?> getAuthStatus() async {
final response = await getAuthStatusWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuthStatusResponseDto',) as AuthStatusResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
@ -151,6 +231,84 @@ class AuthenticationApi {
return null; return null;
} }
/// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response].
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<Response> resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/pin-code';
// ignore: prefer_final_locals
Object? postBody = pinCodeChangeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<void> resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async {
final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /auth/pin-code' operation and returns the [Response].
/// Parameters:
///
/// * [PinCodeSetupDto] pinCodeSetupDto (required):
Future<Response> setupPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/pin-code';
// ignore: prefer_final_locals
Object? postBody = pinCodeSetupDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [PinCodeSetupDto] pinCodeSetupDto (required):
Future<void> setupPinCode(PinCodeSetupDto pinCodeSetupDto,) async {
final response = await setupPinCodeWithHttpInfo(pinCodeSetupDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -272,6 +272,8 @@ class ApiClient {
return AssetVisibilityTypeTransformer().decode(value); return AssetVisibilityTypeTransformer().decode(value);
case 'AudioCodec': case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value); return AudioCodecTypeTransformer().decode(value);
case 'AuthStatusResponseDto':
return AuthStatusResponseDto.fromJson(value);
case 'AvatarUpdate': case 'AvatarUpdate':
return AvatarUpdate.fromJson(value); return AvatarUpdate.fromJson(value);
case 'BulkIdResponseDto': case 'BulkIdResponseDto':
@ -430,6 +432,10 @@ class ApiClient {
return PersonUpdateDto.fromJson(value); return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto': case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value); return PersonWithFacesResponseDto.fromJson(value);
case 'PinCodeChangeDto':
return PinCodeChangeDto.fromJson(value);
case 'PinCodeSetupDto':
return PinCodeSetupDto.fromJson(value);
case 'PlacesResponseDto': case 'PlacesResponseDto':
return PlacesResponseDto.fromJson(value); return PlacesResponseDto.fromJson(value);
case 'PurchaseResponse': case 'PurchaseResponse':

View File

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuthStatusResponseDto {
/// Returns a new [AuthStatusResponseDto] instance.
AuthStatusResponseDto({
required this.password,
required this.pinCode,
});
bool password;
bool pinCode;
@override
bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
other.password == password &&
other.pinCode == pinCode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(password.hashCode) +
(pinCode.hashCode);
@override
String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'password'] = this.password;
json[r'pinCode'] = this.pinCode;
return json;
}
/// Returns a new [AuthStatusResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AuthStatusResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AuthStatusResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AuthStatusResponseDto(
password: mapValueOfType<bool>(json, r'password')!,
pinCode: mapValueOfType<bool>(json, r'pinCode')!,
);
}
return null;
}
static List<AuthStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuthStatusResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AuthStatusResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AuthStatusResponseDto> mapFromJson(dynamic json) {
final map = <String, AuthStatusResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AuthStatusResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AuthStatusResponseDto-objects as value to a dart map
static Map<String, List<AuthStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuthStatusResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AuthStatusResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'password',
'pinCode',
};
}

View File

@ -0,0 +1,133 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PinCodeChangeDto {
/// Returns a new [PinCodeChangeDto] instance.
PinCodeChangeDto({
required this.newPinCode,
this.password,
this.pinCode,
});
String newPinCode;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? password;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? pinCode;
@override
bool operator ==(Object other) => identical(this, other) || other is PinCodeChangeDto &&
other.newPinCode == newPinCode &&
other.password == password &&
other.pinCode == pinCode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(newPinCode.hashCode) +
(password == null ? 0 : password!.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode);
@override
String toString() => 'PinCodeChangeDto[newPinCode=$newPinCode, password=$password, pinCode=$pinCode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'newPinCode'] = this.newPinCode;
if (this.password != null) {
json[r'password'] = this.password;
} else {
// json[r'password'] = null;
}
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
return json;
}
/// Returns a new [PinCodeChangeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PinCodeChangeDto? fromJson(dynamic value) {
upgradeDto(value, "PinCodeChangeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PinCodeChangeDto(
newPinCode: mapValueOfType<String>(json, r'newPinCode')!,
password: mapValueOfType<String>(json, r'password'),
pinCode: mapValueOfType<String>(json, r'pinCode'),
);
}
return null;
}
static List<PinCodeChangeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PinCodeChangeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PinCodeChangeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PinCodeChangeDto> mapFromJson(dynamic json) {
final map = <String, PinCodeChangeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PinCodeChangeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PinCodeChangeDto-objects as value to a dart map
static Map<String, List<PinCodeChangeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PinCodeChangeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PinCodeChangeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'newPinCode',
};
}

View File

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PinCodeSetupDto {
/// Returns a new [PinCodeSetupDto] instance.
PinCodeSetupDto({
required this.pinCode,
});
String pinCode;
@override
bool operator ==(Object other) => identical(this, other) || other is PinCodeSetupDto &&
other.pinCode == pinCode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(pinCode.hashCode);
@override
String toString() => 'PinCodeSetupDto[pinCode=$pinCode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'pinCode'] = this.pinCode;
return json;
}
/// Returns a new [PinCodeSetupDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PinCodeSetupDto? fromJson(dynamic value) {
upgradeDto(value, "PinCodeSetupDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PinCodeSetupDto(
pinCode: mapValueOfType<String>(json, r'pinCode')!,
);
}
return null;
}
static List<PinCodeSetupDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PinCodeSetupDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PinCodeSetupDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PinCodeSetupDto> mapFromJson(dynamic json) {
final map = <String, PinCodeSetupDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PinCodeSetupDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PinCodeSetupDto-objects as value to a dart map
static Map<String, List<PinCodeSetupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PinCodeSetupDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PinCodeSetupDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pinCode',
};
}

View File

@ -17,6 +17,7 @@ class UserAdminUpdateDto {
this.email, this.email,
this.name, this.name,
this.password, this.password,
this.pinCode,
this.quotaSizeInBytes, this.quotaSizeInBytes,
this.shouldChangePassword, this.shouldChangePassword,
this.storageLabel, this.storageLabel,
@ -48,6 +49,8 @@ class UserAdminUpdateDto {
/// ///
String? password; String? password;
String? pinCode;
/// Minimum value: 0 /// Minimum value: 0
int? quotaSizeInBytes; int? quotaSizeInBytes;
@ -67,6 +70,7 @@ class UserAdminUpdateDto {
other.email == email && other.email == email &&
other.name == name && other.name == name &&
other.password == password && other.password == password &&
other.pinCode == pinCode &&
other.quotaSizeInBytes == quotaSizeInBytes && other.quotaSizeInBytes == quotaSizeInBytes &&
other.shouldChangePassword == shouldChangePassword && other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel; other.storageLabel == storageLabel;
@ -78,12 +82,13 @@ class UserAdminUpdateDto {
(email == null ? 0 : email!.hashCode) + (email == null ? 0 : email!.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode) + (password == null ? 0 : password!.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode); (storageLabel == null ? 0 : storageLabel!.hashCode);
@override @override
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -107,6 +112,11 @@ class UserAdminUpdateDto {
} else { } else {
// json[r'password'] = null; // json[r'password'] = null;
} }
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
if (this.quotaSizeInBytes != null) { if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else { } else {
@ -138,6 +148,7 @@ class UserAdminUpdateDto {
email: mapValueOfType<String>(json, r'email'), email: mapValueOfType<String>(json, r'email'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'), password: mapValueOfType<String>(json, r'password'),
pinCode: mapValueOfType<String>(json, r'pinCode'),
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'), quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
storageLabel: mapValueOfType<String>(json, r'storageLabel'), storageLabel: mapValueOfType<String>(json, r'storageLabel'),

View File

@ -2294,6 +2294,139 @@
] ]
} }
}, },
"/auth/pin-code": {
"delete": {
"operationId": "resetPinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"post": {
"operationId": "setupPinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeSetupDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"put": {
"operationId": "changePinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/status": {
"get": {
"operationId": "getAuthStatus",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthStatusResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/validateToken": { "/auth/validateToken": {
"post": { "post": {
"operationId": "validateAccessToken", "operationId": "validateAccessToken",
@ -9031,6 +9164,21 @@
], ],
"type": "string" "type": "string"
}, },
"AuthStatusResponseDto": {
"properties": {
"password": {
"type": "boolean"
},
"pinCode": {
"type": "boolean"
}
},
"required": [
"password",
"pinCode"
],
"type": "object"
},
"AvatarUpdate": { "AvatarUpdate": {
"properties": { "properties": {
"color": { "color": {
@ -10964,6 +11112,37 @@
], ],
"type": "object" "type": "object"
}, },
"PinCodeChangeDto": {
"properties": {
"newPinCode": {
"example": "123456",
"type": "string"
},
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"required": [
"newPinCode"
],
"type": "object"
},
"PinCodeSetupDto": {
"properties": {
"pinCode": {
"example": "123456",
"type": "string"
}
},
"required": [
"pinCode"
],
"type": "object"
},
"PlacesResponseDto": { "PlacesResponseDto": {
"properties": { "properties": {
"admin1name": { "admin1name": {
@ -13958,6 +14137,11 @@
"password": { "password": {
"type": "string" "type": "string"
}, },
"pinCode": {
"example": "123456",
"nullable": true,
"type": "string"
},
"quotaSizeInBytes": { "quotaSizeInBytes": {
"format": "int64", "format": "int64",
"minimum": 0, "minimum": 0,

View File

@ -123,6 +123,7 @@ export type UserAdminUpdateDto = {
email?: string; email?: string;
name?: string; name?: string;
password?: string; password?: string;
pinCode?: string | null;
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean; shouldChangePassword?: boolean;
storageLabel?: string | null; storageLabel?: string | null;
@ -510,6 +511,18 @@ export type LogoutResponseDto = {
redirectUri: string; redirectUri: string;
successful: boolean; successful: boolean;
}; };
export type PinCodeChangeDto = {
newPinCode: string;
password?: string;
pinCode?: string;
};
export type PinCodeSetupDto = {
pinCode: string;
};
export type AuthStatusResponseDto = {
password: boolean;
pinCode: boolean;
};
export type ValidateAccessTokenResponseDto = { export type ValidateAccessTokenResponseDto = {
authStatus: boolean; authStatus: boolean;
}; };
@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) {
method: "POST" method: "POST"
})); }));
} }
export function resetPinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "DELETE",
body: pinCodeChangeDto
})));
}
export function setupPinCode({ pinCodeSetupDto }: {
pinCodeSetupDto: PinCodeSetupDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "POST",
body: pinCodeSetupDto
})));
}
export function changePinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "PUT",
body: pinCodeChangeDto
})));
}
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AuthStatusResponseDto;
}>("/auth/status", {
...opts
}));
}
export function validateAccessToken(opts?: Oazapfts.RequestOpts) { export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;

191
server/package-lock.json generated
View File

@ -23,7 +23,7 @@
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.200.0", "@opentelemetry/exporter-prometheus": "^0.200.0",
"@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0",
"@react-email/components": "^0.0.37", "@react-email/components": "^0.0.38",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
@ -1016,9 +1016,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.25.1", "version": "9.26.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz",
"integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2118,6 +2118,28 @@
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz",
"integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
@ -2952,22 +2974,22 @@
} }
}, },
"node_modules/@opentelemetry/auto-instrumentations-node": { "node_modules/@opentelemetry/auto-instrumentations-node": {
"version": "0.58.0", "version": "0.58.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.58.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.58.1.tgz",
"integrity": "sha512-gtqPqkXp8TG6vrmbzAJUKjJm3nrCiVGgImlV1tj8lsVqpnKDCB1Kl7bCcXod36+Tq/O4rCeTDmW90dCHeuv9jQ==", "integrity": "sha512-hAsNw5XtFTytQ6GrCspIwKKSamXQGfAvRfqOL93VTqaI1WFBhndyXsNrjAzqULvK0JwMJOuZb77ckdrvJrW3vA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/instrumentation": "^0.200.0",
"@opentelemetry/instrumentation-amqplib": "^0.47.0", "@opentelemetry/instrumentation-amqplib": "^0.47.0",
"@opentelemetry/instrumentation-aws-lambda": "^0.51.0", "@opentelemetry/instrumentation-aws-lambda": "^0.51.1",
"@opentelemetry/instrumentation-aws-sdk": "^0.51.0", "@opentelemetry/instrumentation-aws-sdk": "^0.52.0",
"@opentelemetry/instrumentation-bunyan": "^0.46.0", "@opentelemetry/instrumentation-bunyan": "^0.46.0",
"@opentelemetry/instrumentation-cassandra-driver": "^0.46.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.46.0",
"@opentelemetry/instrumentation-connect": "^0.44.0", "@opentelemetry/instrumentation-connect": "^0.44.0",
"@opentelemetry/instrumentation-cucumber": "^0.15.0", "@opentelemetry/instrumentation-cucumber": "^0.15.0",
"@opentelemetry/instrumentation-dataloader": "^0.17.0", "@opentelemetry/instrumentation-dataloader": "^0.17.0",
"@opentelemetry/instrumentation-dns": "^0.44.0", "@opentelemetry/instrumentation-dns": "^0.44.0",
"@opentelemetry/instrumentation-express": "^0.48.1", "@opentelemetry/instrumentation-express": "^0.49.0",
"@opentelemetry/instrumentation-fastify": "^0.45.0", "@opentelemetry/instrumentation-fastify": "^0.45.0",
"@opentelemetry/instrumentation-fs": "^0.20.0", "@opentelemetry/instrumentation-fs": "^0.20.0",
"@opentelemetry/instrumentation-generic-pool": "^0.44.0", "@opentelemetry/instrumentation-generic-pool": "^0.44.0",
@ -2976,13 +2998,13 @@
"@opentelemetry/instrumentation-hapi": "^0.46.0", "@opentelemetry/instrumentation-hapi": "^0.46.0",
"@opentelemetry/instrumentation-http": "^0.200.0", "@opentelemetry/instrumentation-http": "^0.200.0",
"@opentelemetry/instrumentation-ioredis": "^0.48.0", "@opentelemetry/instrumentation-ioredis": "^0.48.0",
"@opentelemetry/instrumentation-kafkajs": "^0.9.1", "@opentelemetry/instrumentation-kafkajs": "^0.9.2",
"@opentelemetry/instrumentation-knex": "^0.45.0", "@opentelemetry/instrumentation-knex": "^0.45.0",
"@opentelemetry/instrumentation-koa": "^0.48.0", "@opentelemetry/instrumentation-koa": "^0.48.0",
"@opentelemetry/instrumentation-lru-memoizer": "^0.45.0", "@opentelemetry/instrumentation-lru-memoizer": "^0.45.0",
"@opentelemetry/instrumentation-memcached": "^0.44.0", "@opentelemetry/instrumentation-memcached": "^0.44.0",
"@opentelemetry/instrumentation-mongodb": "^0.53.0", "@opentelemetry/instrumentation-mongodb": "^0.53.0",
"@opentelemetry/instrumentation-mongoose": "^0.47.0", "@opentelemetry/instrumentation-mongoose": "^0.47.1",
"@opentelemetry/instrumentation-mysql": "^0.46.0", "@opentelemetry/instrumentation-mysql": "^0.46.0",
"@opentelemetry/instrumentation-mysql2": "^0.46.0", "@opentelemetry/instrumentation-mysql2": "^0.46.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.46.0", "@opentelemetry/instrumentation-nestjs-core": "^0.46.0",
@ -3308,9 +3330,9 @@
} }
}, },
"node_modules/@opentelemetry/instrumentation-aws-lambda": { "node_modules/@opentelemetry/instrumentation-aws-lambda": {
"version": "0.51.0", "version": "0.51.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.51.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.51.1.tgz",
"integrity": "sha512-yPtnDum6vykhxA1xZ2kKc3DGmrLdbRAkJG0HiQUcOas47j716wmtqsLCctHyXgO0NpmS/BCzbUnOxxPG6kln7A==", "integrity": "sha512-DxUihz1ZcJtkCKFMnsr5IpQtU1TFnz/QhTEkcb95yfVvmdWx97ezbcxE4lGFjvQYMT8q2NsZjor8s8W/jrMU2w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/instrumentation": "^0.200.0",
@ -3325,9 +3347,9 @@
} }
}, },
"node_modules/@opentelemetry/instrumentation-aws-sdk": { "node_modules/@opentelemetry/instrumentation-aws-sdk": {
"version": "0.51.0", "version": "0.52.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.51.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.52.0.tgz",
"integrity": "sha512-NfmdJqrgJyAPGzPJk2bNl8vBn2kbDIHyTmKVNWhcQWh0VCA5aspi75Gsp5tHmLqk26VAtVtUEDZwK3nApFEtzw==", "integrity": "sha512-xMnghwQP/vO9hNNufaHW3SgNprifLPqmssAQ/zjRopbxa6wpBqunWfKYRRoyu89Xlw0X8/hGNoPEh+CIocCryg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/core": "^2.0.0", "@opentelemetry/core": "^2.0.0",
@ -3440,9 +3462,9 @@
} }
}, },
"node_modules/@opentelemetry/instrumentation-express": { "node_modules/@opentelemetry/instrumentation-express": {
"version": "0.48.1", "version": "0.49.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.48.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.49.0.tgz",
"integrity": "sha512-j8NYOf9DRWtchbWor/zA0poI42TpZG9tViIKA0e1lC+6MshTqSJYtgNv8Fn1sx1Wn/TRyp+5OgSXiE4LDfvpEg==", "integrity": "sha512-j1hbIZzbu7jLQfI/Hz0wHDaniiSWdC3B8/UdH0CEd4lcO8y0pQlz4UTReBaL1BzbkwUhbg6oHuK+m8DXklQPtA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/core": "^2.0.0", "@opentelemetry/core": "^2.0.0",
@ -3588,9 +3610,9 @@
} }
}, },
"node_modules/@opentelemetry/instrumentation-kafkajs": { "node_modules/@opentelemetry/instrumentation-kafkajs": {
"version": "0.9.1", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.9.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.9.2.tgz",
"integrity": "sha512-eGl5WKBqd0unOKm7PJKjEa1G+ac9nvpDjyv870nUYuSnUkyDc/Fag5keddIjHixTJwRp3FmyP7n+AadAjh52Vw==", "integrity": "sha512-aRnrLK3gQv6LP64oiXEDdRVwxNe7AvS98SCtNWEGhHy4nv3CdxpN7b7NU53g3PCF7uPQZ1fVW2C6Xc2tt1SIkg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/instrumentation": "^0.200.0",
@ -3685,9 +3707,9 @@
} }
}, },
"node_modules/@opentelemetry/instrumentation-mongoose": { "node_modules/@opentelemetry/instrumentation-mongoose": {
"version": "0.47.0", "version": "0.47.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.47.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.47.1.tgz",
"integrity": "sha512-zg4ixMNmuACda75eOFa1m5h794zC9wp397stX0LAZvOylSb6dWT52P6ElkVQMV42C/27liEdQWxpabsamB+XPQ==", "integrity": "sha512-0OcL5YpZX9PtF55Oi1RtWUdjElJscR9u6NzAdww81EQc3wFfQWmdREUEBeWaDH5jpiomdFp6zDXms622ofEOjg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@opentelemetry/core": "^2.0.0", "@opentelemetry/core": "^2.0.0",
@ -4446,9 +4468,9 @@
} }
}, },
"node_modules/@react-email/components": { "node_modules/@react-email/components": {
"version": "0.0.37", "version": "0.0.38",
"resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.37.tgz", "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.38.tgz",
"integrity": "sha512-M4MKALwezAf9uVMQHsR6k/MvfQ9H3xyrOppGfOiznPnjcv+/7oWiHERzL7Ani5nrCsc+fhc70zybpwtVQ/NSHQ==", "integrity": "sha512-2cjMBZsSPjD1Iyur/MzGrgW/n5A6ONOJQ97pNaVOClxz/EaqNZTo1lFmKdH7p54P7LG9ZxRXxoTe2075VCCGQA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@react-email/body": "0.0.11", "@react-email/body": "0.0.11",
@ -4470,7 +4492,7 @@
"@react-email/row": "0.0.12", "@react-email/row": "0.0.12",
"@react-email/section": "0.0.16", "@react-email/section": "0.0.16",
"@react-email/tailwind": "1.0.5", "@react-email/tailwind": "1.0.5",
"@react-email/text": "0.1.2" "@react-email/text": "0.1.3"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@ -4654,9 +4676,9 @@
} }
}, },
"node_modules/@react-email/text": { "node_modules/@react-email/text": {
"version": "0.1.2", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.3.tgz",
"integrity": "sha512-B5xDDBxgYpu/j7K4PSXuGmXgEvTheMyvnVXf/Md2u9dhvBHq65CPvSqYxDM1vjDjKd39GQEI7dqT2QIjER2DGA==", "integrity": "sha512-H22KR54MXUg29a+1/lTfg9oCQA65V8+TL4v19OzV7RsOxnEnzGOc287XKh8vc+v7ENewrMV97BzUPOnKz3bqkA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@ -7022,9 +7044,9 @@
} }
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "5.52.0", "version": "5.52.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.0.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.52.1.tgz",
"integrity": "sha512-2PRR7DuH4iFjrIam5kL08VLHe1FCZtr1jsL3Um/18EML9Gd7w9eFgzlriaiYUyUxU0gFVql0sijo1aBcoTrTTA==", "integrity": "sha512-u7CSV9wID3MBEX2DNubEErbAlrADgm8abUBAi6h8rQTnuTkhhgMs2iD7uhqplK8lIgUOkBIW3sDJWaMSInH47A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
@ -7468,13 +7490,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.1", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
"integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/validator": "^13.11.8", "@types/validator": "^13.11.8",
"libphonenumber-js": "^1.10.53", "libphonenumber-js": "^1.11.1",
"validator": "^13.9.0" "validator": "^13.9.0"
} }
}, },
@ -8875,9 +8897,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.25.1", "version": "9.26.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz",
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -8887,11 +8909,12 @@
"@eslint/config-helpers": "^0.2.1", "@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.13.0", "@eslint/core": "^0.13.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.25.1", "@eslint/js": "9.26.0",
"@eslint/plugin-kit": "^0.2.8", "@eslint/plugin-kit": "^0.2.8",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
"@modelcontextprotocol/sdk": "^1.8.0",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4", "ajv": "^6.12.4",
@ -8915,7 +8938,8 @@
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3",
"zod": "^3.24.2"
}, },
"bin": { "bin": {
"eslint": "bin/eslint.js" "eslint": "bin/eslint.js"
@ -8949,9 +8973,9 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.6", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.3.1.tgz",
"integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "integrity": "sha512-vad9VWgEm9xaVXRNmb4aeOt0PWDc61IAdzghkbYQ2wavgax148iKoX1rNJcgkBGCipzLzOnHYVgL7xudM9yccQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -9187,6 +9211,29 @@
"node": ">=0.8.x" "node": ">=0.8.x"
} }
}, },
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
"integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.8.0", "version": "28.8.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
@ -9273,6 +9320,22 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": "^4.11 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/express/node_modules/cookie": { "node_modules/express/node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@ -13370,6 +13433,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pkce-challenge": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@ -18122,6 +18195,26 @@
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
},
"node_modules/zod": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
} }
} }
} }

View File

@ -48,7 +48,7 @@
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.200.0", "@opentelemetry/exporter-prometheus": "^0.200.0",
"@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0",
"@react-email/components": "^0.0.37", "@react-email/components": "^0.0.38",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",

View File

@ -142,4 +142,50 @@ describe(AuthController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
}); });
describe('POST /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
});
describe('PUT /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /auth/status', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/auth/status');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
}); });

View File

@ -1,12 +1,15 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { import {
AuthDto, AuthDto,
AuthStatusResponseDto,
ChangePasswordDto, ChangePasswordDto,
LoginCredentialDto, LoginCredentialDto,
LoginResponseDto, LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
PinCodeChangeDto,
PinCodeSetupDto,
SignUpDto, SignUpDto,
ValidateAccessTokenResponseDto, ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
@ -74,4 +77,28 @@ export class AuthController {
ImmichCookie.IS_AUTHENTICATED, ImmichCookie.IS_AUTHENTICATED,
]); ]);
} }
@Get('status')
@Authenticated()
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
return this.service.getAuthStatus(auth);
}
@Post('pin-code')
@Authenticated()
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated()
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
} }

View File

@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie } from 'src/enum'; import { ImmichCookie } from 'src/enum';
import { Optional, toEmail } from 'src/validation'; import { Optional, PinCode, toEmail } from 'src/validation';
export type CookieResponse = { export type CookieResponse = {
isSecure: boolean; isSecure: boolean;
@ -78,6 +78,26 @@ export class ChangePasswordDto {
newPassword!: string; newPassword!: string;
} }
export class PinCodeSetupDto {
@PinCode()
pinCode!: string;
}
export class PinCodeResetDto {
@PinCode({ optional: true })
pinCode?: string;
@Optional()
@IsString()
@IsNotEmpty()
password?: string;
}
export class PinCodeChangeDto extends PinCodeResetDto {
@PinCode()
newPinCode!: string;
}
export class ValidateAccessTokenResponseDto { export class ValidateAccessTokenResponseDto {
authStatus!: boolean; authStatus!: boolean;
} }
@ -114,3 +134,8 @@ export class OAuthConfigDto {
export class OAuthAuthorizeResponseDto { export class OAuthAuthorizeResponseDto {
url!: string; url!: string;
} }
export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
}

View File

@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
import { User, UserAdmin } from 'src/database'; import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class UserUpdateMeDto { export class UserUpdateMeDto {
@Optional() @Optional()
@ -116,6 +116,9 @@ export class UserAdminUpdateDto {
@IsString() @IsString()
password?: string; password?: string;
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
@Optional() @Optional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -87,6 +87,16 @@ where
"users"."isAdmin" = $1 "users"."isAdmin" = $1
and "users"."deletedAt" is null and "users"."deletedAt" is null
-- UserRepository.getForPinCode
select
"users"."pinCode",
"users"."password"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getByEmail -- UserRepository.getByEmail
select select
"id", "id",

View File

@ -89,13 +89,23 @@ export class UserRepository {
return !!admin; return !!admin;
} }
@GenerateSql({ params: [DummyValue.UUID] })
getForPinCode(id: string) {
return this.db
.selectFrom('users')
.select(['users.pinCode', 'users.password'])
.where('users.id', '=', id)
.where('users.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.EMAIL] }) @GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, withPassword?: boolean) { getByEmail(email: string, options?: { withPassword?: boolean }) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata) .select(withMetadata)
.$if(!!withPassword, (eb) => eb.select('password')) .$if(!!options?.withPassword, (eb) => eb.select('password'))
.where('email', '=', email) .where('email', '=', email)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst(); .executeTakeFirst();

View File

@ -4,8 +4,16 @@ export async function up(db: Kysely<any>): Promise<void> {
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db); const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db);
const databaseName = rows[0].db; const databaseName = rows[0].db;
await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db); await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db);
await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "PK_21a6d86d1ab5d841648212e5353";`.execute(db); const naturalearth_pkey = await sql<{ constraint_name: string }>`SELECT constraint_name
await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "naturalearth_countries_pkey";`.execute(db); FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'naturalearth_countries'
AND constraint_type = 'PRIMARY KEY';`.execute(db);
const naturalearth_pkey_name = naturalearth_pkey.rows[0]?.constraint_name;
if(naturalearth_pkey_name) {
await sql`ALTER TABLE "naturalearth_countries"
DROP CONSTRAINT ${sql.ref(naturalearth_pkey_name)};`.execute(db);
}
await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db); await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db);
await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db); await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db);
await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db); await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db);

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db);
}

View File

@ -16,7 +16,7 @@ export class SharedLinkTable {
userId!: string; userId!: string;
@Column({ type: 'bytea', indexName: 'IDX_sharedlink_key' }) @Column({ type: 'bytea', indexName: 'IDX_sharedlink_key' })
key!: Buffer; // use to access the inidividual asset key!: Buffer; // use to access the individual asset
@Column() @Column()
type!: SharedLinkType; type!: SharedLinkType;

View File

@ -37,6 +37,9 @@ export class UserTable {
@Column({ default: '' }) @Column({ default: '' })
password!: Generated<string>; password!: Generated<string>;
@Column({ nullable: true })
pinCode!: string | null;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Generated<Timestamp>; createdAt!: Generated<Timestamp>;

View File

@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
@ -118,7 +119,7 @@ describe(AuthService.name, () => {
await sut.changePassword(auth, dto); await sut.changePassword(auth, dto);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
}); });
@ -859,4 +860,77 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
}); });
}); });
describe('setupPinCode', () => {
it('should setup a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
mocks.user.update.mockResolvedValue(user);
await sut.setupPinCode(auth, dto);
expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id);
expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) });
});
it('should fail if the user already has a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code');
});
});
describe('changePinCode', () => {
it('should change the PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456', newPinCode: '012345' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.user.update.mockResolvedValue(user);
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await sut.changePinCode(auth, dto);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' });
});
it('should fail if the PIN code does not match', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
).rejects.toThrow('Wrong PIN code');
});
});
describe('resetPinCode', () => {
it('should reset the PIN code', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
});
it('should throw if the PIN code does not match', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
});
});
}); });

View File

@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { import {
AuthDto, AuthDto,
AuthStatusResponseDto,
ChangePasswordDto, ChangePasswordDto,
LoginCredentialDto, LoginCredentialDto,
LogoutResponseDto, LogoutResponseDto,
OAuthCallbackDto, OAuthCallbackDto,
OAuthConfigDto, OAuthConfigDto,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SignUpDto, SignUpDto,
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
@ -56,9 +60,9 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Password login has been disabled'); throw new UnauthorizedException('Password login has been disabled');
} }
let user = await this.userRepository.getByEmail(dto.email, true); let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
if (user) { if (user) {
const isAuthenticated = this.validatePassword(dto.password, user); const isAuthenticated = this.validateSecret(dto.password, user.password);
if (!isAuthenticated) { if (!isAuthenticated) {
user = undefined; user = undefined;
} }
@ -86,12 +90,12 @@ export class AuthService extends BaseService {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> { async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto; const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(auth.user.email, true); const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
if (!user) { if (!user) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const valid = this.validatePassword(password, user); const valid = this.validateSecret(password, user.password);
if (!valid) { if (!valid) {
throw new BadRequestException('Wrong password'); throw new BadRequestException('Wrong password');
} }
@ -103,6 +107,56 @@ export class AuthService extends BaseService {
return mapUserAdmin(updatedUser); return mapUserAdmin(updatedUser);
} }
async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
if (user.pinCode) {
throw new BadRequestException('User already has a PIN code');
}
const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
await this.userRepository.update(auth.user.id, { pinCode: null });
}
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
private resetPinChecks(
user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string },
) {
if (!user.pinCode) {
throw new BadRequestException('User does not have a PIN code');
}
if (dto.password) {
if (!this.validateSecret(dto.password, user.password)) {
throw new BadRequestException('Wrong password');
}
} else if (dto.pinCode) {
if (!this.validateSecret(dto.pinCode, user.pinCode)) {
throw new BadRequestException('Wrong PIN code');
}
} else {
throw new BadRequestException('Either password or pinCode is required');
}
}
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const adminUser = await this.userRepository.getAdmin(); const adminUser = await this.userRepository.getAdmin();
if (adminUser) { if (adminUser) {
@ -371,11 +425,12 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid API key'); throw new UnauthorizedException('Invalid API key');
} }
private validatePassword(inputPassword: string, user: { password?: string }): boolean { private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
if (!user || !user.password) { if (!existingHash) {
return false; return false;
} }
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
} }
private async validateSession(tokenValue: string): Promise<AuthDto> { private async validateSession(tokenValue: string): Promise<AuthDto> {
@ -428,4 +483,16 @@ export class AuthService extends BaseService {
} }
return url; return url;
} }
async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
return {
pinCode: !!user.pinCode,
password: !!user.password,
};
}
} }

View File

@ -70,6 +70,10 @@ export class UserAdminService extends BaseService {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
} }
if (dto.pinCode) {
dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS);
}
if (dto.storageLabel === '') { if (dto.storageLabel === '') {
dto.storageLabel = null; dto.storageLabel = null;
} }

View File

@ -18,6 +18,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Matches,
Validate, Validate,
ValidateBy, ValidateBy,
ValidateIf, ValidateIf,
@ -70,6 +71,22 @@ export class UUIDParamDto {
id!: string; id!: string;
} }
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => {
const decorators = [
IsString(),
IsNotEmpty(),
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
ApiProperty({ example: '123456' }),
];
if (optional) {
decorators.push(Optional(options));
}
return applyDecorators(...decorators);
};
export interface OptionalOptions extends ValidationOptions { export interface OptionalOptions extends ValidationOptions {
nullable?: boolean; nullable?: boolean;
/** convert empty strings to null */ /** convert empty strings to null */

1307
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -59,7 +59,7 @@
"@faker-js/faker": "^9.3.0", "@faker-js/faker": "^9.3.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.4.4", "@sveltejs/enhanced-img": "^0.5.0",
"@sveltejs/kit": "^2.15.2", "@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@ -75,7 +75,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-p": "^0.21.0", "eslint-p": "^0.22.0",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"eslint-plugin-unicorn": "^57.0.0", "eslint-plugin-unicorn": "^57.0.0",
"factory.ts": "^1.4.1", "factory.ts": "^1.4.1",

View File

@ -38,7 +38,7 @@
<div> <div>
<p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p> <p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p>
<div class="mt-5 hidden justify-between lg:flex"> <div class="mt-5 hidden justify-between lg:flex gap-4">
<StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} /> <StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} /> <StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} />
<StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} /> <StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} />

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { ByteUnit } from '$lib/utils/byte-units'; import { ByteUnit } from '$lib/utils/byte-units';
import { Code, Text } from '@immich/ui';
interface Props { interface Props {
icon: string; icon: string;
@ -20,18 +21,16 @@
}); });
</script> </script>
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray"> <div class="flex h-[140px] w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary"> <div class="flex place-items-center gap-4">
<Icon path={icon} size="40" /> <Icon path={icon} size="40" />
<p>{title}</p> <Text size="large" fontWeight="bold">{title}</Text>
</div> </div>
<div class="relative text-center font-mono text-2xl font-semibold"> <div class="relative mx-auto font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span <span class="text-gray-400 dark:text-gray-600">{zeros()}</span><span>{value}</span>
class="text-immich-primary dark:text-immich-dark-primary">{value}</span
>
{#if unit} {#if unit}
<span class="absolute -top-5 end-2 text-base font-light text-gray-400">{unit}</span> <Code color="muted" class="absolute -top-5 end-2 font-light">{unit}</Code>
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -131,6 +131,7 @@
bind:mapMarkers bind:mapMarkers
onSelect={onViewAssets} onSelect={onViewAssets}
showSettings={false} showSettings={false}
rounded
/> />
{/await} {/await}
</div> </div>

View File

@ -1,43 +1,46 @@
<script lang="ts"> <script lang="ts">
import { onMount, type Snippet } from 'svelte'; import { goto } from '$app/navigation';
import { groupBy } from 'lodash-es'; import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk'; import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte'; import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import { import {
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AppRoute } from '$lib/constants';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { downloadAlbum } from '$lib/utils/asset-utils';
import { normalizeSearchString } from '$lib/utils/string-utils';
import {
getSelectedAlbumGroupOption,
type AlbumGroup,
confirmAlbumDelete,
sortAlbums,
stringToSortOrder,
} from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { user } from '$lib/stores/user.store';
import { import {
AlbumFilter,
AlbumGroupBy, AlbumGroupBy,
AlbumSortBy, AlbumSortBy,
AlbumFilter,
AlbumViewMode, AlbumViewMode,
SortOrder, SortOrder,
locale, locale,
type AlbumViewSettings, type AlbumViewSettings,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { goto } from '$app/navigation'; import { makeSharedLinkUrl } from '$lib/utils';
import { AppRoute } from '$lib/constants'; import {
confirmAlbumDelete,
getSelectedAlbumGroupOption,
sortAlbums,
stringToSortOrder,
type AlbumGroup,
} from '$lib/utils/album-utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { mdiDeleteOutline, mdiFolderDownloadOutline, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy'; import { run } from 'svelte/legacy';
@ -140,8 +143,6 @@
let albumGroupOption: string = $state(AlbumGroupBy.None); let albumGroupOption: string = $state(AlbumGroupBy.None);
let showShareByURLModal = $state(false);
let albumToEdit: AlbumResponseDto | null = $state(null); let albumToEdit: AlbumResponseDto | null = $state(null);
let albumToShare: AlbumResponseDto | null = $state(null); let albumToShare: AlbumResponseDto | null = $state(null);
let albumToDelete: AlbumResponseDto | null = null; let albumToDelete: AlbumResponseDto | null = null;
@ -346,18 +347,31 @@
updateAlbumInfo(album); updateAlbumInfo(album);
}; };
const openShareModal = () => { const openShareModal = async () => {
if (!contextMenuTargetAlbum) { if (!contextMenuTargetAlbum) {
return; return;
} }
albumToShare = contextMenuTargetAlbum; albumToShare = contextMenuTargetAlbum;
closeAlbumContextMenu(); closeAlbumContextMenu();
}; const result = await modalManager.show(AlbumShareModal, { album: albumToShare });
const closeShareModal = () => { switch (result?.action) {
albumToShare = null; case 'sharedUsers': {
showShareByURLModal = false; await handleAddUsers(result.data);
return;
}
case 'sharedLink': {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: albumToShare.id });
if (sharedLink) {
handleSharedLinkCreated(albumToShare);
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
}
return;
}
}
}; };
</script> </script>
@ -419,22 +433,4 @@
onClose={() => (albumToEdit = null)} onClose={() => (albumToEdit = null)}
/> />
{/if} {/if}
<!-- Share Modal -->
{#if albumToShare}
{#if showShareByURLModal}
<CreateSharedLinkModal
albumId={albumToShare.id}
onClose={() => closeShareModal()}
onCreated={() => albumToShare && handleSharedLinkCreated(albumToShare)}
/>
{:else}
<UserSelectionModal
album={albumToShare}
onSelect={handleAddUsers}
onShare={() => (showShareByURLModal = true)}
onClose={() => closeShareModal()}
/>
{/if}
{/if}
{/if} {/if}

View File

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { mdiShareVariantOutline } from '@mdi/js'; import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -12,13 +14,13 @@
let { asset }: Props = $props(); let { asset }: Props = $props();
let showModal = $state(false); const handleClick = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
if (sharedLink) {
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
}
};
</script> </script>
<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} /> <CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={handleClick} title={$t('share')} />
{#if showModal}
<Portal target="body">
<CreateSharedLinkModal assetIds={[asset.id]} onClose={() => (showModal = false)} />
</Portal>
{/if}

View File

@ -7,6 +7,7 @@
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
@ -14,7 +15,6 @@
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
@ -22,6 +22,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils'; import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { import {
AssetJobName, AssetJobName,
@ -45,9 +46,8 @@
mdiPresentationPlay, mdiPresentationPlay,
mdiUpload, mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@ -104,7 +104,7 @@
</script> </script>
<div <div
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200" class="flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
> >
<div class="text-white"> <div class="text-white">
{#if showCloseButton} {#if showCloseButton}

View File

@ -4,6 +4,7 @@
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte'; import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
@ -34,7 +35,6 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import ActivityStatus from './activity-status.svelte'; import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte'; import ActivityViewer from './activity-viewer.svelte';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
import DetailPanel from './detail-panel.svelte'; import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte'; import EditorPanel from './editor/editor-panel.svelte';
@ -379,12 +379,13 @@
<section <section
id="immich-asset-viewer" id="immich-asset-viewer"
class="fixed start-0 top-0 z-[1001] grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black" class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
use:focusTrap use:focusTrap
bind:this={assetViewerHtmlElement}
> >
<!-- Top navigation bar --> <!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !isShowEditor} {#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> <div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar <AssetViewerNavBar
{asset} {asset}
{album} {album}
@ -412,26 +413,26 @@
</div> </div>
{/if} {/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex">
<SlideshowBar
{isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')}
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
{/if} {/if}
<!-- Asset Viewer --> <!-- Asset Viewer -->
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}> <div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if $slideshowState != SlideshowState.None}
<div class="z-[1000] absolute w-full flex">
<SlideshowBar
{isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')}
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
/>
</div>
{/if}
{#if previewStackedAsset} {#if previewStackedAsset}
{#key previewStackedAsset.id} {#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image} {#if previewStackedAsset.type === AssetTypeEnum.Image}
@ -504,7 +505,7 @@
/> />
{/if} {/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8"> <div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus <ActivityStatus
disabled={!album?.isActivityEnabled} disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked} isLiked={activityManager.isLiked}
@ -519,7 +520,7 @@
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>
{/if} {/if}
@ -528,7 +529,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="detail-panel" id="detail-panel"
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg" class="row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes" translate="yes"
> >
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} /> <DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
@ -539,7 +540,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="editor-panel" id="editor-panel"
class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg" class="row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes" translate="yes"
> >
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} /> <EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
@ -550,7 +551,7 @@
{@const stackedAssets = stack.assets} {@const stackedAssets = stack.assets}
<div <div
id="stack-slideshow" id="stack-slideshow"
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar" class="flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
> >
<div class="relative w-full"> <div class="relative w-full">
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
@ -588,7 +589,7 @@
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="activity-panel" id="activity-panel"
class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg" class="row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-s-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes" translate="yes"
> >
<ActivityViewer <ActivityViewer

View File

@ -6,7 +6,6 @@
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte'; import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
@ -355,14 +354,12 @@
{/if} {/if}
{#if isShowChangeDate} {#if isShowChangeDate}
<Portal> <ChangeDate
<ChangeDate initialDate={dateTime}
initialDate={dateTime} initialTimeZone={timeZone ?? ''}
initialTimeZone={timeZone ?? ''} onConfirm={handleConfirmChangeDate}
onConfirm={handleConfirmChangeDate} onCancel={() => (isShowChangeDate = false)}
onCancel={() => (isShowChangeDate = false)} />
/>
</Portal>
{/if} {/if}
<div class="flex gap-4 py-4"> <div class="flex gap-4 py-4">

View File

@ -1,27 +1,27 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;

View File

@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { swipe } from 'svelte-gestures';
import type { SwipeCustomEvent } from 'svelte-gestures'; import type { SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { fade } from 'svelte/transition';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
interface Props { interface Props {
assetId: string; assetId: string;

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils'; import { getAssetOriginalUrl, getAssetPlaybackUrl } from '$lib/utils';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
interface Props { interface Props {
assetId: string; assetId: string;

View File

@ -41,7 +41,7 @@ describe('Thumbnail component', () => {
expect(container).not.toBeNull(); expect(container).not.toBeNull();
expect(container!.getAttribute('tabindex')).toBe('0'); expect(container!.getAttribute('tabindex')).toBe('0');
// Guarding against inserting extra tabbable elments in future in <Thumbnail/> // Guarding against inserting extra tabbable elements in future in <Thumbnail/>
const tabbables = getTabbable(container!); const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0); expect(tabbables.length).toBe(0);
}); });

View File

@ -51,7 +51,10 @@
</header> </header>
<div <div
tabindex="-1" tabindex="-1"
class="relative grid h-[calc(100dvh-var(--navbar-height))] grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]" class="relative grid grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
{hideNavbar ? 'pt-[var(--navbar-height)]' : ''}
{hideNavbar ? 'max-md:pt-[var(--navbar-height-md)]' : ''}"
> >
{#if sidebar}{@render sidebar()}{:else if admin} {#if sidebar}{@render sidebar()}{:else if admin}
<AdminSideBar /> <AdminSideBar />

View File

@ -123,7 +123,7 @@
await progressBarController.set(1); await progressBarController.set(1);
} catch (error) { } catch (error) {
// this may happen if browser blocks auto-play of the video on first page load. This can either be a setting // this may happen if browser blocks auto-play of the video on first page load. This can either be a setting
// or just defaut in certain browsers on page load without any DOM interaction by user. // or just default in certain browsers on page load without any DOM interaction by user.
console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`); console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`);
paused = true; paused = true;
await progressBarController.set(0); await progressBarController.set(0);

View File

@ -1,16 +1,24 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import { mdiShareVariantOutline } from '@mdi/js'; import { mdiShareVariantOutline } from '@mdi/js';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let showModal = $state(false);
const { getAssets } = getAssetControlContext(); const { getAssets } = getAssetControlContext();
const handleClick = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, {
assetIds: [...getAssets()].map(({ id }) => id),
});
if (sharedLink) {
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
}
};
</script> </script>
<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={() => (showModal = true)} /> <CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={handleClick} />
{#if showModal}
<CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} />
{/if}

View File

@ -12,6 +12,8 @@
import SelectDate from '$lib/components/shared-components/select-date.svelte'; import SelectDate from '$lib/components/shared-components/select-date.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte'; import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
@ -31,7 +33,6 @@
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
@ -81,7 +82,6 @@
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false);
let showSkeleton = $state(true); let showSkeleton = $state(true);
let isShowSelectDate = $state(false); let isShowSelectDate = $state(false);
let scrubBucketPercent = $state(0); let scrubBucketPercent = $state(0);
@ -317,7 +317,7 @@
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent)); scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
// compensate for lost precision/rouding errors advance to the next bucket, if present // compensate for lost precision/rounding errors advance to the next bucket, if present
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) { if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
scrubBucket = assetStore.buckets[i + 1]; scrubBucket = assetStore.buckets[i + 1];
scrubBucketPercent = 0; scrubBucketPercent = 0;
@ -635,6 +635,17 @@
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => { $effect(() => {
if (isEmpty) { if (isEmpty) {
@ -653,7 +664,7 @@
const shortcuts: ShortcutOptions[] = [ const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset }, { shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
@ -710,10 +721,6 @@
/> />
{/if} {/if}
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if isShowSelectDate} {#if isShowSelectDate}
<SelectDate <SelectDate
initialDate={DateTime.now()} initialDate={DateTime.now()}

View File

@ -191,6 +191,7 @@
clickable={true} clickable={true}
onClickPoint={(selected) => (point = selected)} onClickPoint={(selected) => (point = selected)}
showSettings={false} showSettings={false}
rounded
/> />
{/await} {/await}
</div> </div>

View File

@ -1,251 +0,0 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { makeSharedLinkUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { NotificationType, notificationController } from '../notification/notification';
import SettingInputField from '../settings/setting-input-field.svelte';
import SettingSwitch from '../settings/setting-switch.svelte';
interface Props {
onClose: () => void;
albumId?: string | undefined;
assetIds?: string[];
editingLink?: SharedLinkResponseDto | undefined;
onCreated?: () => void;
}
let {
onClose,
albumId = $bindable(undefined),
assetIds = $bindable([]),
editingLink = undefined,
onCreated = () => {},
}: Props = $props();
let sharedLink: string | null = $state(null);
let description = $state('');
let allowDownload = $state(true);
let allowUpload = $state(false);
let showMetadata = $state(true);
let expirationOption: number = $state(0);
let password = $state('');
let shouldChangeExpirationTime = $state(false);
let enablePassword = $state(false);
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'],
[1, 'hour'],
[6, 'hours'],
[1, 'day'],
[7, 'days'],
[30, 'days'],
[3, 'months'],
[1, 'year'],
];
let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
let expiredDateOptions = $derived([
{ text: $t('never'), value: 0 },
...expirationOptions.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
})),
]);
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
allowDownload = false;
}
});
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
if (editingLink.password) {
password = editingLink.password;
}
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showMetadata = editingLink.showMetadata;
albumId = editingLink.album?.id;
assetIds = editingLink.assets.map(({ id }) => id);
enablePassword = !!editingLink.password;
}
const handleCreateSharedLink = async () => {
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined;
try {
const data = await createSharedLink({
sharedLinkCreateDto: {
type: shareType,
albumId,
assetIds,
expiresAt: expirationDate,
allowUpload,
description,
password,
allowDownload,
showMetadata,
},
});
sharedLink = makeSharedLinkUrl($serverConfig.externalDomain, data.key);
onCreated();
} catch (error) {
handleError(error, $t('errors.failed_to_create_shared_link'));
}
};
const handleEditLink = async () => {
if (!editingLink) {
return;
}
try {
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null;
await updateSharedLink({
id: editingLink.id,
sharedLinkEditDto: {
description,
password: enablePassword ? password : '',
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload,
allowDownload,
showMetadata,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('edited'),
});
onClose();
} catch (error) {
handleError(error, $t('errors.failed_to_edit_shared_link'));
}
};
const getTitle = () => {
if (sharedLink) {
return $t('view_link');
}
if (editingLink) {
return $t('edit_link');
}
return $t('create_link_to_share');
};
</script>
{#if !sharedLink || editingLink}
<FullScreenModal title={getTitle()} icon={mdiLink} {onClose}>
<section>
{#if shareType === SharedLinkType.Album}
{#if !editingLink}
<div>{$t('album_with_link_access')}</div>
{:else}
<div class="text-sm">
{$t('public_album')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
</div>
{/if}
{/if}
{#if shareType === SharedLinkType.Individual}
{#if !editingLink}
<div>{$t('create_link_to_share_description')}</div>
{:else}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
</div>
{/if}
{/if}
<div class="mb-2 mt-4">
<p class="text-xs">{$t('link_options').toUpperCase()}</p>
</div>
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
<div class="flex flex-col">
<div class="mb-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('description')}
bind:value={description}
/>
</div>
<div class="mb-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('password')}
bind:value={password}
disabled={!enablePassword}
/>
</div>
<div class="my-3">
<SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
</div>
<div class="my-3">
<SettingSwitch
bind:checked={allowDownload}
title={$t('allow_public_user_to_download')}
disabled={!showMetadata}
/>
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowUpload} title={$t('allow_public_user_to_upload')} />
</div>
{#if editingLink}
<div class="my-3">
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
</div>
{/if}
<div class="mt-3">
<SettingSelect
bind:value={expirationOption}
options={expiredDateOptions}
label={$t('expire_after')}
disabled={editingLink && !shouldChangeExpirationTime}
number={true}
/>
</div>
</div>
</div>
</section>
{#snippet stickyBottom()}
{#if editingLink}
<Button fullWidth onclick={handleEditLink}>{$t('confirm')}</Button>
{:else}
<Button fullWidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
{/if}
{/snippet}
</FullScreenModal>
{:else}
<QrCodeModal title={$t('view_link')} {onClose} value={sharedLink} />
{/if}

View File

@ -44,7 +44,7 @@
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div class="flex gap-3 w-full"> <div class="flex gap-3 w-full my-3">
{#if !hideCancelButton} {#if !hideCancelButton}
<Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}>
{cancelText} {cancelText}

View File

@ -4,6 +4,8 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
@ -22,7 +24,6 @@
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import Portal from '../portal/portal.svelte'; import Portal from '../portal/portal.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
@ -106,7 +107,6 @@
}; };
}); });
let showShortcuts = $state(false);
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
@ -263,6 +263,18 @@
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {
if ($isViewerOpen) { if ($isViewerOpen) {
@ -270,7 +282,7 @@
} }
const shortcuts: ShortcutOptions[] = [ const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset }, { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
@ -439,10 +451,6 @@
/> />
{/if} {/if}
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if assets.length > 0} {#if assets.length > 0}
<div <div
style:position="relative" style:position="relative"

View File

@ -53,6 +53,7 @@
onSelect?: (assetIds: string[]) => void; onSelect?: (assetIds: string[]) => void;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void; onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>; popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
rounded?: boolean;
} }
let { let {
@ -68,6 +69,7 @@
onSelect = () => {}, onSelect = () => {},
onClickPoint = () => {}, onClickPoint = () => {},
popup, popup,
rounded = false,
}: Props = $props(); }: Props = $props();
let map: maplibregl.Map | undefined = $state(); let map: maplibregl.Map | undefined = $state();
@ -247,7 +249,7 @@
<MapLibre <MapLibre
{hash} {hash}
style="" style=""
class="h-full rounded-2xl" class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{center} {center}
{zoom} {zoom}
attributionControl={false} attributionControl={false}
@ -274,7 +276,9 @@
{#if showSettings} {#if showSettings}
<Control> <Control>
<ControlGroup> <ControlGroup>
<ControlButton onclick={handleSettingsClick}><Icon path={mdiCog} size="100%" /></ControlButton> <ControlButton onclick={handleSettingsClick}
><Icon path={mdiCog} size="100%" class="text-black/80" /></ControlButton
>
</ControlGroup> </ControlGroup>
</Control> </Control>
{/if} {/if}
@ -283,7 +287,7 @@
<Control position="top-right"> <Control position="top-right">
<ControlGroup> <ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}> <ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" /> <Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" class="text-black/80" />
</ControlButton> </ControlButton>
</ControlGroup> </ControlGroup>
</Control> </Control>

View File

@ -49,7 +49,10 @@
<svelte:window bind:innerWidth /> <svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm"> <nav
id="dashboard-navbar"
class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm overflow-hidden"
>
<SkipLink text={$t('skip_to_content')} /> <SkipLink text={$t('skip_to_content')} />
<div <div
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]" class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { import {
isComponentNotification, isComponentNotification,
@ -8,10 +8,10 @@
type ComponentNotification, type ComponentNotification,
type Notification, type Notification,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { onMount } from 'svelte';
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js'; import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props { interface Props {
notification: Notification | ComponentNotification; notification: Notification | ComponentNotification;
@ -100,7 +100,7 @@
/> />
</div> </div>
<p class="whitespace-pre-wrap ps-[28px] pe-[16px] text-sm" data-testid="message"> <p class="whitespace-pre-wrap ps-[28px] pe-[16px] text-sm text-black/80" data-testid="message">
{#if isComponentNotification(notification)} {#if isComponentNotification(notification)}
<notification.component.type {...notification.component.props} /> <notification.component.type {...notification.component.props} />
{:else} {:else}

View File

@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
import { AppRoute } from '$lib/constants';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { searchStore } from '$lib/stores/search.svelte';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterModal from './search-filter-modal.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcuts } from '$lib/actions/shortcut';
import { focusOutside } from '$lib/actions/focus-outside'; import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n'; import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, tick } from 'svelte'; import { onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
interface Props { interface Props {
value?: string; value?: string;
@ -28,10 +29,10 @@
let input = $state<HTMLInputElement>(); let input = $state<HTMLInputElement>();
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>(); let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
let showSuggestions = $state(false); let showSuggestions = $state(false);
let showFilter = $state(false);
let isSearchSuggestions = $state(false); let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state(); let selectedId: string | undefined = $state();
let isFocus = $state(false); let isFocus = $state(false);
let close: (() => Promise<void>) | undefined;
const listboxId = generateId(); const listboxId = generateId();
@ -43,7 +44,6 @@
const params = getMetadataSearchQuery(payload); const params = getMetadataSearchQuery(payload);
closeDropdown(); closeDropdown();
showFilter = false;
searchStore.isSearchEnabled = false; searchStore.isSearchEnabled = false;
await goto(`${AppRoute.SEARCH}?${params}`); await goto(`${AppRoute.SEARCH}?${params}`);
}; };
@ -83,13 +83,27 @@
await handleSearch(searchPayload); await handleSearch(searchPayload);
}; };
const onFilterClick = () => { const onFilterClick = async () => {
showFilter = !showFilter;
value = ''; value = '';
if (showFilter) { if (close) {
closeDropdown(); await close();
close = undefined;
return;
} }
const result = modalManager.open(SearchFilterModal, { searchQuery });
close = result.close;
closeDropdown();
const searchResult = await result.onClose;
close = undefined;
if (!searchResult) {
return;
}
await handleSearch(searchResult);
}; };
const onSubmit = () => { const onSubmit = () => {
@ -122,7 +136,6 @@
const onEscape = () => { const onEscape = () => {
closeDropdown(); closeDropdown();
showFilter = false;
}; };
const onArrow = async (direction: 1 | -1) => { const onArrow = async (direction: 1 | -1) => {
@ -221,9 +234,7 @@
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled && !showFilter {searchStore.isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
? 'border-gray-200 dark:border-gray-700 bg-white'
: 'border-transparent'}"
placeholder={$t('search_your_photos')} placeholder={$t('search_your_photos')}
required required
pattern="^(?!m:$).*$" pattern="^(?!m:$).*$"
@ -231,7 +242,6 @@
bind:this={input} bind:this={input}
onfocus={openDropdown} onfocus={openDropdown}
oninput={onInput} oninput={onInput}
disabled={showFilter}
role="combobox" role="combobox"
aria-controls={listboxId} aria-controls={listboxId}
aria-activedescendant={selectedId ?? ''} aria-activedescendant={selectedId ?? ''}
@ -285,22 +295,7 @@
</div> </div>
{/if} {/if}
<div class="absolute inset-y-0 start-0 flex items-center ps-2"> <div class="absolute inset-y-0 start-0 flex items-center ps-2">
<CircleIconButton <CircleIconButton type="submit" title={$t('search')} icon={mdiMagnify} size="20" onclick={() => {}} />
type="submit"
disabled={showFilter}
title={$t('search')}
icon={mdiMagnify}
size="20"
onclick={() => {}}
/>
</div> </div>
</form> </form>
{#if showFilter}
<SearchFilterModal
{searchQuery}
onSearch={(payload) => handleSearch(payload)}
onClose={() => (showFilter = false)}
/>
{/if}
</div> </div>

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { serverConfig } from '$lib/stores/server-config.store';
import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils'; import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils';
import type { SharedLinkResponseDto } from '@immich/sdk'; import type { SharedLinkResponseDto } from '@immich/sdk';
import { mdiContentCopy } from '@mdi/js'; import { mdiContentCopy } from '@mdi/js';
@ -15,7 +14,7 @@
let { link, menuItem = false }: Props = $props(); let { link, menuItem = false }: Props = $props();
const handleCopy = async () => { const handleCopy = async () => {
await copyToClipboard(makeSharedLinkUrl($serverConfig.externalDomain, link.key)); await copyToClipboard(makeSharedLinkUrl(link.key));
}; };
</script> </script>

View File

@ -0,0 +1,114 @@
<script lang="ts">
interface Props {
label: string;
value?: string;
pinLength?: number;
tabindexStart?: number;
}
let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
let pinCodeInputElements: HTMLInputElement[] = $state([]);
$effect(() => {
if (value === '') {
pinValues = Array.from({ length: pinLength }).fill('');
}
});
const focusNext = (index: number) => {
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
};
const focusPrev = (index: number) => {
if (index > 0) {
pinCodeInputElements[index - 1]?.focus();
}
};
const handleInput = (event: Event, index: number) => {
const target = event.target as HTMLInputElement;
let currentPinValue = target.value;
if (target.value.length > 1) {
currentPinValue = value.slice(0, 1);
}
if (Number.isNaN(Number(value))) {
pinValues[index] = '';
target.value = '';
return;
}
pinValues[index] = currentPinValue;
value = pinValues.join('').trim();
if (value && index < pinLength - 1) {
focusNext(index);
}
};
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
const target = event.currentTarget as HTMLInputElement;
const index = pinCodeInputElements.indexOf(target);
switch (event.key) {
case 'Tab': {
return;
}
case 'Backspace': {
if (target.value === '' && index > 0) {
focusPrev(index);
pinValues[index - 1] = '';
} else if (target.value !== '') {
pinValues[index] = '';
}
return;
}
case 'ArrowLeft': {
if (index > 0) {
focusPrev(index);
}
return;
}
case 'ArrowRight': {
if (index < pinLength - 1) {
focusNext(index);
}
return;
}
default: {
if (Number.isNaN(Number(event.key))) {
event.preventDefault();
}
break;
}
}
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<label class="text-xs text-dark" for={pinCodeInputElements[0]?.id}>{label.toUpperCase()}</label>
{/if}
<div class="flex gap-2">
{#each { length: pinLength } as _, index (index)}
<input
tabindex={tabindexStart + index}
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
bind:value={pinValues[index]}
onkeydown={handleKeydown}
oninput={(event) => handleInput(event, index)}
aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
/>
{/each}
</div>
</div>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { handleError } from '$lib/utils/handle-error';
import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
import { Button } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let hasPinCode = $state(false);
let currentPinCode = $state('');
let newPinCode = $state('');
let confirmPinCode = $state('');
let isLoading = $state(false);
let canSubmit = $derived(
(hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
);
onMount(async () => {
const authStatus = await getAuthStatus();
hasPinCode = authStatus.pinCode;
});
const handleSubmit = async (event: Event) => {
event.preventDefault();
await (hasPinCode ? handleChange() : handleSetup());
};
const handleSetup = async () => {
isLoading = true;
try {
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
resetForm();
notificationController.show({
message: $t('pin_code_setup_successfully'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('unable_to_setup_pin_code'));
} finally {
isLoading = false;
hasPinCode = true;
}
};
const handleChange = async () => {
isLoading = true;
try {
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
resetForm();
notificationController.show({
message: $t('pin_code_changed_successfully'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('unable_to_change_pin_code'));
} finally {
isLoading = false;
}
};
const resetForm = () => {
currentPinCode = '';
newPinCode = '';
confirmPinCode = '';
};
</script>
<section class="my-4">
<div in:fade={{ duration: 200 }}>
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
<div class="flex flex-col gap-6 place-items-center place-content-center">
{#if hasPinCode}
<p class="text-dark">Change PIN code</p>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput
label={$t('confirm_new_pin_code')}
bind:value={confirmPinCode}
tabindexStart={13}
pinLength={6}
/>
{:else}
<p class="text-dark">{$t('setup_pin_code')}</p>
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput
label={$t('confirm_new_pin_code')}
bind:value={confirmPinCode}
tabindexStart={7}
pinLength={6}
/>
{/if}
</div>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{hasPinCode ? $t('save') : $t('create')}
</Button>
</div>
</form>
</div>
</section>

View File

@ -1,24 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte';
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants'; import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { oauth } from '$lib/utils'; import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk'; import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
import AppSettings from './app-settings.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import DeviceList from './device-list.svelte';
import OAuthSettings from './oauth-settings.svelte';
import PartnerSettings from './partner-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
import { t } from 'svelte-i18n';
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
import { import {
mdiAccountGroupOutline, mdiAccountGroupOutline,
mdiAccountOutline, mdiAccountOutline,
@ -29,11 +21,21 @@
mdiDownload, mdiDownload,
mdiFeatureSearchOutline, mdiFeatureSearchOutline,
mdiKeyOutline, mdiKeyOutline,
mdiLockSmart,
mdiOnepassword, mdiOnepassword,
mdiServerOutline, mdiServerOutline,
mdiTwoFactorAuthentication, mdiTwoFactorAuthentication,
} from '@mdi/js'; } from '@mdi/js';
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte'; import { t } from 'svelte-i18n';
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
import AppSettings from './app-settings.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import DeviceList from './device-list.svelte';
import OAuthSettings from './oauth-settings.svelte';
import PartnerSettings from './partner-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
interface Props { interface Props {
keys?: ApiKeyResponseDto[]; keys?: ApiKeyResponseDto[];
@ -135,6 +137,16 @@
<PartnerSettings user={$user} /> <PartnerSettings user={$user} />
</SettingAccordion> </SettingAccordion>
<SettingAccordion
icon={mdiLockSmart}
key="user-pin-code-settings"
title={$t('user_pin_code_settings')}
subtitle={$t('user_pin_code_settings_description')}
autoScrollTo={true}
>
<ChangePinCodeSettings />
</SettingAccordion>
<SettingAccordion <SettingAccordion
icon={mdiKeyOutline} icon={mdiKeyOutline}
key="user-purchase-settings" key="user-purchase-settings"

View File

@ -367,8 +367,6 @@ export enum SettingInputFieldType {
} }
export const AlbumPageViewMode = { export const AlbumPageViewMode = {
LINK_SHARING: 'link-sharing',
SELECT_USERS: 'select-users',
SELECT_THUMBNAIL: 'select-thumbnail', SELECT_THUMBNAIL: 'select-thumbnail',
SELECT_ASSETS: 'select-assets', SELECT_ASSETS: 'select-assets',
VIEW_USERS: 'view-users', VIEW_USERS: 'view-users',
@ -377,8 +375,6 @@ export const AlbumPageViewMode = {
}; };
export type AlbumPageViewMode = export type AlbumPageViewMode =
| typeof AlbumPageViewMode.LINK_SHARING
| typeof AlbumPageViewMode.SELECT_USERS
| typeof AlbumPageViewMode.SELECT_THUMBNAIL | typeof AlbumPageViewMode.SELECT_THUMBNAIL
| typeof AlbumPageViewMode.SELECT_ASSETS | typeof AlbumPageViewMode.SELECT_ASSETS
| typeof AlbumPageViewMode.VIEW_USERS | typeof AlbumPageViewMode.VIEW_USERS

View File

@ -3,9 +3,8 @@
import Dropdown from '$lib/components/elements/dropdown.svelte'; import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { serverConfig } from '$lib/stores/server-config.store'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils'; import { makeSharedLinkUrl } from '$lib/utils';
import { import {
AlbumUserRole, AlbumUserRole,
@ -20,23 +19,21 @@
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js'; import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../components/shared-components/user-avatar.svelte';
interface Props { interface Props {
album: AlbumResponseDto; album: AlbumResponseDto;
onClose: () => void; onClose: (result?: { action: 'sharedLink' } | { action: 'sharedUsers'; data: AlbumUserAddDto[] }) => void;
onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
onShare: () => void;
} }
let { album, onClose, onSelect, onShare }: Props = $props(); let { album, onClose }: Props = $props();
let users: UserResponseDto[] = $state([]); let users: UserResponseDto[] = $state([]);
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({}); let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
let sharedLinkUrl = $state(''); let sharedLinkUrl = $state('');
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => { const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
sharedLinkUrl = makeSharedLinkUrl($serverConfig.externalDomain, sharedLink.key); sharedLinkUrl = makeSharedLinkUrl(sharedLink.key);
}; };
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [ const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
@ -160,8 +157,10 @@
shape="round" shape="round"
disabled={Object.keys(selectedUsers).length === 0} disabled={Object.keys(selectedUsers).length === 0}
onclick={() => onclick={() =>
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))} onClose({
>{$t('add')}</Button action: 'sharedUsers',
data: Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
})}>{$t('add')}</Button
> >
</div> </div>
{/if} {/if}
@ -182,7 +181,13 @@
</Stack> </Stack>
{/if} {/if}
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button> <Button
leadingIcon={mdiLink}
size="small"
shape="round"
fullWidth
onclick={() => onClose({ action: 'sharedLink' })}>{$t('create_link')}</Button
>
</Stack> </Stack>
</FullScreenModal> </FullScreenModal>
{/if} {/if}

View File

@ -1,24 +1,23 @@
<script lang="ts"> <script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import QRCode from '$lib/components/shared-components/qrcode.svelte'; import QRCode from '$lib/components/shared-components/qrcode.svelte';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { HStack, IconButton, Input } from '@immich/ui'; import { HStack, IconButton, Input, Modal, ModalBody } from '@immich/ui';
import { mdiContentCopy, mdiLink } from '@mdi/js'; import { mdiContentCopy, mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
title: string; title: string;
onClose: () => void;
value: string; value: string;
onClose: () => void;
}; };
let { onClose, title, value }: Props = $props(); let { title, value, onClose }: Props = $props();
let modalWidth = $state(0); let modalWidth = $state(0);
</script> </script>
<FullScreenModal {title} icon={mdiLink} {onClose}> <Modal {title} icon={mdiLink} {onClose} size="small">
<div class="w-full"> <ModalBody>
<div class="w-full py-2 px-10"> <div class="w-full py-2 px-10">
<div bind:clientWidth={modalWidth} class="w-full"> <div bind:clientWidth={modalWidth} class="w-full">
<QRCode {value} width={modalWidth} /> <QRCode {value} width={modalWidth} />
@ -37,5 +36,5 @@
/> />
</div> </div>
</HStack> </HStack>
</div> </ModalBody>
</FullScreenModal> </Modal>

View File

@ -1,8 +1,8 @@
<script lang="ts" module> <script lang="ts" module>
import { MediaType, QueryType, validQueryTypes } from '$lib/constants'; import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from './search-date-section.svelte'; import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte'; import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
import type { SearchLocationFilter } from './search-location-section.svelte'; import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
export type SearchFilter = { export type SearchFilter = {
query: string; query: string;
@ -19,32 +19,32 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import SearchCameraSection, {
type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
import SearchMediaSection from '$lib/components/shared-components/search-bar/search-media-section.svelte';
import SearchPeopleSection from '$lib/components/shared-components/search-bar/search-people-section.svelte';
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { parseUtcDate } from '$lib/utils/date-time'; import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button } from '@immich/ui'; import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js'; import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
import SearchDateSection from './search-date-section.svelte';
import SearchDisplaySection from './search-display-section.svelte';
import SearchLocationSection from './search-location-section.svelte';
import SearchMediaSection from './search-media-section.svelte';
import SearchPeopleSection from './search-people-section.svelte';
import SearchRatingsSection from './search-ratings-section.svelte';
import SearchTagsSection from './search-tags-section.svelte';
import SearchTextSection from './search-text-section.svelte';
interface Props { interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto; searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: () => void; onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
} }
let { searchQuery, onClose, onSearch }: Props = $props(); let { searchQuery, onClose }: Props = $props();
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined); const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
@ -141,7 +141,7 @@
rating: filter.rating, rating: filter.rating,
}; };
onSearch(payload); onClose(payload);
}; };
const onreset = (event: Event) => { const onreset = (event: Event) => {
@ -161,44 +161,50 @@
}); });
</script> </script>
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}> <Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}>
<form id={formId} autocomplete="off" {onsubmit} {onreset}> <ModalBody>
<div class="space-y-10 pb-10" tabindex="-1"> <form id={formId} autocomplete="off" {onsubmit} {onreset}>
<!-- PEOPLE --> <div class="space-y-10 pb-10" tabindex="-1">
<SearchPeopleSection bind:selectedPeople={filter.personIds} /> <!-- PEOPLE -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
<!-- TEXT --> <!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} /> <SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS --> <!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} /> <SearchTagsSection bind:selectedTags={filter.tagIds} />
<!-- LOCATION --> <!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} /> <SearchLocationSection bind:filters={filter.location} />
<!-- CAMERA MODEL --> <!-- CAMERA MODEL -->
<SearchCameraSection bind:filters={filter.camera} /> <SearchCameraSection bind:filters={filter.camera} />
<!-- DATE RANGE --> <!-- DATE RANGE -->
<SearchDateSection bind:filters={filter.date} /> <SearchDateSection bind:filters={filter.date} />
<!-- RATING --> <!-- RATING -->
{#if $preferences?.ratings.enabled} {#if $preferences?.ratings.enabled}
<SearchRatingsSection bind:rating={filter.rating} /> <SearchRatingsSection bind:rating={filter.rating} />
{/if} {/if}
<div class="grid md:grid-cols-2 gap-x-5 gap-y-10"> <div class="grid md:grid-cols-2 gap-x-5 gap-y-10">
<!-- MEDIA TYPE --> <!-- MEDIA TYPE -->
<SearchMediaSection bind:filteredMedia={filter.mediaType} /> <SearchMediaSection bind:filteredMedia={filter.mediaType} />
<!-- DISPLAY OPTIONS --> <!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} /> <SearchDisplaySection bind:filters={filter.display} />
</div>
</div> </div>
</div> </form>
</form> </ModalBody>
{#snippet stickyBottom()} <ModalFooter>
<Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}>{$t('clear_all')}</Button> <div class="flex gap-3 w-full">
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button> <Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}
{/snippet} >{$t('clear_all')}</Button
</FullScreenModal> >
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button>
</div>
</ModalFooter>
</Modal>

View File

@ -0,0 +1,235 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { NotificationType, notificationController } from '../components/shared-components/notification/notification';
import SettingInputField from '../components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '../components/shared-components/settings/setting-switch.svelte';
interface Props {
onClose: (sharedLink?: SharedLinkResponseDto) => void;
albumId?: string | undefined;
assetIds?: string[];
editingLink?: SharedLinkResponseDto | undefined;
}
let { onClose, albumId = $bindable(undefined), assetIds = $bindable([]), editingLink = undefined }: Props = $props();
let sharedLink: string | null = $state(null);
let description = $state('');
let allowDownload = $state(true);
let allowUpload = $state(false);
let showMetadata = $state(true);
let expirationOption: number = $state(0);
let password = $state('');
let shouldChangeExpirationTime = $state(false);
let enablePassword = $state(false);
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'],
[1, 'hour'],
[6, 'hours'],
[1, 'day'],
[7, 'days'],
[30, 'days'],
[3, 'months'],
[1, 'year'],
];
let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
let expiredDateOptions = $derived([
{ text: $t('never'), value: 0 },
...expirationOptions.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
})),
]);
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
allowDownload = false;
}
});
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
if (editingLink.password) {
password = editingLink.password;
}
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showMetadata = editingLink.showMetadata;
albumId = editingLink.album?.id;
assetIds = editingLink.assets.map(({ id }) => id);
enablePassword = !!editingLink.password;
}
const handleCreateSharedLink = async () => {
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined;
try {
const data = await createSharedLink({
sharedLinkCreateDto: {
type: shareType,
albumId,
assetIds,
expiresAt: expirationDate,
allowUpload,
description,
password,
allowDownload,
showMetadata,
},
});
onClose(data);
} catch (error) {
handleError(error, $t('errors.failed_to_create_shared_link'));
}
};
const handleEditLink = async () => {
if (!editingLink) {
return;
}
try {
const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null;
await updateSharedLink({
id: editingLink.id,
sharedLinkEditDto: {
description,
password: enablePassword ? password : '',
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload,
allowDownload,
showMetadata,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('edited'),
});
onClose();
} catch (error) {
handleError(error, $t('errors.failed_to_edit_shared_link'));
}
};
const getTitle = () => {
if (sharedLink) {
return $t('view_link');
}
if (editingLink) {
return $t('edit_link');
}
return $t('create_link_to_share');
};
</script>
<Modal title={getTitle()} icon={mdiLink} size="small" {onClose}>
<ModalBody>
{#if shareType === SharedLinkType.Album}
{#if !editingLink}
<div>{$t('album_with_link_access')}</div>
{:else}
<div class="text-sm">
{$t('public_album')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
</div>
{/if}
{/if}
{#if shareType === SharedLinkType.Individual}
{#if !editingLink}
<div>{$t('create_link_to_share_description')}</div>
{:else}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
</div>
{/if}
{/if}
<div class="mb-2 mt-4">
<p class="text-xs">{$t('link_options').toUpperCase()}</p>
</div>
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
<div class="flex flex-col">
<div class="mb-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('description')}
bind:value={description}
/>
</div>
<div class="mb-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('password')}
bind:value={password}
disabled={!enablePassword}
/>
</div>
<div class="my-3">
<SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
</div>
<div class="my-3">
<SettingSwitch
bind:checked={allowDownload}
title={$t('allow_public_user_to_download')}
disabled={!showMetadata}
/>
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowUpload} title={$t('allow_public_user_to_upload')} />
</div>
{#if editingLink}
<div class="my-3">
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
</div>
{/if}
<div class="mt-3">
<SettingSelect
bind:value={expirationOption}
options={expiredDateOptions}
label={$t('expire_after')}
disabled={editingLink && !shouldChangeExpirationTime}
number={true}
/>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
{#if editingLink}
<Button fullWidth onclick={handleEditLink}>{$t('confirm')}</Button>
{:else}
<Button fullWidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
{/if}
</ModalFooter>
</Modal>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { Modal, ModalBody } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '../components/elements/icon.svelte';
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
interface Props {
onClose: () => void;
shortcuts?: Shortcuts;
}
let {
onClose,
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
],
actions: [
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
{ key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['l'], action: $t('add_to_album') },
{ key: ['⇧', 'l'], action: $t('add_to_shared_album') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
],
},
}: Props = $props();
</script>
<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
<ModalBody>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
{#if shortcuts.general.length > 0}
<div class="p-4">
<h2>{$t('general')}</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
{/if}
{#if shortcuts.actions.length > 0}
<div class="p-4">
<h2>{$t('actions')}</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon path={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</ModalBody>
</Modal>

View File

@ -6,14 +6,17 @@
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js'; import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
user: UserAdminResponseDto; user: UserAdminResponseDto;
canResetPassword?: boolean; canResetPassword?: boolean;
onClose: ( onClose: (
data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string }, data?:
| { action: 'update'; data: UserAdminResponseDto }
| { action: 'resetPassword'; data: string }
| { action: 'resetPinCode' },
) => void; ) => void;
} }
@ -76,6 +79,24 @@
} }
}; };
const resetUserPincode = async () => {
const isConfirmed = await modalManager.openDialog({
prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
return;
}
try {
await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
onClose({ action: 'resetPinCode' });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}
};
// TODO move password reset server-side // TODO move password reset server-side
function generatePassword(length: number = 16) { function generatePassword(length: number = 16) {
let generatedPassword = ''; let generatedPassword = '';
@ -151,13 +172,34 @@
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div class="flex gap-3 w-full"> <div class="w-full">
{#if canResetPassword} <div class="flex gap-3 w-full">
<Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} {#if canResetPassword}
>{$t('reset_password')}</Button <Button
shape="round"
color="warning"
variant="filled"
fullWidth
onclick={resetPassword}
leadingIcon={mdiOnepassword}
>
{$t('reset_password')}</Button
>
{/if}
<Button
shape="round"
color="warning"
variant="filled"
fullWidth
onclick={resetUserPincode}
leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button
> >
{/if} </div>
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
<div class="w-full mt-4">
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
</div>
</div> </div>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View File

@ -14,11 +14,8 @@
const handleRestoreUser = async () => { const handleRestoreUser = async () => {
try { try {
const { deletedAt } = await restoreUserAdmin({ id: user.id }); await restoreUserAdmin({ id: user.id });
onClose(true);
if (deletedAt === undefined) {
onClose(true);
}
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_restore_user')); handleError(error, $t('errors.unable_to_restore_user'));
} }

View File

@ -2,6 +2,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
import { defaultLang, langs, locales } from '$lib/constants'; import { defaultLang, langs, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { lang } from '$lib/stores/preferences.store'; import { lang } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
AssetJobName, AssetJobName,
@ -256,8 +257,8 @@ export const copyToClipboard = async (secret: string) => {
} }
}; };
export const makeSharedLinkUrl = (externalDomain: string, key: string) => { export const makeSharedLinkUrl = (key: string) => {
return new URL(`share/${key}`, externalDomain || globalThis.location.origin).href; return new URL(`share/${key}`, get(serverConfig).externalDomain || globalThis.location.origin).href;
}; };
export const oauth = { export const oauth = {

Some files were not shown because too many files have changed in this diff Show More