Compare commits

...

32 Commits

Author SHA1 Message Date
Alex Tran
b34de624ce Added changelog for F-droid 2022-05-29 09:11:22 -05:00
Alex Tran
7886c42742 Update Fastlane iOS build version + speicify database container to restart always 2022-05-29 08:42:27 -05:00
Alex
d476b15312 Implemented user profile upload and show on web/mobile (#191)
* Update mobile dependencies

* Added image picker

* Added mechanism to upload profile image

* Added image type to send to web

* Added styling for circle avatar

* Fixxed issue with sharp cannot resize image properly

* Finished displaying and uploading user profile

* Added user profile to web
2022-05-28 22:35:45 -05:00
Alex Tran
bdf38e7668 Added endpoint for getting user profile image 2022-05-27 22:24:58 -05:00
Alex Tran
e33566a04a Upload profile picture and convert into webp 2022-05-27 22:15:35 -05:00
Alex
c28251b8b4 [WEB] View large images on web (#189)
* Added selection icon to thumbnail

* Added micro-interaction and video file indication

* Added page to add page

* Added image viewer

* navigate assets

* Added separate component for viewing the video file

* Added FFmpeg modules

* Added correct content-type header for serving image file

* Added loading spinner
2022-05-27 14:02:06 -05:00
Alex Tran
337db1c508 fixed typo for bug template 2022-05-26 15:12:08 -05:00
Alex Tran
ad2a1ba901 Update and add issue templates 2022-05-26 15:10:50 -05:00
Alex Tran
fa6f6f8e9f Fixed default DB_HOSTNAME to be the correct database container 2022-05-24 08:49:38 -05:00
Pavle Portic
a44043a4e5 Add ability to pass redis hostname as env var (#174)
* Add ability to pass redis hostname as env var

* Read postgres host from env var in microservices

* Update .env.example with postgres and redis hostname vars
2022-05-23 17:23:02 -05:00
shruuub
87b15c60c0 Fix typos and grammar, plus change Linux to Unix (#180)
Fixed Readme typo
2022-05-23 11:58:00 -05:00
Alex
2c83e52c15 Added dependency in docker-compose (#173)
* Added dependency in docker-compose
* downgrade sharp version to 0.28
2022-05-22 10:15:38 -05:00
Alex
55c5027539 Add webp thumbnail conversion task to optimize performance of fast scrolling (#172)
* Update readme

* Added webp to table and entity

* Added cronjob and sharp dependencies

* Added conversion of webp every 5 minutes and endpoint will now server webp image if exist
2022-05-22 06:56:36 -05:00
Alex
ce06af0c9b Fixed lodash library not invoking in production build (#171)
* Added staging docker-compose file
* Use lodash-es and remove hydration option on photos page fixed the problem
2022-05-22 04:48:38 -05:00
Alex Tran
baaf7ad153 disable hydration 2022-05-21 23:50:54 -05:00
Alex Tran
4a25e7dc22 remove footer to fix error when reloading the page 2022-05-21 23:36:58 -05:00
Alex Tran
f90563d18c not prerender photos page 2022-05-21 23:28:02 -05:00
Alex Tran
8352ecd3b9 Update readme 2022-05-21 18:53:30 -05:00
Alex Tran
69b34a4364 Update readme 2022-05-21 18:49:23 -05:00
Alex
6023c3c624 Show assets on web (#168)
* Implemented lazy loading thumbnail
* Display assets as date-time grouping
* Update Readme
* Modify GitHub action to run from the latest update
2022-05-21 16:50:56 -05:00
Alex Tran
171e7ffa77 Update readme 2022-05-21 08:30:27 -05:00
Alex Tran
d9f918005a Added python3 to prod target of web Dockerfile 2022-05-21 02:34:39 -05:00
Alex Tran
e8ade4866b Added python3 to docker image of web 2022-05-21 02:30:00 -05:00
Alex Tran
bbfa789a4e update readme 2022-05-21 02:25:15 -05:00
Alex
a779c3803c Add web interface with admin functionality (#167) 2022-05-21 02:23:55 -05:00
Jaime Baez
79dea504b0 Add e2e testing setup (#163)
* Setup e2e testing

* Add user e2e tests

* Rename database host env variable to DB_HOST

* Force push (try to recover DB_HOST env)

* Rename db host env variable to `DB_HOSTNAME`

* Remove unnecessary `initDb` from test-utils

The current database.config is running the migrations:
`migrationsRun: true`
2022-05-19 18:30:47 -05:00
Migelo
4900fecd10 fix immich-server service name in README (#166) 2022-05-18 06:26:37 -04:00
Alex
adfaab7eb2 Update to flutter 3 (#162) 2022-05-14 09:25:19 -05:00
Alex
c5adbea6e1 Fixed incorrect microservices URLs after updating dockerfiles (#159) 2022-05-11 06:18:11 -05:00
Alex
bb89fa4aab Modify docker-compose to be compatible with k8s (#149)
* Modify docker-compose file using a hyphen for services instead of underscore

* Change URL in Nginx setting
2022-05-08 07:07:58 -05:00
Alex
43d639104d Bug/fixed permission not requested android 10 (#150)
* Added  android:requestLegacyExternalStorage=true to manifest

* Up pubspec version code for android build
2022-05-08 06:47:38 -05:00
Alex
a1792a7d94 Pump docker-compose container version 2022-05-06 07:47:19 -05:00
140 changed files with 10163 additions and 390 deletions

View File

@@ -1,15 +1,26 @@
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve
title: "[BUG]" title: '[BUG] <title>'
labels: bug labels: bug, need triage
assignees: '' assignees: ''
--- ---
<!--
Note: Please search to see if an issue already exists for the bug you encountered.
-->
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Task List**
[ ] I have read thoroughly the README setup and installation instructions.
[ ] If my setup is different, I have included my docker-compose file.
[ ] I have included my redacted `.env` file.
[ ] I have included information on my machine, and environment.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,32 @@
name: Feature Request
description: Request a feature that you would like for the app
title: "[Feature]: "
labels: ["feature", "need triage"]
assignees:
- ""
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: feature-detail
attributes:
label: Feature detail
placeholder: Describe the feature you would like to see for the app
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Choose the platform for the feature
options:
- Web
- Mobile App
- Server
validations:
required: true

View File

@@ -8,14 +8,15 @@ on:
branches: [main] branches: [main]
jobs: jobs:
build_and_push_server_latest: build_and_push_server_latest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: "main" # branch # ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0 uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -37,28 +38,59 @@ jobs:
altran1502/immich-server:latest altran1502/immich-server:latest
build_and_push_microservice_latest: build_and_push_microservice_latest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: "main" # branch # ref: "main" # branch
- name: Set up QEMU fetch-depth: 0
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx - name: Set up QEMU
id: buildx uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-buildx-action@v2.0.0 - name: Set up Docker Buildx
- name: Login to Docker Hub id: buildx
uses: docker/login-action@v2 uses: docker/setup-buildx-action@v2.0.0
with: - name: Login to Docker Hub
username: ${{ secrets.DOCKERHUB_USERNAME }} uses: docker/login-action@v2
password: ${{ secrets.DOCKERHUB_TOKEN }} with:
- name: Build and Push Microservices username: ${{ secrets.DOCKERHUB_USERNAME }}
uses: docker/build-push-action@v3.0.0 password: ${{ secrets.DOCKERHUB_TOKEN }}
with: - name: Build and Push Microservices
context: ./microservices uses: docker/build-push-action@v3.0.0
file: ./microservices/Dockerfile with:
platforms: linux/arm/v7,linux/amd64 context: ./microservices
push: ${{ github.event_name != 'pull_request' }} file: ./microservices/Dockerfile
tags: | platforms: linux/arm/v7,linux/amd64
altran1502/immich-microservices:latest push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:latest
build_and_push_web_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# ref: "main" # branch
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
target: prod
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-web:latest

View File

@@ -15,11 +15,11 @@ jobs:
ref: "main" ref: "main"
fetch-depth: 0 fetch-depth: 0
- name: 'Get Previous tag' - name: "Get Previous tag"
id: previoustag id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
fallback: latest fallback: latest
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0 uses: docker/setup-qemu-action@v2.0.0
@@ -53,11 +53,11 @@ jobs:
ref: "main" ref: "main"
fetch-depth: 0 fetch-depth: 0
- name: 'Get Previous tag' - name: "Get Previous tag"
id: previoustag id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
fallback: latest fallback: latest
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0 uses: docker/setup-qemu-action@v2.0.0
@@ -80,4 +80,43 @@ jobs:
platforms: linux/arm/v7,linux/amd64 platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: | tags: |
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }} altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
build_and_push_web_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
fetch-depth: 0
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-web release
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
target: prod
tags: |
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}

View File

@@ -7,6 +7,12 @@ dev-update:
dev-scale: dev-scale:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test
prod: prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans

9
NOTES.md Normal file
View File

@@ -0,0 +1,9 @@
# TODO
Server scenario with web
[ ] 1 user exist without admin right -> make admin on first check
[ ] 2 users exist without admin right -> ask user to choose which account will be the admin
[ X ] No users exist -> prompt signup form for Admin

View File

@@ -31,6 +31,7 @@ Loading ~4000 images/videos
## Screenshots ## Screenshots
### Mobile client
<p align="left"> <p align="left">
<img src="design/login-screen.png" width="150" title="Login With Custom URL"> <img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/backup-screen.png" width="150" title="Backup Setting Info"> <img src="design/backup-screen.png" width="150" title="Backup Setting Info">
@@ -39,14 +40,18 @@ Loading ~4000 images/videos
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> <img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums"> <img src="design/shared-albums.png" width="150" title="Shared Albums">
<img src="design/nsc6.png" width="150" title="EXIF Info"> <img src="design/nsc6.png" width="150" title="EXIF Info">
</p>
### Web client
<p align="center">
<img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard">
</p> </p>
# Note # Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!** **!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continous functions, features and api changes. This project is under heavy development, there will be continuous functions, features and api changes.
# Features # Features
@@ -67,29 +72,30 @@ This project is under heavy development, there will be continous functions, feat
- Show curated objects on the search page - Show curated objects on the search page
- Shared album with users on the same server - Shared album with users on the same server
- Selective backup - albums can be included and excluded during the backup process. - Selective backup - albums can be included and excluded during the backup process.
- Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming.
# System Requirement # System Requirement
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
I haven't tested with `Docker for Windows` as well as `WSL` on Windows I haven't tested with `Docker for Windows` as well as `WSL` on Windows
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Dockec image on arm64v7 yet.* *Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Docker image on arm64v7 yet.*
**RAM**: At least 2GB, preffered 4GB. **RAM**: At least 2GB, preffered 4GB.
**Core**: At least 2 cores, preffered 4 cores. **Core**: At least 2 cores, preffered 4 cores.
# Development and Testing out the application # Getting Started
You can use docker compose for development and testing out the application, there are several services that compose Immich: You can use docker compose for development and testing out the application, there are several services that compose Immich:
1. **NestJs** - Backend of the application 1. **NestJs** - Backend of the application
2. **PostgreSQL** - Main database of the application 2. **SvelteKit** - Web frontend of the application
3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. 3. **PostgreSQL** - Main database of the application
4. **Nginx** - Load balancing and optimized file uploading. 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
5. **TensorFlow** - Object Detection and Image Classification. 5. **Nginx** - Load balancing and optimized file uploading.
6. **TensorFlow** - Object Detection and Image Classification.
## Step 1: Populate .env file ## Step 1: Populate .env file
@@ -108,52 +114,75 @@ Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is own
**Example** **Example**
```bash ```bash
###################################################################################
# Database # Database
###################################################################################
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_DATABASE_NAME=immich DB_DATABASE_NAME=immich
###################################################################################
# Upload File Config # Upload File Config
###################################################################################
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here> UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
###################################################################################
# JWT SECRET # JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX # MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false ENABLE_MAPBOX=false
MAPBOX_KEY= MAPBOX_KEY=
###################################################################################
# WEB
###################################################################################
# This is the URL of your vm/server where you host Immich, so that the web frontend
# know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
VITE_SERVER_ENDPOINT=http://192.168.1.216:2283
``` ```
## Step 2: Start the server ## Step 2: Start the server
To start, run To **start**, run
```bash ```bash
docker-compose -f ./docker/docker-compose.yml up docker-compose -f ./docker/docker-compose.yml up
``` ```
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling. If you have a few thousand photos/videos, I suggest running docker-compose with *scaling* option for the `immich_server` container to handle high I/O load when using fast scrolling.
```bash ```bash
docker-compose -f ./docker/docker-compose.yml up --scale immich_server=5 docker-compose -f ./docker/docker-compose.yml up --scale immich-server=5
``` ```
To *update* docker-compose with newest image (if you have started the docker-compose previously)
```bash
docker-compose -f ./docker/docker-compose.yml pull && docker-compose -f ./docker/docker-compose.yml up
```
The server will be running at `http://your-ip:2283` through `Nginx` The server will be running at `http://your-ip:2283` through `Nginx`
## Step 3: Register User ## Step 3: Register User
Use the command below on your terminal to create user as we don't have user interface for this function yet. Access the web interface at `http://your-ip:2285` to register an admin account.
```bash <p align="left">
curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \ <img src="design/admin-registration-form.png" width="300" title="Admin Registration">
--header 'Content-Type: application/json' \ <p/>
--data-raw '{
"email": "testuser@email.com", Additional accounts on the server can be created by the admin account.
"password": "password"
}' <p align="left">
``` <img src="design/admin-interface.png" width="500" title="Admin User Management">
<p/>
## Step 4: Run mobile app ## Step 4: Run mobile app
@@ -188,15 +217,26 @@ You can get the app on F-droid by clicking the image below.
<img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store">
<p/> <p/>
# Development
The development environment can be started from the root of the project after populating the `.env` file with the command:
```bash
make dev # required Makefile installed on the system.
```
All servers and web container are hot reload for quick feedback loop.
# Support # Support
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsore**](https://github.com/sponsors/alextran1502), or one time donation with Buy Me a coffee link below. If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsor**](https://github.com/sponsors/alextran1502), or a one time donation with the Buy Me a coffee link below.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/altran1502) [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/altran1502)
This is also a meaningful way to give me motivation and encounragment to continue working on the app. This is also a meaningful way to give me motivation and encouragement to continue working on the app.
Cheer! 🎉 Cheers! 🎉
# Known Issue # Known Issue
@@ -204,13 +244,13 @@ Cheer! 🎉
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup* *This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup*
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`: TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
```bash ```bash
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags
``` ```
If you are running virtualization in Promox, the VM doesn't have the flag enable. If you are running virtualization in Promox, the VM doesn't have the flag enabled.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab. You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

BIN
design/admin-interface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -1,15 +1,63 @@
###################################################################################
# Database # Database
###################################################################################
DB_HOSTNAME=immich_postgres
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_DATABASE_NAME=immich DB_DATABASE_NAME=immich
###################################################################################
# Redis
###################################################################################
REDIS_HOSTNAME=immich_redis
###################################################################################
# Upload File Config # Upload File Config
###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# JWT SECRET # JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX # MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false ENABLE_MAPBOX=false
MAPBOX_KEY= MAPBOX_KEY=
###################################################################################
# WEB
###################################################################################
# This is the URL of your vm/server where you host Immich, so that the web frontend
# know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
# !CAUTION! THERE IS NO FORWARD SLASH AT THE END
VITE_SERVER_ENDPOINT=

16
docker/.env.test Normal file
View File

@@ -0,0 +1,16 @@
DB_HOSTNAME=immich_postgres_test
# Database
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=e2e_test
# Upload File Config
UPLOAD_LOCATION=./upload
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=

View File

@@ -1,7 +1,7 @@
version: "3.8" version: "3.8"
services: services:
immich_server: immich-server:
image: immich-server-dev:1.9.0 image: immich-server-dev:1.9.0
build: build:
context: ../server context: ../server
@@ -21,9 +21,9 @@ services:
- redis - redis
- database - database
networks: networks:
- immich_network - immich-network
immich_microservices: immich-microservices:
image: immich-microservices-dev:1.9.0 image: immich-microservices-dev:1.9.0
build: build:
context: ../microservices context: ../microservices
@@ -42,14 +42,32 @@ services:
depends_on: depends_on:
- database - database
networks: networks:
- immich_network - immich-network
immich-web:
image: immich-web-dev:1.9.0
build:
context: ../web
dockerfile: Dockerfile
target: dev
command: npm run dev --host
env_file:
- .env
ports:
- 3002:3002
- 24678:24678
volumes:
- ../web:/usr/src/app
- /usr/src/app/node_modules
networks:
- immich-network
restart: always
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks: networks:
- immich_network - immich-network
database: database:
container_name: immich_postgres container_name: immich_postgres
@@ -66,7 +84,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
networks: networks:
- immich_network - immich-network
nginx: nginx:
container_name: proxy_nginx container_name: proxy_nginx
@@ -79,11 +97,11 @@ services:
logging: logging:
driver: none driver: none
networks: networks:
- immich_network - immich-network
depends_on: depends_on:
- immich_server - immich-server
networks: networks:
immich_network: immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -1,7 +1,7 @@
version: "3.8" version: "3.8"
services: services:
immich_server: immich-server:
image: immich-server-dev:1.9.0 image: immich-server-dev:1.9.0
build: build:
context: ../server context: ../server
@@ -19,9 +19,9 @@ services:
- redis - redis
- database - database
networks: networks:
- immich_network - immich-network
immich_microservices: immich-microservices:
image: immich-microservices-dev:1.9.0 image: immich-microservices-dev:1.9.0
build: build:
context: ../microservices context: ../microservices
@@ -46,13 +46,13 @@ services:
- database - database
- immich_server - immich_server
networks: networks:
- immich_network - immich-network
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks: networks:
- immich_network - immich-network
database: database:
container_name: immich_postgres container_name: immich_postgres
@@ -69,7 +69,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
networks: networks:
- immich_network - immich-network
nginx: nginx:
container_name: proxy_nginx container_name: proxy_nginx
@@ -82,11 +82,11 @@ services:
logging: logging:
driver: none driver: none
networks: networks:
- immich_network - immich-network
depends_on: depends_on:
- immich_server - immich-server
networks: networks:
immich_network: immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -0,0 +1,105 @@
version: "3.8"
services:
immich-server:
image: immich-server-staging:latest
build:
context: ../server
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3000"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich-network
restart: always
immich-microservices:
image: immich-microservices-staging:latest
build:
context: ../microservices
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- database
networks:
- immich-network
restart: always
immich-web:
image: immich-web-staging:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
build:
context: ../web
dockerfile: Dockerfile
target: prod
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
container_name: immich_postgres
image: postgres:14
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich-network
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich-network
depends_on:
- immich-server
restart: always
networks:
immich-network:
volumes:
pgdata:

View File

@@ -0,0 +1,52 @@
version: "3.8"
services:
immich_server_test:
image: immich-server-dev:1.9.0
build:
context: ../server
dockerfile: Dockerfile
command: npm run test:e2e
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
env_file:
- .env.test
environment:
- NODE_ENV=development
depends_on:
- redis
- database
networks:
- immich_network_test
redis:
container_name: immich_redis_test
image: redis:6.2
networks:
- immich_network_test
database:
container_name: immich_postgres_test
image: postgres:14
env_file:
- .env.test
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata-test:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network_test
networks:
immich_network_test:
volumes:
pgdata-test:

View File

@@ -1,8 +1,8 @@
version: "3.8" version: "3.8"
services: services:
immich_server: immich-server:
image: altran1502/immich-server:v1.8.0_12-dev image: altran1502/immich-server:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3000" - "3000"
@@ -16,11 +16,11 @@ services:
- redis - redis
- database - database
networks: networks:
- immich_network - immich-network
restart: unless-stopped restart: always
immich_microservices: immich-microservices:
image: altran1502/immich-microservices:v1.8.0_12-dev image: altran1502/immich-microservices:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3001" - "3001"
@@ -33,14 +33,28 @@ services:
depends_on: depends_on:
- database - database
networks: networks:
- immich_network - immich-network
restart: unless-stopped restart: always
immich-web:
image: altran1502/immich-web:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks: networks:
- immich_network - immich-network
restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
@@ -57,7 +71,8 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
networks: networks:
- immich_network - immich-network
restart: always
nginx: nginx:
container_name: proxy_nginx container_name: proxy_nginx
@@ -70,11 +85,12 @@ services:
logging: logging:
driver: none driver: none
networks: networks:
- immich_network - immich-network
depends_on: depends_on:
- immich_server - immich-server
restart: always
networks: networks:
immich_network: immich-network:
volumes: volumes:
pgdata: pgdata:

View File

@@ -41,6 +41,6 @@ server {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_pass http://immich_server:3000; proxy_pass http://immich-server:3000;
} }
} }

View File

@@ -2,7 +2,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = { export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres', type: 'postgres',
host: 'immich_postgres', host: process.env.DB_HOSTNAME || 'immich_postgres',
port: 5432, port: 5432,
username: process.env.DB_USERNAME, username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,

View File

@@ -5,7 +5,7 @@ import { ImageClassifierService } from './image-classifier.service';
export class ImageClassifierController { export class ImageClassifierController {
constructor( constructor(
private readonly imageClassifierService: ImageClassifierService, private readonly imageClassifierService: ImageClassifierService,
) {} ) { }
@Post('/tagImage') @Post('/tagImage')
async tagImage(@Body('thumbnailPath') thumbnailPath: string) { async tagImage(@Body('thumbnailPath') thumbnailPath: string) {

View File

@@ -1,11 +1,12 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { ObjectDetectionService } from './object-detection.service'; import { ObjectDetectionService } from './object-detection.service';
import { Logger } from '@nestjs/common';
@Controller('object-detection') @Controller('object-detection')
export class ObjectDetectionController { export class ObjectDetectionController {
constructor( constructor(
private readonly objectDetectionService: ObjectDetectionService, private readonly objectDetectionService: ObjectDetectionService,
) {} ) { }
@Post('/detectObject') @Post('/detectObject')
async detectObject(@Body('thumbnailPath') thumbnailPath: string) { async detectObject(@Body('thumbnailPath') thumbnailPath: string) {

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher"> <application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user

View File

@@ -1,16 +1,21 @@
// Generated file. // Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file. // If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app; package io.flutter.app;
import android.app.Application;
import android.content.Context; import android.content.Context;
import androidx.annotation.CallSuper; import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex; import androidx.multidex.MultiDex;
/** /**
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. * Extension of {@link android.app.Application}, adding multidex support.
*/ */
public class FlutterMultiDexApplication extends FlutterApplication { public class FlutterMultiDexApplication extends Application {
@Override @Override
@CallSuper @CallSuper
protected void attachBaseContext(Context base) { protected void attachBaseContext(Context base) {

View File

@@ -0,0 +1 @@
* Hotfix: Permission is being requested now when open backup screen on Android10

View File

@@ -0,0 +1 @@
* User can now upload profile picture from the home page control drawer.

View File

@@ -9,6 +9,8 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
@@ -30,6 +32,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
@@ -50,6 +53,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_udid/ios" :path: ".symlinks/plugins/flutter_udid/ios"
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios: path_provider_ios:
@@ -66,10 +71,11 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>1.10.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>14</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -43,6 +43,12 @@
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -68,5 +74,7 @@
<true /> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false /> <false />
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
</dict> </dict>
</plist> </plist>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.9.0" version_number: "1.10.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
[bundle exec] fastlane ios beta [bundle exec] fastlane ios beta
``` ```
iOS deployment iOS Beta
---- ----

View File

@@ -5,27 +5,12 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000332"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000946">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: latest_testflight_build_number" time="4.608292"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="16.3225">
</testcase>
<testcase classname="fastlane.lanes" name="2: increment_build_number" time="0.747162">
</testcase>
<testcase classname="fastlane.lanes" name="3: build_app" time="88.727281">
</testcase>
<testcase classname="fastlane.lanes" name="4: upload_to_testflight" time="7.79397">
</testcase> </testcase>

View File

@@ -76,7 +76,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
} }
Future<void> initApp() async { Future<void> initApp() async {
WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
@override @override
@@ -87,7 +87,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance?.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }

View File

@@ -38,5 +38,7 @@ class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is HiveBackupAlbumsAdapter && runtimeType == other.runtimeType && typeId == other.typeId; other is HiveBackupAlbumsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
} }

View File

@@ -73,7 +73,7 @@ class BackupService {
}); });
// Build thumbnail multipart data // Build thumbnail multipart data
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280)); var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) { if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes( thumbnailUploadData = MultipartFile.fromBytes(
List.from(thumbnailData), List.from(thumbnailData),

View File

@@ -45,12 +45,16 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
LinearPercentIndicator( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
lineHeight: 5.0, child: LinearPercentIndicator(
percent: backupState.serverInfo.diskUsagePercentage / 100.0, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
backgroundColor: Colors.grey, barRadius: const Radius.circular(2),
progressColor: Theme.of(context).primaryColor, lineHeight: 6.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 12.0), padding: const EdgeInsets.only(top: 12.0),

View File

@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
enum UploadProfileStatus {
idle,
loading,
success,
failure,
}
class UploadProfileImageState {
// enum
final UploadProfileStatus status;
final String profileImagePath;
UploadProfileImageState({
required this.status,
required this.profileImagePath,
});
UploadProfileImageState copyWith({
UploadProfileStatus? status,
String? profileImagePath,
}) {
return UploadProfileImageState(
status: status ?? this.status,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'status': status.index});
result.addAll({'profileImagePath': profileImagePath});
return result;
}
factory UploadProfileImageState.fromMap(Map<String, dynamic> map) {
return UploadProfileImageState(
status: UploadProfileStatus.values[map['status'] ?? 0],
profileImagePath: map['profileImagePath'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory UploadProfileImageState.fromJson(String source) => UploadProfileImageState.fromMap(json.decode(source));
@override
String toString() => 'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UploadProfileImageState && other.status == status && other.profileImagePath == profileImagePath;
}
@override
int get hashCode => status.hashCode ^ profileImagePath.hashCode;
}
class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState> {
UploadProfileImageNotifier()
: super(UploadProfileImageState(
profileImagePath: '',
status: UploadProfileStatus.idle,
));
Future<bool> upload(XFile file) async {
state = state.copyWith(status: UploadProfileStatus.loading);
var res = await UserService().uploadProfileImage(file);
if (res != null) {
debugPrint("Succesfully upload profile image");
state = state.copyWith(status: UploadProfileStatus.success, profileImagePath: res.profileImagePath);
return true;
}
state = state.copyWith(status: UploadProfileStatus.failure);
return false;
}
}
final uploadProfileImageProvider =
StateNotifierProvider<UploadProfileImageNotifier, UploadProfileImageState>(((ref) => UploadProfileImageNotifier()));

View File

@@ -1,7 +1,11 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -9,17 +13,21 @@ import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'dart:math';
class ProfileDrawer extends HookConsumerWidget { class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key); const ProfileDrawer({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState _authState = ref.watch(authenticationProvider); AuthenticationState _authState = ref.watch(authenticationProvider);
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status;
final appInfo = useState({}); final appInfo = useState({});
var dummmy = Random().nextInt(1024);
_getPackageInfo() async { _getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform(); PackageInfo packageInfo = await PackageInfo.fromPlatform();
@@ -30,19 +38,74 @@ class ProfileDrawer extends HookConsumerWidget {
}; };
} }
_buildUserProfileImage() {
if (_authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (_authState.profileImagePath.isNotEmpty) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
backgroundColor: Colors.transparent,
);
} else {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage('$endpoint/user/profile-image/${_authState.userId}?d=${dummmy++}'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
}
return Container();
}
_pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024);
if (image != null) {
var success = await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
ref
.watch(authenticationProvider.notifier)
.updateUserProfileImagePath(ref.read(uploadProfileImageProvider).profileImagePath);
}
}
}
useEffect(() { useEffect(() {
_getPackageInfo(); _getPackageInfo();
_buildUserProfileImage();
return null; return null;
}, []); }, []);
return Drawer( return Drawer(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(5),
bottomRight: Radius.circular(5),
),
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -51,22 +114,60 @@ class ProfileDrawer extends HookConsumerWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ children: [
DrawerHeader( DrawerHeader(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.grey[200], gradient: LinearGradient(
colors: [Color.fromARGB(255, 216, 219, 238), Color.fromARGB(255, 226, 230, 231)],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Image( Stack(
image: AssetImage('assets/immich-logo-no-outline.png'), clipBehavior: Clip.none,
width: 50, children: [
filterQuality: FilterQuality.high, _buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
child: Material(
color: Colors.grey[50],
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
size: 14,
),
),
),
),
),
],
), ),
const Padding(padding: EdgeInsets.all(8)), const Padding(padding: EdgeInsets.all(8)),
Text( Text(
_authState.userEmail, "${_authState.firstName} ${_authState.lastName}",
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_authState.userEmail,
style: TextStyle(color: Colors.grey[800], fontSize: 12),
),
) )
], ],
), ),
@@ -97,7 +198,15 @@ class ProfileDrawer extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Card( child: Card(
elevation: 0,
color: Colors.grey[100], color: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column( child: Column(

View File

@@ -8,6 +8,11 @@ class AuthenticationState {
final String userId; final String userId;
final String userEmail; final String userEmail;
final bool isAuthenticated; final bool isAuthenticated;
final String firstName;
final String lastName;
final bool isAdmin;
final bool isFirstLogin;
final String profileImagePath;
final DeviceInfoRemote deviceInfo; final DeviceInfoRemote deviceInfo;
AuthenticationState({ AuthenticationState({
@@ -16,6 +21,11 @@ class AuthenticationState {
required this.userId, required this.userId,
required this.userEmail, required this.userEmail,
required this.isAuthenticated, required this.isAuthenticated,
required this.firstName,
required this.lastName,
required this.isAdmin,
required this.isFirstLogin,
required this.profileImagePath,
required this.deviceInfo, required this.deviceInfo,
}); });
@@ -25,6 +35,11 @@ class AuthenticationState {
String? userId, String? userId,
String? userEmail, String? userEmail,
bool? isAuthenticated, bool? isAuthenticated,
String? firstName,
String? lastName,
bool? isAdmin,
bool? isFirstLoggedIn,
String? profileImagePath,
DeviceInfoRemote? deviceInfo, DeviceInfoRemote? deviceInfo,
}) { }) {
return AuthenticationState( return AuthenticationState(
@@ -33,24 +48,36 @@ class AuthenticationState {
userId: userId ?? this.userId, userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail, userEmail: userEmail ?? this.userEmail,
isAuthenticated: isAuthenticated ?? this.isAuthenticated, isAuthenticated: isAuthenticated ?? this.isAuthenticated,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLoggedIn ?? isFirstLogin,
profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo, deviceInfo: deviceInfo ?? this.deviceInfo,
); );
} }
@override @override
String toString() { String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, deviceInfo: $deviceInfo)'; return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, isFirstLoggedIn: $isFirstLogin, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'deviceId': deviceId,
'deviceType': deviceType, result.addAll({'deviceId': deviceId});
'userId': userId, result.addAll({'deviceType': deviceType});
'userEmail': userEmail, result.addAll({'userId': userId});
'isAuthenticated': isAuthenticated, result.addAll({'userEmail': userEmail});
'deviceInfo': deviceInfo.toMap(), result.addAll({'isAuthenticated': isAuthenticated});
}; result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'deviceInfo': deviceInfo.toMap()});
return result;
} }
factory AuthenticationState.fromMap(Map<String, dynamic> map) { factory AuthenticationState.fromMap(Map<String, dynamic> map) {
@@ -60,6 +87,11 @@ class AuthenticationState {
userId: map['userId'] ?? '', userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '', userEmail: map['userEmail'] ?? '',
isAuthenticated: map['isAuthenticated'] ?? false, isAuthenticated: map['isAuthenticated'] ?? false,
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
profileImagePath: map['profileImagePath'] ?? '',
deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']), deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
); );
} }
@@ -78,6 +110,11 @@ class AuthenticationState {
other.userId == userId && other.userId == userId &&
other.userEmail == userEmail && other.userEmail == userEmail &&
other.isAuthenticated == isAuthenticated && other.isAuthenticated == isAuthenticated &&
other.firstName == firstName &&
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo; other.deviceInfo == deviceInfo;
} }
@@ -88,6 +125,11 @@ class AuthenticationState {
userId.hashCode ^ userId.hashCode ^
userEmail.hashCode ^ userEmail.hashCode ^
isAuthenticated.hashCode ^ isAuthenticated.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode; deviceInfo.hashCode;
} }
} }

View File

@@ -4,31 +4,58 @@ class LogInReponse {
final String accessToken; final String accessToken;
final String userId; final String userId;
final String userEmail; final String userEmail;
final String firstName;
final String lastName;
final String profileImagePath;
final bool isAdmin;
final bool isFirstLogin;
LogInReponse({ LogInReponse({
required this.accessToken, required this.accessToken,
required this.userId, required this.userId,
required this.userEmail, required this.userEmail,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.isFirstLogin,
}); });
LogInReponse copyWith({ LogInReponse copyWith({
String? accessToken, String? accessToken,
String? userId, String? userId,
String? userEmail, String? userEmail,
String? firstName,
String? lastName,
String? profileImagePath,
bool? isAdmin,
bool? isFirstLogin,
}) { }) {
return LogInReponse( return LogInReponse(
accessToken: accessToken ?? this.accessToken, accessToken: accessToken ?? this.accessToken,
userId: userId ?? this.userId, userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail, userEmail: userEmail ?? this.userEmail,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
profileImagePath: profileImagePath ?? this.profileImagePath,
isAdmin: isAdmin ?? this.isAdmin,
isFirstLogin: isFirstLogin ?? this.isFirstLogin,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'accessToken': accessToken,
'userId': userId, result.addAll({'accessToken': accessToken});
'userEmail': userEmail, result.addAll({'userId': userId});
}; result.addAll({'userEmail': userEmail});
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'isAdmin': isAdmin});
result.addAll({'isFirstLogin': isFirstLogin});
return result;
} }
factory LogInReponse.fromMap(Map<String, dynamic> map) { factory LogInReponse.fromMap(Map<String, dynamic> map) {
@@ -36,6 +63,11 @@ class LogInReponse {
accessToken: map['accessToken'] ?? '', accessToken: map['accessToken'] ?? '',
userId: map['userId'] ?? '', userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '', userEmail: map['userEmail'] ?? '',
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
isAdmin: map['isAdmin'] ?? false,
isFirstLogin: map['isFirstLogin'] ?? false,
); );
} }
@@ -44,7 +76,9 @@ class LogInReponse {
factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source)); factory LogInReponse.fromJson(String source) => LogInReponse.fromMap(json.decode(source));
@override @override
String toString() => 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail)'; String toString() {
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, isFirstLogin: $isFirstLogin)';
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -53,9 +87,23 @@ class LogInReponse {
return other is LogInReponse && return other is LogInReponse &&
other.accessToken == accessToken && other.accessToken == accessToken &&
other.userId == userId && other.userId == userId &&
other.userEmail == userEmail; other.userEmail == userEmail &&
other.firstName == firstName &&
other.lastName == lastName &&
other.profileImagePath == profileImagePath &&
other.isAdmin == isAdmin &&
other.isFirstLogin == isFirstLogin;
} }
@override @override
int get hashCode => accessToken.hashCode ^ userId.hashCode ^ userEmail.hashCode; int get hashCode {
return accessToken.hashCode ^
userId.hashCode ^
userEmail.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode ^
isFirstLogin.hashCode;
}
} }

View File

@@ -17,9 +17,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
deviceType: "", deviceType: "",
isAuthenticated: false,
userId: "", userId: "",
userEmail: "", userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isAdmin: false,
isFirstLogin: false,
isAuthenticated: false,
deviceInfo: DeviceInfoRemote( deviceInfo: DeviceInfoRemote(
id: 0, id: 0,
userId: "", userId: "",
@@ -76,6 +81,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
isAuthenticated: true, isAuthenticated: true,
userId: payload.userId, userId: payload.userId,
userEmail: payload.userEmail, userEmail: payload.userEmail,
firstName: payload.firstName,
lastName: payload.lastName,
profileImagePath: payload.profileImagePath,
isAdmin: payload.isAdmin,
isFirstLoggedIn: payload.isFirstLogin,
); );
if (isSavedLoginInfo) { if (isSavedLoginInfo) {
@@ -114,9 +124,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
state = AuthenticationState( state = AuthenticationState(
deviceId: "", deviceId: "",
deviceType: "", deviceType: "",
isAuthenticated: false,
userId: "", userId: "",
userEmail: "", userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isFirstLogin: false,
isAuthenticated: false,
isAdmin: false,
deviceInfo: DeviceInfoRemote( deviceInfo: DeviceInfoRemote(
id: 0, id: 0,
userId: "", userId: "",
@@ -139,6 +154,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType); DeviceInfoRemote deviceInfoRemote = await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: deviceInfoRemote); state = state.copyWith(deviceInfo: deviceInfoRemote);
} }
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
class UploadProfileImageResponse {
final String userId;
final String profileImagePath;
UploadProfileImageResponse({
required this.userId,
required this.profileImagePath,
});
UploadProfileImageResponse copyWith({
String? userId,
String? profileImagePath,
}) {
return UploadProfileImageResponse(
userId: userId ?? this.userId,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'userId': userId});
result.addAll({'profileImagePath': profileImagePath});
return result;
}
factory UploadProfileImageResponse.fromMap(Map<String, dynamic> map) {
return UploadProfileImageResponse(
userId: map['userId'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory UploadProfileImageResponse.fromJson(String source) => UploadProfileImageResponse.fromMap(json.decode(source));
@override
String toString() => 'UploadProfileImageReponse(userId: $userId, profileImagePath: $profileImagePath)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UploadProfileImageResponse && other.userId == userId && other.profileImagePath == profileImagePath;
}
@override
int get hashCode => userId.hashCode ^ profileImagePath.hashCode;
}

View File

@@ -2,8 +2,15 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart';
import 'package:immich_mobile/shared/models/user_info.model.dart'; import 'package:immich_mobile/shared/models/user_info.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:http_parser/http_parser.dart';
class UserService { class UserService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@@ -21,4 +28,39 @@ class UserService {
return []; return [];
} }
Future<UploadProfileImageResponse?> uploadProfileImage(XFile image) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var mimeType = FileHelper.getMimeType(image.path);
final imageData = MultipartFile.fromBytes(
await image.readAsBytes(),
filename: image.name,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
final formData = FormData.fromMap({'file': imageData});
try {
Response res = await dio.post(
'$savedEndpoint/user/profile-image',
data: formData,
);
var payload = UploadProfileImageResponse.fromJson(res.toString());
return payload;
} on DioError catch (e) {
debugPrint("Error uploading file: ${e.response}");
return null;
} catch (e) {
debugPrint("Error uploading file: $e");
return null;
}
}
} }

View File

@@ -7,28 +7,28 @@ packages:
name: _fe_analyzer_shared name: _fe_analyzer_shared
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "34.0.0" version: "38.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.4.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.11" version: "3.3.0"
args: args:
dependency: transitive dependency: transitive
description: description:
name: args name: args
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.0" version: "2.3.1"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -42,14 +42,14 @@ packages:
name: auto_route name: auto_route
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.2" version: "4.0.1"
auto_route_generator: auto_route_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: auto_route_generator name: auto_route_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "4.0.0"
badges: badges:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -70,7 +70,7 @@ packages:
name: build name: build
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.1" version: "2.3.0"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
@@ -84,21 +84,21 @@ packages:
name: build_daemon name: build_daemon
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.1.0"
build_resolvers: build_resolvers:
dependency: transitive dependency: transitive
description: description:
name: build_resolvers name: build_resolvers
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.6" version: "2.0.8"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.7" version: "2.1.10"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
@@ -119,14 +119,14 @@ packages:
name: built_value name: built_value
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "8.1.4" version: "8.3.0"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.2.1"
cached_network_image_platform_interface: cached_network_image_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -168,14 +168,7 @@ packages:
name: chewie name: chewie
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.2" version: "1.3.2"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.5"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -196,7 +189,7 @@ packages:
name: collection name: collection
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.15.0" version: "1.16.0"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@@ -204,13 +197,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
csslib: csslib:
dependency: transitive dependency: transitive
description: description:
@@ -231,14 +231,14 @@ packages:
name: dart_style name: dart_style
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.1" version: "2.2.3"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.4" version: "4.0.6"
equatable: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -259,14 +259,14 @@ packages:
name: fake_async name: fake_async
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.2" version: "1.2.1"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -280,7 +280,7 @@ packages:
name: fixnum name: fixnum
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -292,7 +292,7 @@ packages:
name: flutter_blurhash name: flutter_blurhash
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.0" version: "0.6.8"
flutter_cache_manager: flutter_cache_manager:
dependency: transitive dependency: transitive
description: description:
@@ -306,7 +306,7 @@ packages:
name: flutter_hooks name: flutter_hooks
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.18.2" version: "0.18.4"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -328,13 +328,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.14.0" version: "0.14.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
flutter_riverpod: flutter_riverpod:
dependency: transitive dependency: transitive
description: description:
name: flutter_riverpod name: flutter_riverpod
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0-dev.0" version: "2.0.0-dev.7"
flutter_spinkit: flutter_spinkit:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -372,7 +379,7 @@ packages:
name: fluttertoast name: fluttertoast
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "8.0.8" version: "8.0.9"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@@ -400,7 +407,7 @@ packages:
name: hive name: hive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.2.1"
hive_flutter: hive_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -421,7 +428,7 @@ packages:
name: hooks_riverpod name: hooks_riverpod
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0-dev.0" version: "2.0.0-dev.7"
html: html:
dependency: transitive dependency: transitive
description: description:
@@ -442,7 +449,7 @@ packages:
name: http_multi_server name: http_multi_server
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.2.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -456,7 +463,42 @@ packages:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.1" version: "3.1.3"
image_picker:
dependency: "direct main"
description:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.4+13"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.8"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+5"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -477,14 +519,14 @@ packages:
name: js name: js
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.3" version: "0.6.4"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.4.0" version: "4.5.0"
latlong2: latlong2:
dependency: transitive dependency: transitive
description: description:
@@ -526,7 +568,7 @@ packages:
name: material_color_utilities name: material_color_utilities
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.3" version: "0.1.4"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -547,7 +589,7 @@ packages:
name: mime name: mime
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -575,14 +617,14 @@ packages:
name: package_info_plus name: package_info_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.4.0" version: "1.4.2"
package_info_plus_linux: package_info_plus_linux:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_linux name: package_info_plus_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.3" version: "1.0.5"
package_info_plus_macos: package_info_plus_macos:
dependency: transitive dependency: transitive
description: description:
@@ -603,70 +645,70 @@ packages:
name: package_info_plus_web name: package_info_plus_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
package_info_plus_windows: package_info_plus_windows:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_windows name: package_info_plus_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
path: path:
dependency: transitive dependency: transitive
description: description:
name: path name: path
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.0" version: "1.8.1"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.8" version: "2.0.10"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.11" version: "2.0.14"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
name: path_provider_ios name: path_provider_ios
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.7" version: "2.0.9"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
name: path_provider_linux name: path_provider_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_macos: path_provider_macos:
dependency: transitive dependency: transitive
description: description:
name: path_provider_macos name: path_provider_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.0.6"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.3" version: "2.0.4"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.0.6"
pedantic: pedantic:
dependency: transitive dependency: transitive
description: description:
@@ -680,21 +722,21 @@ packages:
name: percent_indicator name: percent_indicator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.4.0" version: "4.2.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.4.0" version: "5.0.0"
photo_manager: photo_manager:
dependency: "direct main" dependency: "direct main"
description: description:
name: photo_manager name: photo_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.6" version: "2.1.0+2"
photo_view: photo_view:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -750,14 +792,14 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "6.0.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
name: pub_semver name: pub_semver
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
pubspec_parse: pubspec_parse:
dependency: transitive dependency: transitive
description: description:
@@ -771,14 +813,14 @@ packages:
name: quiver name: quiver
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1+1" version: "3.1.0"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0-dev.0" version: "2.0.0-dev.7"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -792,7 +834,7 @@ packages:
name: shelf name: shelf
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
shelf_web_socket: shelf_web_socket:
dependency: transitive dependency: transitive
description: description:
@@ -811,7 +853,7 @@ packages:
name: sliver_tools name: sliver_tools
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.5" version: "0.2.6"
socket_io_client: socket_io_client:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -832,21 +874,21 @@ packages:
name: source_gen name: source_gen
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
source_helper: source_helper:
dependency: transitive dependency: transitive
description: description:
name: source_helper name: source_helper
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.1" version: "1.3.2"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.1" version: "1.8.2"
sprintf: sprintf:
dependency: transitive dependency: transitive
description: description:
@@ -860,14 +902,14 @@ packages:
name: sqflite name: sqflite
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.0.2+1"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.0" version: "2.2.1+1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -909,7 +951,7 @@ packages:
name: synchronized name: synchronized
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0+2"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -923,7 +965,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.8" version: "0.4.9"
timing: timing:
dependency: transitive dependency: transitive
description: description:
@@ -951,7 +993,7 @@ packages:
name: typed_data name: typed_data
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.1"
unicode: unicode:
dependency: transitive dependency: transitive
description: description:
@@ -979,49 +1021,56 @@ packages:
name: uuid name: uuid
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.5" version: "3.0.6"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_player name: video_player
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.18" version: "2.4.2"
video_player_android: video_player_android:
dependency: transitive dependency: transitive
description: description:
name: video_player_android name: video_player_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.17" version: "2.3.3"
video_player_avfoundation: video_player_avfoundation:
dependency: transitive dependency: transitive
description: description:
name: video_player_avfoundation name: video_player_avfoundation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.18" version: "2.3.4"
video_player_platform_interface: video_player_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: video_player_platform_interface name: video_player_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.1" version: "5.1.2"
video_player_web: video_player_web:
dependency: transitive dependency: transitive
description: description:
name: video_player_web name: video_player_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.6" version: "2.0.10"
visibility_detector: visibility_detector:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1035,7 +1084,7 @@ packages:
name: wakelock name: wakelock
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.6" version: "0.6.1+2"
wakelock_macos: wakelock_macos:
dependency: transitive dependency: transitive
description: description:
@@ -1077,14 +1126,14 @@ packages:
name: web_socket_channel name: web_socket_channel
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.2.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.8" version: "2.5.2"
wkt_parser: wkt_parser:
dependency: transitive dependency: transitive
description: description:
@@ -1098,21 +1147,21 @@ packages:
name: xdg_directories name: xdg_directories
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.0+1"
xml: xml:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.3.1" version: "5.4.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
name: yaml name: yaml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.0" version: "3.1.1"
sdks: sdks:
dart: ">=2.15.1 <3.0.0" dart: ">=2.17.0 <3.0.0"
flutter: ">=2.8.0" flutter: ">=3.0.0"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.9.0+13 version: 1.10.0+15
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"
@@ -14,13 +14,13 @@ dependencies:
photo_manager: ^2.0.6 photo_manager: ^2.0.6
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
hive: hive: ^2.2.1
hive_flutter: hive_flutter: ^1.1.0
dio: ^4.0.4 dio: ^4.0.4
cached_network_image: ^3.2.0 cached_network_image: ^3.2.1
percent_indicator: ^3.4.0 percent_indicator: ^4.2.2
intl: ^0.17.0 intl: ^0.17.0
auto_route: ^3.2.2 auto_route: ^4.0.1
exif: ^3.1.1 exif: ^3.1.1
transparent_image: ^2.0.0 transparent_image: ^2.0.0
visibility_detector: ^0.2.2 visibility_detector: ^0.2.2
@@ -38,6 +38,7 @@ dependencies:
flutter_spinkit: ^5.1.0 flutter_spinkit: ^5.1.0
flutter_swipe_detector: ^2.0.0 flutter_swipe_detector: ^2.0.0
equatable: ^2.0.3 equatable: ^2.0.3
image_picker: ^0.8.5+3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -45,7 +46,7 @@ dev_dependencies:
flutter_lints: ^1.0.0 flutter_lints: ^1.0.0
hive_generator: ^1.1.2 hive_generator: ^1.1.2
build_runner: ^2.1.7 build_runner: ^2.1.7
auto_route_generator: ^3.2.1 auto_route_generator: ^4.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -1,5 +1,6 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 120 "printWidth": 120,
"semi": true
} }

View File

@@ -6,7 +6,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN apk add --update-cache build-base python3 RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
RUN npm install RUN npm install

637
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/platform-fastify": "^8.2.6", "@nestjs/platform-fastify": "^8.2.6",
"@nestjs/platform-socket.io": "^8.2.6", "@nestjs/platform-socket.io": "^8.2.6",
"@nestjs/schedule": "^2.0.1",
"@nestjs/typeorm": "^8.0.3", "@nestjs/typeorm": "^8.0.3",
"@nestjs/websockets": "^8.2.6", "@nestjs/websockets": "^8.2.6",
"@socket.io/redis-adapter": "^7.1.0", "@socket.io/redis-adapter": "^7.1.0",
@@ -44,6 +45,7 @@
"diskusage": "^1.1.3", "diskusage": "^1.1.3",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2", "passport": "^0.5.2",
@@ -53,6 +55,7 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sharp": "^0.28.0",
"socket.io-redis": "^6.1.1", "socket.io-redis": "^6.1.1",
"systeminformation": "^5.11.0", "systeminformation": "^5.11.0",
"typeorm": "^0.2.41" "typeorm": "^0.2.41"
@@ -63,6 +66,7 @@
"@nestjs/testing": "^8.0.0", "@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.7",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/imagemin": "^8.0.0", "@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
@@ -70,6 +74,7 @@
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",

View File

@@ -19,7 +19,7 @@ import {
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { multerOption } from '../../config/multer-option.config'; import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
@@ -39,7 +39,7 @@ export class AssetController {
private wsCommunicateionGateway: CommunicationGateway, private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService, private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
) {} ) { }
@Post('upload') @Post('upload')
@UseInterceptors( @UseInterceptors(
@@ -48,7 +48,7 @@ export class AssetController {
{ name: 'assetData', maxCount: 1 }, { name: 'assetData', maxCount: 1 },
{ name: 'thumbnailData', maxCount: 1 }, { name: 'thumbnailData', maxCount: 1 },
], ],
multerOption, assetUploadOption,
), ),
) )
async uploadFile( async uploadFile(

View File

@@ -1,19 +1,16 @@
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity'; import { AssetEntity, AssetType } from './entities/asset.entity';
import _ from 'lodash'; import _ from 'lodash';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
import { createReadStream, stat } from 'fs'; import { createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { promisify } from 'util'; import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import path from 'path';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -22,7 +19,7 @@ export class AssetService {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
) {} ) { }
public async updateThumbnailInfo(assetId: string, path: string) { public async updateThumbnailInfo(assetId: string, path: string) {
return await this.assetRepository.update(assetId, { return await this.assetRepository.update(assetId, {
@@ -114,34 +111,66 @@ export class AssetService {
public async getAssetThumbnail(assetId: string) { public async getAssetThumbnail(assetId: string) {
const asset = await this.assetRepository.findOne({ id: assetId }); const asset = await this.assetRepository.findOne({ id: assetId });
return new StreamableFile(createReadStream(asset.resizePath)); if (asset.webpPath != '') {
return new StreamableFile(createReadStream(asset.webpPath));
} else {
return new StreamableFile(createReadStream(asset.resizePath));
}
} }
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) { public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
let file = null; let file = null;
const asset = await this.findOne(query.did, query.aid); const asset = await this.findOne(query.did, query.aid);
if (!asset) { if (!asset) {
throw new BadRequestException('Asset does not exist'); throw new BadRequestException('Asset does not exist');
} }
// Handle Sending Images // Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
res.set({ /**
'Content-Type': asset.mimeType, * Serve file viewer on the web
}); */
if (query.isWeb) {
res.set({
'Content-Type': 'image/jpeg',
});
return new StreamableFile(createReadStream(asset.resizePath));
}
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) { if (query.isThumb === 'false' || !query.isThumb) {
res.set({
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.originalPath); file = createReadStream(asset.originalPath);
} else { } else {
file = createReadStream(asset.resizePath); if (asset.webpPath != '') {
res.set({
'Content-Type': 'image/webp',
});
file = createReadStream(asset.webpPath);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
file = createReadStream(asset.resizePath);
}
} }
file.on('error', (error) => { file.on('error', (error) => {
Logger.log(`Cannot create read stream ${error}`); Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream'); return new BadRequestException('Cannot Create Read Stream');
}); });
return new StreamableFile(file); return new StreamableFile(file);
} else if (asset.type == AssetType.VIDEO) { } else if (asset.type == AssetType.VIDEO) {
// Handle Handling Video // Handle Video
const { size } = await fileInfo(asset.originalPath); const { size } = await fileInfo(asset.originalPath);
const range = headers.range; const range = headers.range;
@@ -183,6 +212,8 @@ export class AssetService {
const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
return new StreamableFile(videoStream); return new StreamableFile(videoStream);
} else { } else {
res.set({ res.set({
'Content-Type': asset.mimeType, 'Content-Type': asset.mimeType,

View File

@@ -13,4 +13,8 @@ export class ServeFileDto {
@IsOptional() @IsOptional()
@IsBooleanString() @IsBooleanString()
isThumb: string; isThumb: string;
@IsOptional()
@IsBooleanString()
isWeb: string;
} }

View File

@@ -26,6 +26,9 @@ export class AssetEntity {
@Column({ nullable: true }) @Column({ nullable: true })
resizePath: string; resizePath: string;
@Column({ nullable: true })
webpPath: string;
@Column() @Column()
createdAt: string; createdAt: string;

View File

@@ -7,16 +7,16 @@ import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) { }
@Post('/login') @Post('/login')
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) { async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {
return await this.authService.login(loginCredential); return await this.authService.login(loginCredential);
} }
@Post('/signUp') @Post('/admin-sign-up')
async signUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) { async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.signUp(signUpCrendential); return await this.authService.adminSignUp(signUpCrendential);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)

View File

@@ -14,12 +14,12 @@ export class AuthService {
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>, private userRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService, private immichJwtService: ImmichJwtService,
) {} ) { }
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> { private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
const user = await this.userRepository.findOne( const user = await this.userRepository.findOne(
{ email: loginCredential.email }, { email: loginCredential.email },
{ select: ['id', 'email', 'password', 'salt'] }, { select: ['id', 'email', 'password', 'salt', 'firstName', 'lastName', 'isAdmin', 'profileImagePath', 'isFirstLoggedIn'] },
); );
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt); const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
@@ -44,32 +44,45 @@ export class AuthService {
accessToken: await this.immichJwtService.generateToken(payload), accessToken: await this.immichJwtService.generateToken(payload),
userId: validatedUser.id, userId: validatedUser.id,
userEmail: validatedUser.email, userEmail: validatedUser.email,
firstName: validatedUser.firstName,
lastName: validatedUser.lastName,
isAdmin: validatedUser.isAdmin,
profileImagePath: validatedUser.profileImagePath,
isFirstLogin: validatedUser.isFirstLoggedIn
}; };
} }
public async signUp(signUpCrendential: SignUpDto) {
const registerUser = await this.userRepository.findOne({ email: signUpCrendential.email });
if (registerUser) { public async adminSignUp(signUpCrendential: SignUpDto) {
throw new BadRequestException('User exist'); const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
throw new BadRequestException('The server already has an admin')
} }
const newUser = new UserEntity();
newUser.email = signUpCrendential.email; const newAdminUser = new UserEntity();
newUser.salt = await bcrypt.genSalt(); newAdminUser.email = signUpCrendential.email;
newUser.password = await this.hashPassword(signUpCrendential.password, newUser.salt); newAdminUser.salt = await bcrypt.genSalt();
newAdminUser.password = await this.hashPassword(signUpCrendential.password, newAdminUser.salt);
newAdminUser.firstName = signUpCrendential.firstName;
newAdminUser.lastName = signUpCrendential.lastName;
newAdminUser.isAdmin = true;
try { try {
const savedUser = await this.userRepository.save(newUser); const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
return { return {
id: savedUser.id, id: savedNewAdminUserUser.id,
email: savedUser.email, email: savedNewAdminUserUser.email,
createdAt: savedUser.createdAt, firstName: savedNewAdminUserUser.firstName,
lastName: savedNewAdminUserUser.lastName,
createdAt: savedNewAdminUserUser.createdAt,
}; };
} catch (e) { } catch (e) {
Logger.error('e', 'signUp'); Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new user'); throw new InternalServerErrorException('Failed to register new admin user');
} }
} }

View File

@@ -6,4 +6,10 @@ export class SignUpDto {
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
} }

View File

@@ -1 +1,27 @@
export class CreateUserDto {} import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
@IsOptional()
profileImagePath: string;
@IsOptional()
isAdmin: boolean;
@IsOptional()
isFirstLoggedIn: boolean;
@IsOptional()
id: string;
}

View File

@@ -5,6 +5,15 @@ export class UserEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isAdmin: boolean;
@Column() @Column()
email: string; email: string;
@@ -14,6 +23,12 @@ export class UserEntity {
@Column({ select: false }) @Column({ select: false })
salt: string; salt: string;
@Column()
profileImagePath: string;
@Column()
isFirstLoggedIn: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: string; createdAt: string;
} }

View File

@@ -1,15 +1,54 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query, UseInterceptors, UploadedFile, Response } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { UpdateUserDto } from './dto/update-user.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
import { Response as Res } from 'express';
@UseGuards(JwtAuthGuard)
@Controller('user') @Controller('user')
export class UserController { export class UserController {
constructor(private readonly userService: UserService) {} constructor(private readonly userService: UserService) { }
@UseGuards(JwtAuthGuard)
@Get() @Get()
async getAllUsers(@GetAuthUser() authUser: AuthUserDto) { async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) {
return await this.userService.getAllUsers(authUser); return await this.userService.getAllUsers(authUser, isAll);
}
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@Post()
async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return await this.userService.createUser(createUserDto);
}
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean) {
return await this.userService.getUserCount(isAdmin);
}
@UseGuards(JwtAuthGuard)
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) {
return await this.userService.updateUser(updateUserDto)
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@Post('/profile-image')
async createProfileImage(@GetAuthUser() authUser: AuthUserDto, @UploadedFile() fileInfo: Express.Multer.File) {
return await this.userService.createProfileImage(authUser, fileInfo);
}
@Get('/profile-image/:userId')
async getProfileImage(@Param('userId') userId: string,
@Response({ passthrough: true }) res: Res,
) {
return await this.userService.getUserProfileImage(userId, res);
} }
} }

View File

@@ -3,10 +3,14 @@ import { UserService } from './user.service';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity'; import { UserEntity } from './entities/user.entity';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([UserEntity])], imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController], controllers: [UserController],
providers: [UserService], providers: [UserService, ImmichJwtService],
}) })
export class UserModule {} export class UserModule { }

View File

@@ -1,21 +1,166 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity'; import { UserEntity } from './entities/user.entity';
import * as bcrypt from 'bcrypt';
import sharp from 'sharp';
import { createReadStream, unlink, unlinkSync } from 'fs';
import { Response as Res } from 'express';
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor( constructor(
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>, private userRepository: Repository<UserEntity>,
) {} ) { }
async getAllUsers(authUser: AuthUserDto, isAll: boolean) {
if (isAll) {
return await this.userRepository.find();
}
async getAllUsers(authUser: AuthUserDto) {
return await this.userRepository.find({ return await this.userRepository.find({
where: { id: Not(authUser.id) }, where: { id: Not(authUser.id) },
order: {
createdAt: 'DESC'
}
}); });
} }
async getUserCount(isAdmin: boolean) {
let users;
if (isAdmin) {
users = await this.userRepository.find({ where: { isAdmin: true } });
} else {
users = await this.userRepository.find();
}
return {
userCount: users.length
}
}
async createUser(createUserDto: CreateUserDto) {
const user = await this.userRepository.findOne({ where: { email: createUserDto.email } });
if (user) {
throw new BadRequestException('User exists');
}
const newUser = new UserEntity();
newUser.email = createUserDto.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
newUser.firstName = createUserDto.firstName;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
try {
const savedUser = await this.userRepository.save(newUser);
return {
id: savedUser.id,
email: savedUser.email,
firstName: savedUser.firstName,
lastName: savedUser.lastName,
createdAt: savedUser.createdAt,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
async updateUser(updateUserDto: UpdateUserDto) {
const user = await this.userRepository.findOne(updateUserDto.id);
user.lastName = updateUserDto.lastName || user.lastName;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
// If payload includes password - Create new password for user
if (updateUserDto.password) {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(updateUserDto.password, user.salt);
}
if (updateUserDto.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } })
if (adminUser) {
throw new BadRequestException("Admin user exists")
}
user.isAdmin = true;
}
try {
const updatedUser = await this.userRepository.save(user);
return {
id: updatedUser.id,
email: updatedUser.email,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
profileImagePath: updatedUser.profileImagePath,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) {
try {
await this.userRepository.update(authUser.id, {
profileImagePath: fileInfo.path
})
return {
userId: authUser.id,
profileImagePath: fileInfo.path
};
} catch (e) {
Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new user profile image');
}
}
async getUserProfileImage(userId: string, res: Res) {
try {
const user = await this.userRepository.findOne({ id: userId })
if (!user.profileImagePath) {
console.log("empty return")
throw new BadRequestException('User does not have a profile image');
}
res.set({
'Content-Type': 'image/jpeg',
});
const fileStream = createReadStream(user.profileImagePath)
return new StreamableFile(fileStream);
} catch (e) {
console.log("error getting user profile")
}
}
} }

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
@Controller()
export class AppController {
constructor() { }
@Get()
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
const host = headers.host;
return res.redirect(`http://${host}:2285`)
}
}

View File

@@ -15,20 +15,31 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module'; import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module'; import { CommunicationModule } from './api-v1/communication/communication.module';
import { SharingModule } from './api-v1/sharing/sharing.module'; import { SharingModule } from './api-v1/sharing/sharing.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot(immichAppConfig), ConfigModule.forRoot(immichAppConfig),
TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forRoot(databaseConfig),
UserModule, UserModule,
AssetModule, AssetModule,
AuthModule, AuthModule,
ImmichJwtModule, ImmichJwtModule,
DeviceInfoModule, DeviceInfoModule,
BullModule.forRootAsync({ BullModule.forRootAsync({
useFactory: async () => ({ useFactory: async () => ({
redis: { redis: {
host: 'immich_redis', host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: 6379, port: 6379,
}, },
}), }),
@@ -43,8 +54,12 @@ import { SharingModule } from './api-v1/sharing/sharing.module';
CommunicationModule, CommunicationModule,
SharingModule, SharingModule,
ScheduleModule.forRoot(),
ScheduleTasksModule
], ],
controllers: [], controllers: [AppController],
providers: [], providers: [],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@@ -17,5 +17,6 @@ export const immichAppConfig: ConfigModuleOptions = {
then: Joi.string().optional().allow(null, ''), then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(), otherwise: Joi.string().required(),
}), }),
VITE_SERVER_ENDPOINT: Joi.string().required(),
}), }),
}; };

View File

@@ -8,7 +8,7 @@ import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto'; import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const multerOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) { if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
cb(null, true); cb(null, true);
@@ -19,14 +19,14 @@ export const multerOption: MulterOptions = {
storage: diskStorage({ storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => { destination: (req: Request, file: Express.Multer.File, cb: any) => {
const uploadPath = APP_UPLOAD_LOCATION; const basePath = APP_UPLOAD_LOCATION;
const fileInfo = req.body as CreateAssetDto; const fileInfo = req.body as CreateAssetDto;
const yearInfo = new Date(fileInfo.createdAt).getFullYear(); const yearInfo = new Date(fileInfo.createdAt).getFullYear();
const monthInfo = new Date(fileInfo.createdAt).getMonth(); const monthInfo = new Date(fileInfo.createdAt).getMonth();
if (file.fieldname == 'assetData') { if (file.fieldname == 'assetData') {
const originalUploadFolder = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`; const originalUploadFolder = `${basePath}/${req.user['id']}/original/${req.body['deviceId']}`;
if (!existsSync(originalUploadFolder)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true }); mkdirSync(originalUploadFolder, { recursive: true });
@@ -35,7 +35,7 @@ export const multerOption: MulterOptions = {
// Save original to disk // Save original to disk
cb(null, originalUploadFolder); cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') { } else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${uploadPath}/${req.user['id']}/thumb/${req.body['deviceId']}`; const thumbnailUploadFolder = `${basePath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) { if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true }); mkdirSync(thumbnailUploadFolder, { recursive: true });
@@ -56,3 +56,5 @@ export const multerOption: MulterOptions = {
}, },
}), }),
}; };

View File

@@ -2,7 +2,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = { export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres', type: 'postgres',
host: 'immich_postgres', host: process.env.DB_HOSTNAME || 'immich_postgres',
port: 5432, port: 5432,
username: process.env.DB_USERNAME, username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,

View File

@@ -0,0 +1,40 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION;
const profileImageLocation = `${basePath}/${req.user['id']}/profile`;
if (!existsSync(profileImageLocation)) {
mkdirSync(profileImageLocation, { recursive: true });
}
cb(null, profileImageLocation);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const userId = req.user['id'];
cb(null, `${userId}${extname(file.originalname)}`);
},
}),
};

View File

@@ -3,7 +3,7 @@
export const serverVersion = { export const serverVersion = {
major: 1, major: 1,
minor: 9, minor: 10,
patch: 0, patch: 0,
build: 13, build: 14,
}; };

View File

@@ -1,4 +1,4 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../api-v1/user/entities/user.entity'; import { UserEntity } from '../api-v1/user/entities/user.entity';
// import { AuthUserDto } from './dto/auth-user.dto'; // import { AuthUserDto } from './dto/auth-user.dto';

View File

@@ -7,6 +7,8 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.set('trust proxy'); app.set('trust proxy');
app.useWebSocketAdapter(new RedisIoAdapter(app)); app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@@ -0,0 +1,30 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private reflector: Reflector, private jwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['authorization']) {
const bearerToken = request.headers['authorization'].split(" ")[1]
const { userId } = await this.jwtService.validateToken(bearerToken);
const user = await this.userRepository.findOne(userId);
return user.isAdmin;
}
return false;
}
}

View File

@@ -3,12 +3,13 @@ import { RedisClient } from 'redis';
import { ServerOptions } from 'socket.io'; import { ServerOptions } from 'socket.io';
import { createAdapter } from 'socket.io-redis'; import { createAdapter } from 'socket.io-redis';
// const pubClient = createClient({ url: 'redis://immich_redis:6379' }); const redis_host = process.env.REDIS_HOSTNAME || 'immich_redis'
// const pubClient = createClient({ url: `redis://${redis_host}:6379` });
// const subClient = pubClient.duplicate(); // const subClient = pubClient.duplicate();
const pubClient = new RedisClient({ const pubClient = new RedisClient({
host: redis_host,
port: 6379, port: 6379,
host: 'immich_redis',
}); });
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
add column if not exists "firstName" varchar default '';
alter table users
add column if not exists "lastName" varchar default '';
alter table users
add column if not exists "profileImagePath" varchar default '';
alter table users
add column if not exists "isAdmin" bool default false;
alter table users
add column if not exists "isFirstLoggedIn" bool default true;
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
drop column "firstName";
alter table users
drop column "lastName";
alter table users
drop column "isAdmin";
`);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateAssetTableWithWebpPath1653214255670 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
add column if not exists "webpPath" varchar default '';
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
drop column if exists "webpPath";
`);
}
}

View File

@@ -115,7 +115,7 @@ export class BackgroundTaskProcessor {
async tagImage(job) { async tagImage(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data; const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich_microservices:3001/image-classifier/tagImage', { const res = await axios.post('http://immich-microservices:3001/image-classifier/tagImage', {
thumbnailPath: thumbnailPath, thumbnailPath: thumbnailPath,
}); });
@@ -132,19 +132,24 @@ export class BackgroundTaskProcessor {
@Process('detect-object') @Process('detect-object')
async detectObject(job) { async detectObject(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data; try {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich_microservices:3001/object-detection/detectObject', { const res = await axios.post('http://immich-microservices:3001/object-detection/detectObject', {
thumbnailPath: thumbnailPath, thumbnailPath: thumbnailPath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
}); });
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`)
} }
} }
} }

View File

@@ -9,7 +9,7 @@ export class BackgroundTaskService {
constructor( constructor(
@InjectQueue('background-task') @InjectQueue('background-task')
private backgroundTaskQueue: Queue, private backgroundTaskQueue: Queue,
) {} ) { }
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) { async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
await this.backgroundTaskQueue.add( await this.backgroundTaskQueue.add(

View File

@@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp';
@Injectable()
export class ImageConversionService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>
) { }
@Cron(CronExpression.EVERY_5_MINUTES
, {
name: 'webp-conversion'
})
async webpConversion() {
Logger.log('Starting Webp Conversion Tasks', 'ImageConversionService')
const assets = await this.assetRepository.find({
where: {
webpPath: ''
},
take: 500
});
if (assets.length == 0) {
Logger.log('All assets has webp file - aborting task', 'ImageConversionService')
return;
}
for (const asset of assets) {
const resizePath = asset.resizePath;
if (resizePath != '') {
const webpPath = resizePath.replace('jpeg', 'webp')
sharp(resizePath).resize(250).webp().toFile(webpPath, (err, info) => {
if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath })
}
});
}
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ImageConversionService } from './image-conversion.service';
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity]),
],
providers: [ImageConversionService],
})
export class ScheduleTasksModule { }

View File

@@ -1,24 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

37
server/test/test-utils.ts Normal file
View File

@@ -0,0 +1,37 @@
import { getConnection } from 'typeorm';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
type CustomAuthCallback = () => AuthUserDto;
export async function clearDb() {
const entities = getConnection().entityMetadatas;
for (const entity of entities) {
const repository = getConnection().getRepository(entity.name);
await repository.query(`TRUNCATE ${entity.tableName} RESTART IDENTITY CASCADE;`);
}
}
export function getAuthUser(): AuthUserDto {
return {
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
email: 'test@email.com',
};
}
export function auth(builder: TestingModuleBuilder): TestingModuleBuilder {
return authCustom(builder, getAuthUser);
}
export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCallback): TestingModuleBuilder {
const canActivate: CanActivate = {
canActivate: (context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
req.user = callback();
return true;
},
};
return builder.overrideGuard(JwtAuthGuard).useValue(canActivate);
}

View File

@@ -0,0 +1,96 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { clearDb, authCustom } from './test-utils';
import { databaseConfig } from '../src/config/database.config';
import { UserModule } from '../src/api-v1/user/user.module';
import { AuthModule } from '../src/api-v1/auth/auth.module';
import { AuthService } from '../src/api-v1/auth/auth.service';
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
import { SignUpDto } from '../src/api-v1/auth/dto/sign-up.dto';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
function _createUser(authService: AuthService, data: SignUpDto) {
return authService.signUp(data);
}
describe('User', () => {
let app: INestApplication;
afterAll(async () => {
await clearDb();
await app.close();
});
describe('without auth', () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [UserModule, ImmichJwtModule, TypeOrmModule.forRoot(databaseConfig)],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('prevents fetching users if not auth', async () => {
const { status } = await request(app.getHttpServer()).get('/user');
expect(status).toEqual(401);
});
});
describe('with auth', () => {
let authService: AuthService;
let authUser: AuthUserDto;
beforeAll(async () => {
const builder = Test.createTestingModule({
imports: [UserModule, AuthModule, TypeOrmModule.forRoot(databaseConfig)],
});
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
app = moduleFixture.createNestApplication();
authService = app.get(AuthService);
await app.init();
});
describe('with users in DB', () => {
const authUserEmail = 'auth-user@test.com';
const userOneEmail = 'one@test.com';
const userTwoEmail = 'two@test.com';
beforeAll(async () => {
await Promise.allSettled([
_createUser(authService, { email: authUserEmail, password: '1234' }).then((user) => (authUser = user)),
_createUser(authService, { email: userOneEmail, password: '1234' }),
_createUser(authService, { email: userTwoEmail, password: '1234' }),
]);
});
it('fetches the user collection excluding the auth user', async () => {
const { status, body } = await request(app.getHttpServer()).get('/user');
expect(status).toEqual(200);
expect(body).toHaveLength(2);
expect(body).toEqual(
expect.arrayContaining([
{
email: userOneEmail,
id: expect.anything(),
createdAt: expect.anything(),
},
{
email: userTwoEmail,
id: expect.anything(),
createdAt: expect.anything(),
},
]),
);
expect(body).toEqual(expect.not.arrayContaining([expect.objectContaining({ email: authUserEmail })]));
});
});
});
});

View File

@@ -9,15 +9,13 @@
"target": "es2017", "target": "es2017",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
}, },
"exclude": [ "exclude": [
"dist",
"node_modules",
"upload" "upload"
], ],
"include": [
"src"
]
} }

4
web/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
upload/
dist/

20
web/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
.vercel
.output

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

7
web/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true
}

8
web/CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# default-template
## 0.0.2-next.0
### Patch Changes
- [chore] upgrade cookie library ([#4592](https://github.com/sveltejs/kit/pull/4592))

39
web/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Our Node base image
FROM node:16-alpine3.14 as base
WORKDIR /usr/src/app
RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./
RUN apk add --update-cache build-base python3
RUN npm install
COPY --chown=node:node . .
EXPOSE 3000
EXPOSE 24678
FROM base AS dev
ENV CHOKIDAR_USEPOLLING=true
CMD ["npm", "run", "dev"]
FROM node:16-alpine3.14 as prod
WORKDIR /usr/src/app
RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./
COPY --chown=node:node . .
RUN apk add --update-cache build-base python3
RUN npm install
EXPOSE 3000
# Issue build command in entrypoint.sh to capture user .env file instead of the builder .env file.

38
web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm init svelte
# create a new project in my-app
npm init svelte my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

1
web/entrypoint.sh Normal file
View File

@@ -0,0 +1 @@
npm run build && node /usr/src/app/build/index.js

5184
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
web/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "web",
"version": "0.0.1",
"scripts": {
"dev": "svelte-kit dev --host 0.0.0.0 --port 3002",
"build": "svelte-kit build",
"package": "svelte-kit package",
"preview": "svelte-kit preview",
"prepare": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "^1.0.0-next.73",
"@sveltejs/kit": "next",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"autoprefixer": "^10.4.7",
"eslint": "^8.12.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"postcss": "^8.4.13",
"prettier": "^2.5.1",
"prettier-plugin-svelte": "^2.5.0",
"svelte": "^3.46.0",
"svelte-check": "^2.2.6",
"svelte-preprocess": "^4.10.1",
"tailwindcss": "^3.0.24",
"tslib": "^2.3.1",
"typescript": "~4.6.2"
},
"type": "module",
"dependencies": {
"cookie": "^0.4.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"moment": "^2.29.3",
"svelte-material-icons": "^2.0.2"
}
}

6
web/postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
web/src/app.css Normal file
View File

@@ -0,0 +1,31 @@
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Work Sans', sans-serif;
}
body {
min-height: 100vh;
margin: 0;
background-color: #f6f8fe;
color: #5f6368;
}
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm
}
.immich-form-label {
@apply font-medium text-sm text-gray-500
}
.immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium
}
}

32
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare namespace App {
interface Locals {
user?: {
id: string,
email: string,
accessToken: string,
firstName: string,
lastName: string,
isAdmin: boolean,
}
}
// interface Platform {}
interface Session {
user?: {
id: string,
email: string,
accessToken: string,
firstName: string,
lastName: string
isAdmin: boolean,
}
}
// interface Stuff {}
}

12
web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%svelte.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head%
</head>
<body>
<div>%svelte.body%</div>
</body>
</html>

60
web/src/hooks.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit';
import * as cookie from 'cookie';
import { serverEndpoint } from '$lib/constants';
import { session } from '$app/stores';
export const handle: Handle = async ({ event, resolve, }) => {
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
if (!cookies.session) {
return await resolve(event)
}
try {
const { email, isAdmin, firstName, lastName, id, accessToken } = JSON.parse(cookies.session);
const res = await fetch(`${serverEndpoint}/auth/validateToken`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
if (res.status === 201) {
event.locals.user = {
id,
accessToken,
firstName,
lastName,
isAdmin,
email
};
}
const response = await resolve(event);
return response;
} catch (error) {
console.log('Error parsing session', error);
return await resolve(event);
}
};
export const getSession: GetSession = async ({ locals }) => {
if (!locals.user) return {}
return {
user: {
id: locals.user.id,
accessToken: locals.user.accessToken,
firstName: locals.user.firstName,
lastName: locals.user.lastName,
isAdmin: locals.user.isAdmin,
email: locals.user.email
}
}
}

60
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,60 @@
import { serverEndpoint } from './constants';
type ISend = {
method: string,
path: string,
data?: any,
token: string
customHeaders?: Record<string, string>,
}
type IOption = {
method: string,
headers: Record<string, string>,
body: any
}
async function send({ method, path, data, token, customHeaders }: ISend) {
const opts: IOption = { method, headers: {} } as IOption;
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
if (customHeaders) {
console.log(customHeaders);
// opts.headers[customHeader.$1]
}
if (token) {
opts.headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${serverEndpoint}/${path}`, opts)
.then((r) => r.text())
.then((json) => {
try {
return JSON.parse(json);
} catch (err) {
return json;
}
});
}
export function getRequest(path: string, token: string, customHeaders?: Record<string, string>) {
return send({ method: 'GET', path, token, customHeaders });
}
export function delRequest(path: string, token: string, customHeaders?: Record<string, string>) {
return send({ method: 'DELETE', path, token, customHeaders });
}
export function postRequest(path: string, data: any, token: string, customHeaders?: Record<string, string>) {
return send({ method: 'POST', path, data, token, customHeaders });
}
export function putRequest(path: string, data: any, token: string, customHeaders?: Record<string, string>) {
return send({ method: 'PUT', path, data, token, customHeaders });
}

74
web/src/lib/auth-api.ts Normal file
View File

@@ -0,0 +1,74 @@
type AdminRegistrationResult = Promise<{
error?: string
success?: string
user?: {
email: string
}
}>
type LoginResult = Promise<{
error?: string
success?: string
needUpdate?: boolean
needSelectAdmin?: boolean
user?: {
accessToken: string
firstName: string
lastName: string
isAdmin: boolean
id: string
email: string
}
}>
type UpdateResult = Promise<{
error?: string
success?: string,
user?: {
accessToken: string
firstName: string
lastName: string
isAdmin: boolean
id: string
email: string
}
}>
export async function sendRegistrationForm(form: HTMLFormElement): AdminRegistrationResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}
export async function sendLoginForm(form: HTMLFormElement): LoginResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}
export async function sendUpdateForm(form: HTMLFormElement): UpdateResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}

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