Compare commits

...

133 commits

Author SHA1 Message Date
Caramel 1e9dad9ac2
Update README.md 2023-07-06 13:51:47 +02:00
Caramel 441e7d2af1
fix docker build 2023-06-15 14:22:58 +02:00
Caramel 5195f1f5d8
Up version number to 0.5.2 2023-06-15 13:28:28 +02:00
Caramel b0c53850db
update packages 2023-06-15 13:27:30 +02:00
Caramel e2de37a6dd
Fix build for node 20 2023-06-15 13:24:34 +02:00
Caramel b8db72bac4
Change some typescript compilation options and fix eslint 2023-06-15 13:09:23 +02:00
Caramel 3b6244461e
Fix eslint config 2023-06-09 10:35:40 +02:00
Caramel 2a11f162b3
update packages to mitigate security issue 2023-06-07 23:12:00 +02:00
Caramel e09f661cdf
Fix clipboard on webkit 2023-06-06 00:39:59 +02:00
Caramel 409c3af475
Fix icons 2023-06-02 17:00:12 +02:00
Caramel 18bee2e0cd
Update packages 2023-06-02 16:35:31 +02:00
Caramel 5e6c12ce6d
Update README.md 2023-05-11 15:17:35 +02:00
Caramel cea5443309
Fix link in readme 2023-04-20 19:44:27 +02:00
Caramel 0e1146f578
Rebrand 2023-03-15 14:24:26 +01:00
Caramel b7b5d7015e
Fix outdated webpack 2023-03-15 14:17:43 +01:00
Caramel c65b7ca066
Merge pull request #38 from kaiiiz/patch-dup-reqs
Fix duplicate requests in picsur-img.component
2023-03-15 14:11:38 +01:00
Caramel 10036f1269
Fix bug cause of updates 2023-03-15 14:10:53 +01:00
Caramel dadc954564
Update packages 2023-03-15 14:07:08 +01:00
kaiiiz 86737ebe6a Fix duplicate requests in picsur-img.component
This is caused by improper initial state (Loading) that triggers `onInview` multiple times.
2023-03-12 22:09:09 +08:00
Caramel 949b5c95c7
Update README.md 2023-02-23 12:19:07 +01:00
Rubikscraft 8bc13b106d
Update README.md 2023-01-05 01:12:46 +01:00
rubikscraft 767256d2c4
Run prettier 2022-12-28 14:39:43 +01:00
rubikscraft 86179356a4
Update jsonwebtoken 2022-12-28 14:38:05 +01:00
rubikscraft f794c724c0
Update dependencies, for real this time
Just why doesn't yarn do this automatically?
this is stupid
2022-12-28 14:33:52 +01:00
rubikscraft 221d7c1072
Hopefully trigger dependabot refresh? 2022-12-28 14:06:28 +01:00
Rubikscraft 26ed0e9ea5 Merge branch 'dev' 2022-12-28 10:41:11 +01:00
rubikscraft a0984efd67
Update readme 2022-12-28 10:39:47 +01:00
rubikscraft 5d0ecbbd31
Update docker file 2022-12-28 10:27:36 +01:00
Rubikscraft 40bd2349a3
Change bundle limits and build script 2022-12-27 16:56:59 +01:00
Rubikscraft 5a6a366f9d
Add new db migration 2022-12-27 16:14:50 +01:00
Rubikscraft adc58476ff
Run prettier 2022-12-27 16:05:53 +01:00
Rubikscraft 8999e69f26
Update version number 2022-12-27 15:55:25 +01:00
Rubikscraft 2f4c74b8da Move to axios 2022-12-26 16:55:54 +01:00
rubikscraft 9bf2dfd6fc
Import some changes 2022-12-26 16:47:02 +01:00
rubikscraft 05fabea82b
Change way derivatives are handled 2022-12-26 15:56:17 +01:00
Rubikscraft 4566fa947d Edit some logging behaviour 2022-12-26 15:34:04 +01:00
Rubikscraft 844d11b9ca Move entities 2022-12-26 12:49:42 +01:00
Rubikscraft 72b6b2a3d7 Use nestjs scheduler 2022-12-26 12:48:23 +01:00
Rubikscraft 9845fae599 Change upload mechanisms 2022-12-26 12:46:33 +01:00
rubikscraft f6f94a9e01
Fix data bug 2022-12-26 12:37:48 +01:00
rubikscraft 7b6ffb5010 add ratelimits 2022-12-25 23:37:39 +01:00
rubikscraft 78ff25034c
Run prettier 2022-12-25 23:36:59 +01:00
rubikscraft a750d65baa
Add usage reporting 2022-12-25 23:36:42 +01:00
rubikscraft 286333f598
Add loading bars for slow internet 2022-12-25 23:30:35 +01:00
rubikscraft d65ac16943 fix some bugs 2022-12-25 23:26:34 +01:00
rubikscraft f69e455996
Finish hostname override 2022-12-25 23:25:39 +01:00
rubikscraft dac43896ce
Add client side preference verification
Add support for hostname override
2022-12-25 23:24:16 +01:00
rubikscraft 145ff6973f
Change settings layout 2022-12-25 23:19:49 +01:00
rubikscraft 3b50cdeb1e
Migrate to new MS lib 2022-12-25 23:15:45 +01:00
rubikscraft 7dc3a198e4
Improve preference managment 2022-12-25 23:11:13 +01:00
rubikscraft b769ec9c8e
add proxy ip resolving 2022-12-25 23:04:00 +01:00
rubikscraft 58ff75c728 fix bug in caching time being ignored 2022-12-25 23:00:58 +01:00
rubikscraft 9cdf909994 fix bug accepting qoi files 2022-12-25 23:00:41 +01:00
Rubikscraft 72264bd88f Update README.md 2022-12-25 23:00:33 +01:00
Rubikscraft 7cec8eb816 Update README.md 2022-12-25 23:00:26 +01:00
Rubikscraft 96641e3c60 Dynamic usage 2022-12-25 22:58:37 +01:00
rubikscraft d7b4bc19e0
Add route splitting 2022-12-25 22:57:26 +01:00
rubikscraft 7c9bf1ff7e
Fix some packages 2022-12-25 22:54:37 +01:00
Rubikscraft 35328d0433 Test usage reporting 2022-12-25 22:47:43 +01:00
Rubikscraft d41530d40c Test some stuff 2022-12-25 22:43:36 +01:00
Rubikscraft 9fcd83427b update readme 2022-12-25 22:41:26 +01:00
rubikscraft 523dcf1f2c
Run prettier 2022-12-25 22:35:04 +01:00
rubikscraft 6a17cd12a5
Fix many more graphical bugs 2022-12-25 22:28:53 +01:00
rubikscraft 00621cf637
fix css problems 2022-12-25 13:59:43 +01:00
Rubikscraft ff2386c161
Fix some color bugs 2022-12-25 13:03:57 +01:00
rubikscraft 9e5178db8b
Partly done migration to angular 15 2022-12-20 16:21:25 +01:00
Rubikscraft e6b70d0b1c
V0.4.1 dependency upgrade 2022-10-11 19:47:02 +02:00
rubikscraft 0dd0c44113
update readme 2022-09-11 16:38:03 +02:00
rubikscraft 95fd0b9aa7
reduce docker image size from 2gb to 300mb 2022-09-11 16:35:49 +02:00
rubikscraft 9792cedb94
fix small bug 2022-09-11 16:22:37 +02:00
rubikscraft ef255d6e66
fix bug in frontend caching 2022-09-11 16:09:19 +02:00
rubikscraft c866522888
update deps 2022-09-11 16:08:46 +02:00
rubikscraft 539f4bb042
fix snackbar colors 2022-09-10 19:28:47 +02:00
rubikscraft 941e0deb0a
make sure expired images dissapear in the frontend 2022-09-10 17:24:18 +02:00
rubikscraft 6621a167e7
fix some bugs 2022-09-10 14:45:14 +02:00
rubikscraft 74eb9a2503
remove unneccessary log 2022-09-09 15:46:10 +02:00
rubikscraft b3d7cc1546
update dependencies 2022-09-09 15:20:54 +02:00
rubikscraft bd86a8c336
run prettier 2022-09-09 15:14:31 +02:00
rubikscraft e0e804d27d
add ability to make image expire 2022-09-09 15:13:56 +02:00
rubikscraft 08af514758
change way auth keys behave 2022-09-07 09:48:52 +02:00
rubikscraft a19d0bab25
move to uuid columns 2022-09-07 09:32:56 +02:00
rubikscraft 03fec5f832
add expiring images to backend 2022-09-06 19:45:17 +02:00
rubikscraft 422b4a73c4
update packages and run prettier 2022-09-06 16:32:16 +02:00
rubikscraft 32ee928b6c
Fix subscription leak 2022-09-04 20:21:57 +02:00
rubikscraft 887b80aee8
add check for username already taken 2022-09-04 20:21:12 +02:00
rubikscraft 864758f296
create db migration 2022-09-04 20:04:20 +02:00
rubikscraft 2976d746de
try some logos 2022-09-04 19:50:03 +02:00
rubikscraft c57ae98f2c
split up utilservice, and change error behaviour 2022-09-04 19:37:52 +02:00
rubikscraft e96c24a669
give image delete result a neater ui 2022-09-04 15:19:23 +02:00
rubikscraft 838cb237af
add logo to login and register screen 2022-09-04 15:08:25 +02:00
rubikscraft 7f5a6b1f7a
refactor common components 2022-09-04 15:01:22 +02:00
rubikscraft 485ca2d3ff
strip info from login request 2022-09-04 14:43:34 +02:00
rubikscraft f602e71520
add loading info to register and login 2022-09-04 14:43:19 +02:00
rubikscraft 27b5fdeae1
allowing chosing preferred format for sharex 2022-09-04 12:54:54 +02:00
rubikscraft 94763e1e41
add sharex config exporter 2022-09-04 12:33:37 +02:00
rubikscraft 9580ccc928
make apikeys editor better 2022-09-04 11:32:51 +02:00
rubikscraft 5878f0ad1d
allow adding names to apikeys 2022-09-03 21:51:56 +02:00
rubikscraft ec3e58d1b2
Fix bug in deletionkeys 2022-09-03 21:08:28 +02:00
rubikscraft 92e44aea66
Add support for deletekeys 2022-09-03 20:03:28 +02:00
rubikscraft 8ffb06c059
Fix permission bug 2022-09-03 19:18:53 +02:00
rubikscraft 6b0504ec9c
finish api key management interface 2022-09-03 17:07:43 +02:00
rubikscraft a91363962a
add basic api key management 2022-09-03 16:41:50 +02:00
rubikscraft 0813ed0cbc
fix fab layout bug 2022-09-03 16:41:35 +02:00
rubikscraft bfbc6fc8e3
add frontend apikey service 2022-09-03 15:41:56 +02:00
rubikscraft 47dd528778
Downgrade typescript because of angular language server 2022-09-03 15:35:07 +02:00
rubikscraft 833a89426a
add route for api key management 2022-09-03 15:02:43 +02:00
rubikscraft db7b02b629
add ability to auth using api key 2022-09-03 14:46:39 +02:00
rubikscraft c68360c81f
add api key management endpoints 2022-09-03 13:50:53 +02:00
rubikscraft a7981ce8ad
make working apikey db
fix bug in user delete call
2022-09-02 21:28:14 +02:00
rubikscraft caa18ea3bd
refactor class names 2022-09-02 17:18:22 +02:00
rubikscraft 482ab2bfb6
Add ApiKey entity 2022-09-02 17:14:13 +02:00
rubikscraft 7eb203555c
Update readme 2022-09-02 17:06:55 +02:00
rubikscraft 8fe5833036
Add FAQ 2022-09-02 16:59:14 +02:00
rubikscraft b92e9c4f98
send back error message in image form 2022-09-02 16:48:07 +02:00
rubikscraft 94b10929df
fix branding bug 2022-09-02 14:57:55 +02:00
rubikscraft aa61e12856
Update the branding 2022-09-02 14:49:53 +02:00
rubikscraft fc7bf915dc
update dependencies 2022-09-02 12:26:05 +02:00
rubikscraft 66342025d9
add ability to build for arm64 2022-09-02 12:23:18 +02:00
rubikscraft 2519afae6a
Change register button location 2022-09-02 10:43:23 +02:00
rubikscraft b23e5c6660
Add more info about config options 2022-09-01 20:25:40 +02:00
rubikscraft c8c0443940
insert missing info into license 2022-09-01 19:49:40 +02:00
rubikscraft 7962adb5ca
Make the "Please Log In" text link to the login page 2022-09-01 18:58:47 +02:00
rubikscraft 83cc652b52
Add ability to tell image to shrink only
Useful for thumbnails, no need to enlarge a small image
2022-09-01 15:40:56 +02:00
rubikscraft 3a1f279d32
fix ui and download bug 2022-09-01 14:53:55 +02:00
rubikscraft 1fdbe9edc7
change images list to show filename 2022-09-01 13:44:40 +02:00
rubikscraft 654671ba4c
make sure image list shows latest images first 2022-09-01 13:41:08 +02:00
rubikscraft 69c405c7cc
add delete button to image view layout 2022-09-01 13:38:33 +02:00
rubikscraft d1419e67cf
store filename of uploaded file 2022-09-01 13:16:23 +02:00
Rubikscraft a7f19b424a
Create FUNDING.yml 2022-08-31 13:46:18 +02:00
rubikscraft c99cff55e1
Update dependencies 2022-08-30 15:46:36 +02:00
rubikscraft 0e2388f808
Support http connections, allow pasting to upload 2022-08-29 20:30:24 +02:00
rubikscraft ba7f1db412
fix bug in chrome 2022-08-29 15:57:10 +02:00
rubikscraft ea5a14483f
update readme 2022-08-28 17:00:44 +02:00
377 changed files with 14484 additions and 6237 deletions

View file

@ -1,24 +1,22 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
ignorePatterns: ['.eslintrc.cjs', 'dist', '*.exclude.*'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
},
root: true,
};

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: caramelfur

1
.gitignore vendored
View file

@ -10,5 +10,4 @@ yarn-error.log
!.yarn/versions
.pnp.*
temp

2
.nvmrc
View file

@ -1 +1 @@
v18.8
v20

View file

@ -1,3 +1,4 @@
node_modules
dist
.angular
.yarn

View file

@ -3,3 +3,6 @@ endOfLine: lf
singleQuote: true
tabWidth: 2
trailingComma: all
plugins:
- prettier-plugin-sh

View file

@ -1,5 +1,5 @@
{
"vsicons.presets.angular": true,
"skipRefreshExplorerOnWindowFocus": true,
"angular.log": "verbose"
"angular.log": "verbose",
"discord.enabled": true
}

File diff suppressed because one or more lines are too long

4
.yarn/versions/6db1bf03.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- root-workspace-0b6124
- picsur-backend
- picsur-frontend

4
.yarn/versions/8df55c81.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- picsur-backend
- picsur-frontend
- picsur-shared

4
.yarn/versions/9e39fdb4.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- root-workspace-0b6124
- picsur-backend
- picsur-frontend

4
.yarn/versions/a9a7dc82.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- root-workspace-0b6124
- picsur-backend
- picsur-frontend

4
.yarn/versions/ccff8772.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- root-workspace-0b6124
- picsur-backend
- picsur-frontend

4
.yarn/versions/e0bbb8ad.yml vendored Normal file
View file

@ -0,0 +1,4 @@
undecided:
- root-workspace-0b6124
- picsur-backend
- picsur-frontend

View file

@ -2,6 +2,8 @@ nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
spec: "@yarnpkg/plugin-version"
spec: '@yarnpkg/plugin-version'
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: '@yarnpkg/plugin-workspace-tools'
yarnPath: .yarn/releases/yarn-berry.cjs

View file

@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
Picsur - An easy to use, selfhostable image sharing service like Imgur
Copyright (C) 2022 Caramel <picsur@caramelfur.dev>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published

106
README.md
View file

@ -1,4 +1,4 @@
<img align="left" width="100" height="100" src="branding/logo/picsur.svg"/>
<img align="left" width="100" height="100" style="border-radius: 15%" src="branding/logo/picsur.svg"/>
<a href="https://discord.gg/GPZNwV3VKE">
<img align="right" style="margin: 5px" src="https://img.shields.io/discord/986634827337965638?color=454FBF&label=Chat%20on%20Discord"/>
@ -6,9 +6,7 @@
# Picsur
<br>
> Totally not an imgur clone
> Totally not an Imgur clone
I couldn't really find any open source project that allowed you to easily host images. So I decided to create one.
@ -21,16 +19,16 @@ But it does function, so feel free to give it a try.
## Demo
You can view a live demo here: <https://picsur.rubikscraft.nl/>
You can view a live demo here: <https://picsur.org/>
The images are deleted every five minutes, and the maximum filesize is 16MB. But it should give you an indication of how it works.
## Features
Here is a list of done features, and what is planned.
For a more detailed list, you can always visit [the project](https://github.com/rubikscraft/Picsur/projects/1).
For a more detailed list, you can always visit [the project](https://github.com/CaramelFur/Picsur/projects/1).
Right now, not every done feature here is available in the current release. But these will all be available with the next one.
Every featured marked here should work in the latest release.
- [x] Uploading and viewing images
- [x] Anonymous uploads
@ -39,27 +37,81 @@ Right now, not every done feature here is available in the current release. But
- [x] Proper CORS restrictions
- [x] Exif stripping
- [x] Ability to keep original
- [x] Support for [QOI format](https://qoiformat.org/)
- [x] Support for many formats
- QOI
- JPG
- PNG
- WEBP (animated supported)
- TIFF
- BMP
- GIF (animated supported)
- [x] Convert images
- [x] Resize images
- [x] Apply filters
- [x] Edit images
- Resize
- Rotate
- Flip
- Strip transparency
- Negative
- Greyscale
- [x] Deletable images
- [x] Proper DB migrations
- [x] Show own images in list
- [x] Correct previews on chats
- [x] Expiring images
- [x] ShareX endpoint
- [x] ARM64 and AMD64 Docker image
- [ ] Correct previews on chats
- [ ] Expiring images
- [ ] White mode
- [ ] ShareX endpoint
- [ ] Arm64 image
- [ ] Public gallery
- [ ] Albums
## Bugs
If you encounter any bugs or oddities, please open an issue [here](https://github.com/rubikscraft/Picsur/issues). Cause without feedback I'll never know they exists.
If you encounter any bugs or oddities, please open an issue [here](https://github.com/CaramelFur/Picsur/issues). Cause without feedback I'll never know they exists.
## Star
If you like this project, don't forget to give it a star. It tells me that I'm not wasting my time on something that people don't like.
## Running
## Faq
### Is this project maintained?
Yes it still is. If I were to stop maintaining it, I would archive the repository.
However I do not have a lot of time on my hands, so updates are not always as frequent as I would like them to be.
### Why do my images dissapear of the public instance?
The public instance is only a demo, and therefore only keeps images for 5 minutes. This is to prevent the server from running out of disk space, and to prevent people from using it to host questionable images.
If you wish to keep your images, you will have to host your own instance.
### How do I allow users to register their own accounts?
By default, users can't register their own accounts. This is to prevent users from accidentally allowing anyone to upload to their instance.
If you want to allow this you can though. To change this you go to `settings -> roles -> guest -> edit`, and then give the guest role the `Register` permission. Upon saving the role, the register button will appear on the login page.
### I want to keep my original image files, how?
By default, Picsur will not keep your original image files. Since for most purposes this is not needed, and it saves disk space.
If you want to enable this however, you can do so by going to `settings -> general`, and then enabling the `Keep original` option. Upon saving the settings, the original files will be kept.
Do keep in mind here, that the exif data will NOT be removed from the original image. So make sure you do not accidentally share sensitive data.
### This service says its supports the QOI format, what is this?
QOI is a new lossless image format that is designed to be very fast to encode and decode. All while still offering good compression ratios. This is the primary format the server will store images in when uploaded.
You can [read more about QOI here](https://qoiformat.org/).
### What is the default admin login?
The default username is `admin`, and the default password is set from the `PICSUR_ADMIN_PASSWORD` environment variable.
## Running your own instance
You easily run this service yourself via Docker. Here is an example docker-compose file:
@ -67,7 +119,7 @@ You easily run this service yourself via Docker. Here is an example docker-compo
version: '3'
services:
picsur:
image: ghcr.io/rubikscraft/picsur:latest
image: ghcr.io/caramelfur/picsur:latest
container_name: picsur
ports:
- '8080:8080'
@ -81,16 +133,23 @@ services:
# PICSUR_DB_PASSWORD: picsur
# PICSUR_DB_DATABASE: picsur
## The default username is admin, this is not modifyable
# PICSUR_ADMIN_PASSWORD: picsur
## Optional, random secret will be generated if not set
# PICSUR_JWT_SECRET: CHANGE_ME
# PICSUR_JWT_EXPIRY: 1d
# PICSUR_JWT_EXPIRY: 7d
## Maximum accepted size for uploads in bytes
# PICSUR_MAX_FILE_SIZE: 128000000
## No need to touch this, unless you use a custom frontend
# PICSUR_STATIC_FRONTEND_ROOT: "/picsur/frontend/dist"
## Warning: Verbose mode might log sensitive data
# PICSUR_VERBOSE: "true"
restart: unless-stopped
picsur_postgres:
image: postgres:11-alpine
image: postgres:14-alpine
container_name: picsur_postgres
environment:
POSTGRES_DB: picsur
@ -103,8 +162,15 @@ volumes:
picsur-data:
```
## Thanks
- @chennin for monthly donating 4$
- @awg13 for donating 5$
## Api
Here is a usually up to date documentation of the api:
[![Run in Postman](https://run.pstmn.io/button.svg)](https://www.postman.com/rubikscraft-team/workspace/picsur/collection/1841871-78e559b6-4f39-4092-87c3-92fa29547d03)
[![Run in Postman](https://run.pstmn.io/button.svg)](https://www.postman.com/caramel-team/workspace/picsur/collection/1841871-78e559b6-4f39-4092-87c3-92fa29547d03)
If you wish to build your own frontend or app for picsur, this will surely come in handy. Also take a look at the `./shared` folder in the source code, as it contains typescript schema definitions for the api.

9
backend/.eslintrc.cjs Normal file
View file

@ -0,0 +1,9 @@
module.exports = {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
extends: ['../.eslintrc.cjs'],
root: false,
};

View file

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View file

@ -1,87 +1,85 @@
{
"name": "picsur-backend",
"version": "0.3.0",
"version": "0.5.2",
"description": "Backend for Picsur",
"license": "GPL-3.0",
"repository": "https://github.com/rubikscraft/Picsur",
"author": "Rubikscraft <contact@rubikscraft.nl>",
"repository": "https://github.com/caramelfur/Picsur",
"author": "Caramel <picsur@caramelfur.dev>",
"type": "module",
"main": "dist/main.js",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"start": "nest start --exec \"node --es-module-specifier-resolution=node\"",
"start:dev": "yarn clean && nest start --watch --exec \"node --es-module-specifier-resolution=node\"",
"start:debug": "nest start --debug --watch --exec \"node --es-module-specifier-resolution=node\"",
"start:prod": "node --es-module-specifier-resolution=node dist/main",
"start": "nest start --exec \"node --experimental-loader=extensionless\"",
"start:dev": "yarn clean && nest start --watch --exec \"node --experimental-loader=extensionless\"",
"start:debug": "nest start --debug --watch --exec \"node --experimental-loader=extensionless\"",
"start:prod": "node --experimental-loader=extensionless dist/main",
"typeorm": "typeorm-ts-node-esm",
"migrate": "yarn typeorm migration:generate -d ./src/datasource.ts",
"migrate": "PICSUR_PRODUCTION=\"true\" yarn typeorm migration:generate -d ./src/datasource.ts",
"format": "prettier --write \"src/**/*.ts\"",
"clean": "rimraf dist",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"purge": "rm -rf dist && rm -rf node_modules"
},
"dependencies": {
"@fastify/helmet": "^9.1.0",
"@fastify/multipart": "^7.1.0",
"@fastify/static": "^6.5.0",
"@nestjs/common": "^9.0.11",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.11",
"@nestjs/jwt": "^9.0.0",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-fastify": "^9.0.11",
"@nestjs/serve-static": "^3.0.0",
"@fastify/helmet": "^10.1.1",
"@fastify/multipart": "^7.6.1",
"@fastify/reply-from": "^9.3.0",
"@fastify/static": "^6.10.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^2.3.4",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.1.0",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-fastify": "^10.0.0",
"@nestjs/schedule": "^3.0.0",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/throttler": "^4.0.0",
"@nestjs/typeorm": "^9.0.1",
"bcrypt": "^5.0.1",
"bmp-img": "^1.1.0",
"bcrypt": "^5.1.0",
"bmp-img": "^1.2.1",
"cors": "^2.8.5",
"fastify-static": "^4.7.0",
"file-type": "^18.0.0",
"ms": "^2.1.3",
"p-timeout": "^6.0.0",
"extensionless": "^1.4.5",
"file-type": "^18.5.0",
"is-docker": "^3.0.0",
"ms": "2.1.3",
"node-fetch": "^3.3.1",
"p-timeout": "^6.1.2",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-strategy": "^1.0.0",
"pg": "^8.8.0",
"pg": "^8.11.0",
"picsur-shared": "*",
"posix.js": "^0.1.1",
"qoi-img": "^1.1.0",
"qoi-img": "^2.1.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.6",
"sharp": "^0.30.7",
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"sharp": "^0.32.1",
"stream-parser": "^0.3.1",
"thunks": "^4.9.6",
"typeorm": "0.3.7",
"zod": "^3.18.0"
"typeorm": "0.3.16",
"zod": "^3.21.4"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.1",
"@nestjs/testing": "^9.0.11",
"@nestjs/cli": "^10.0.1",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.12",
"@types/ms": "^0.7.31",
"@types/cors": "^2.8.13",
"@types/multer": "^1.4.7",
"@types/node": "^18.7.13",
"@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.8",
"@types/passport-local": "^1.0.35",
"@types/passport-strategy": "^0.2.35",
"@types/sharp": "^0.30.5",
"@types/sharp": "^0.32.0",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.35.1",
"eslint": "^8.22.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.7.1",
"prettier": "^2.8.8",
"source-map-support": "^0.5.21",
"ts-loader": "^9.3.1",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "4.8.2",
"webpack": "^5.74.0"
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
}
}

View file

@ -1,13 +1,16 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import cors from 'cors';
import { IncomingMessage, ServerResponse } from 'http';
import { EarlyConfigModule } from './config/early/early-config.module';
import { ServeStaticConfigService } from './config/early/serve-static.config.service';
import { DatabaseModule } from './database/database.module';
import { PicsurLayersModule } from './layers/PicsurLayers.module';
import { PicsurLoggerModule } from './logger/logger.module';
import { AuthManagerModule } from './managers/auth/auth.module';
import { DemoManagerModule } from './managers/demo/demo.module';
import { UsageManagerModule } from './managers/usage/usage.module';
import { PicsurRoutesModule } from './routes/routes.module';
const mainCorsConfig = cors({
@ -27,7 +30,7 @@ const imageCorsConfig = cors({
const imageCorsOverride = (
req: IncomingMessage,
res: ServerResponse,
next: Function,
next: () => void,
) => {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@ -41,10 +44,13 @@ const imageCorsOverride = (
useExisting: ServeStaticConfigService,
imports: [EarlyConfigModule],
}),
ScheduleModule.forRoot(),
DatabaseModule,
AuthManagerModule,
UsageManagerModule,
DemoManagerModule,
PicsurRoutesModule,
PicsurLayersModule,
],
})
export class AppModule implements NestModule {

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EApiKeyBackend } from '../../database/entities/apikey.entity';
import { ApiKeyDbService } from './apikey-db.service';
@Module({
imports: [TypeOrmModule.forFeature([EApiKeyBackend])],
providers: [ApiKeyDbService],
exports: [ApiKeyDbService],
})
export class ApiKeyDbModule {}

View file

@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { generateRandomString } from 'picsur-shared/dist/util/random';
import { Repository } from 'typeorm';
import { EApiKeyBackend } from '../../database/entities/apikey.entity';
import { EUserBackend } from '../../database/entities/users/user.entity';
@Injectable()
export class ApiKeyDbService {
private readonly logger = new Logger(ApiKeyDbService.name);
constructor(
@InjectRepository(EApiKeyBackend)
private readonly apikeyRepo: Repository<EApiKeyBackend>,
) {}
async createApiKey(userid: string): AsyncFailable<EApiKeyBackend<string>> {
const apikey = new EApiKeyBackend<string>();
apikey.user = userid;
apikey.created = new Date();
// YYYY-MM-DD- followed by a random number
apikey.name =
new Date().toISOString().slice(0, 10) +
'_' +
Math.round(Math.random() * 100);
apikey.key = generateRandomString(32); // Might collide, probably not
/*
And yes it might be more secure here to sha256 the key, to ensure that they are not leaked upon db breach
But this would mean that the user has to keep track of it themselves, and it makes many other things less smooth
So just foking protect ya database, and we'll be fine
*/
try {
return this.apikeyRepo.save(apikey);
} catch (e) {
return Fail(FT.Database, e);
}
}
async findOne(
id: string,
userid: string | undefined,
): AsyncFailable<EApiKeyBackend<string>> {
try {
const apikey = await this.apikeyRepo.findOne({
where: {
user:
userid !== undefined
? // This is stupid, but typeorm do typeorm
({ id: userid } as any)
: undefined,
id,
},
loadRelationIds: true,
});
if (!apikey) return Fail(FT.NotFound, 'API key not found');
return apikey as EApiKeyBackend<string>;
} catch (e) {
return Fail(FT.Database, e);
}
}
async findMany(
count: number,
page: number,
userid: string | undefined,
): AsyncFailable<FindResult<EApiKeyBackend<string>>> {
if (count < 1 || page < 0) return Fail(FT.UsrValidation, 'Invalid page');
if (count > 100) return Fail(FT.UsrValidation, 'Too many results');
try {
const [apikeys, amount] = await this.apikeyRepo.findAndCount({
where: {
user:
userid !== undefined
? // This is stupid, but typeorm do typeorm
({ id: userid } as any)
: undefined,
},
order: { created: 'DESC' },
skip: count * page,
take: count,
loadRelationIds: true,
});
return {
results: apikeys as EApiKeyBackend<string>[],
total: amount,
page,
pages: Math.ceil(amount / count),
};
} catch (e) {
return Fail(FT.Database, e);
}
}
async updateApiKey(
id: string,
name: string,
userid: string | undefined,
): AsyncFailable<EApiKeyBackend<string>> {
const apikey = await this.findOne(id, userid);
if (HasFailed(apikey)) return apikey;
try {
apikey.name = name;
return this.apikeyRepo.save(apikey);
} catch (e) {
return Fail(FT.Database, e);
}
}
async deleteApiKey(
id: string,
userid: string | undefined,
): AsyncFailable<EApiKeyBackend<string>> {
const apikeyToDelete = await this.findOne(id, userid);
if (HasFailed(apikeyToDelete)) return apikeyToDelete;
const apiKeyCopy = { ...apikeyToDelete };
try {
await this.apikeyRepo.remove(apikeyToDelete);
return apiKeyCopy as EApiKeyBackend<string>;
} catch (e) {
return Fail(FT.Database, e);
}
}
async resolve(key: string): AsyncFailable<EApiKeyBackend<EUserBackend>> {
try {
const apikey = await this.apikeyRepo.findOne({
where: { key },
relations: ['user'],
});
if (!apikey) return Fail(FT.NotFound, 'API key not found');
this.updateLastUsed(apikey);
return apikey as EApiKeyBackend<EUserBackend>;
} catch (e) {
return Fail(FT.Database, e);
}
}
private updateLastUsed(apikey: EApiKeyBackend) {
(async () => {
apikey.last_used = new Date();
this.apikeyRepo.save(apikey);
})().catch(this.logger.error.bind(this.logger));
}
}

View file

@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity';
import { EImageBackend } from '../../database/entities/image.entity';
import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
import { EImageBackend } from '../../database/entities/images/image.entity';
import { ImageDBService } from './image-db.service';
import { ImageFileDBService } from './image-file-db.service';

View file

@ -1,37 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types/failable';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { In, Repository } from 'typeorm';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity';
import { EImageBackend } from '../../database/entities/image.entity';
import { generateRandomString } from 'picsur-shared/dist/util/random';
import { In, LessThan, Repository } from 'typeorm';
import { EImageBackend } from '../../database/entities/images/image.entity';
@Injectable()
export class ImageDBService {
constructor(
@InjectRepository(EImageBackend)
private readonly imageRepo: Repository<EImageBackend>,
@InjectRepository(EImageFileBackend)
private readonly imageFileRepo: Repository<EImageFileBackend>,
@InjectRepository(EImageDerivativeBackend)
private readonly imageDerivativeRepo: Repository<EImageDerivativeBackend>,
) {}
public async create(userid: string): AsyncFailable<EImageBackend> {
public async create(
userid: string,
filename: string,
withDeleteKey: boolean,
): AsyncFailable<EImageBackend> {
let imageEntity = new EImageBackend();
imageEntity.user_id = userid;
imageEntity.created = new Date();
imageEntity.file_name = filename;
if (withDeleteKey) imageEntity.delete_key = generateRandomString(32);
try {
imageEntity = await this.imageRepo.save(imageEntity, { reload: true });
imageEntity = await this.imageRepo.save(imageEntity, {
reload: true,
});
if (imageEntity.delete_key === null) delete imageEntity.delete_key;
return imageEntity;
} catch (e) {
return Fail(FT.Database, e);
}
return imageEntity;
}
public async findOne(
@ -62,6 +64,7 @@ export class ImageDBService {
const [found, amount] = await this.imageRepo.findAndCount({
skip: count * page,
take: count,
order: { created: 'DESC' },
where: {
user_id: userid,
},
@ -80,6 +83,39 @@ export class ImageDBService {
}
}
public async count(): AsyncFailable<number> {
try {
return await this.imageRepo.count();
} catch (e) {
return Fail(FT.Database, e);
}
}
public async update(
id: string,
userid: string | undefined,
options: Partial<Pick<EImageBackend, 'file_name' | 'expires_at'>>,
): AsyncFailable<EImageBackend> {
try {
const found = await this.imageRepo.findOne({
where: { id, user_id: userid },
});
if (!found) return Fail(FT.NotFound, 'Image not found');
if (options.file_name !== undefined) found.file_name = options.file_name;
if (options.expires_at !== undefined)
found.expires_at = options.expires_at;
await this.imageRepo.save(found);
return found;
} catch (e) {
return Fail(FT.Database, e);
}
}
public async delete(
ids: string[],
userid: string | undefined,
@ -97,18 +133,10 @@ export class ImageDBService {
const available_ids = deletable_images.map((i) => i.id);
if (available_ids.length === 0) return Fail(FT.NotFound, 'Images not found');
if (available_ids.length === 0)
return Fail(FT.NotFound, 'Images not found');
await Promise.all([
this.imageDerivativeRepo.delete({
image_id: In(available_ids),
}),
this.imageFileRepo.delete({
image_id: In(available_ids),
}),
this.imageRepo.delete({ id: In(available_ids) }),
]);
await this.imageRepo.delete({ id: In(available_ids) });
return deletable_images;
} catch (e) {
@ -116,17 +144,49 @@ export class ImageDBService {
}
}
public async deleteWithKey(
id: string,
key: string,
): AsyncFailable<EImageBackend> {
try {
const found = await this.imageRepo.findOne({
where: { id, delete_key: key },
});
if (!found) return Fail(FT.NotFound, 'Image not found');
await this.imageRepo.delete({ id: found.id });
return found;
} catch (e) {
return Fail(FT.Database, e);
}
}
public async deleteAll(IAmSure: boolean): AsyncFailable<true> {
if (!IAmSure)
return Fail(FT.SysValidation, 'You must confirm that you want to delete all images');
return Fail(
FT.SysValidation,
'You must confirm that you want to delete all images',
);
try {
await this.imageDerivativeRepo.delete({});
await this.imageFileRepo.delete({});
await this.imageRepo.delete({});
} catch (e) {
return Fail(FT.Database, e);
}
return true;
}
public async cleanupExpired(): AsyncFailable<number> {
try {
const res = await this.imageRepo.delete({
expires_at: LessThan(new Date()),
});
return res.affected ?? 0;
} catch (e) {
return Fail(FT.Database, e);
}
}
}

View file

@ -1,10 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { LessThan, Repository } from 'typeorm';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity';
import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
const A_DAY_IN_SECONDS = 24 * 60 * 60;
@ -57,6 +62,40 @@ export class ImageFileDBService {
}
}
public async migrateFile(
imageId: string,
sourceVariant: ImageEntryVariant,
targetVariant: ImageEntryVariant,
): AsyncFailable<EImageFileBackend> {
try {
const sourceFile = await this.getFile(imageId, sourceVariant);
if (HasFailed(sourceFile)) return sourceFile;
sourceFile.variant = targetVariant;
return await this.imageFileRepo.save(sourceFile);
} catch (e) {
return Fail(FT.Database, e);
}
}
public async deleteFile(
imageId: string,
variant: ImageEntryVariant,
): AsyncFailable<EImageFileBackend> {
try {
const found = await this.imageFileRepo.findOne({
where: { image_id: imageId, variant: variant },
});
if (!found) return Fail(FT.NotFound, 'Image not found');
await this.imageFileRepo.delete({ image_id: imageId, variant: variant });
return found;
} catch (e) {
return Fail(FT.Database, e);
}
}
// This is useful because you dont have to pull the whole image file
public async getFileTypes(
imageId: string,
@ -129,7 +168,7 @@ export class ImageFileDBService {
): AsyncFailable<number> {
try {
const result = await this.imageDerivativeRepo.delete({
last_read: LessThan(new Date()),
last_read: LessThan(new Date(Date.now() - olderThanSeconds * 1000)),
});
return result.affected ?? 0;

View file

@ -2,29 +2,29 @@ import { Injectable, Logger } from '@nestjs/common';
import {
DecodedPref,
PrefValueType,
PrefValueTypeStrings
PrefValueTypeStrings,
} from 'picsur-shared/dist/dto/preferences.dto';
import {
AsyncFailable,
Fail,
Failable,
FT,
HasFailed
} from 'picsur-shared/dist/types';
HasFailed,
} from 'picsur-shared/dist/types/failable';
type Enum = Record<string, string>;
type EnumValue<E> = E[keyof E];
type PrefValueTypeType<E extends Enum> = {
[key in EnumValue<E>]: PrefValueTypeStrings;
};
type EncodedPref = {
key: string;
type EncodedPref<E extends Enum> = {
key: EnumValue<E>;
value: string;
};
@Injectable()
export class PreferenceCommonService {
private readonly logger = new Logger('PreferenceCommonService');
private readonly logger = new Logger(PreferenceCommonService.name);
// Preferences values are only validated upon encoding, not decoding
// The preference keys are always validated
@ -32,7 +32,7 @@ export class PreferenceCommonService {
// E is either the SysPreference or the UsrPreference enum
// the pref value types is the object containing the type of each key in E
public DecodePref<E extends Enum>(
preference: EncodedPref,
preference: EncodedPref<E>,
prefType: E,
prefValueTypes: PrefValueTypeType<E>,
): Failable<DecodedPref> {
@ -69,7 +69,7 @@ export class PreferenceCommonService {
value: PrefValueType,
prefType: E,
prefValueTypes: PrefValueTypeType<E>,
): AsyncFailable<EncodedPref> {
): AsyncFailable<EncodedPref<E>> {
const validatedKey = this.validatePrefKey(key, prefType);
if (HasFailed(validatedKey)) return validatedKey;

View file

@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EarlyConfigModule } from '../../config/early/early-config.module';
import { ESysPreferenceBackend } from '../../database/entities/sys-preference.entity';
import { EUsrPreferenceBackend } from '../../database/entities/usr-preference.entity';
import { ESysPreferenceBackend } from '../../database/entities/system/sys-preference.entity';
import { EUsrPreferenceBackend } from '../../database/entities/system/usr-preference.entity';
import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service';
import { SysPreferenceService } from './sys-preference-db.service';
import { UsrPreferenceService } from './usr-preference-db.service';
import { SysPreferenceDbService } from './sys-preference-db.service';
import { UsrPreferenceDbService } from './usr-preference-db.service';
@Module({
imports: [
@ -14,11 +14,11 @@ import { UsrPreferenceService } from './usr-preference-db.service';
EarlyConfigModule,
],
providers: [
SysPreferenceService,
UsrPreferenceService,
SysPreferenceDbService,
UsrPreferenceDbService,
PreferenceDefaultsService,
PreferenceCommonService,
],
exports: [SysPreferenceService, UsrPreferenceService],
exports: [SysPreferenceDbService, UsrPreferenceDbService],
})
export class PreferenceModule {}
export class PreferenceDbModule {}

View file

@ -11,19 +11,21 @@ import { EarlyJwtConfigService } from '../../config/early/early-jwt.config.servi
@Injectable()
export class PreferenceDefaultsService {
private readonly logger = new Logger('PreferenceDefaultsService');
private readonly logger = new Logger(PreferenceDefaultsService.name);
constructor(private readonly jwtConfigService: EarlyJwtConfigService) {}
public readonly usrDefaults: {
[key in UsrPreference]: () => PrefValueType;
private readonly usrDefaults: {
[key in UsrPreference]: (() => PrefValueType) | PrefValueType;
} = {
[UsrPreference.KeepOriginal]: () => false,
[UsrPreference.KeepOriginal]: false,
};
public readonly sysDefaults: {
[key in SysPreference]: () => PrefValueType;
private readonly sysDefaults: {
[key in SysPreference]: (() => PrefValueType) | PrefValueType;
} = {
[SysPreference.HostOverride]: '',
[SysPreference.JwtSecret]: () => {
const envSecret = this.jwtConfigService.getJwtSecret();
if (envSecret) {
@ -37,13 +39,36 @@ export class PreferenceDefaultsService {
},
[SysPreference.JwtExpiresIn]: () =>
this.jwtConfigService.getJwtExpiresIn() ?? '7d',
[SysPreference.BCryptStrength]: () => 12,
[SysPreference.BCryptStrength]: 10,
[SysPreference.RemoveDerivativesAfter]: () => '7d',
[SysPreference.SaveDerivatives]: () => true,
[SysPreference.AllowEditing]: () => true,
[SysPreference.RemoveDerivativesAfter]: '7d',
[SysPreference.AllowEditing]: true,
[SysPreference.ConversionTimeLimit]: () => '10s',
[SysPreference.ConversionMemoryLimit]: () => 512,
[SysPreference.ConversionTimeLimit]: '15s',
[SysPreference.ConversionMemoryLimit]: 512,
[SysPreference.EnableTracking]: false,
[SysPreference.TrackingUrl]: '',
[SysPreference.TrackingId]: '',
[SysPreference.EnableTelemetry]: true,
};
public getSysDefault(pref: SysPreference): PrefValueType {
const value = this.sysDefaults[pref];
if (typeof value === 'function') {
return value();
} else {
return value;
}
}
public getUsrDefault(pref: UsrPreference): PrefValueType {
const value = this.usrDefaults[pref];
if (typeof value === 'function') {
return value();
} else {
return value;
}
}
}

View file

@ -3,26 +3,32 @@ import { InjectRepository } from '@nestjs/typeorm';
import {
DecodedSysPref,
PrefValueType,
PrefValueTypeStrings
PrefValueTypeStrings,
} from 'picsur-shared/dist/dto/preferences.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import {
SysPreference,
SysPreferenceList,
SysPreferenceValidators,
SysPreferenceValueTypes,
} from 'picsur-shared/dist/dto/sys-preferences.enum';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { Repository } from 'typeorm';
import {
ESysPreferenceBackend,
ESysPreferenceSchema
} from '../../database/entities/sys-preference.entity';
import {
SysPreferenceList,
SysPreferenceValueTypes
} from '../../models/constants/syspreferences.const';
import { MutexFallBack } from '../../models/util/mutex-fallback';
ESysPreferenceSchema,
} from '../../database/entities/system/sys-preference.entity';
import { MutexFallBack } from '../../util/mutex-fallback';
import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service';
@Injectable()
export class SysPreferenceService {
private readonly logger = new Logger('SysPreferenceService');
export class SysPreferenceDbService {
private readonly logger = new Logger(SysPreferenceDbService.name);
constructor(
@InjectRepository(ESysPreferenceBackend)
@ -36,7 +42,7 @@ export class SysPreferenceService {
value: PrefValueType,
): AsyncFailable<DecodedSysPref> {
// Validate
let sysPreference = await this.encodeSysPref(key, value);
const sysPreference = await this.encodeSysPref(key, value);
if (HasFailed(sysPreference)) return sysPreference;
// Set
@ -59,7 +65,7 @@ export class SysPreferenceService {
public async getPreference(key: string): AsyncFailable<DecodedSysPref> {
// Validate
let validatedKey = this.prefCommon.validatePrefKey(key, SysPreference);
const validatedKey = this.prefCommon.validatePrefKey(key, SysPreference);
if (HasFailed(validatedKey)) return validatedKey;
// See the comment in 'mutex-fallback.ts' for why we are using a mutex here
@ -85,7 +91,7 @@ export class SysPreferenceService {
// Return
return this.prefCommon.DecodePref(
result.data,
result.data as any,
SysPreference,
SysPreferenceValueTypes,
);
@ -111,7 +117,7 @@ export class SysPreferenceService {
key: string,
type: PrefValueTypeStrings,
): AsyncFailable<PrefValueType> {
let pref = await this.getPreference(key);
const pref = await this.getPreference(key);
if (HasFailed(pref)) return pref;
if (pref.type !== type)
return Fail(FT.UsrValidation, 'Invalid preference type');
@ -121,7 +127,7 @@ export class SysPreferenceService {
public async getAllPreferences(): AsyncFailable<DecodedSysPref[]> {
// TODO: We are fetching each value invidually, we should fetch all at once
let internalSysPrefs = await Promise.all(
const internalSysPrefs = await Promise.all(
SysPreferenceList.map((key) => this.getPreference(key)),
);
if (internalSysPrefs.some((pref) => HasFailed(pref))) {
@ -136,7 +142,7 @@ export class SysPreferenceService {
private async saveDefault(
key: SysPreference, // Force enum here because we dont validate
): AsyncFailable<DecodedSysPref> {
return this.setPreference(key, this.defaultsService.sysDefaults[key]());
return this.setPreference(key, this.defaultsService.getSysDefault(key));
}
private async encodeSysPref(
@ -151,7 +157,13 @@ export class SysPreferenceService {
);
if (HasFailed(validated)) return validated;
let verifySysPreference = new ESysPreferenceBackend();
const valueValidated =
SysPreferenceValidators[key as SysPreference].safeParse(value);
if (!valueValidated.success) {
return Fail(FT.UsrValidation, undefined, valueValidated.error);
}
const verifySysPreference = new ESysPreferenceBackend();
verifySysPreference.key = validated.key;
verifySysPreference.value = validated.value;

View file

@ -3,26 +3,32 @@ import { InjectRepository } from '@nestjs/typeorm';
import {
DecodedUsrPref,
PrefValueType,
PrefValueTypeStrings
PrefValueTypeStrings,
} from 'picsur-shared/dist/dto/preferences.dto';
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import {
UsrPreference,
UsrPreferenceList,
UsrPreferenceValidators,
UsrPreferenceValueTypes,
} from 'picsur-shared/dist/dto/usr-preferences.enum';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { Repository } from 'typeorm';
import {
EUsrPreferenceBackend,
EUsrPreferenceSchema
} from '../../database/entities/usr-preference.entity';
import {
UsrPreferenceList,
UsrPreferenceValueTypes
} from '../../models/constants/usrpreferences.const';
import { MutexFallBack } from '../../models/util/mutex-fallback';
EUsrPreferenceSchema,
} from '../../database/entities/system/usr-preference.entity';
import { MutexFallBack } from '../../util/mutex-fallback';
import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service';
@Injectable()
export class UsrPreferenceService {
private readonly logger = new Logger('UsrPreferenceService');
export class UsrPreferenceDbService {
private readonly logger = new Logger(UsrPreferenceDbService.name);
constructor(
@InjectRepository(EUsrPreferenceBackend)
@ -37,7 +43,7 @@ export class UsrPreferenceService {
value: PrefValueType,
): AsyncFailable<DecodedUsrPref> {
// Validate
let usrPreference = await this.encodeUsrPref(userid, key, value);
const usrPreference = await this.encodeUsrPref(userid, key, value);
if (HasFailed(usrPreference)) return usrPreference;
// Set
@ -65,7 +71,7 @@ export class UsrPreferenceService {
key: string,
): AsyncFailable<DecodedUsrPref> {
// Validate
let validatedKey = this.prefCommon.validatePrefKey(key, UsrPreference);
const validatedKey = this.prefCommon.validatePrefKey(key, UsrPreference);
if (HasFailed(validatedKey)) return validatedKey;
// See the comment in 'mutex-fallback.ts' for why we are using a mutex here
@ -91,7 +97,7 @@ export class UsrPreferenceService {
// Return
const unpacked = this.prefCommon.DecodePref(
result.data,
result.data as any,
UsrPreference,
UsrPreferenceValueTypes,
);
@ -144,7 +150,7 @@ export class UsrPreferenceService {
key: string,
type: PrefValueTypeStrings,
): AsyncFailable<PrefValueType> {
let pref = await this.getPreference(userid, key);
const pref = await this.getPreference(userid, key);
if (HasFailed(pref)) return pref;
if (pref.type !== type)
return Fail(FT.UsrValidation, 'Invalid preference type');
@ -156,7 +162,7 @@ export class UsrPreferenceService {
userid: string,
): AsyncFailable<DecodedUsrPref[]> {
// TODO: We are fetching each value invidually, we should fetch all at once
let internalSysPrefs = await Promise.all(
const internalSysPrefs = await Promise.all(
UsrPreferenceList.map((key) => this.getPreference(userid, key)),
);
if (internalSysPrefs.some((pref) => HasFailed(pref))) {
@ -175,7 +181,7 @@ export class UsrPreferenceService {
return this.setPreference(
userid,
key,
this.defaultsService.usrDefaults[key](),
this.defaultsService.getUsrDefault(key),
);
}
@ -192,7 +198,13 @@ export class UsrPreferenceService {
);
if (HasFailed(validated)) return validated;
let verifySysPreference = new EUsrPreferenceBackend();
const valueValidated =
UsrPreferenceValidators[key as UsrPreference].safeParse(value);
if (!valueValidated.success) {
return Fail(FT.UsrValidation, undefined, valueValidated.error);
}
const verifySysPreference = new EUsrPreferenceBackend();
verifySysPreference.key = validated.key;
verifySysPreference.value = validated.value;
verifySysPreference.user_id = userid;

View file

@ -1,26 +1,26 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HasFailed } from 'picsur-shared/dist/types';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { EarlyConfigModule } from '../../config/early/early-config.module';
import { HostConfigService } from '../../config/early/host.config.service';
import { ERoleBackend } from '../../database/entities/role.entity';
import { ERoleBackend } from '../../database/entities/users/role.entity';
import {
ImmutableRolesList,
SystemRoleDefaults,
SystemRolesList
SystemRolesList,
} from '../../models/constants/roles.const';
import { RolesService } from './role-db.service';
import { RoleDbService } from './role-db.service';
@Module({
imports: [EarlyConfigModule, TypeOrmModule.forFeature([ERoleBackend])],
providers: [RolesService],
exports: [RolesService],
providers: [RoleDbService],
exports: [RoleDbService],
})
export class RolesModule implements OnModuleInit {
private readonly logger = new Logger('RolesModule');
export class RoleDbModule implements OnModuleInit {
private readonly logger = new Logger(RoleDbModule.name);
constructor(
private readonly rolesService: RolesService,
private readonly rolesService: RoleDbService,
private readonly hostConfig: HostConfigService,
) {}
@ -28,7 +28,7 @@ export class RolesModule implements OnModuleInit {
// Nuking roles in dev environment makes testing easier
// This ensures that the roles are always started with their default permissions
if (!this.hostConfig.isProduction()) {
await this.nukeRoles();
//await this.nukeRoles();
}
await this.ensureSystemRolesExist();
@ -48,7 +48,10 @@ export class RolesModule implements OnModuleInit {
this.logger.verbose(`Ensuring system role "${systemRole}" exists`);
const exists = await this.rolesService.exists(systemRole);
if (exists) continue;
if (exists) {
this.logger.verbose(`System role "${systemRole}" already exists`);
continue;
}
const newRole = await this.rolesService.create(
systemRole,

View file

@ -6,26 +6,25 @@ import {
Fail,
FT,
HasFailed,
HasSuccess
} from 'picsur-shared/dist/types';
HasSuccess,
} from 'picsur-shared/dist/types/failable';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { In, Repository } from 'typeorm';
import { ERoleBackend } from '../../database/entities/role.entity';
import { ERoleBackend } from '../../database/entities/users/role.entity';
import { Permissions } from '../../models/constants/permissions.const';
import {
ImmutableRolesList,
UndeletableRolesList
UndeletableRolesList,
} from '../../models/constants/roles.const';
@Injectable()
export class RolesService {
private readonly logger = new Logger('UsersService');
export class RoleDbService {
private readonly logger = new Logger(RoleDbService.name);
constructor(
@InjectRepository(ERoleBackend)
private readonly rolesRepository: Repository<ERoleBackend>,
) {
}
) {}
public async create(
name: string,
@ -34,7 +33,7 @@ export class RolesService {
if (await this.exists(name))
return Fail(FT.Conflict, 'Role already exists');
let role = new ERoleBackend();
const role = new ERoleBackend();
role.name = name;
role.permissions = permissions;
@ -106,7 +105,7 @@ export class RolesService {
role: string | ERoleBackend,
permissions: Permissions,
// Extra bypass for internal use
allowImmutable: boolean = false,
allowImmutable = false,
): AsyncFailable<ERoleBackend> {
const roleToModify = await this.resolve(role);
if (HasFailed(roleToModify)) return roleToModify;
@ -141,6 +140,7 @@ export class RolesService {
try {
const found = await this.rolesRepository.find({
where: { name: In(names) },
order: { name: 'ASC' },
});
if (!found) return Fail(FT.NotFound, 'No roles found');
@ -152,7 +152,9 @@ export class RolesService {
public async findAll(): AsyncFailable<ERoleBackend[]> {
try {
const found = await this.rolesRepository.find();
const found = await this.rolesRepository.find({
order: { name: 'ASC' },
});
if (!found) return Fail(FT.NotFound, 'No roles found');
return found;
} catch (e) {
@ -164,7 +166,7 @@ export class RolesService {
return HasSuccess(await this.findOne(name));
}
public async nukeSystemRoles(IAmSure: boolean = false): AsyncFailable<true> {
public async nukeSystemRoles(IAmSure = false): AsyncFailable<true> {
if (!IAmSure)
return Fail(
FT.SysValidation,

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ESystemStateBackend } from '../../database/entities/system/system-state.entity';
import { SystemStateDbService } from './system-state-db.service';
@Module({
imports: [TypeOrmModule.forFeature([ESystemStateBackend])],
providers: [SystemStateDbService],
exports: [SystemStateDbService],
})
export class SystemStateDbModule {}

View file

@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types/failable';
import { Repository } from 'typeorm';
import { ESystemStateBackend } from '../../database/entities/system/system-state.entity';
@Injectable()
export class SystemStateDbService {
private readonly logger = new Logger(SystemStateDbService.name);
constructor(
@InjectRepository(ESystemStateBackend)
private readonly stateRepo: Repository<ESystemStateBackend>,
) {}
async get(key: string): AsyncFailable<string | null> {
try {
const state = await this.stateRepo.findOne({ where: { key } });
return state?.value ?? null;
} catch (err) {
return Fail(FT.Database, err);
}
}
async set(key: string, value: string): AsyncFailable<true> {
try {
await this.stateRepo.save({ key, value });
return true;
} catch (err) {
return Fail(FT.Database, err);
}
}
async clear(key: string): AsyncFailable<true> {
try {
await this.stateRepo.delete({ key });
return true;
} catch (err) {
return Fail(FT.Database, err);
}
}
}

View file

@ -1,29 +1,29 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HasFailed } from 'picsur-shared/dist/types';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { generateRandomString } from 'picsur-shared/dist/util/random';
import { AuthConfigService } from '../../config/early/auth.config.service';
import { EarlyConfigModule } from '../../config/early/early-config.module';
import { EUserBackend } from '../../database/entities/user.entity';
import { PreferenceModule } from '../preference-db/preference-db.module';
import { RolesModule } from '../role-db/role-db.module';
import { UsersService } from './user-db.service';
import { EUserBackend } from '../../database/entities/users/user.entity';
import { PreferenceDbModule } from '../preference-db/preference-db.module';
import { RoleDbModule } from '../role-db/role-db.module';
import { UserDbService } from './user-db.service';
@Module({
imports: [
EarlyConfigModule,
RolesModule,
PreferenceModule,
RoleDbModule,
PreferenceDbModule,
TypeOrmModule.forFeature([EUserBackend]),
],
providers: [UsersService],
exports: [UsersService],
providers: [UserDbService],
exports: [UserDbService],
})
export class UsersModule implements OnModuleInit {
private readonly logger = new Logger('UsersModule');
export class UserDbModule implements OnModuleInit {
private readonly logger = new Logger(UserDbModule.name);
constructor(
private readonly usersService: UsersService,
private readonly usersService: UserDbService,
private readonly authConfigService: AuthConfigService,
) {}

View file

@ -7,35 +7,35 @@ import {
Fail,
FT,
HasFailed,
HasSuccess
} from 'picsur-shared/dist/types';
HasSuccess,
} from 'picsur-shared/dist/types/failable';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { Repository } from 'typeorm';
import { EUserBackend } from '../../database/entities/user.entity';
import { EUserBackend } from '../../database/entities/users/user.entity';
import { Permissions } from '../../models/constants/permissions.const';
import {
DefaultRolesList,
SoulBoundRolesList
SoulBoundRolesList,
} from '../../models/constants/roles.const';
import {
ImmutableUsersList,
LockedLoginUsersList,
UndeletableUsersList
UndeletableUsersList,
} from '../../models/constants/special-users.const';
import { GetCols } from '../../models/util/collection';
import { SysPreferenceService } from '../preference-db/sys-preference-db.service';
import { RolesService } from '../role-db/role-db.service';
import { GetCols } from '../../util/collection';
import { SysPreferenceDbService } from '../preference-db/sys-preference-db.service';
import { RoleDbService } from '../role-db/role-db.service';
@Injectable()
export class UsersService {
private readonly logger = new Logger('UsersService');
export class UserDbService {
private readonly logger = new Logger(UserDbService.name);
constructor(
@InjectRepository(EUserBackend)
private readonly usersRepository: Repository<EUserBackend>,
private readonly rolesService: RolesService,
private readonly prefService: SysPreferenceService,
private readonly rolesService: RoleDbService,
private readonly prefService: SysPreferenceDbService,
) {}
// Creation and deletion
@ -53,7 +53,7 @@ export class UsersService {
const strength = await this.getBCryptStrength();
const hashedPassword = await bcrypt.hash(password, strength);
let user = new EUserBackend();
const user = new EUserBackend();
user.username = username;
user.hashed_password = hashedPassword;
if (byPassRoleCheck) {
@ -73,15 +73,15 @@ export class UsersService {
}
public async delete(uuid: string): AsyncFailable<EUserBackend> {
const userToModify = await this.findOne(uuid);
if (HasFailed(userToModify)) return userToModify;
const userToDelete = await this.findOne(uuid);
if (HasFailed(userToDelete)) return userToDelete;
if (UndeletableUsersList.includes(userToModify.username)) {
if (UndeletableUsersList.includes(userToDelete.username)) {
return Fail(FT.Permission, 'Cannot delete system user');
}
try {
return await this.usersRepository.remove(userToModify);
return await this.usersRepository.remove(userToDelete);
} catch (e) {
return Fail(FT.Database, e);
}
@ -166,26 +166,49 @@ export class UsersService {
password: string,
): AsyncFailable<EUserBackend> {
const user = await this.findByUsername(username, true);
if (HasFailed(user)) return user;
if (HasFailed(user)) {
if (user.getType() === FT.NotFound)
return Fail(
FT.Authentication,
'Wrong username or password',
user.getDebugMessage(),
);
else return user;
}
if (LockedLoginUsersList.includes(user.username)) {
// Error should be kept in backend
return Fail(FT.Authentication, 'Wrong username');
return Fail(FT.Authentication, 'Wrong username or password');
}
if (!(await bcrypt.compare(password, user.hashed_password ?? '')))
return Fail(FT.Authentication, 'Wrong password');
return Fail(FT.Authentication, 'Wrong username or password');
return await this.findOne(user.id ?? '');
}
// Listing
public async checkUsername(username: string): AsyncFailable<{
available: boolean;
}> {
try {
const found = await this.usersRepository.findOne({
where: { username },
select: ['id'],
});
return { available: !found };
} catch (e) {
return Fail(FT.Database, e);
}
}
public async findByUsername(
username: string,
// Also fetch fields that aren't normally sent to the client
// (e.g. hashed password)
getPrivate: boolean = false,
getPrivate = false,
): AsyncFailable<EUserBackend> {
try {
const found = await this.usersRepository.findOne({
@ -224,6 +247,7 @@ export class UsersService {
const [users, amount] = await this.usersRepository.findAndCount({
take: count,
skip: count * page,
order: { username: 'ASC' },
});
if (users === undefined) return Fail(FT.NotFound, 'Users not found');
@ -239,6 +263,14 @@ export class UsersService {
}
}
public async count(): AsyncFailable<number> {
try {
return await this.usersRepository.count();
} catch (e) {
return Fail(FT.Database, e);
}
}
public async exists(username: string): Promise<boolean> {
return HasSuccess(await this.findByUsername(username));
}

View file

@ -1,6 +1,8 @@
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
export const ReportUrl = 'https://metrics.picsur.org';
export const ReportInterval = 1000 * 60 * 60;
export const EnvPrefix = 'PICSUR_';
export const DefaultName = 'picsur';

View file

@ -10,7 +10,7 @@ export class AuthConfigService {
public getDefaultAdminPassword(): string {
return ParseString(
this.configService.get(`${EnvPrefix}ADMIN_PASSWORD`),
'admin',
'picsur',
);
}
}

View file

@ -4,6 +4,7 @@ import { AuthConfigService } from './auth.config.service';
import { EarlyJwtConfigService } from './early-jwt.config.service';
import { HostConfigService } from './host.config.service';
import { MultipartConfigService } from './multipart.config.service';
import { RedisConfigService } from './redis.config.service';
import { ServeStaticConfigService } from './serve-static.config.service';
import { TypeOrmConfigService } from './type-orm.config.service';
@ -21,6 +22,7 @@ import { TypeOrmConfigService } from './type-orm.config.service';
HostConfigService,
AuthConfigService,
MultipartConfigService,
RedisConfigService,
],
exports: [
ConfigModule,
@ -30,6 +32,7 @@ import { TypeOrmConfigService } from './type-orm.config.service';
HostConfigService,
AuthConfigService,
MultipartConfigService,
RedisConfigService,
],
})
export class EarlyConfigModule {}

View file

@ -3,20 +3,28 @@ import { ConfigService } from '@nestjs/config';
import {
ParseBool,
ParseInt,
ParseString
ParseString,
} from 'picsur-shared/dist/util/parse-simple';
import { EnvPrefix } from '../config.static';
@Injectable()
export class HostConfigService {
private readonly logger = new Logger('HostConfigService');
private readonly logger = new Logger(HostConfigService.name);
constructor(private readonly configService: ConfigService) {
this.logger.log('Production: ' + this.isProduction());
this.logger.log('Verbose: ' + this.isVerbose());
this.logger.log('Host: ' + this.getHost());
this.logger.log('Port: ' + this.getPort());
this.logger.log('Demo: ' + this.isDemo());
this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's');
if (this.isDemo()) {
this.logger.log('Running in demo mode');
this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's');
}
if (!this.isTelemetry()) {
this.logger.log('Telemetry disabled');
}
}
public getHost(): string {
@ -42,6 +50,14 @@ export class HostConfigService {
return ParseBool(this.configService.get(`${EnvPrefix}PRODUCTION`), false);
}
public isVerbose() {
return ParseBool(this.configService.get(`${EnvPrefix}VERBOSE`), false);
}
public isTelemetry() {
return ParseBool(this.configService.get(`${EnvPrefix}TELEMETRY`), true);
}
public getVersion() {
return ParseString(this.configService.get(`npm_package_version`), '0.0.0');
}

View file

@ -5,7 +5,7 @@ import { EnvPrefix } from '../config.static';
@Injectable()
export class MultipartConfigService {
private readonly logger = new Logger('MultipartConfigService');
private readonly logger = new Logger(MultipartConfigService.name);
constructor(private readonly configService: ConfigService) {
this.logger.log('Max file size: ' + this.getMaxFileSize());
@ -18,12 +18,12 @@ export class MultipartConfigService {
);
}
public getLimits() {
public getLimits(fileLimit?: number) {
return {
fieldNameSize: 128,
fieldSize: 1024,
fields: 16,
files: 16,
fields: 20,
files: fileLimit ?? 20,
fileSize: this.getMaxFileSize(),
};
}

View file

@ -0,0 +1,20 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ParseString } from 'picsur-shared/dist/util/parse-simple';
import { EnvPrefix } from '../config.static';
@Injectable()
export class RedisConfigService {
private readonly logger = new Logger(RedisConfigService.name);
constructor(private readonly configService: ConfigService) {
this.logger.log('Redis URL: ' + this.getRedisUrl());
}
public getRedisUrl(): string {
return ParseString(
this.configService.get(`${EnvPrefix}REDIS_URL`),
'redis://localhost:6379',
);
}
}

View file

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ServeStaticModuleOptions,
ServeStaticModuleOptionsFactory
ServeStaticModuleOptionsFactory,
} from '@nestjs/serve-static';
import { join } from 'path';
import { ParseString } from 'picsur-shared/dist/util/parse-simple';
@ -12,7 +12,7 @@ import { EnvPrefix, PackageRoot } from '../config.static';
export class ServeStaticConfigService
implements ServeStaticModuleOptionsFactory
{
private readonly logger = new Logger('ServeStaticConfigService');
private readonly logger = new Logger(ServeStaticConfigService.name);
private defaultLocation = join(PackageRoot, '../frontend/dist');

View file

@ -1,15 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ParseInt, ParseString } from 'picsur-shared/dist/util/parse-simple';
import { EntityList } from '../../database/entities';
import { MigrationList } from '../../database/migrations';
import { EntityList } from '../../database/entities/index';
import { MigrationList } from '../../database/migrations/index';
import { DefaultName, EnvPrefix } from '../config.static';
import { HostConfigService } from './host.config.service';
@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
private readonly logger = new Logger('TypeOrmConfigService');
private readonly logger = new Logger(TypeOrmConfigService.name);
constructor(
private readonly configService: ConfigService,
@ -31,43 +31,42 @@ export class TypeOrmConfigService implements TypeOrmOptionsFactory {
this.configService.get(`${EnvPrefix}DB_HOST`),
'localhost',
),
port: ParseInt(
this.configService.get<number>(`${EnvPrefix}DB_PORT`),
5432,
),
port: ParseInt(this.configService.get(`${EnvPrefix}DB_PORT`), 5432),
username: ParseString(
this.configService.get<string>(`${EnvPrefix}DB_USERNAME`),
this.configService.get(`${EnvPrefix}DB_USERNAME`),
DefaultName,
),
password: ParseString(
this.configService.get<string>(`${EnvPrefix}DB_PASSWORD`),
this.configService.get(`${EnvPrefix}DB_PASSWORD`),
DefaultName,
),
database: ParseString(
this.configService.get<string>(`${EnvPrefix}DB_DATABASE`),
this.configService.get(`${EnvPrefix}DB_DATABASE`),
DefaultName,
),
};
return varOptions;
}
public createTypeOrmOptions(connectionName?: string) {
public createTypeOrmOptions() {
const varOptions = this.getTypeOrmServerOptions();
return {
type: 'postgres' as 'postgres',
synchronize: false,
type: 'postgres' as const,
synchronize: !this.hostService.isProduction(),
migrationsRun: true,
entities: EntityList,
migrations: MigrationList,
useUTC: true,
cli: {
migrationsDir: 'src/database/migrations',
entitiesDir: 'src/database/entities',
},
...varOptions,
};
} as TypeOrmModuleOptions;
}
}

View file

@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
@Injectable()
export class InfoConfigService {
private readonly logger = new Logger(InfoConfigService.name);
constructor(private readonly prefService: SysPreferenceDbService) {}
public async getHostnameOverride(): Promise<string | undefined> {
const hostname = await this.prefService.getStringPreference(
SysPreference.HostOverride,
);
if (HasFailed(hostname)) {
hostname.print(this.logger);
return undefined;
}
if (hostname === '') return undefined;
return hostname;
}
}

View file

@ -1,14 +1,14 @@
import { FactoryProvider, Injectable, Logger } from '@nestjs/common';
import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt';
import ms from 'ms';
import { ThrowIfFailed } from 'picsur-shared/dist/types';
import { SysPreferenceService } from '../../collections/preference-db/sys-preference-db.service';
import { ThrowIfFailed } from 'picsur-shared/dist/types/failable';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
@Injectable()
export class JwtConfigService implements JwtOptionsFactory {
private readonly logger = new Logger('JwtConfigService');
private readonly logger = new Logger(JwtConfigService.name);
constructor(private readonly prefService: SysPreferenceService) {
constructor(private readonly prefService: SysPreferenceDbService) {
this.printDebug().catch(this.logger.error);
}
@ -32,8 +32,8 @@ export class JwtConfigService implements JwtOptionsFactory {
await this.prefService.getStringPreference('jwt_expires_in'),
);
let milliseconds = ms(expiresIn);
if (milliseconds === undefined) {
let milliseconds = ms(expiresIn as any);
if (isNaN(milliseconds)) {
milliseconds = 1000 * 60 * 60 * 24; // 1 day
}

View file

@ -1,9 +1,11 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { PreferenceModule } from '../../collections/preference-db/preference-db.module';
import { SysPreferenceService } from '../../collections/preference-db/sys-preference-db.service';
import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { EarlyConfigModule } from '../early/early-config.module';
import { EarlyJwtConfigService } from '../early/early-jwt.config.service';
import { InfoConfigService } from './info.config.service';
import { JwtConfigService } from './jwt.config.service';
import { UsageConfigService } from './usage.config.service';
// This module contains all configservices that depend on the syspref module
// The syspref module can only be used when connected to the database
@ -11,16 +13,21 @@ import { JwtConfigService } from './jwt.config.service';
// Otherwise we will create a circular depedency
@Module({
imports: [PreferenceModule, EarlyConfigModule],
providers: [JwtConfigService],
exports: [JwtConfigService, EarlyConfigModule],
imports: [EarlyConfigModule, PreferenceDbModule],
providers: [JwtConfigService, InfoConfigService, UsageConfigService],
exports: [
EarlyConfigModule,
JwtConfigService,
InfoConfigService,
UsageConfigService,
],
})
export class LateConfigModule implements OnModuleInit {
private readonly logger = new Logger('LateConfigModule');
private readonly logger = new Logger(LateConfigModule.name);
constructor(
private readonly envJwtConfigService: EarlyJwtConfigService,
private readonly prefService: SysPreferenceService,
private readonly prefService: SysPreferenceDbService,
) {}
async onModuleInit() {

View file

@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { URLRegex, UUIDRegex } from 'picsur-shared/dist/util/common-regex';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { ReportInterval, ReportUrl } from '../config.static';
@Injectable()
export class UsageConfigService {
constructor(private readonly sysPref: SysPreferenceDbService) {}
async getTrackingUrl(): AsyncFailable<string | null> {
const trackingUrl = await this.sysPref.getStringPreference(
SysPreference.TrackingUrl,
);
if (HasFailed(trackingUrl)) return trackingUrl;
if (trackingUrl === '') return null;
if (!URLRegex.test(trackingUrl)) {
return Fail(FT.UsrValidation, undefined, 'Invalid tracking URL');
}
return trackingUrl;
}
async getTrackingID(): AsyncFailable<string | null> {
const trackingID = await this.sysPref.getStringPreference(
SysPreference.TrackingId,
);
if (HasFailed(trackingID)) return trackingID;
if (trackingID === '') return null;
if (!UUIDRegex.test(trackingID)) {
return Fail(FT.UsrValidation, undefined, 'Invalid tracking ID');
}
return trackingID;
}
async getMetricsEnabled(): AsyncFailable<boolean> {
return this.sysPref.getBooleanPreference(SysPreference.EnableTelemetry);
}
async getMetricsInterval(): Promise<number> {
return ReportInterval;
}
async getMetricsUrl(): Promise<string> {
return ReportUrl;
}
}

View file

@ -0,0 +1,54 @@
import { EApiKeySchema } from 'picsur-shared/dist/entities/apikey.entity';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { z } from 'zod';
import { EUserBackend } from './users/user.entity';
const OverriddenEApiKeySchema = EApiKeySchema.omit({ user: true }).merge(
z.object({
user: z.string().or(z.object({})),
}),
);
type OverriddenEApiKey = z.infer<typeof OverriddenEApiKeySchema>;
@Entity()
export class EApiKeyBackend<
T extends string | EUserBackend = string | EUserBackend,
> implements OverriddenEApiKey
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({
nullable: false,
unique: true,
})
key: string;
@ManyToOne(() => EUserBackend, (user) => user.apikeys, {
nullable: false,
onDelete: 'CASCADE',
})
user: T;
@Column({ nullable: false })
name: string;
@Column({
type: 'timestamptz',
nullable: false,
})
created: Date;
@Column({
type: 'timestamptz',
nullable: true,
})
last_used: Date;
}

View file

@ -1,26 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity()
@Unique(['image_id', 'key'])
export class EImageDerivativeBackend {
@PrimaryGeneratedColumn('uuid')
private _id?: string;
@Index()
@Column({ nullable: false })
image_id: string;
@Index()
@Column({ nullable: false })
key: string;
@Column({ nullable: false })
filetype: string;
@Column({ name: 'last_read', nullable: false })
last_read: Date;
// Binary data
@Column({ type: 'bytea', nullable: false })
data: Buffer;
}

View file

@ -1,18 +0,0 @@
import { EImage } from 'picsur-shared/dist/entities/image.entity';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class EImageBackend implements EImage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
nullable: false,
})
user_id: string;
@Column({
nullable: false,
})
created: Date;
}

View file

@ -0,0 +1,49 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { EImageBackend } from './image.entity';
@Entity()
@Unique(['image_id', 'key'])
export class EImageDerivativeBackend {
@PrimaryGeneratedColumn('uuid')
private _id?: string;
// We do a little trickery
@Index()
@ManyToOne(() => EImageBackend, (image) => image.derivatives, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'image_id' })
private _image?: any;
@Column({
name: 'image_id',
})
image_id: string;
@Index()
@Column({ nullable: false })
key: string;
@Column({ nullable: false })
filetype: string;
@Column({
type: 'timestamptz',
name: 'last_read',
nullable: false,
})
last_read: Date;
// Binary data
@Column({ type: 'bytea', nullable: false })
data: Buffer;
}

View file

@ -1,5 +1,14 @@
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum';
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { EImageBackend } from './image.entity';
@Entity()
@Unique(['image_id', 'variant'])
@ -7,8 +16,18 @@ export class EImageFileBackend {
@PrimaryGeneratedColumn('uuid')
private _id?: string;
// We do a little trickery
@Index()
@Column({ nullable: false })
@ManyToOne(() => EImageBackend, (image) => image.files, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'image_id' })
private _image?: any;
@Column({
name: 'image_id',
})
image_id: string;
@Index()

View file

@ -0,0 +1,46 @@
import { EImage } from 'picsur-shared/dist/entities/image.entity';
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { EImageDerivativeBackend } from './image-derivative.entity';
import { EImageFileBackend } from './image-file.entity';
@Entity()
export class EImageBackend implements EImage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
nullable: false,
type: 'uuid',
})
user_id: string;
@Column({
type: 'timestamptz',
nullable: false,
})
created: Date;
@Column({
nullable: false,
default: 'image',
})
file_name: string;
@Column({
type: 'timestamptz',
nullable: true,
})
expires_at: Date | null;
@Column({
nullable: true,
select: false,
})
delete_key?: string;
@OneToMany(() => EImageDerivativeBackend, (derivative) => derivative.image_id)
derivatives: EImageDerivativeBackend[];
@OneToMany(() => EImageFileBackend, (file) => file.image_id)
files: EImageFileBackend[];
}

View file

@ -1,10 +1,12 @@
import { EImageDerivativeBackend } from './image-derivative.entity';
import { EImageFileBackend } from './image-file.entity';
import { EImageBackend } from './image.entity';
import { ERoleBackend } from './role.entity';
import { ESysPreferenceBackend } from './sys-preference.entity';
import { EUserBackend } from './user.entity';
import { EUsrPreferenceBackend } from './usr-preference.entity';
import { EApiKeyBackend } from './apikey.entity';
import { EImageDerivativeBackend } from './images/image-derivative.entity';
import { EImageFileBackend } from './images/image-file.entity';
import { EImageBackend } from './images/image.entity';
import { ESysPreferenceBackend } from './system/sys-preference.entity';
import { ESystemStateBackend } from './system/system-state.entity';
import { EUsrPreferenceBackend } from './system/usr-preference.entity';
import { ERoleBackend } from './users/role.entity';
import { EUserBackend } from './users/user.entity';
export const EntityList = [
EImageBackend,
@ -14,4 +16,6 @@ export const EntityList = [
ERoleBackend,
ESysPreferenceBackend,
EUsrPreferenceBackend,
EApiKeyBackend,
ESystemStateBackend,
];

View file

@ -1,6 +1,6 @@
import { IsEntityID } from 'picsur-shared/dist/validators/entity-id.validator';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import z from 'zod';
import * as z from 'zod';
export const ESysPreferenceSchema = z.object({
id: IsEntityID().optional(),

View file

@ -0,0 +1,14 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class ESystemStateBackend {
@PrimaryGeneratedColumn('uuid')
id?: string;
@Index()
@Column({ nullable: false, unique: true })
key: string;
@Column({ nullable: false })
value: string;
}

View file

@ -1,6 +1,15 @@
import { IsEntityID } from 'picsur-shared/dist/validators/entity-id.validator';
import { Column, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
import z from 'zod';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import * as z from 'zod';
import { EUserBackend } from '../users/user.entity';
export const EUsrPreferenceSchema = z.object({
id: IsEntityID().optional(),
@ -23,7 +32,17 @@ export class EUsrPreferenceBackend implements EUsrPreference {
@Column({ nullable: false })
value: string;
// We do a little trickery
@Index()
@Column({ nullable: false })
@ManyToOne(() => EUserBackend, (user) => user.preferences, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_id' })
private _user?: any;
@Column({
name: 'user_id',
})
user_id: string;
}

View file

@ -1,6 +1,6 @@
import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import type { Permissions } from '../../models/constants/permissions.const';
import type { Permissions } from '../../../models/constants/permissions.const';
@Entity()
export class ERoleBackend implements ERole {

View file

@ -1,6 +1,14 @@
import { EUserSchema } from 'picsur-shared/dist/entities/user.entity';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
Index,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { z } from 'zod';
import { EApiKeyBackend } from '../apikey.entity';
import { EUsrPreferenceBackend } from '../system/usr-preference.entity';
// Different data for public and private
const OverriddenEUserSchema = EUserSchema.omit({ hashedPassword: true }).merge(
@ -24,4 +32,11 @@ export class EUserBackend implements OverriddenEUser {
@Column({ nullable: false, select: false })
hashed_password?: string;
// This will never be populated, it is only here to auto delete apikeys when a user is deleted
@OneToMany(() => EApiKeyBackend, (apikey) => apikey.user)
apikeys?: EApiKeyBackend[];
@OneToMany(() => EUsrPreferenceBackend, (pref) => pref.user_id)
preferences?: EUsrPreferenceBackend[];
}

View file

@ -1,44 +1,93 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V030A1661692206479 implements MigrationInterface {
name = 'V030A1661692206479'
name = 'V030A1661692206479';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "e_image_derivative_backend" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "image_id" character varying NOT NULL, "key" character varying NOT NULL, "filetype" character varying NOT NULL, "last_read" TIMESTAMP NOT NULL, "data" bytea NOT NULL, CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75" UNIQUE ("image_id", "key"), CONSTRAINT "PK_ff1ecff935b8d7bdcea89087810" PRIMARY KEY ("_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_37055605f39b3f8847232d604f" ON "e_image_derivative_backend" ("image_id") `);
await queryRunner.query(`CREATE INDEX "IDX_7dc534a666f442383341896062" ON "e_image_derivative_backend" ("key") `);
await queryRunner.query(`CREATE TABLE "e_image_file_backend" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "image_id" character varying NOT NULL, "variant" character varying NOT NULL, "filetype" character varying NOT NULL, "data" bytea NOT NULL, CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a" UNIQUE ("image_id", "variant"), CONSTRAINT "PK_95953be58a506e5de46feec6186" PRIMARY KEY ("_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_8055f37d3b9f52f421b94ee84d" ON "e_image_file_backend" ("image_id") `);
await queryRunner.query(`CREATE INDEX "IDX_d0500b00b0b4109b623f897c2d" ON "e_image_file_backend" ("variant") `);
await queryRunner.query(`CREATE TABLE "e_image_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" character varying NOT NULL, "created" TIMESTAMP NOT NULL, CONSTRAINT "PK_5f7993001a7c82564ec5300540d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "e_role_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "permissions" text array NOT NULL, CONSTRAINT "UQ_cbedb9f42a98a82d91422e7fedf" UNIQUE ("name"), CONSTRAINT "PK_af7ba6a46bf69a7b10c425f0367" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_cbedb9f42a98a82d91422e7fed" ON "e_role_backend" ("name") `);
await queryRunner.query(`CREATE TABLE "e_sys_preference_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "value" character varying NOT NULL, CONSTRAINT "UQ_b04e47c4814fb6e315c5879fa75" UNIQUE ("key"), CONSTRAINT "PK_b79f051e19b46e74cf255e9ba3b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_b04e47c4814fb6e315c5879fa7" ON "e_sys_preference_backend" ("key") `);
await queryRunner.query(`CREATE TABLE "e_user_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "username" character varying NOT NULL, "roles" text array NOT NULL, "hashed_password" character varying NOT NULL, CONSTRAINT "UQ_ae538430fd08b28f4ab297eff09" UNIQUE ("username"), CONSTRAINT "PK_0b9d256d52e55a48d32e8b64d96" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_ae538430fd08b28f4ab297eff0" ON "e_user_backend" ("username") `);
await queryRunner.query(`CREATE TABLE "e_usr_preference_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "value" character varying NOT NULL, "user_id" character varying NOT NULL, CONSTRAINT "UQ_576678406a479d569123a33e132" UNIQUE ("key", "user_id"), CONSTRAINT "PK_8f8251016cd9283e7eb04c5498b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_673fe530e2484ff7e31ac81099" ON "e_usr_preference_backend" ("key") `);
await queryRunner.query(`CREATE INDEX "IDX_f1a427e855045fa793c275861a" ON "e_usr_preference_backend" ("user_id") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_f1a427e855045fa793c275861a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_673fe530e2484ff7e31ac81099"`);
await queryRunner.query(`DROP TABLE "e_usr_preference_backend"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ae538430fd08b28f4ab297eff0"`);
await queryRunner.query(`DROP TABLE "e_user_backend"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b04e47c4814fb6e315c5879fa7"`);
await queryRunner.query(`DROP TABLE "e_sys_preference_backend"`);
await queryRunner.query(`DROP INDEX "public"."IDX_cbedb9f42a98a82d91422e7fed"`);
await queryRunner.query(`DROP TABLE "e_role_backend"`);
await queryRunner.query(`DROP TABLE "e_image_backend"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d0500b00b0b4109b623f897c2d"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8055f37d3b9f52f421b94ee84d"`);
await queryRunner.query(`DROP TABLE "e_image_file_backend"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7dc534a666f442383341896062"`);
await queryRunner.query(`DROP INDEX "public"."IDX_37055605f39b3f8847232d604f"`);
await queryRunner.query(`DROP TABLE "e_image_derivative_backend"`);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "e_image_derivative_backend" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "image_id" character varying NOT NULL, "key" character varying NOT NULL, "filetype" character varying NOT NULL, "last_read" TIMESTAMP NOT NULL, "data" bytea NOT NULL, CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75" UNIQUE ("image_id", "key"), CONSTRAINT "PK_ff1ecff935b8d7bdcea89087810" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_37055605f39b3f8847232d604f" ON "e_image_derivative_backend" ("image_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7dc534a666f442383341896062" ON "e_image_derivative_backend" ("key") `,
);
await queryRunner.query(
`CREATE TABLE "e_image_file_backend" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "image_id" character varying NOT NULL, "variant" character varying NOT NULL, "filetype" character varying NOT NULL, "data" bytea NOT NULL, CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a" UNIQUE ("image_id", "variant"), CONSTRAINT "PK_95953be58a506e5de46feec6186" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_8055f37d3b9f52f421b94ee84d" ON "e_image_file_backend" ("image_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_d0500b00b0b4109b623f897c2d" ON "e_image_file_backend" ("variant") `,
);
await queryRunner.query(
`CREATE TABLE "e_image_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" character varying NOT NULL, "created" TIMESTAMP NOT NULL, CONSTRAINT "PK_5f7993001a7c82564ec5300540d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "e_role_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "permissions" text array NOT NULL, CONSTRAINT "UQ_cbedb9f42a98a82d91422e7fedf" UNIQUE ("name"), CONSTRAINT "PK_af7ba6a46bf69a7b10c425f0367" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_cbedb9f42a98a82d91422e7fed" ON "e_role_backend" ("name") `,
);
await queryRunner.query(
`CREATE TABLE "e_sys_preference_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "value" character varying NOT NULL, CONSTRAINT "UQ_b04e47c4814fb6e315c5879fa75" UNIQUE ("key"), CONSTRAINT "PK_b79f051e19b46e74cf255e9ba3b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_b04e47c4814fb6e315c5879fa7" ON "e_sys_preference_backend" ("key") `,
);
await queryRunner.query(
`CREATE TABLE "e_user_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "username" character varying NOT NULL, "roles" text array NOT NULL, "hashed_password" character varying NOT NULL, CONSTRAINT "UQ_ae538430fd08b28f4ab297eff09" UNIQUE ("username"), CONSTRAINT "PK_0b9d256d52e55a48d32e8b64d96" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ae538430fd08b28f4ab297eff0" ON "e_user_backend" ("username") `,
);
await queryRunner.query(
`CREATE TABLE "e_usr_preference_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "value" character varying NOT NULL, "user_id" character varying NOT NULL, CONSTRAINT "UQ_576678406a479d569123a33e132" UNIQUE ("key", "user_id"), CONSTRAINT "PK_8f8251016cd9283e7eb04c5498b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_673fe530e2484ff7e31ac81099" ON "e_usr_preference_backend" ("key") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_f1a427e855045fa793c275861a" ON "e_usr_preference_backend" ("user_id") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_f1a427e855045fa793c275861a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_673fe530e2484ff7e31ac81099"`,
);
await queryRunner.query(`DROP TABLE "e_usr_preference_backend"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_ae538430fd08b28f4ab297eff0"`,
);
await queryRunner.query(`DROP TABLE "e_user_backend"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_b04e47c4814fb6e315c5879fa7"`,
);
await queryRunner.query(`DROP TABLE "e_sys_preference_backend"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_cbedb9f42a98a82d91422e7fed"`,
);
await queryRunner.query(`DROP TABLE "e_role_backend"`);
await queryRunner.query(`DROP TABLE "e_image_backend"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_d0500b00b0b4109b623f897c2d"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_8055f37d3b9f52f421b94ee84d"`,
);
await queryRunner.query(`DROP TABLE "e_image_file_backend"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_7dc534a666f442383341896062"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_37055605f39b3f8847232d604f"`,
);
await queryRunner.query(`DROP TABLE "e_image_derivative_backend"`);
}
}

View file

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V032A1662029904716 implements MigrationInterface {
name = 'V032A1662029904716';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_image_backend" ADD "file_name" character varying NOT NULL DEFAULT 'image'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_image_backend" DROP COLUMN "file_name"`,
);
}
}

View file

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V040A1662314197741 implements MigrationInterface {
name = 'V040A1662314197741';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "e_api_key_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "name" character varying NOT NULL, "created" TIMESTAMP NOT NULL, "last_used" TIMESTAMP, "userId" uuid NOT NULL, CONSTRAINT "UQ_a244964afdff398bab8a45017c8" UNIQUE ("key"), CONSTRAINT "PK_e31f7dfe2db917a6ed1024f4e8b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_a244964afdff398bab8a45017c" ON "e_api_key_backend" ("key") `,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ADD "delete_key" character varying`,
);
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" ADD CONSTRAINT "FK_3a32374df29b25152a84f0d1025" FOREIGN KEY ("userId") REFERENCES "e_user_backend"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" DROP CONSTRAINT "FK_3a32374df29b25152a84f0d1025"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" DROP COLUMN "delete_key"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a244964afdff398bab8a45017c"`,
);
await queryRunner.query(`DROP TABLE "e_api_key_backend"`);
}
}

View file

@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V040B1662485374471 implements MigrationInterface {
name = 'V040B1662485374471';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_image_backend" ADD "expires_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" DROP CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_8055f37d3b9f52f421b94ee84d"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ALTER COLUMN "image_id" SET DATA TYPE UUID USING image_id::uuid`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" DROP CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_37055605f39b3f8847232d604f"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ALTER COLUMN "image_id" SET DATA TYPE UUID USING image_id::uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_8055f37d3b9f52f421b94ee84d" ON "e_image_file_backend" ("image_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_37055605f39b3f8847232d604f" ON "e_image_derivative_backend" ("image_id") `,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ADD CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a" UNIQUE ("image_id", "variant")`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ADD CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75" UNIQUE ("image_id", "key")`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ADD CONSTRAINT "FK_8055f37d3b9f52f421b94ee84db" FOREIGN KEY ("image_id") REFERENCES "e_image_backend"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ADD CONSTRAINT "FK_37055605f39b3f8847232d604f8" FOREIGN KEY ("image_id") REFERENCES "e_image_backend"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" DROP CONSTRAINT "FK_37055605f39b3f8847232d604f8"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" DROP CONSTRAINT "FK_8055f37d3b9f52f421b94ee84db"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" DROP CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" DROP CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_37055605f39b3f8847232d604f"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_8055f37d3b9f52f421b94ee84d"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ALTER COLUMN "image_id" SET DATA TYPE character varying`,
);
await queryRunner.query(
`CREATE INDEX "IDX_37055605f39b3f8847232d604f" ON "e_image_derivative_backend" ("image_id") `,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ADD CONSTRAINT "UQ_fa03f5333afd74c5cc5ff780d75" UNIQUE ("image_id", "key")`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" DROP COLUMN "image_id"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ALTER COLUMN "image_id" SET DATA TYPE character varying`,
);
await queryRunner.query(
`CREATE INDEX "IDX_8055f37d3b9f52f421b94ee84d" ON "e_image_file_backend" ("image_id") `,
);
await queryRunner.query(
`ALTER TABLE "e_image_file_backend" ADD CONSTRAINT "UQ_872384f20feaf7bfd27e28b8d4a" UNIQUE ("image_id", "variant")`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" DROP COLUMN "expires_at"`,
);
}
}

View file

@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V040C1662535484200 implements MigrationInterface {
name = 'V040C1662535484200';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" DROP CONSTRAINT "UQ_576678406a479d569123a33e132"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_f1a427e855045fa793c275861a"`,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" ALTER COLUMN "user_id" SET DATA TYPE UUID USING user_id::uuid`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "user_id" SET DATA TYPE UUID USING user_id::uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f1a427e855045fa793c275861a" ON "e_usr_preference_backend" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" ADD CONSTRAINT "UQ_576678406a479d569123a33e132" UNIQUE ("key", "user_id")`,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" ADD CONSTRAINT "FK_f1a427e855045fa793c275861a7" FOREIGN KEY ("user_id") REFERENCES "e_user_backend"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" DROP CONSTRAINT "FK_f1a427e855045fa793c275861a7"`,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" DROP CONSTRAINT "UQ_576678406a479d569123a33e132"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_f1a427e855045fa793c275861a"`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "user_id" SET DATA TYPE character varying`,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" ALTER COLUMN "user_id" SET DATA TYPE character varying`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f1a427e855045fa793c275861a" ON "e_usr_preference_backend" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "e_usr_preference_backend" ADD CONSTRAINT "UQ_576678406a479d569123a33e132" UNIQUE ("key", "user_id")`,
);
}
}

View file

@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V040D1662728275448 implements MigrationInterface {
name = 'V040D1662728275448';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "created" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" ALTER COLUMN "last_used" SET DATA TYPE TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "created" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ALTER COLUMN "last_read" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "last_read" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "e_image_derivative_backend" ALTER COLUMN "last_read" SET DATA TYPE TIMESTAMP, ALTER COLUMN "last_read" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "e_image_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP, ALTER COLUMN "created" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" ALTER COLUMN "last_used" SET DATA TYPE TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "e_api_key_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP, ALTER COLUMN "created" SET NOT NULL`,
);
}
}

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class V050A1672154027079 implements MigrationInterface {
name = 'V050A1672154027079';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "e_system_state_backend" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" character varying NOT NULL, "value" character varying NOT NULL, CONSTRAINT "UQ_f11f1605928b497b24f4b3ecc1f" UNIQUE ("key"), CONSTRAINT "PK_097ea165dadc8c14237481afd64" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f11f1605928b497b24f4b3ecc1" ON "e_system_state_backend" ("key") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_f11f1605928b497b24f4b3ecc1"`,
);
await queryRunner.query(`DROP TABLE "e_system_state_backend"`);
}
}

View file

@ -1,3 +1,19 @@
import { MigrationInterface } from 'typeorm';
import { V030A1661692206479 } from './1661692206479-V_0_3_0_a';
import { V032A1662029904716 } from './1662029904716-V_0_3_2_a';
import { V040A1662314197741 } from './1662314197741-V_0_4_0_a';
import { V040B1662485374471 } from './1662485374471-V_0_4_0_b';
import { V040C1662535484200 } from './1662535484200-V_0_4_0_c';
import { V040D1662728275448 } from './1662728275448-V_0_4_0_d';
import { V050A1672154027079 } from './1672154027079-V_0_5_0_a';
import { Newable } from 'picsur-shared/dist/types/newable.js';
export const MigrationList: Function[] = [V030A1661692206479];
export const MigrationList: Newable<MigrationInterface>[] = [
V030A1661692206479,
V032A1662029904716,
V040A1662314197741,
V040B1662485374471,
V040C1662535484200,
V040D1662728275448,
V050A1672154027079,
];

View file

@ -1,24 +1,12 @@
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter, NestFastifyApplication
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { DataSource, InstanceChecker } from 'typeorm';
import { DataSource } from 'typeorm';
import { TypeOrmConfigService } from './config/early/type-orm.config.service';
import { DatabaseModule } from './database/database.module';
// TODO, upgrade to a version beyond typeorm 3.8, cause 3.8 is bugged
// So here we monkeypatch 3.7
function patchAsyncDataSourceSetup() {
const oldIsDataSource = InstanceChecker.isDataSource;
InstanceChecker.isDataSource = function (obj: unknown): obj is DataSource {
if (obj instanceof Promise) {
return true;
}
return oldIsDataSource(obj);
};
}
patchAsyncDataSourceSetup();
async function createDataSource() {
// Create nest app
const app = await NestFactory.create<NestFastifyApplication>(
@ -29,7 +17,7 @@ async function createDataSource() {
const configFactory = app.get(TypeOrmConfigService);
const config = await configFactory.createTypeOrmOptions();
return new DataSource(config);
return new DataSource(config as any);
}
export default createDataSource().catch(console.error);
export default createDataSource();

View file

@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { EarlyConfigModule } from '../config/early/early-config.module';
import { ImageIdPipe } from './image-id/image-id.pipe';
import { MultiPartPipe } from './multipart/multipart.pipe';
import { PostFilePipe } from './multipart/postfile.pipe';
import { MultiPartPipe } from './multipart/postfiles.pipe';
@Module({
imports: [EarlyConfigModule],

View file

@ -1,12 +1,12 @@
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
import { Injectable, PipeTransform } from '@nestjs/common';
import { Ext2FileType } from 'picsur-shared/dist/dto/mimes.dto';
import { Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { FT, Fail, HasFailed } from 'picsur-shared/dist/types/failable';
import { UUIDRegex } from 'picsur-shared/dist/util/common-regex';
import { ImageFullId } from '../../models/constants/image-full-id.const';
@Injectable()
export class ImageFullIdPipe implements PipeTransform<string, ImageFullId> {
transform(value: string, metadata: ArgumentMetadata): ImageFullId {
transform(value: string): ImageFullId {
const split = value.split('.');
if (split.length === 2) {
const [id, ext] = split;

View file

@ -1,13 +1,10 @@
import {
ArgumentMetadata, Injectable,
PipeTransform
} from '@nestjs/common';
import { Fail, FT } from 'picsur-shared/dist/types';
import { Injectable, PipeTransform } from '@nestjs/common';
import { FT, Fail } from 'picsur-shared/dist/types/failable';
import { UUIDRegex } from 'picsur-shared/dist/util/common-regex';
@Injectable()
export class ImageIdPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
transform(value: string): string {
if (UUIDRegex.test(value)) return value;
throw Fail(FT.UsrValidation, 'Invalid image id');
}

View file

@ -3,6 +3,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// Since pipes dont have direct access to the request object, we need this decorator to inject it
export const InjectRequest = createParamDecorator(
async (data: any, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest();
return {
data: data,
request: ctx.switchToHttp().getRequest(),
};
},
);

View file

@ -1,7 +1,8 @@
import { InjectRequest } from './inject-request.decorator';
import { MultiPartPipe } from './multipart.pipe';
import { PostFilePipe } from './postfile.pipe';
import { MultiPartPipe } from './postfiles.pipe';
export const PostFile = () => InjectRequest(PostFilePipe);
export const MultiPart = () => InjectRequest(MultiPartPipe);
export const PostFiles = (maxFiles?: number) =>
InjectRequest(maxFiles, MultiPartPipe);

View file

@ -1,80 +0,0 @@
import { MultipartFields, MultipartFile } from '@fastify/multipart';
import {
ArgumentMetadata, Injectable,
Logger,
PipeTransform,
Scope
} from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
import { MultipartConfigService } from '../../config/early/multipart.config.service';
import {
CreateMultiPartFieldDto,
CreateMultiPartFileDto
} from '../../models/dto/multipart.dto';
@Injectable({ scope: Scope.REQUEST })
export class MultiPartPipe implements PipeTransform {
private readonly logger = new Logger('MultiPartPipe');
constructor(
private readonly multipartConfigService: MultipartConfigService,
) {}
async transform<T extends Object>(
req: FastifyRequest,
metadata: ArgumentMetadata,
) {
let zodSchema = (metadata?.metatype as ZodDtoStatic)?.zodSchema;
if (!zodSchema) {
this.logger.error('Invalid scheme on multipart body');
throw Fail(FT.Internal, 'Invalid scheme on backend');
}
let multipartData = {};
if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
// Fetch all fields from the request
let fields: MultipartFields | null = null;
try {
fields = (
await req.file({
limits: this.multipartConfigService.getLimits(),
})
).fields;
} catch (e) {
this.logger.warn(e);
}
if (!fields) throw Fail(FT.UsrValidation, 'Invalid file');
// Loop over every formfield that was sent
for (const key of Object.keys(fields)) {
// Ignore duplicate fields
if (Array.isArray(fields[key])) {
continue;
}
// Use the value property to differentiate between a field and a file
// And then put the value into the correct property on the validatable class
if ((fields[key] as any).value) {
(multipartData as any)[key] = CreateMultiPartFieldDto(
fields[key] as MultipartFile,
);
} else {
const file = await CreateMultiPartFileDto(fields[key] as MultipartFile);
if (HasFailed(file)) throw file;
(multipartData as any)[key] = file;
}
}
// Now validate the class we made, if any properties were invalid, it will error here
const result = zodSchema.safeParse(multipartData);
if (!result.success) {
this.logger.warn(result.error);
throw Fail(FT.UsrValidation, 'Invalid file');
}
return result.data;
}
}

View file

@ -1,27 +1,22 @@
import { Multipart } from '@fastify/multipart';
import {
Injectable,
Logger,
PipeTransform,
Scope
} from '@nestjs/common';
import { Multipart, MultipartFile } from '@fastify/multipart';
import { Injectable, Logger, PipeTransform, Scope } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { Fail, FT } from 'picsur-shared/dist/types';
import { Fail, FT } from 'picsur-shared/dist/types/failable';
import { MultipartConfigService } from '../../config/early/multipart.config.service';
@Injectable({ scope: Scope.REQUEST })
export class PostFilePipe implements PipeTransform {
private readonly logger = new Logger('PostFilePipe');
private readonly logger = new Logger(PostFilePipe.name);
constructor(
private readonly multipartConfigService: MultipartConfigService,
) {}
async transform({ req }: { req: FastifyRequest }) {
if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
async transform({ request }: { request: FastifyRequest }) {
if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
// Only one file is allowed
const file = await req.file({
const file = await request.file({
limits: {
...this.multipartConfigService.getLimits(),
files: 1,
@ -35,7 +30,9 @@ export class PostFilePipe implements PipeTransform {
) as any;
// Remove non-file fields
const files = allFields.filter((entry) => entry.file !== undefined);
const files: MultipartFile[] = allFields.filter(
(entry) => (entry as any).file !== undefined,
) as MultipartFile[];
if (files.length !== 1) throw Fail(FT.UsrValidation, 'Invalid file');

View file

@ -0,0 +1,28 @@
import { MultipartFile } from '@fastify/multipart';
import { Injectable, Logger, PipeTransform, Scope } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { FT, Fail } from 'picsur-shared/dist/types/failable';
import { MultipartConfigService } from '../../config/early/multipart.config.service';
export type FileIterator = AsyncIterableIterator<MultipartFile>;
@Injectable({ scope: Scope.REQUEST })
export class MultiPartPipe implements PipeTransform {
private readonly logger = new Logger(MultiPartPipe.name);
constructor(
private readonly multipartConfigService: MultipartConfigService,
) {}
async transform({ request, data }: { data: any; request: FastifyRequest }) {
const filesLimit = typeof data === 'number' ? data : undefined;
if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid files');
const files = request.files({
limits: this.multipartConfigService.getLimits(filesLimit),
});
return files;
}
}

View file

@ -2,13 +2,13 @@ import {
createParamDecorator,
ExecutionContext,
SetMetadata,
UseGuards
UseGuards,
} from '@nestjs/common';
import { Fail, FT } from 'picsur-shared/dist/types';
import { Fail, FT } from 'picsur-shared/dist/types/failable';
import { CombineFCDecorators } from 'picsur-shared/dist/util/decorator';
import { LocalAuthGuard } from '../managers/auth/guards/local-auth.guard';
import { Permission, Permissions } from '../models/constants/permissions.const';
import AuthFasityRequest from '../models/interfaces/authrequest.dto';
import AuthFastifyRequest from '../models/interfaces/authrequest.dto';
export const RequiredPermissions = (...permissions: Permissions) => {
return SetMetadata('permissions', permissions);
@ -26,10 +26,14 @@ export const UseLocalAuth = (...permissions: Permissions) =>
export const HasPermission = createParamDecorator(
(data: Permission, ctx: ExecutionContext) => {
const req: AuthFasityRequest = ctx.switchToHttp().getRequest();
const req: AuthFastifyRequest = ctx.switchToHttp().getRequest();
const permissions = req.userPermissions;
if (!permissions) {
throw Fail(FT.Internal, undefined, 'Permissions are missing from request');
throw Fail(
FT.Internal,
undefined,
'Permissions are missing from request',
);
}
return permissions.includes(data);
@ -38,10 +42,14 @@ export const HasPermission = createParamDecorator(
export const GetPermissions = createParamDecorator(
(data: Permission, ctx: ExecutionContext) => {
const req: AuthFasityRequest = ctx.switchToHttp().getRequest();
const req: AuthFastifyRequest = ctx.switchToHttp().getRequest();
const permissions = req.userPermissions;
if (!permissions) {
throw Fail(FT.Internal, undefined, 'Permissions are missing from request');
throw Fail(
FT.Internal,
undefined,
'Permissions are missing from request',
);
}
return permissions;

View file

@ -1,17 +1,17 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Fail, FT } from 'picsur-shared/dist/types';
import AuthFasityRequest from '../models/interfaces/authrequest.dto';
import { Fail, FT } from 'picsur-shared/dist/types/failable';
import AuthFastifyRequest from '../models/interfaces/authrequest.dto';
export const ReqUser = createParamDecorator(
(input: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest() as AuthFasityRequest;
const request = ctx.switchToHttp().getRequest() as AuthFastifyRequest;
return request.user;
},
);
export const ReqUserID = createParamDecorator(
(input: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest() as AuthFasityRequest;
const request = ctx.switchToHttp().getRequest() as AuthFastifyRequest;
const id = request.user.id;
if (!id) throw Fail(FT.Internal, undefined, 'User ID is not set');
return id;

View file

@ -6,13 +6,17 @@ import { Newable } from 'picsur-shared/dist/types/newable';
type ReturnsMethodDecorator<ReturnType> = <
T extends (...args: any) => ReturnType | Promise<ReturnType>,
>(
target: Object,
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>,
) => TypedPropertyDescriptor<T> | void;
export function Returns<N extends Object>(
export function Returns<N extends object>(
newable: Newable<N>,
): ReturnsMethodDecorator<N> {
return SetMetadata('returns', newable);
}
export function ReturnsAnything(): ReturnsMethodDecorator<any> {
return SetMetadata('noreturns', true);
}

View file

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { MainExceptionFilter } from './exception/exception.filter';
import { SuccessInterceptor } from './success/success.interceptor';
import { PicsurThrottlerGuard } from './throttler/PicsurThrottler.guard';
import { ZodValidationPipe } from './validate/zod-validator.pipe';
@Module({
imports: [
ThrottlerModule.forRoot({
ttl: 60,
limit: 60,
}),
],
providers: [
PicsurThrottlerGuard,
MainExceptionFilter,
SuccessInterceptor,
ZodValidationPipe,
],
exports: [
PicsurThrottlerGuard,
MainExceptionFilter,
SuccessInterceptor,
ZodValidationPipe,
],
})
export class PicsurLayersModule {}

View file

@ -1,11 +1,20 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import {
ArgumentsHost,
Catch,
ExceptionFilter,
ForbiddenException,
Logger,
MethodNotAllowedException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorResponse } from 'picsur-shared/dist/dto/api/api.dto';
import {
Fail,
Failure,
FT,
IsFailure
IsFailure,
} from 'picsur-shared/dist/types/failable';
// This will catch any exception that is made in any request
@ -24,32 +33,13 @@ export class MainExceptionFilter implements ExceptionFilter {
const traceString = `(${request.ip} -> ${request.method} ${request.url})`;
if (!IsFailure(exception)) {
MainExceptionFilter.logger.error(
traceString + ' Unkown exception: ' + exception,
);
exception = Fail(FT.Internal, 'Unknown exception', exception);
exception = this.transformKnownExceptions(exception);
}
const status = exception.getCode();
const type = exception.getType();
const message = exception.getReason();
const logmessage =
message +
(exception.getDebugMessage() ? ' - ' + exception.getDebugMessage() : '');
if (exception.isImportant()) {
MainExceptionFilter.logger.error(
`${traceString} ${exception.getName()}: ${logmessage}`,
);
if (exception.getStack()) {
MainExceptionFilter.logger.debug(exception.getStack());
}
} else {
MainExceptionFilter.logger.warn(
`${traceString} ${exception.getName()}: ${logmessage}`,
);
}
exception.print(MainExceptionFilter.logger, { prefix: traceString });
const toSend: ApiErrorResponse = {
success: false,
@ -59,10 +49,26 @@ export class MainExceptionFilter implements ExceptionFilter {
data: {
type,
message,
message: exception.getReason(),
},
};
response.status(status).send(toSend);
}
private transformKnownExceptions(exception: any): Failure {
if (exception instanceof UnauthorizedException) {
return Fail(FT.Permission, exception);
} else if (exception instanceof ForbiddenException) {
return Fail(FT.Permission, exception);
} else if (exception instanceof NotFoundException) {
return Fail(FT.RouteNotFound, exception);
} else if (exception instanceof MethodNotAllowedException) {
return Fail(FT.RouteNotFound, exception);
} else if (exception instanceof Error) {
return Fail(FT.Internal, exception);
} else {
return Fail(FT.Unknown, exception);
}
}
}

View file

@ -4,12 +4,12 @@ import {
Injectable,
Logger,
NestInterceptor,
Optional
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FastifyReply } from 'fastify';
import { ApiAnySuccessResponse } from 'picsur-shared/dist/dto/api/api.dto';
import { Fail, FT } from 'picsur-shared/dist/types';
import { Fail, FT } from 'picsur-shared/dist/types/failable';
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
import { map, Observable } from 'rxjs';
@ -20,7 +20,7 @@ export interface ZodValidationInterceptorOptions {
}
@Injectable()
export class SuccessInterceptor<T> implements NestInterceptor {
export class SuccessInterceptor implements NestInterceptor {
private readonly logger = new Logger();
// TODO: make work
@ -46,10 +46,29 @@ export class SuccessInterceptor<T> implements NestInterceptor {
return data;
}
}),
map((data) => {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse<FastifyReply>();
const traceString = `(${request.ip} -> ${request.method} ${request.url})`;
this.logger.verbose(
`Handled ${traceString} with ${response.statusCode} in ${Math.ceil(
response.getResponseTime(),
)}ms`,
SuccessInterceptor.name,
);
return data;
}),
);
}
private validate(context: ExecutionContext, data: unknown): unknown {
const canReturnAnything =
(this.reflector.get('noreturns', context.getHandler()) ?? false) === true;
if (canReturnAnything) return data;
const schemaStatic = this.reflector.get<ZodDtoStatic>(
'returns',
context.getHandler(),
@ -63,7 +82,7 @@ export class SuccessInterceptor<T> implements NestInterceptor {
);
}
let schema = schemaStatic.zodSchema;
const schema = schemaStatic.zodSchema;
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
@ -86,7 +105,7 @@ export class SuccessInterceptor<T> implements NestInterceptor {
const response = context.switchToHttp().getResponse<FastifyReply>();
const newResponse: ApiAnySuccessResponse = {
success: true as true, // really typescript
success: true as const, // really typescript
statusCode: response.statusCode,
timestamp: new Date().toISOString(),
timeMs: Math.round(response.getResponseTime()),

View file

@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { FT, Fail } from 'picsur-shared/dist/types/failable';
@Injectable()
export class PicsurThrottlerGuard extends ThrottlerGuard {
protected override throwThrottlingException(): void {
throw Fail(FT.RateLimit);
}
}

View file

@ -7,9 +7,9 @@ import {
ArgumentMetadata,
Injectable,
Optional,
PipeTransform
PipeTransform,
} from '@nestjs/common';
import { Fail, FT } from 'picsur-shared/dist/types';
import { Fail, FT } from 'picsur-shared/dist/types/failable';
import { ZodDtoStatic } from 'picsur-shared/dist/util/create-zod-dto';
export interface ZodValidationPipeOptions {
@ -30,17 +30,13 @@ export class ZodValidationPipe implements PipeTransform {
public transform(value: unknown, metadata: ArgumentMetadata): unknown {
if (!this.validateCustom && metadata.type === 'custom') return value;
let zodSchema = (metadata?.metatype as ZodDtoStatic)?.zodSchema;
const zodSchema = (metadata?.metatype as ZodDtoStatic)?.zodSchema;
if (zodSchema) {
const parseResult = zodSchema.safeParse(value);
if (!parseResult.success) {
throw Fail(
FT.UsrValidation,
'Invalid data',
parseResult.error
);
throw Fail(FT.UsrValidation, 'Invalid data', parseResult.error);
}
return parseResult.data;

View file

@ -5,7 +5,7 @@ import { HostConfigService } from '../config/early/host.config.service';
export class PicsurLoggerService extends ConsoleLogger {
constructor(hostService: HostConfigService) {
super();
if (hostService.isProduction()) {
if (hostService.isProduction() && !hostService.isVerbose()) {
super.setLogLevels(['error', 'warn', 'log']);
}
}

View file

@ -1,47 +1,57 @@
import fastifyHelmet from '@fastify/helmet';
import multipart from '@fastify/multipart';
import { NestFactory, Reflector } from '@nestjs/core';
import fastifyReplyFrom from '@fastify/reply-from';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import { UsersService } from './collections/user-db/user-db.service';
import { HostConfigService } from './config/early/host.config.service';
import { MainExceptionFilter } from './layers/exception/exception.filter';
import { SuccessInterceptor } from './layers/success/success.interceptor';
import { PicsurThrottlerGuard } from './layers/throttler/PicsurThrottler.guard';
import { ZodValidationPipe } from './layers/validate/zod-validator.pipe';
import { PicsurLoggerService } from './logger/logger.service';
import { MainAuthGuard } from './managers/auth/guards/main.guard';
import { HelmetOptions } from './security';
async function bootstrap() {
const isProduction = process.env['PICSUR_PRODUCTION'] !== undefined;
// Create fasify
const fastifyAdapter = new FastifyAdapter();
const fastifyAdapter = new FastifyAdapter({
trustProxy: [
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
],
});
// TODO: generic error messages
await fastifyAdapter.register(multipart as any);
await fastifyAdapter.register(fastifyHelmet as any, HelmetOptions);
await fastifyAdapter.register(fastifyReplyFrom as any);
// Create nest app
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
fastifyAdapter,
{
bufferLogs: true,
bufferLogs: isProduction,
autoFlushLogs: true,
},
);
// Configure logger
app.useLogger(app.get(PicsurLoggerService));
app.flushLogs();
app.useGlobalFilters(new MainExceptionFilter());
app.useGlobalInterceptors(new SuccessInterceptor(app.get(Reflector)));
app.useGlobalPipes(new ZodValidationPipe());
app.useGlobalGuards(
new MainAuthGuard(app.get(Reflector), app.get(UsersService)),
);
app.useGlobalFilters(app.get(MainExceptionFilter));
app.useGlobalInterceptors(app.get(SuccessInterceptor));
app.useGlobalPipes(app.get(ZodValidationPipe));
app.useGlobalGuards(app.get(PicsurThrottlerGuard), app.get(MainAuthGuard));
// Start app
const hostConfigService = app.get(HostConfigService);

View file

@ -1,24 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PreferenceModule } from '../../collections/preference-db/preference-db.module';
import { UsersModule } from '../../collections/user-db/user-db.module';
import { ApiKeyDbModule } from '../../collections/apikey-db/apikey-db.module';
import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module';
import { UserDbModule } from '../../collections/user-db/user-db.module';
import {
JwtConfigService,
JwtSecretProvider,
} from '../../config/late/jwt.config.service';
import { LateConfigModule } from '../../config/late/late-config.module';
import { AuthManagerService } from './auth.service';
import { ApiKeyStrategy } from './guards/apikey.strategy';
import { GuestStrategy } from './guards/guest.strategy';
import { JwtStrategy } from './guards/jwt.strategy';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { LocalAuthStrategy } from './guards/local-auth.strategy';
import { MainAuthGuard } from './guards/main.guard';
import { GuestService } from './guest.service';
@Module({
imports: [
UsersModule,
UserDbModule,
PassportModule,
PreferenceModule,
PreferenceDbModule,
ApiKeyDbModule,
LateConfigModule,
JwtModule.registerAsync({
useExisting: JwtConfigService,
@ -27,12 +32,15 @@ import { GuestService } from './guest.service';
],
providers: [
AuthManagerService,
GuestService,
JwtSecretProvider,
LocalAuthStrategy,
JwtStrategy,
GuestStrategy,
JwtSecretProvider,
GuestService,
ApiKeyStrategy,
LocalAuthGuard,
MainAuthGuard,
],
exports: [UsersModule, AuthManagerService],
exports: [UserDbModule, AuthManagerService, LocalAuthGuard, MainAuthGuard],
})
export class AuthManagerModule {}

View file

@ -1,18 +1,18 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtDataSchema } from 'picsur-shared/dist/dto/jwt.dto';
import { JwtData, JwtDataSchema } from 'picsur-shared/dist/dto/jwt.dto';
import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types/failable';
@Injectable()
export class AuthManagerService {
private readonly logger = new Logger('AuthService');
private readonly logger = new Logger(AuthManagerService.name);
constructor(private readonly jwtService: JwtService) {}
async createToken(user: EUser): AsyncFailable<string> {
const jwtData = {
user,
const jwtData: JwtData = {
uid: user.id,
};
// Validate to be sure, this makes client experience better

View file

@ -0,0 +1,62 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { EUser, EUserSchema } from 'picsur-shared/dist/entities/user.entity';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { IsApiKey } from 'picsur-shared/dist/validators/api-key.validator';
import { ApiKeyDbService } from '../../../collections/apikey-db/apikey-db.service';
import { EUserBackend2EUser } from '../../../models/transformers/user.transformer';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(
HeaderAPIKeyStrategy,
'apikey',
) {
private readonly logger = new Logger(ApiKeyStrategy.name);
constructor(private readonly apikeyDB: ApiKeyDbService) {
super(
{
header: 'Authorization',
prefix: 'Api-Key ',
},
false,
(
apikey: string,
verified: (err: Error | null, user?: object, info?: object) => void,
) => {
this.validate(apikey)
.then((user) => {
verified(null, user === false ? undefined : user);
})
.catch((err) => {
verified(err, undefined);
});
},
);
}
async validate(apikey: string): Promise<EUser | false> {
const apiValidation = await IsApiKey().safeParseAsync(apikey);
if (!apiValidation.success) {
this.logger.warn('Invalid apikey format: ' + apikey);
return false;
}
const apikeyResult = await this.apikeyDB.resolve(apikey);
if (HasFailed(apikeyResult)) {
this.logger.warn('Invalid apikey: ' + apikey);
return false;
}
const user = EUserBackend2EUser(apikeyResult.user);
const userValidation = await EUserSchema.safeParseAsync(user);
if (!userValidation.success) {
this.logger.error('Invalid user: ' + JSON.stringify(user));
return false;
}
return userValidation.data;
}
}

View file

@ -1,16 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-strategy';
import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { EUserBackend2EUser } from '../../../models/transformers/user.transformer';
import { GuestService } from '../guest.service';
import { ReqType } from './reqtype';
class GuestPassportStrategy extends Strategy {
// Will be overridden by the nest implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async validate(req: ReqType): Promise<any> {
return undefined;
}
override async authenticate(req: ReqType, options?: any) {
override async authenticate(req: ReqType) {
const user = await this.validate(req);
this.success(user);
}
@ -26,7 +29,7 @@ export class GuestStrategy extends PassportStrategy(
}
// Return the guest user created by the guestservice
override async validate(payload: any) {
return await this.guestService.getGuestUser();
override async validate(): Promise<EUser> {
return EUserBackend2EUser(await this.guestService.getGuestUser());
}
}

View file

@ -3,12 +3,18 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy as JwtPassportStrategy } from 'passport-jwt';
import { JwtDataSchema } from 'picsur-shared/dist/dto/jwt.dto';
import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { ThrowIfFailed } from 'picsur-shared/dist/types/failable';
import { UserDbService } from '../../../collections/user-db/user-db.service';
import { EUserBackend2EUser } from '../../../models/transformers/user.transformer';
@Injectable()
export class JwtStrategy extends PassportStrategy(JwtPassportStrategy, 'jwt') {
private readonly logger = new Logger('JwtStrategy');
private readonly logger = new Logger(JwtStrategy.name);
constructor(@Inject('JWT_SECRET') jwtSecret: string) {
constructor(
@Inject('JWT_SECRET') jwtSecret: string,
private readonly usersService: UserDbService,
) {
// This will validate the jwt token itself
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@ -24,7 +30,11 @@ export class JwtStrategy extends PassportStrategy(JwtPassportStrategy, 'jwt') {
return false;
}
const backendUser = ThrowIfFailed(
await this.usersService.findOne(result.data.uid),
);
// And return the user
return result.data.user;
return EUserBackend2EUser(backendUser);
}
}

View file

@ -2,21 +2,28 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types';
import { UsersService } from '../../../collections/user-db/user-db.service';
import {
AsyncFailable,
ThrowIfFailed,
} from 'picsur-shared/dist/types/failable';
import { UserDbService } from '../../../collections/user-db/user-db.service';
import { EUserBackend2EUser } from '../../../models/transformers/user.transformer';
@Injectable()
export class LocalAuthStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private readonly usersService: UsersService) {
constructor(private readonly usersService: UserDbService) {
super();
}
async validate(username: string, password: string): AsyncFailable<EUser> {
const start = Date.now();
// All this does is call the usersservice authenticate for authentication
const user = await this.usersService.authenticate(username, password);
if (HasFailed(user)) throw user;
return EUserBackend2EUser(user);
// Wait atleast 500ms
const wait = 450 - (Date.now() - start);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
return EUserBackend2EUser(ThrowIfFailed(user));
}
}

View file

@ -2,8 +2,16 @@ import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { EUser, EUserSchema } from 'picsur-shared/dist/entities/user.entity';
import { Fail, Failable, FT, HasFailed } from 'picsur-shared/dist/types';
import { UsersService } from '../../../collections/user-db/user-db.service';
import {
AsyncFailable,
Fail,
Failable,
FT,
HasFailed,
ThrowIfFailed,
} from 'picsur-shared/dist/types/failable';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { UserDbService } from '../../../collections/user-db/user-db.service';
import { Permissions } from '../../../models/constants/permissions.const';
import { isPermissionsArray } from '../../../models/validators/permissions.validator';
@ -12,12 +20,12 @@ import { isPermissionsArray } from '../../../models/validators/permissions.valid
// This way a user will get his own account when logged in, but received guest permissions when not
@Injectable()
export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
private readonly logger = new Logger('MainAuthGuard');
export class MainAuthGuard extends AuthGuard(['apikey', 'jwt', 'guest']) {
private readonly logger = new Logger(MainAuthGuard.name);
constructor(
private readonly reflector: Reflector,
private readonly usersService: UsersService,
private readonly usersService: UserDbService,
) {
super();
}
@ -33,9 +41,10 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
);
}
const user = await this.validateUser(
context.switchToHttp().getRequest().user,
);
const unsafeUser: EUser = context.switchToHttp().getRequest().user;
const user = ThrowIfFailed(await this.validateUser(unsafeUser));
if (!user.id) {
throw Fail(
FT.Internal,
@ -57,14 +66,11 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
// These are the permissions the user has
const userPermissions = await this.usersService.getPermissions(user.id);
if (HasFailed(userPermissions)) {
throw Fail(
FT.Internal,
undefined,
'Fetching user permissions failed: ' + userPermissions.getReason(),
);
throw userPermissions;
}
context.switchToHttp().getRequest().userPermissions = userPermissions;
context.switchToHttp().getRequest().user = user;
if (permissions.every((permission) => userPermissions.includes(permission)))
return true;
@ -75,16 +81,23 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
const handlerName = context.getHandler().name;
// Fall back to class permissions if none on function
// But function has higher priority than class
const permissions =
this.reflector.get<Permissions>('permissions', context.getHandler()) ??
const permissionsHandler: Permissions | undefined =
this.reflector.get<Permissions>('permissions', context.getHandler());
const permissionsClass: Permissions | undefined =
this.reflector.get<Permissions>('permissions', context.getClass());
if (permissions === undefined)
if (permissionsHandler === undefined && permissionsClass === undefined) {
return Fail(
FT.Internal,
undefined,
`${handlerName} does not have any permissions defined, denying access`,
);
}
const permissions = makeUnique([
...(permissionsHandler ?? []),
...(permissionsClass ?? []),
]);
if (!isPermissionsArray(permissions))
return Fail(
@ -96,10 +109,10 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
return permissions;
}
private async validateUser(user: EUser): Promise<EUser> {
private async validateUser(user: EUser): AsyncFailable<EUser> {
const result = EUserSchema.safeParse(user);
if (!result.success) {
throw Fail(
return Fail(
FT.Internal,
undefined,
`Invalid user object, where it should always be valid: ${result.error}`,

View file

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common';
import { HasFailed } from 'picsur-shared/dist/types';
import { UsersService } from '../../collections/user-db/user-db.service';
import { EUserBackend } from '../../database/entities/user.entity';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { UserDbService } from '../../collections/user-db/user-db.service';
import { EUserBackend } from '../../database/entities/users/user.entity';
@Injectable()
export class GuestService {
private fallBackUser: EUserBackend;
constructor(private readonly usersService: UsersService) {
constructor(private readonly usersService: UserDbService) {
this.fallBackUser = new EUserBackend();
this.fallBackUser.username = 'guest';
this.fallBackUser.roles = ['guest'];

View file

@ -1,21 +1,22 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { ImageDBModule } from '../../collections/image-db/image-db.module';
import { RolesModule } from '../../collections/role-db/role-db.module';
import { RoleDbModule } from '../../collections/role-db/role-db.module';
import { EarlyConfigModule } from '../../config/early/early-config.module';
import { HostConfigService } from '../../config/early/host.config.service';
import { DemoManagerService } from './demo.service';
@Module({
imports: [ImageDBModule, EarlyConfigModule, RolesModule],
imports: [ImageDBModule, EarlyConfigModule, RoleDbModule],
providers: [DemoManagerService],
})
export class DemoManagerModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger('DemoManagerModule');
private interval: NodeJS.Timeout;
export class DemoManagerModule implements OnModuleInit {
private readonly logger = new Logger(DemoManagerModule.name);
constructor(
private readonly demoManagerService: DemoManagerService,
private readonly hostConfigService: HostConfigService,
private readonly schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
@ -27,14 +28,12 @@ export class DemoManagerModule implements OnModuleInit, OnModuleDestroy {
private async setupDemoMode() {
this.demoManagerService.setupRoles();
this.interval = setInterval(
const interval = setInterval(
// Run demoManagerService.execute() every interval
this.demoManagerService.execute.bind(this.demoManagerService),
this.hostConfigService.getDemoInterval(),
);
}
onModuleDestroy() {
if (this.interval) clearInterval(this.interval);
this.schedulerRegistry.addInterval('demo', interval);
}
}

View file

@ -1,15 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImageDBService } from '../../collections/image-db/image-db.service';
import { RolesService } from '../../collections/role-db/role-db.service';
import { RoleDbService } from '../../collections/role-db/role-db.service';
import { Permission } from '../../models/constants/permissions.const';
@Injectable()
export class DemoManagerService {
private readonly logger = new Logger('DemoManagerService');
private readonly logger = new Logger(DemoManagerService.name);
constructor(
private readonly imagesService: ImageDBService,
private readonly rolesService: RolesService,
private readonly rolesService: RoleDbService,
) {}
public async setupRoles() {

View file

@ -3,18 +3,23 @@ import ms from 'ms';
import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto';
import {
FileType,
SupportedFileTypeCategory
SupportedFileTypeCategory,
} from 'picsur-shared/dist/dto/mimes.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
} from 'picsur-shared/dist/types/failable';
import { SharpOptions } from 'sharp';
import { SysPreferenceService } from '../../collections/preference-db/sys-preference-db.service';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { SharpWrapper } from '../../workers/sharp.wrapper';
import { ImageResult } from './imageresult';
@Injectable()
export class ImageConverterService {
constructor(private readonly sysPref: SysPreferenceService) {}
constructor(private readonly sysPref: SysPreferenceDbService) {}
public async convert(
image: Buffer,
@ -57,7 +62,8 @@ export class ImageConverterService {
if (HasFailed(memLimit) || HasFailed(timeLimit)) {
return Fail(FT.Internal, 'Failed to get conversion limits');
}
const timeLimitMS = ms(timeLimit);
let timeLimitMS = ms(timeLimit as any);
if (isNaN(timeLimitMS) || timeLimitMS === 0) timeLimitMS = 15 * 1000; // 15 seconds
const sharpWrapper = new SharpWrapper(timeLimitMS, memLimit);
const sharpOptions: SharpOptions = {
@ -78,13 +84,16 @@ export class ImageConverterService {
height: options.height,
fit: 'fill',
kernel: 'cubic',
withoutEnlargement: options.shrinkonly,
});
} else {
sharpWrapper.operation('resize', {
width: options.width,
height: options.height,
fit: 'contain',
fit: 'inside',
kernel: 'cubic',
withoutEnlargement: options.shrinkonly,
});
}
}
@ -118,16 +127,4 @@ export class ImageConverterService {
filetype: targetFiletype.identifier,
};
}
private async convertAnimation(
image: Buffer,
targetFiletype: FileType,
options: ImageRequestParams,
): AsyncFailable<ImageResult> {
// Apng and gif are stored as is for now
return {
image: image,
filetype: targetFiletype.identifier,
};
}
}

View file

@ -0,0 +1,78 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import ms from 'ms';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { HasFailed } from 'picsur-shared/dist/types/failable';
import { ImageDBModule } from '../../collections/image-db/image-db.module';
import { ImageDBService } from '../../collections/image-db/image-db.service';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service';
import { ImageManagerService } from './image.service';
@Module({
imports: [ImageDBModule, PreferenceDbModule],
providers: [
ImageManagerService,
ImageProcessorService,
ImageConverterService,
],
exports: [ImageManagerService, ImageConverterService],
})
export class ImageManagerModule implements OnModuleInit {
private readonly logger = new Logger(ImageManagerModule.name);
constructor(
private readonly prefManager: SysPreferenceDbService,
private readonly imageFileDB: ImageFileDBService,
private readonly imageDB: ImageDBService,
) {}
async onModuleInit() {
await this.imageManagerCron();
}
@Interval(1000 * 60)
private async imageManagerCron() {
await this.cleanupDerivatives();
await this.cleanupExpired();
}
private async cleanupDerivatives() {
const remove_derivatives_after = await this.prefManager.getStringPreference(
SysPreference.RemoveDerivativesAfter,
);
if (HasFailed(remove_derivatives_after)) {
this.logger.warn('Failed to get remove_derivatives_after preference');
return;
}
let after_ms = ms(remove_derivatives_after as any);
if (isNaN(after_ms) || after_ms === 0) {
this.logger.log('remove_derivatives_after is 0, skipping cron');
return;
}
if (after_ms < 60000) after_ms = 60000;
const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000);
if (HasFailed(result)) {
result.print(this.logger);
}
if (result > 0) this.logger.log(`Cleaned up ${result} derivatives`);
}
private async cleanupExpired() {
const cleanedUp = await this.imageDB.cleanupExpired();
if (HasFailed(cleanedUp)) {
cleanedUp.print(this.logger);
return;
}
if (cleanedUp > 0)
this.logger.log(`Cleaned up ${cleanedUp} expired images`);
}
}

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