Merge branch 'dev'

This commit is contained in:
Rubikscraft 2022-12-28 10:41:11 +01:00
commit 26ed0e9ea5
193 changed files with 5100 additions and 2799 deletions

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

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

View file

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

View file

@ -19,7 +19,7 @@ But it does function, so feel free to give it a try.
## Demo ## 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. The images are deleted every five minutes, and the maximum filesize is 16MB. But it should give you an indication of how it works.
@ -57,9 +57,9 @@ Every featured marked here should work in the latest release.
- [x] Proper DB migrations - [x] Proper DB migrations
- [x] Show own images in list - [x] Show own images in list
- [x] Correct previews on chats - [x] Correct previews on chats
- [X] Expiring images - [x] Expiring images
- [X] ShareX endpoint - [x] ShareX endpoint
- [X] Arm64 image - [x] ARM64 and AMD64 Docker image
- [ ] White mode - [ ] White mode
- [ ] Public gallery - [ ] Public gallery
@ -74,6 +74,12 @@ If you like this project, don't forget to give it a star. It tells me that I'm n
## Faq ## 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? ### 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. 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.
@ -150,6 +156,10 @@ volumes:
picsur-data: picsur-data:
``` ```
## Thanks
- @awg13 for donating 5$
## Api ## Api
Here is a usually up to date documentation of the api: Here is a usually up to date documentation of the api:

View file

@ -1,6 +1,6 @@
{ {
"name": "picsur-backend", "name": "picsur-backend",
"version": "0.4.1", "version": "0.5.0",
"description": "Backend for Picsur", "description": "Backend for Picsur",
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": "https://github.com/rubikscraft/Picsur", "repository": "https://github.com/rubikscraft/Picsur",
@ -22,65 +22,69 @@
"purge": "rm -rf dist && rm -rf node_modules" "purge": "rm -rf dist && rm -rf node_modules"
}, },
"dependencies": { "dependencies": {
"@fastify/helmet": "^10.0.1", "@fastify/helmet": "^10.1.0",
"@fastify/multipart": "^7.2.0", "@fastify/multipart": "^7.3.0",
"@fastify/static": "^6.5.0", "@fastify/reply-from": "^8.3.1",
"@nestjs/common": "^9.1.4", "@fastify/static": "^6.6.0",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.1.4", "@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^9.0.0", "@nestjs/jwt": "^9.0.0",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.0",
"@nestjs/platform-fastify": "^9.1.4", "@nestjs/platform-fastify": "^9.2.1",
"@nestjs/schedule": "^2.1.0",
"@nestjs/serve-static": "^3.0.0", "@nestjs/serve-static": "^3.0.0",
"@nestjs/throttler": "^3.1.0",
"@nestjs/typeorm": "^9.0.1", "@nestjs/typeorm": "^9.0.1",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"bmp-img": "^1.2.1", "bmp-img": "^1.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"file-type": "^18.0.0", "file-type": "^18.0.0",
"is-docker": "^3.0.0",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-fetch": "^3.2.10",
"p-timeout": "^6.0.0", "p-timeout": "^6.0.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-headerapikey": "^1.2.2", "passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-strategy": "^1.0.0", "passport-strategy": "^1.0.0",
"pg": "^8.8.0", "pg": "^8.8.0",
"picsur-shared": "*", "picsur-shared": "*",
"posix.js": "^0.1.1", "posix.js": "^0.1.1",
"qoi-img": "^2.1.0", "qoi-img": "^2.1.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.5.7", "rxjs": "^7.8.0",
"sharp": "^0.31.1", "sharp": "^0.31.3",
"stream-parser": "^0.3.1", "stream-parser": "^0.3.1",
"thunks": "^4.9.6", "thunks": "^4.9.6",
"typeorm": "0.3.10", "typeorm": "0.3.11",
"zod": "^3.19.1" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.4", "@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.1.4", "@nestjs/testing": "^9.2.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.13",
"@types/ms": "^0.7.31",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^18.8.4", "@types/node": "^18.11.17",
"@types/passport-jwt": "^3.0.7", "@types/passport-jwt": "^3.0.8",
"@types/passport-local": "^1.0.34", "@types/passport-local": "^1.0.34",
"@types/passport-strategy": "^0.2.35", "@types/passport-strategy": "^0.2.35",
"@types/sharp": "^0.31.0", "@types/sharp": "^0.31.0",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.40.0", "@typescript-eslint/parser": "^5.47.0",
"eslint": "^8.25.0", "eslint": "^8.30.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.7.1", "prettier": "^2.8.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-loader": "^9.4.1", "ts-loader": "^9.4.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0", "tsconfig-paths": "^4.1.1",
"typescript": "4.8.4" "typescript": "^4.9.4"
} }
} }

View file

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

View file

@ -5,7 +5,7 @@ import { FindResult } from 'picsur-shared/dist/types/find-result';
import { generateRandomString } from 'picsur-shared/dist/util/random'; import { generateRandomString } from 'picsur-shared/dist/util/random';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { EApiKeyBackend } from '../../database/entities/apikey.entity'; import { EApiKeyBackend } from '../../database/entities/apikey.entity';
import { EUserBackend } from '../../database/entities/user.entity'; import { EUserBackend } from '../../database/entities/users/user.entity';
@Injectable() @Injectable()
export class ApiKeyDbService { export class ApiKeyDbService {

View file

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

View file

@ -4,7 +4,7 @@ import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result'; import { FindResult } from 'picsur-shared/dist/types/find-result';
import { generateRandomString } from 'picsur-shared/dist/util/random'; import { generateRandomString } from 'picsur-shared/dist/util/random';
import { In, LessThan, Repository } from 'typeorm'; import { In, LessThan, Repository } from 'typeorm';
import { EImageBackend } from '../../database/entities/image.entity'; import { EImageBackend } from '../../database/entities/images/image.entity';
@Injectable() @Injectable()
export class ImageDBService { export class ImageDBService {
@ -83,6 +83,14 @@ export class ImageDBService {
} }
} }
public async count(): AsyncFailable<number> {
try {
return await this.imageRepo.count();
} catch (e) {
return Fail(FT.Database, e);
}
}
public async update( public async update(
id: string, id: string,
userid: string | undefined, userid: string | undefined,

View file

@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum'; 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';
import { LessThan, Repository } from 'typeorm'; import { LessThan, Repository } from 'typeorm';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity'; import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity'; import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
const A_DAY_IN_SECONDS = 24 * 60 * 60; const A_DAY_IN_SECONDS = 24 * 60 * 60;
@ -57,6 +57,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 // This is useful because you dont have to pull the whole image file
public async getFileTypes( public async getFileTypes(
imageId: string, imageId: string,
@ -129,7 +163,7 @@ export class ImageFileDBService {
): AsyncFailable<number> { ): AsyncFailable<number> {
try { try {
const result = await this.imageDerivativeRepo.delete({ const result = await this.imageDerivativeRepo.delete({
last_read: LessThan(new Date()), last_read: LessThan(new Date(Date.now() - olderThanSeconds * 1000)),
}); });
return result.affected ?? 0; return result.affected ?? 0;

View file

@ -17,8 +17,8 @@ type EnumValue<E> = E[keyof E];
type PrefValueTypeType<E extends Enum> = { type PrefValueTypeType<E extends Enum> = {
[key in EnumValue<E>]: PrefValueTypeStrings; [key in EnumValue<E>]: PrefValueTypeStrings;
}; };
type EncodedPref = { type EncodedPref<E extends Enum> = {
key: string; key: EnumValue<E>;
value: string; value: string;
}; };
@ -32,7 +32,7 @@ export class PreferenceCommonService {
// E is either the SysPreference or the UsrPreference enum // E is either the SysPreference or the UsrPreference enum
// the pref value types is the object containing the type of each key in E // the pref value types is the object containing the type of each key in E
public DecodePref<E extends Enum>( public DecodePref<E extends Enum>(
preference: EncodedPref, preference: EncodedPref<E>,
prefType: E, prefType: E,
prefValueTypes: PrefValueTypeType<E>, prefValueTypes: PrefValueTypeType<E>,
): Failable<DecodedPref> { ): Failable<DecodedPref> {
@ -69,7 +69,7 @@ export class PreferenceCommonService {
value: PrefValueType, value: PrefValueType,
prefType: E, prefType: E,
prefValueTypes: PrefValueTypeType<E>, prefValueTypes: PrefValueTypeType<E>,
): AsyncFailable<EncodedPref> { ): AsyncFailable<EncodedPref<E>> {
const validatedKey = this.validatePrefKey(key, prefType); const validatedKey = this.validatePrefKey(key, prefType);
if (HasFailed(validatedKey)) return validatedKey; if (HasFailed(validatedKey)) return validatedKey;

View file

@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { EarlyConfigModule } from '../../config/early/early-config.module'; import { EarlyConfigModule } from '../../config/early/early-config.module';
import { ESysPreferenceBackend } from '../../database/entities/sys-preference.entity'; import { ESysPreferenceBackend } from '../../database/entities/system/sys-preference.entity';
import { EUsrPreferenceBackend } from '../../database/entities/usr-preference.entity'; import { EUsrPreferenceBackend } from '../../database/entities/system/usr-preference.entity';
import { PreferenceCommonService } from './preference-common.service'; import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service'; import { PreferenceDefaultsService } from './preference-defaults.service';
import { SysPreferenceDbService } from './sys-preference-db.service'; import { SysPreferenceDbService } from './sys-preference-db.service';

View file

@ -15,15 +15,17 @@ export class PreferenceDefaultsService {
constructor(private readonly jwtConfigService: EarlyJwtConfigService) {} constructor(private readonly jwtConfigService: EarlyJwtConfigService) {}
public readonly usrDefaults: { private readonly usrDefaults: {
[key in UsrPreference]: () => PrefValueType; [key in UsrPreference]: (() => PrefValueType) | PrefValueType;
} = { } = {
[UsrPreference.KeepOriginal]: () => false, [UsrPreference.KeepOriginal]: false,
}; };
public readonly sysDefaults: { private readonly sysDefaults: {
[key in SysPreference]: () => PrefValueType; [key in SysPreference]: (() => PrefValueType) | PrefValueType;
} = { } = {
[SysPreference.HostOverride]: '',
[SysPreference.JwtSecret]: () => { [SysPreference.JwtSecret]: () => {
const envSecret = this.jwtConfigService.getJwtSecret(); const envSecret = this.jwtConfigService.getJwtSecret();
if (envSecret) { if (envSecret) {
@ -37,13 +39,36 @@ export class PreferenceDefaultsService {
}, },
[SysPreference.JwtExpiresIn]: () => [SysPreference.JwtExpiresIn]: () =>
this.jwtConfigService.getJwtExpiresIn() ?? '7d', this.jwtConfigService.getJwtExpiresIn() ?? '7d',
[SysPreference.BCryptStrength]: () => 12, [SysPreference.BCryptStrength]: 10,
[SysPreference.RemoveDerivativesAfter]: () => '7d', [SysPreference.RemoveDerivativesAfter]: '7d',
[SysPreference.SaveDerivatives]: () => true, [SysPreference.AllowEditing]: true,
[SysPreference.AllowEditing]: () => true,
[SysPreference.ConversionTimeLimit]: () => '10s', [SysPreference.ConversionTimeLimit]: '15s',
[SysPreference.ConversionMemoryLimit]: () => 512, [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

@ -5,17 +5,18 @@ import {
PrefValueType, PrefValueType,
PrefValueTypeStrings, PrefValueTypeStrings,
} from 'picsur-shared/dist/dto/preferences.dto'; } from 'picsur-shared/dist/dto/preferences.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; import {
SysPreference,
SysPreferenceList,
SysPreferenceValidators,
SysPreferenceValueTypes,
} 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';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { import {
ESysPreferenceBackend, ESysPreferenceBackend,
ESysPreferenceSchema, ESysPreferenceSchema,
} from '../../database/entities/sys-preference.entity'; } from '../../database/entities/system/sys-preference.entity';
import {
SysPreferenceList,
SysPreferenceValueTypes,
} from '../../models/constants/syspreferences.const';
import { MutexFallBack } from '../../util/mutex-fallback'; import { MutexFallBack } from '../../util/mutex-fallback';
import { PreferenceCommonService } from './preference-common.service'; import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service'; import { PreferenceDefaultsService } from './preference-defaults.service';
@ -85,7 +86,7 @@ export class SysPreferenceDbService {
// Return // Return
return this.prefCommon.DecodePref( return this.prefCommon.DecodePref(
result.data, result.data as any,
SysPreference, SysPreference,
SysPreferenceValueTypes, SysPreferenceValueTypes,
); );
@ -136,7 +137,7 @@ export class SysPreferenceDbService {
private async saveDefault( private async saveDefault(
key: SysPreference, // Force enum here because we dont validate key: SysPreference, // Force enum here because we dont validate
): AsyncFailable<DecodedSysPref> { ): AsyncFailable<DecodedSysPref> {
return this.setPreference(key, this.defaultsService.sysDefaults[key]()); return this.setPreference(key, this.defaultsService.getSysDefault(key));
} }
private async encodeSysPref( private async encodeSysPref(
@ -151,6 +152,12 @@ export class SysPreferenceDbService {
); );
if (HasFailed(validated)) return validated; if (HasFailed(validated)) return validated;
const valueValidated =
SysPreferenceValidators[key as SysPreference].safeParse(value);
if (!valueValidated.success) {
return Fail(FT.UsrValidation, undefined, valueValidated.error);
}
let verifySysPreference = new ESysPreferenceBackend(); let verifySysPreference = new ESysPreferenceBackend();
verifySysPreference.key = validated.key; verifySysPreference.key = validated.key;
verifySysPreference.value = validated.value; verifySysPreference.value = validated.value;

View file

@ -5,17 +5,18 @@ import {
PrefValueType, PrefValueType,
PrefValueTypeStrings, PrefValueTypeStrings,
} from 'picsur-shared/dist/dto/preferences.dto'; } from 'picsur-shared/dist/dto/preferences.dto';
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum'; import {
UsrPreference,
UsrPreferenceList,
UsrPreferenceValidators,
UsrPreferenceValueTypes,
} from 'picsur-shared/dist/dto/usr-preferences.enum';
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { import {
EUsrPreferenceBackend, EUsrPreferenceBackend,
EUsrPreferenceSchema, EUsrPreferenceSchema,
} from '../../database/entities/usr-preference.entity'; } from '../../database/entities/system/usr-preference.entity';
import {
UsrPreferenceList,
UsrPreferenceValueTypes,
} from '../../models/constants/usrpreferences.const';
import { MutexFallBack } from '../../util/mutex-fallback'; import { MutexFallBack } from '../../util/mutex-fallback';
import { PreferenceCommonService } from './preference-common.service'; import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service'; import { PreferenceDefaultsService } from './preference-defaults.service';
@ -91,7 +92,7 @@ export class UsrPreferenceDbService {
// Return // Return
const unpacked = this.prefCommon.DecodePref( const unpacked = this.prefCommon.DecodePref(
result.data, result.data as any,
UsrPreference, UsrPreference,
UsrPreferenceValueTypes, UsrPreferenceValueTypes,
); );
@ -175,7 +176,7 @@ export class UsrPreferenceDbService {
return this.setPreference( return this.setPreference(
userid, userid,
key, key,
this.defaultsService.usrDefaults[key](), this.defaultsService.getUsrDefault(key),
); );
} }
@ -192,6 +193,12 @@ export class UsrPreferenceDbService {
); );
if (HasFailed(validated)) return validated; if (HasFailed(validated)) return validated;
const valueValidated =
UsrPreferenceValidators[key as UsrPreference].safeParse(value);
if (!valueValidated.success) {
return Fail(FT.UsrValidation, undefined, valueValidated.error);
}
let verifySysPreference = new EUsrPreferenceBackend(); let verifySysPreference = new EUsrPreferenceBackend();
verifySysPreference.key = validated.key; verifySysPreference.key = validated.key;
verifySysPreference.value = validated.value; verifySysPreference.value = validated.value;

View file

@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { EarlyConfigModule } from '../../config/early/early-config.module'; import { EarlyConfigModule } from '../../config/early/early-config.module';
import { HostConfigService } from '../../config/early/host.config.service'; import { HostConfigService } from '../../config/early/host.config.service';
import { ERoleBackend } from '../../database/entities/role.entity'; import { ERoleBackend } from '../../database/entities/users/role.entity';
import { import {
ImmutableRolesList, ImmutableRolesList,
SystemRoleDefaults, SystemRoleDefaults,

View file

@ -10,7 +10,7 @@ import {
} from 'picsur-shared/dist/types'; } from 'picsur-shared/dist/types';
import { makeUnique } from 'picsur-shared/dist/util/unique'; import { makeUnique } from 'picsur-shared/dist/util/unique';
import { In, Repository } from 'typeorm'; 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 { Permissions } from '../../models/constants/permissions.const';
import { import {
ImmutableRolesList, ImmutableRolesList,

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';
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

@ -4,7 +4,7 @@ import { HasFailed } from 'picsur-shared/dist/types';
import { generateRandomString } from 'picsur-shared/dist/util/random'; import { generateRandomString } from 'picsur-shared/dist/util/random';
import { AuthConfigService } from '../../config/early/auth.config.service'; import { AuthConfigService } from '../../config/early/auth.config.service';
import { EarlyConfigModule } from '../../config/early/early-config.module'; import { EarlyConfigModule } from '../../config/early/early-config.module';
import { EUserBackend } from '../../database/entities/user.entity'; import { EUserBackend } from '../../database/entities/users/user.entity';
import { PreferenceDbModule } from '../preference-db/preference-db.module'; import { PreferenceDbModule } from '../preference-db/preference-db.module';
import { RoleDbModule } from '../role-db/role-db.module'; import { RoleDbModule } from '../role-db/role-db.module';
import { UserDbService } from './user-db.service'; import { UserDbService } from './user-db.service';

View file

@ -12,7 +12,7 @@ import {
import { FindResult } from 'picsur-shared/dist/types/find-result'; import { FindResult } from 'picsur-shared/dist/types/find-result';
import { makeUnique } from 'picsur-shared/dist/util/unique'; import { makeUnique } from 'picsur-shared/dist/util/unique';
import { Repository } from 'typeorm'; 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 { Permissions } from '../../models/constants/permissions.const';
import { import {
DefaultRolesList, DefaultRolesList,
@ -263,6 +263,14 @@ export class UserDbService {
} }
} }
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> { public async exists(username: string): Promise<boolean> {
return HasSuccess(await this.findByUsername(username)); return HasSuccess(await this.findByUsername(username));
} }

View file

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

View file

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

View file

@ -16,8 +16,15 @@ export class HostConfigService {
this.logger.log('Verbose: ' + this.isVerbose()); this.logger.log('Verbose: ' + this.isVerbose());
this.logger.log('Host: ' + this.getHost()); this.logger.log('Host: ' + this.getHost());
this.logger.log('Port: ' + this.getPort()); 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 { public getHost(): string {
@ -47,6 +54,10 @@ export class HostConfigService {
return ParseBool(this.configService.get(`${EnvPrefix}VERBOSE`), false); return ParseBool(this.configService.get(`${EnvPrefix}VERBOSE`), false);
} }
public isTelemetry() {
return ParseBool(this.configService.get(`${EnvPrefix}TELEMETRY`), true);
}
public getVersion() { public getVersion() {
return ParseString(this.configService.get(`npm_package_version`), '0.0.0'); return ParseString(this.configService.get(`npm_package_version`), '0.0.0');
} }

View file

@ -18,12 +18,12 @@ export class MultipartConfigService {
); );
} }
public getLimits() { public getLimits(fileLimit?: number) {
return { return {
fieldNameSize: 128, fieldNameSize: 128,
fieldSize: 1024, fieldSize: 1024,
fields: 16, fields: 20,
files: 16, files: fileLimit ?? 20,
fileSize: this.getMaxFileSize(), 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

@ -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';
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

@ -32,8 +32,8 @@ export class JwtConfigService implements JwtOptionsFactory {
await this.prefService.getStringPreference('jwt_expires_in'), await this.prefService.getStringPreference('jwt_expires_in'),
); );
let milliseconds = ms(expiresIn); let milliseconds = ms(expiresIn as any);
if (milliseconds === undefined) { if (isNaN(milliseconds)) {
milliseconds = 1000 * 60 * 60 * 24; // 1 day milliseconds = 1000 * 60 * 60 * 24; // 1 day
} }

View file

@ -3,7 +3,9 @@ import { PreferenceDbModule } from '../../collections/preference-db/preference-d
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { EarlyConfigModule } from '../early/early-config.module'; import { EarlyConfigModule } from '../early/early-config.module';
import { EarlyJwtConfigService } from '../early/early-jwt.config.service'; import { EarlyJwtConfigService } from '../early/early-jwt.config.service';
import { InfoConfigService } from './info.config.service';
import { JwtConfigService } from './jwt.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 // This module contains all configservices that depend on the syspref module
// The syspref module can only be used when connected to the database // The syspref module can only be used when connected to the database
@ -11,9 +13,14 @@ import { JwtConfigService } from './jwt.config.service';
// Otherwise we will create a circular depedency // Otherwise we will create a circular depedency
@Module({ @Module({
imports: [PreferenceDbModule, EarlyConfigModule], imports: [EarlyConfigModule, PreferenceDbModule],
providers: [JwtConfigService], providers: [JwtConfigService, InfoConfigService, UsageConfigService],
exports: [JwtConfigService, EarlyConfigModule], exports: [
EarlyConfigModule,
JwtConfigService,
InfoConfigService,
UsageConfigService,
],
}) })
export class LateConfigModule implements OnModuleInit { export class LateConfigModule implements OnModuleInit {
private readonly logger = new Logger(LateConfigModule.name); private readonly logger = new Logger(LateConfigModule.name);

View file

@ -0,0 +1,53 @@
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';
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

@ -7,7 +7,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
import { EUserBackend } from './user.entity'; import { EUserBackend } from './users/user.entity';
const OverriddenEApiKeySchema = EApiKeySchema.omit({ user: true }).merge( const OverriddenEApiKeySchema = EApiKeySchema.omit({ user: true }).merge(
z.object({ z.object({

View file

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

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

@ -9,7 +9,7 @@ import {
Unique, Unique,
} from 'typeorm'; } from 'typeorm';
import z from 'zod'; import z from 'zod';
import { EUserBackend } from './user.entity'; import { EUserBackend } from '../users/user.entity';
export const EUsrPreferenceSchema = z.object({ export const EUsrPreferenceSchema = z.object({
id: IsEntityID().optional(), id: IsEntityID().optional(),

View file

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

View file

@ -7,8 +7,8 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
import { EApiKeyBackend } from './apikey.entity'; import { EApiKeyBackend } from '../apikey.entity';
import { EUsrPreferenceBackend } from './usr-preference.entity'; import { EUsrPreferenceBackend } from '../system/usr-preference.entity';
// Different data for public and private // Different data for public and private
const OverriddenEUserSchema = EUserSchema.omit({ hashedPassword: true }).merge( const OverriddenEUserSchema = EUserSchema.omit({ hashedPassword: true }).merge(

View file

@ -0,0 +1,16 @@
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

@ -4,6 +4,7 @@ import { V040A1662314197741 } from './1662314197741-V_0_4_0_a';
import { V040B1662485374471 } from './1662485374471-V_0_4_0_b'; import { V040B1662485374471 } from './1662485374471-V_0_4_0_b';
import { V040C1662535484200 } from './1662535484200-V_0_4_0_c'; import { V040C1662535484200 } from './1662535484200-V_0_4_0_c';
import { V040D1662728275448 } from './1662728275448-V_0_4_0_d'; import { V040D1662728275448 } from './1662728275448-V_0_4_0_d';
import { V050A1672154027079 } from './1672154027079-V_0_5_0_a';
export const MigrationList: Function[] = [ export const MigrationList: Function[] = [
V030A1661692206479, V030A1661692206479,
@ -12,4 +13,5 @@ export const MigrationList: Function[] = [
V040B1662485374471, V040B1662485374471,
V040C1662535484200, V040C1662535484200,
V040D1662728275448, V040D1662728275448,
V050A1672154027079,
]; ];

View file

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

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 // Since pipes dont have direct access to the request object, we need this decorator to inject it
export const InjectRequest = createParamDecorator( export const InjectRequest = createParamDecorator(
async (data: any, ctx: ExecutionContext) => { 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 { InjectRequest } from './inject-request.decorator';
import { MultiPartPipe } from './multipart.pipe';
import { PostFilePipe } from './postfile.pipe'; import { PostFilePipe } from './postfile.pipe';
import { MultiPartPipe } from './postfiles.pipe';
export const PostFile = () => InjectRequest(PostFilePipe); export const PostFile = () => InjectRequest(PostFilePipe);
export const MultiPart = () => InjectRequest(MultiPartPipe); export const PostFiles = (maxFiles?: number) =>
InjectRequest(maxFiles, MultiPartPipe);

View file

@ -1,82 +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.name);
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 ?? null;
} 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,4 +1,4 @@
import { Multipart, MultipartFields, MultipartFile } from '@fastify/multipart'; import { Multipart, MultipartFile } from '@fastify/multipart';
import { Injectable, Logger, PipeTransform, Scope } from '@nestjs/common'; import { Injectable, Logger, PipeTransform, Scope } from '@nestjs/common';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { Fail, FT } from 'picsur-shared/dist/types'; import { Fail, FT } from 'picsur-shared/dist/types';
@ -12,11 +12,11 @@ export class PostFilePipe implements PipeTransform {
private readonly multipartConfigService: MultipartConfigService, private readonly multipartConfigService: MultipartConfigService,
) {} ) {}
async transform({ req }: { req: FastifyRequest }) { async transform({ request, data }: { data: any; request: FastifyRequest }) {
if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file'); if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
// Only one file is allowed // Only one file is allowed
const file = await req.file({ const file = await request.file({
limits: { limits: {
...this.multipartConfigService.getLimits(), ...this.multipartConfigService.getLimits(),
files: 1, files: 1,
@ -30,13 +30,15 @@ export class PostFilePipe implements PipeTransform {
) as any; ) as any;
// Remove non-file fields // Remove non-file fields
const files: MultipartFile[] = allFields.filter((entry) => (entry as any).file !== undefined) as MultipartFile[]; const files: MultipartFile[] = allFields.filter(
(entry) => (entry as any).file !== undefined,
) as MultipartFile[];
if (files.length !== 1) throw Fail(FT.UsrValidation, 'Invalid file'); if (files.length !== 1) throw Fail(FT.UsrValidation, 'Invalid file');
// Return a buffer of the file // Return a buffer of the file
try { try {
return await files[0]?.toBuffer(); return await files[0].toBuffer();
} catch (e) { } catch (e) {
this.logger.warn(e); this.logger.warn(e);
throw Fail(FT.Internal, 'Invalid file'); throw Fail(FT.Internal, 'Invalid file');

View file

@ -0,0 +1,37 @@
import { MultipartFile } from '@fastify/multipart';
import {
ArgumentMetadata,
Injectable,
Logger,
PipeTransform,
Scope,
} from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { Fail, FT } from 'picsur-shared/dist/types';
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<T extends Object>(
{ request, data }: { data: any; request: FastifyRequest },
metadata: ArgumentMetadata,
) {
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

@ -16,3 +16,7 @@ export function Returns<N extends Object>(
): ReturnsMethodDecorator<N> { ): ReturnsMethodDecorator<N> {
return SetMetadata('returns', newable); 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

@ -39,23 +39,7 @@ export class MainExceptionFilter implements ExceptionFilter {
const status = exception.getCode(); const status = exception.getCode();
const type = exception.getType(); const type = exception.getType();
const message = exception.getReason(); exception.print(MainExceptionFilter.logger, { prefix: traceString });
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}`,
);
}
const toSend: ApiErrorResponse = { const toSend: ApiErrorResponse = {
success: false, success: false,
@ -65,7 +49,7 @@ export class MainExceptionFilter implements ExceptionFilter {
data: { data: {
type, type,
message, message: exception.getReason(),
}, },
}; };

View file

@ -46,10 +46,29 @@ export class SuccessInterceptor<T> implements NestInterceptor {
return data; 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 { 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>( const schemaStatic = this.reflector.get<ZodDtoStatic>(
'returns', 'returns',
context.getHandler(), context.getHandler(),

View file

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

View file

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

View file

@ -13,7 +13,9 @@ import { AuthManagerService } from './auth.service';
import { ApiKeyStrategy } from './guards/apikey.strategy'; import { ApiKeyStrategy } from './guards/apikey.strategy';
import { GuestStrategy } from './guards/guest.strategy'; import { GuestStrategy } from './guards/guest.strategy';
import { JwtStrategy } from './guards/jwt.strategy'; import { JwtStrategy } from './guards/jwt.strategy';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { LocalAuthStrategy } from './guards/local-auth.strategy'; import { LocalAuthStrategy } from './guards/local-auth.strategy';
import { MainAuthGuard } from './guards/main.guard';
import { GuestService } from './guest.service'; import { GuestService } from './guest.service';
@Module({ @Module({
@ -30,13 +32,15 @@ import { GuestService } from './guest.service';
], ],
providers: [ providers: [
AuthManagerService, AuthManagerService,
GuestService,
JwtSecretProvider,
LocalAuthStrategy, LocalAuthStrategy,
JwtStrategy, JwtStrategy,
GuestStrategy, GuestStrategy,
JwtSecretProvider,
ApiKeyStrategy, ApiKeyStrategy,
GuestService, LocalAuthGuard,
MainAuthGuard,
], ],
exports: [UserDbModule, AuthManagerService], exports: [UserDbModule, AuthManagerService, LocalAuthGuard, MainAuthGuard],
}) })
export class AuthManagerModule {} export class AuthManagerModule {}

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { UserDbService } from '../../collections/user-db/user-db.service'; import { UserDbService } from '../../collections/user-db/user-db.service';
import { EUserBackend } from '../../database/entities/user.entity'; import { EUserBackend } from '../../database/entities/users/user.entity';
@Injectable() @Injectable()
export class GuestService { export class GuestService {

View file

@ -1,4 +1,5 @@
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 { ImageDBModule } from '../../collections/image-db/image-db.module';
import { RoleDbModule } 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 { EarlyConfigModule } from '../../config/early/early-config.module';
@ -9,13 +10,13 @@ import { DemoManagerService } from './demo.service';
imports: [ImageDBModule, EarlyConfigModule, RoleDbModule], imports: [ImageDBModule, EarlyConfigModule, RoleDbModule],
providers: [DemoManagerService], providers: [DemoManagerService],
}) })
export class DemoManagerModule implements OnModuleInit, OnModuleDestroy { export class DemoManagerModule implements OnModuleInit {
private readonly logger = new Logger(DemoManagerModule.name); private readonly logger = new Logger(DemoManagerModule.name);
private interval: NodeJS.Timeout;
constructor( constructor(
private readonly demoManagerService: DemoManagerService, private readonly demoManagerService: DemoManagerService,
private readonly hostConfigService: HostConfigService, private readonly hostConfigService: HostConfigService,
private readonly schedulerRegistry: SchedulerRegistry,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@ -27,14 +28,12 @@ export class DemoManagerModule implements OnModuleInit, OnModuleDestroy {
private async setupDemoMode() { private async setupDemoMode() {
this.demoManagerService.setupRoles(); this.demoManagerService.setupRoles();
this.interval = setInterval(
const interval = setInterval(
// Run demoManagerService.execute() every interval // Run demoManagerService.execute() every interval
this.demoManagerService.execute.bind(this.demoManagerService), this.demoManagerService.execute.bind(this.demoManagerService),
this.hostConfigService.getDemoInterval(), this.hostConfigService.getDemoInterval(),
); );
} this.schedulerRegistry.addInterval('demo', interval);
onModuleDestroy() {
if (this.interval) clearInterval(this.interval);
} }
} }

View file

@ -57,7 +57,8 @@ export class ImageConverterService {
if (HasFailed(memLimit) || HasFailed(timeLimit)) { if (HasFailed(memLimit) || HasFailed(timeLimit)) {
return Fail(FT.Internal, 'Failed to get conversion limits'); 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 sharpWrapper = new SharpWrapper(timeLimitMS, memLimit);
const sharpOptions: SharpOptions = { const sharpOptions: SharpOptions = {
@ -121,16 +122,4 @@ export class ImageConverterService {
filetype: targetFiletype.identifier, 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

@ -1,4 +1,5 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import ms from 'ms'; import ms from 'ms';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
@ -18,11 +19,10 @@ import { ImageManagerService } from './image.service';
ImageProcessorService, ImageProcessorService,
ImageConverterService, ImageConverterService,
], ],
exports: [ImageManagerService], exports: [ImageManagerService, ImageConverterService],
}) })
export class ImageManagerModule implements OnModuleInit, OnModuleDestroy { export class ImageManagerModule implements OnModuleInit {
private readonly logger = new Logger(ImageManagerModule.name); private readonly logger = new Logger(ImageManagerModule.name);
private interval: NodeJS.Timeout;
constructor( constructor(
private readonly prefManager: SysPreferenceDbService, private readonly prefManager: SysPreferenceDbService,
@ -31,14 +31,10 @@ export class ImageManagerModule implements OnModuleInit, OnModuleDestroy {
) {} ) {}
async onModuleInit() { async onModuleInit() {
this.interval = setInterval(
// Run demoManagerService.execute() every interval
this.imageManagerCron.bind(this),
1000 * 60,
);
await this.imageManagerCron(); await this.imageManagerCron();
} }
@Interval(1000 * 60)
private async imageManagerCron() { private async imageManagerCron() {
await this.cleanupDerivatives(); await this.cleanupDerivatives();
await this.cleanupExpired(); await this.cleanupExpired();
@ -53,31 +49,30 @@ export class ImageManagerModule implements OnModuleInit, OnModuleDestroy {
return; return;
} }
const after_ms = ms(remove_derivatives_after); let after_ms = ms(remove_derivatives_after as any);
if (after_ms === 0) { if (isNaN(after_ms) || after_ms === 0) {
this.logger.log('remove_derivatives_after is 0, skipping cron'); this.logger.log('remove_derivatives_after is 0, skipping cron');
return; return;
} }
if (after_ms < 60000) after_ms = 60000;
const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000); const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000);
if (HasFailed(result)) { if (HasFailed(result)) {
this.logger.warn(result.print()); result.print(this.logger);
} }
this.logger.log(`Cleaned up ${result} derivatives`); if (result > 0) this.logger.log(`Cleaned up ${result} derivatives`);
} }
private async cleanupExpired() { private async cleanupExpired() {
const cleanedUp = await this.imageDB.cleanupExpired(); const cleanedUp = await this.imageDB.cleanupExpired();
if (HasFailed(cleanedUp)) { if (HasFailed(cleanedUp)) {
this.logger.warn(cleanedUp.print()); cleanedUp.print(this.logger);
return;
} }
this.logger.log(`Cleaned up ${cleanedUp} expired images`); if (cleanedUp > 0)
} this.logger.log(`Cleaned up ${cleanedUp} expired images`);
onModuleDestroy() {
if (this.interval) clearInterval(this.interval);
} }
} }

View file

@ -19,9 +19,9 @@ import { ImageDBService } from '../../collections/image-db/image-db.service';
import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; import { ImageFileDBService } from '../../collections/image-db/image-file-db.service';
import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service'; import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity'; import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity'; import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
import { EImageBackend } from '../../database/entities/image.entity'; import { EImageBackend } from '../../database/entities/images/image.entity';
import { MutexFallBack } from '../../util/mutex-fallback'; import { MutexFallBack } from '../../util/mutex-fallback';
import { ImageConverterService } from './image-converter.service'; import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service'; import { ImageProcessorService } from './image-processor.service';
@ -145,19 +145,15 @@ export class ImageManagerService {
const converted_key = this.getConvertHash({ mime: fileType, ...options }); const converted_key = this.getConvertHash({ mime: fileType, ...options });
const [save_derivatives, allow_editing] = await Promise.all([ const allow_editing = await this.sysPref.getBooleanPreference(
this.sysPref.getBooleanPreference(SysPreference.SaveDerivatives), SysPreference.AllowEditing,
this.sysPref.getBooleanPreference(SysPreference.AllowEditing), );
]);
if (HasFailed(save_derivatives)) return save_derivatives;
if (HasFailed(allow_editing)) return allow_editing; if (HasFailed(allow_editing)) return allow_editing;
return MutexFallBack( return MutexFallBack(
converted_key, converted_key,
() => { () => {
if (save_derivatives) return this.imageFilesService.getDerivative(imageId, converted_key);
return this.imageFilesService.getDerivative(imageId, converted_key);
else return Promise.resolve(null);
}, },
async () => { async () => {
const masterImage = await this.getMaster(imageId); const masterImage = await this.getMaster(imageId);
@ -181,21 +177,12 @@ export class ImageManagerService {
} in ${Date.now() - startTime}ms`, } in ${Date.now() - startTime}ms`,
); );
if (save_derivatives) { return await this.imageFilesService.addDerivative(
return await this.imageFilesService.addDerivative( imageId,
imageId, converted_key,
converted_key, convertResult.filetype,
convertResult.filetype, convertResult.image,
convertResult.image, );
);
} else {
const derivative = new EImageDerivativeBackend();
derivative.filetype = convertResult.filetype;
derivative.data = convertResult.image;
derivative.image_id = imageId;
derivative.key = converted_key;
return derivative;
}
}, },
); );
} }
@ -256,7 +243,7 @@ export class ImageManagerService {
let mime: string | undefined; let mime: string | undefined;
if (filetypeResult === undefined) { if (filetypeResult === undefined) {
if (IsQOI(image)) mime = 'image/qoi'; if (IsQOI(image)) mime = 'image/x-qoi';
} else { } else {
mime = filetypeResult.mime; mime = filetypeResult.mime;
} }

View file

@ -0,0 +1,43 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { ImageDBModule } from '../../collections/image-db/image-db.module';
import { SystemStateDbModule } from '../../collections/system-state-db/system-state-db.module';
import { UserDbModule } from '../../collections/user-db/user-db.module';
import { LateConfigModule } from '../../config/late/late-config.module';
import { UsageConfigService } from '../../config/late/usage.config.service';
import { UsageService } from './usage.service';
@Module({
imports: [LateConfigModule, SystemStateDbModule, ImageDBModule, UserDbModule],
providers: [UsageService],
exports: [UsageService],
})
export class UsageManagerModule implements OnModuleInit {
private readonly logger = new Logger(UsageManagerModule.name);
constructor(
private readonly usageService: UsageService,
private readonly usageConfigService: UsageConfigService,
private readonly schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
if (!(await this.usageConfigService.getMetricsEnabled())) {
this.logger.log('Telemetry is disabled');
}
const interval = setInterval(
this.cronJob.bind(this),
await this.usageConfigService.getMetricsInterval(),
);
this.schedulerRegistry.addInterval('usage', interval);
this.cronJob();
}
private cronJob() {
this.usageService.execute().catch((err) => {
this.logger.warn(err);
});
}
}

View file

@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import isDocker from 'is-docker';
import fetch from 'node-fetch';
import os from 'os';
import { FallbackIfFailed, HasFailed } from 'picsur-shared/dist/types';
import { UUIDRegex } from 'picsur-shared/dist/util/common-regex';
import { ImageDBService } from '../../collections/image-db/image-db.service';
import { SystemStateDbService } from '../../collections/system-state-db/system-state-db.service';
import { UserDbService } from '../../collections/user-db/user-db.service';
import { HostConfigService } from '../../config/early/host.config.service';
import { UsageConfigService } from '../../config/late/usage.config.service';
interface UsageData {
id?: string;
uptime: number;
version: string;
demo_active: boolean;
users: number;
images: number;
architecture: string;
cpu_count: number;
ram_total: number;
hostname: string;
is_docker: boolean;
is_production: boolean;
}
@Injectable()
export class UsageService {
private readonly logger = new Logger(UsageService.name);
constructor(
private readonly systemState: SystemStateDbService,
private readonly hostConfig: HostConfigService,
private readonly usageConfig: UsageConfigService,
private readonly userRepo: UserDbService,
private readonly imageRepo: ImageDBService,
) {}
public async execute() {
if (!(await this.usageConfig.getMetricsEnabled())) return;
const id = await this.getSystemID();
if (id === null) {
await this.sendInitialData();
} else {
await this.sendUpdateData(id);
}
}
private async sendInitialData() {
const url =
(await this.usageConfig.getMetricsUrl()) + '/api/install/create';
const result: any = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(await this.collectData()),
}).then((res) => res.json());
const id = result?.data?.id;
if (typeof id !== 'string')
return this.logger.warn(
'Invalid response when sending initial data: ' + JSON.stringify(result),
);
if (!UUIDRegex.test(id))
return this.logger.warn('Invalid system ID: ' + id);
await this.setSystemID(id);
}
private async sendUpdateData(id: string) {
const url =
(await this.usageConfig.getMetricsUrl()) + '/api/install/update';
const body = await this.collectData();
body.id = id;
const result = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (result.status < 200 || result.status >= 300) {
const data: any = await result.json();
if (data?.type === 'notfound') {
this.logger.warn('System ID not found, clearing');
await this.clearSystemID();
} else {
this.logger.warn(
'Failed to send update data: ' + JSON.stringify(await result.json()),
);
}
}
}
private async getSystemID(): Promise<string | null> {
const result = await this.systemState.get('systemID');
if (HasFailed(result)) {
this.logger.warn(result);
return null;
}
if (result === null) return null;
if (UUIDRegex.test(result)) return result;
this.logger.warn('Invalid system ID');
return null;
}
private async setSystemID(id: string) {
if (!UUIDRegex.test(id)) {
return this.logger.warn('Invalid system ID');
}
const result = await this.systemState.set('systemID', id);
if (HasFailed(result)) {
this.logger.warn(result);
}
}
private async clearSystemID() {
const result = await this.systemState.clear('systemID');
if (HasFailed(result)) {
this.logger.warn(result);
}
}
private async collectData(): Promise<UsageData> {
const users = FallbackIfFailed(await this.userRepo.count(), 0, this.logger);
const images = FallbackIfFailed(
await this.imageRepo.count(),
0,
this.logger,
);
const data: UsageData = {
uptime: Math.floor(process.uptime()),
version: this.hostConfig.getVersion(),
demo_active: this.hostConfig.isDemo(),
users,
images,
architecture: process.arch,
cpu_count: os.cpus().length,
ram_total: Math.floor(os.totalmem() / 1024 / 1024),
hostname: os.hostname(),
is_docker: isDocker(),
is_production: this.hostConfig.isProduction(),
};
return data;
}
}

View file

@ -1,21 +0,0 @@
import { PrefValueTypeStrings } from 'picsur-shared/dist/dto/preferences.dto';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
export type SysPreferences = SysPreference[];
export const SysPreferenceList: string[] = Object.values(SysPreference);
// Syspref Value types
export const SysPreferenceValueTypes: {
[key in SysPreference]: PrefValueTypeStrings;
} = {
[SysPreference.JwtSecret]: 'string',
[SysPreference.JwtExpiresIn]: 'string',
[SysPreference.BCryptStrength]: 'number',
[SysPreference.RemoveDerivativesAfter]: 'string',
[SysPreference.SaveDerivatives]: 'boolean',
[SysPreference.AllowEditing]: 'boolean',
[SysPreference.ConversionTimeLimit]: 'string',
[SysPreference.ConversionMemoryLimit]: 'number',
};

View file

@ -1,12 +0,0 @@
import { PrefValueTypeStrings } from 'picsur-shared/dist/dto/preferences.dto';
import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum';
export type UsrPreferences = UsrPreference[];
export const UsrPreferenceList: string[] = Object.values(UsrPreference);
// Syspref Value types
export const UsrPreferenceValueTypes: {
[key in UsrPreference]: PrefValueTypeStrings;
} = {
[UsrPreference.KeepOriginal]: 'boolean',
};

View file

@ -1,9 +0,0 @@
import { createZodDto } from 'picsur-shared/dist/util/create-zod-dto';
import { z } from 'zod';
import { MultiPartFileDtoSchema } from './multipart.dto';
// A validation class for form based file upload of an image
export const ImageUploadDtoSchema = z.object({
image: MultiPartFileDtoSchema,
});
export class ImageUploadDto extends createZodDto(ImageUploadDtoSchema) {}

View file

@ -1,48 +0,0 @@
import { MultipartFile } from '@fastify/multipart';
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { z } from 'zod';
export const MultiPartFileDtoSchema = z.object({
fieldname: z.string(),
encoding: z.string(),
filename: z.string(),
mimetype: z.string(),
buffer: z.any(),
file: z.any(),
});
export type MultiPartFileDto = z.infer<typeof MultiPartFileDtoSchema>;
export async function CreateMultiPartFileDto(
file: MultipartFile,
): AsyncFailable<MultiPartFileDto> {
try {
const buffer = await file.toBuffer();
return {
fieldname: file.fieldname,
encoding: file.encoding,
filename: file.filename,
mimetype: file.mimetype,
buffer,
file: file.file,
};
} catch (e) {
return Fail(FT.Internal, e);
}
}
export const MultiPartFieldDtoSchema = z.object({
fieldname: z.string(),
encoding: z.string(),
value: z.string(),
});
export type MultiPartFieldDto = z.infer<typeof MultiPartFieldDtoSchema>;
export function CreateMultiPartFieldDto(
file: MultipartFile,
): MultiPartFieldDto {
return {
fieldname: file.fieldname,
encoding: file.encoding,
value: (file as any).value,
};
}

View file

@ -1,5 +1,5 @@
import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { EUserBackend } from '../../database/entities/user.entity'; import { EUserBackend } from '../../database/entities/users/user.entity';
export function EUserBackend2EUser(eUser: EUserBackend): EUser { export function EUserBackend2EUser(eUser: EUserBackend): EUser {
if (eUser.hashed_password === undefined) return eUser as EUser; if (eUser.hashed_password === undefined) return eUser as EUser;

View file

@ -4,6 +4,7 @@ import { ExperimentModule } from './experiment/experiment.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { PrefModule } from './pref/pref.module'; import { PrefModule } from './pref/pref.module';
import { RolesApiModule } from './roles/roles.module'; import { RolesApiModule } from './roles/roles.module';
import { UsageApiModule } from './usage/usage.module';
import { UserApiModule } from './user/user.module'; import { UserApiModule } from './user/user.module';
@Module({ @Module({
@ -14,6 +15,7 @@ import { UserApiModule } from './user/user.module';
InfoModule, InfoModule,
RolesApiModule, RolesApiModule,
ApiKeysModule, ApiKeysModule,
UsageApiModule,
], ],
}) })
export class PicsurApiModule {} export class PicsurApiModule {}

View file

@ -1,4 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
ApiKeyCreateResponse, ApiKeyCreateResponse,
ApiKeyDeleteRequest, ApiKeyDeleteRequest,
@ -53,6 +54,7 @@ export class ApiKeysController {
@Post('create') @Post('create')
@Returns(ApiKeyCreateResponse) @Returns(ApiKeyCreateResponse)
@Throttle(10)
async createApiKey( async createApiKey(
@ReqUserID() userID: string, @ReqUserID() userID: string,
): Promise<ApiKeyCreateResponse> { ): Promise<ApiKeyCreateResponse> {

View file

@ -1,22 +1,18 @@
import { Controller, Get, Request, Response } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { UserInfoResponse } from 'picsur-shared/dist/dto/api/user-manage.dto';
import { NoPermissions } from '../../../decorators/permissions.decorator'; import { NoPermissions } from '../../../decorators/permissions.decorator';
import { Returns } from '../../../decorators/returns.decorator';
import type AuthFastifyRequest from '../../../models/interfaces/authrequest.dto';
@Controller('api/experiment') @Controller('api/experiment')
@NoPermissions() @NoPermissions()
export class ExperimentController { export class ExperimentController {
constructor() {} constructor() {}
@Get() // @Get()
@Returns(UserInfoResponse) // @Returns(UserInfoResponse)
async testRoute( // async testRoute(
@Request() req: AuthFastifyRequest, // @Request() req: AuthFastifyRequest,
@Response({ passthrough: true }) res: FastifyReply, // @Response({ passthrough: true }) res: FastifyReply,
): Promise<UserInfoResponse> { // ): Promise<UserInfoResponse> {
res.header('Location', '/error/delete-success'); // res.header('Location', '/error/delete-success');
res.code(302); // res.code(302);
return req.user; // return req.user;
} // }
} }

View file

@ -10,7 +10,11 @@ import {
SupportedAnimFileTypes, SupportedAnimFileTypes,
SupportedImageFileTypes, SupportedImageFileTypes,
} from 'picsur-shared/dist/dto/mimes.dto'; } from 'picsur-shared/dist/dto/mimes.dto';
import { TrackingState } from 'picsur-shared/dist/dto/tracking-state.enum';
import { FallbackIfFailed } from 'picsur-shared/dist/types';
import { HostConfigService } from '../../../config/early/host.config.service'; import { HostConfigService } from '../../../config/early/host.config.service';
import { InfoConfigService } from '../../../config/late/info.config.service';
import { UsageConfigService } from '../../../config/late/usage.config.service';
import { NoPermissions } from '../../../decorators/permissions.decorator'; import { NoPermissions } from '../../../decorators/permissions.decorator';
import { Returns } from '../../../decorators/returns.decorator'; import { Returns } from '../../../decorators/returns.decorator';
import { PermissionsList } from '../../../models/constants/permissions.const'; import { PermissionsList } from '../../../models/constants/permissions.const';
@ -18,15 +22,29 @@ import { PermissionsList } from '../../../models/constants/permissions.const';
@Controller('api/info') @Controller('api/info')
@NoPermissions() @NoPermissions()
export class InfoController { export class InfoController {
constructor(private readonly hostConfig: HostConfigService) {} constructor(
private readonly hostConfig: HostConfigService,
private readonly infoConfig: InfoConfigService,
private readonly usageService: UsageConfigService,
) {}
@Get() @Get()
@Returns(InfoResponse) @Returns(InfoResponse)
async getInfo(): Promise<InfoResponse> { async getInfo(): Promise<InfoResponse> {
const trackingID =
FallbackIfFailed(await this.usageService.getTrackingID(), null) ??
undefined;
const hostOverride = await this.infoConfig.getHostnameOverride();
return { return {
demo: this.hostConfig.isDemo(), demo: this.hostConfig.isDemo(),
production: this.hostConfig.isProduction(), production: this.hostConfig.isProduction(),
version: this.hostConfig.getVersion(), version: this.hostConfig.getVersion(),
host_override: hostOverride,
tracking: {
id: trackingID,
state: TrackingState.Detailed,
},
}; };
} }

View file

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EarlyConfigModule } from '../../../config/early/early-config.module'; import { LateConfigModule } from '../../../config/late/late-config.module';
import { InfoController } from './info.controller'; import { InfoController } from './info.controller';
@Module({ @Module({
imports: [EarlyConfigModule], imports: [LateConfigModule],
controllers: [InfoController], controllers: [InfoController],
}) })
export class InfoModule {} export class InfoModule {}

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
GetPreferenceResponse, GetPreferenceResponse,
MultiplePreferencesResponse, MultiplePreferencesResponse,
@ -20,6 +21,7 @@ export class SysPrefController {
@Get() @Get()
@Returns(MultiplePreferencesResponse) @Returns(MultiplePreferencesResponse)
@Throttle(20)
async getAllSysPrefs(): Promise<MultiplePreferencesResponse> { async getAllSysPrefs(): Promise<MultiplePreferencesResponse> {
const prefs = ThrowIfFailed(await this.prefService.getAllPreferences()); const prefs = ThrowIfFailed(await this.prefService.getAllPreferences());
@ -39,6 +41,7 @@ export class SysPrefController {
@Post(':key') @Post(':key')
@Returns(UpdatePreferenceResponse) @Returns(UpdatePreferenceResponse)
@Throttle(30)
async setSysPref( async setSysPref(
@Param('key') key: string, @Param('key') key: string,
@Body() body: UpdatePreferenceRequest, @Body() body: UpdatePreferenceRequest,

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
GetPreferenceResponse, GetPreferenceResponse,
MultiplePreferencesResponse, MultiplePreferencesResponse,
@ -21,7 +22,8 @@ export class UsrPrefController {
@Get() @Get()
@Returns(MultiplePreferencesResponse) @Returns(MultiplePreferencesResponse)
async getAllSysPrefs( @Throttle(20)
async getAllUsrPrefs(
@ReqUserID() userid: string, @ReqUserID() userid: string,
): Promise<MultiplePreferencesResponse> { ): Promise<MultiplePreferencesResponse> {
const prefs = ThrowIfFailed( const prefs = ThrowIfFailed(
@ -36,7 +38,7 @@ export class UsrPrefController {
@Get(':key') @Get(':key')
@Returns(GetPreferenceResponse) @Returns(GetPreferenceResponse)
async getSysPref( async getUsrPref(
@Param('key') key: string, @Param('key') key: string,
@ReqUserID() userid: string, @ReqUserID() userid: string,
): Promise<GetPreferenceResponse> { ): Promise<GetPreferenceResponse> {
@ -49,7 +51,8 @@ export class UsrPrefController {
@Post(':key') @Post(':key')
@Returns(UpdatePreferenceResponse) @Returns(UpdatePreferenceResponse)
async setSysPref( @Throttle(30)
async setUsrPref(
@Param('key') key: string, @Param('key') key: string,
@ReqUserID() userid: string, @ReqUserID() userid: string,
@Body() body: UpdatePreferenceRequest, @Body() body: UpdatePreferenceRequest,

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Post } from '@nestjs/common'; import { Body, Controller, Get, Logger, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
RoleCreateRequest, RoleCreateRequest,
RoleCreateResponse, RoleCreateResponse,
@ -56,6 +57,7 @@ export class RolesController {
@Post('update') @Post('update')
@Returns(RoleUpdateResponse) @Returns(RoleUpdateResponse)
@Throttle(20)
async updateRole( async updateRole(
@Body() body: RoleUpdateRequest, @Body() body: RoleUpdateRequest,
): Promise<RoleUpdateResponse> { ): Promise<RoleUpdateResponse> {
@ -73,6 +75,7 @@ export class RolesController {
@Post('create') @Post('create')
@Returns(RoleCreateResponse) @Returns(RoleCreateResponse)
@Throttle(10)
async createRole( async createRole(
@Body() role: RoleCreateRequest, @Body() role: RoleCreateRequest,
): Promise<RoleCreateResponse> { ): Promise<RoleCreateResponse> {

View file

@ -0,0 +1,47 @@
import { Controller, Logger, Post, Req, Res } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Fail, FT, ThrowIfFailed } from 'picsur-shared/dist/types';
import { UsageConfigService } from '../../../config/late/usage.config.service';
import { NoPermissions } from '../../../decorators/permissions.decorator';
import { ReturnsAnything } from '../../../decorators/returns.decorator';
@Controller('api/usage')
@NoPermissions()
export class UsageController {
private readonly logger = new Logger(UsageController.name);
constructor(private readonly usageService: UsageConfigService) {}
@Post(['report', 'report/*'])
@ReturnsAnything()
@Throttle(120)
async deleteRole(
@Req() req: FastifyRequest,
@Res({
passthrough: true,
})
res: FastifyReply,
) {
const trackingUrl = ThrowIfFailed(await this.usageService.getTrackingUrl());
if (trackingUrl === null) {
throw Fail(FT.NotFound, undefined, 'Tracking URL not set');
}
await res.from(`${trackingUrl}/api`, {
rewriteRequestHeaders(request, headers) {
const req = request as any as FastifyRequest;
// remove cookies
delete headers.cookie;
// Add real ip, this should not work, but ackee uses a bad ip resolver
// So we might aswell use it
headers['X-Forwarded-For'] = req.ip;
return headers;
},
});
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LateConfigModule } from '../../../config/late/late-config.module';
import { UsageController } from './usage.controller';
@Module({
imports: [LateConfigModule],
controllers: [UsageController],
})
export class UsageApiModule {}

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Post } from '@nestjs/common'; import { Body, Controller, Get, Logger, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
GetSpecialUsersResponse, GetSpecialUsersResponse,
UserCreateRequest, UserCreateRequest,
@ -46,6 +47,7 @@ export class UserAdminController {
@Post('create') @Post('create')
@Returns(UserCreateResponse) @Returns(UserCreateResponse)
@Throttle(10)
async register( async register(
@Body() create: UserCreateRequest, @Body() create: UserCreateRequest,
): Promise<UserCreateResponse> { ): Promise<UserCreateResponse> {
@ -78,6 +80,7 @@ export class UserAdminController {
@Post('update') @Post('update')
@Returns(UserUpdateResponse) @Returns(UserUpdateResponse)
@Throttle(20)
async setPermissions( async setPermissions(
@Body() body: UserUpdateRequest, @Body() body: UserUpdateRequest,
): Promise<UserUpdateResponse> { ): Promise<UserUpdateResponse> {

View file

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Post } from '@nestjs/common'; import { Body, Controller, Get, Logger, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { import {
UserCheckNameRequest, UserCheckNameRequest,
UserCheckNameResponse, UserCheckNameResponse,
@ -34,6 +35,7 @@ export class UserController {
@Post('login') @Post('login')
@Returns(UserLoginResponse) @Returns(UserLoginResponse)
@UseLocalAuth(Permission.UserLogin) @UseLocalAuth(Permission.UserLogin)
@Throttle(30, 300)
async login(@ReqUser() user: EUser): Promise<UserLoginResponse> { async login(@ReqUser() user: EUser): Promise<UserLoginResponse> {
const jwt_token = ThrowIfFailed(await this.authService.createToken(user)); const jwt_token = ThrowIfFailed(await this.authService.createToken(user));
@ -43,6 +45,7 @@ export class UserController {
@Post('register') @Post('register')
@Returns(UserRegisterResponse) @Returns(UserRegisterResponse)
@RequiredPermissions(Permission.UserRegister) @RequiredPermissions(Permission.UserRegister)
@Throttle(5, 300)
async register( async register(
@Body() register: UserRegisterRequest, @Body() register: UserRegisterRequest,
): Promise<UserRegisterResponse> { ): Promise<UserRegisterResponse> {
@ -56,6 +59,7 @@ export class UserController {
@Post('checkname') @Post('checkname')
@Returns(UserCheckNameResponse) @Returns(UserCheckNameResponse)
@RequiredPermissions(Permission.UserRegister) @RequiredPermissions(Permission.UserRegister)
@Throttle(20)
async checkName( async checkName(
@Body() checkName: UserCheckNameRequest, @Body() checkName: UserCheckNameRequest,
): Promise<UserCheckNameResponse> { ): Promise<UserCheckNameResponse> {
@ -67,6 +71,7 @@ export class UserController {
@Get('me') @Get('me')
@Returns(UserMeResponse) @Returns(UserMeResponse)
@RequiredPermissions(Permission.UserKeepLogin) @RequiredPermissions(Permission.UserKeepLogin)
@Throttle(10)
async me(@ReqUserID() userid: string): Promise<UserMeResponse> { async me(@ReqUserID() userid: string): Promise<UserMeResponse> {
const backenduser = ThrowIfFailed(await this.usersService.findOne(userid)); const backenduser = ThrowIfFailed(await this.usersService.findOne(userid));
@ -81,6 +86,7 @@ export class UserController {
@Get('me/permissions') @Get('me/permissions')
@Returns(UserMePermissionsResponse) @Returns(UserMePermissionsResponse)
@NoPermissions() @NoPermissions()
@Throttle(20)
async refresh( async refresh(
@ReqUserID() userid: string, @ReqUserID() userid: string,
): Promise<UserMePermissionsResponse> { ): Promise<UserMePermissionsResponse> {

View file

@ -7,6 +7,7 @@ import {
Post, Post,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type { FastifyReply } from 'fastify'; import type { FastifyReply } from 'fastify';
import { import {
ImageDeleteRequest, ImageDeleteRequest,
@ -20,8 +21,9 @@ import {
ImageUploadResponse, ImageUploadResponse,
} from 'picsur-shared/dist/dto/api/image-manage.dto'; } from 'picsur-shared/dist/dto/api/image-manage.dto';
import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; import { Permission } from 'picsur-shared/dist/dto/permissions.enum';
import { HasFailed, ThrowIfFailed } from 'picsur-shared/dist/types'; import { Fail, FT, HasFailed, ThrowIfFailed } from 'picsur-shared/dist/types';
import { MultiPart } from '../../decorators/multipart/multipart.decorator'; import { PostFiles } from '../../decorators/multipart/multipart.decorator';
import type { FileIterator } from '../../decorators/multipart/postfiles.pipe';
import { import {
HasPermission, HasPermission,
RequiredPermissions, RequiredPermissions,
@ -29,7 +31,7 @@ import {
import { ReqUserID } from '../../decorators/request-user.decorator'; import { ReqUserID } from '../../decorators/request-user.decorator';
import { Returns } from '../../decorators/returns.decorator'; import { Returns } from '../../decorators/returns.decorator';
import { ImageManagerService } from '../../managers/image/image.service'; import { ImageManagerService } from '../../managers/image/image.service';
import { ImageUploadDto } from '../../models/dto/image-upload.dto'; import { GetNextAsync } from '../../util/iterator';
@Controller('api/image') @Controller('api/image')
@RequiredPermissions(Permission.ImageUpload) @RequiredPermissions(Permission.ImageUpload)
export class ImageManageController { export class ImageManageController {
@ -39,16 +41,26 @@ export class ImageManageController {
@Post('upload') @Post('upload')
@Returns(ImageUploadResponse) @Returns(ImageUploadResponse)
@Throttle(20)
async uploadImage( async uploadImage(
@MultiPart() multipart: ImageUploadDto, @PostFiles(1) multipart: FileIterator,
@ReqUserID() userid: string, @ReqUserID() userid: string,
@HasPermission(Permission.ImageDeleteKey) withDeleteKey: boolean, @HasPermission(Permission.ImageDeleteKey) withDeleteKey: boolean,
): Promise<ImageUploadResponse> { ): Promise<ImageUploadResponse> {
const file = ThrowIfFailed(await GetNextAsync(multipart));
let buffer: Buffer;
try {
buffer = await file.toBuffer();
} catch (e) {
throw Fail(FT.Internal, e);
}
const image = ThrowIfFailed( const image = ThrowIfFailed(
await this.imagesService.upload( await this.imagesService.upload(
userid, userid,
multipart.image.filename, file.filename,
multipart.image.buffer, buffer,
withDeleteKey, withDeleteKey,
), ),
); );

View file

@ -1,4 +1,5 @@
import { Controller, Get, Head, Logger, Query, Res } from '@nestjs/common'; import { Controller, Get, Head, Logger, Query, Res } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import type { FastifyReply } from 'fastify'; import type { FastifyReply } from 'fastify';
import { import {
ImageMetaResponse, ImageMetaResponse,
@ -21,6 +22,7 @@ import { BrandMessageType, GetBrandMessage } from '../../util/branding';
// This is the only controller with CORS enabled // This is the only controller with CORS enabled
@Controller('i') @Controller('i')
@RequiredPermissions(Permission.ImageView) @RequiredPermissions(Permission.ImageView)
@SkipThrottle()
export class ImageController { export class ImageController {
private readonly logger = new Logger(ImageController.name); private readonly logger = new Logger(ImageController.name);

View file

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UserDbModule } from '../../collections/user-db/user-db.module'; import { UserDbModule } from '../../collections/user-db/user-db.module';
import { DecoratorsModule } from '../../decorators/decorators.module'; import { DecoratorsModule } from '../../decorators/decorators.module';
import { ImageManagerModule } from '../../managers/image/image.module'; import { ImageManagerModule } from '../../managers/image/image-manager.module';
import { ImageManageController } from './image-manage.controller'; import { ImageManageController } from './image-manage.controller';
import { ImageController } from './image.controller'; import { ImageController } from './image.controller';

View file

@ -0,0 +1,9 @@
import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
export async function GetNextAsync<T>(
iterator: AsyncIterableIterator<T>,
): AsyncFailable<T> {
const { done, value } = await iterator.next();
if (done) return Fail(FT.BadRequest);
return value;
}

View file

@ -41,7 +41,9 @@
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"ngx-auto-unsubscribe-decorator", "ngx-auto-unsubscribe-decorator",
"moment" "moment",
"platform",
"form-data"
], ],
"optimization": true, "optimization": true,
"webWorkerTsConfig": "tsconfig.worker.json", "webWorkerTsConfig": "tsconfig.worker.json",
@ -54,7 +56,7 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1500kb", "maximumWarning": "1mb",
"maximumError": "2mb" "maximumError": "2mb"
}, },
{ {
@ -102,6 +104,5 @@
} }
} }
} }
}, }
"defaultProject": "picsur-frontend"
} }

View file

@ -1,5 +1,9 @@
import webpack from 'webpack'; import webpack from 'webpack';
// import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
export default { export default {
plugins: [new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/)], plugins: [
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/),
// new BundleAnalyzerPlugin(),
],
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "picsur-frontend", "name": "picsur-frontend",
"version": "0.4.1", "version": "0.5.0",
"description": "Frontend for Picsur", "description": "Frontend for Picsur",
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": "https://github.com/rubikscraft/Picsur", "repository": "https://github.com/rubikscraft/Picsur",
@ -14,30 +14,34 @@
"purge": "rm -rf dist && rm -rf node_modules && rm -rf .angular" "purge": "rm -rf dist && rm -rf node_modules && rm -rf .angular"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^14.0.1", "@angular-builders/custom-webpack": "^15.0.0",
"@angular-devkit/build-angular": "14.2.5", "@angular-devkit/build-angular": "^15.0.4",
"@angular/animations": "^14.2.5", "@angular/animations": "^15.0.4",
"@angular/cdk": "^14.2.4", "@angular/cdk": "^15.0.3",
"@angular/cli": "^14.2.5", "@angular/cli": "^15.0.4",
"@angular/common": "^14.2.5", "@angular/common": "^15.0.4",
"@angular/compiler": "^14.2.5", "@angular/compiler": "^15.0.4",
"@angular/compiler-cli": "^14.2.5", "@angular/compiler-cli": "^15.0.4",
"@angular/core": "^14.2.5", "@angular/core": "^15.0.4",
"@angular/forms": "^14.2.5", "@angular/forms": "^15.0.4",
"@angular/material": "^14.2.4", "@angular/material": "^15.0.3",
"@angular/platform-browser": "^14.2.5", "@angular/platform-browser": "^15.0.4",
"@angular/platform-browser-dynamic": "^14.2.5", "@angular/platform-browser-dynamic": "^15.0.4",
"@angular/router": "^14.2.5", "@angular/router": "^15.0.4",
"@fontsource/material-icons": "^4.5.4", "@fontsource/material-icons": "^4.5.4",
"@fontsource/material-icons-outlined": "^4.5.4", "@fontsource/material-icons-outlined": "^4.5.4",
"@fontsource/roboto": "^4.5.8", "@fontsource/roboto": "^4.5.8",
"@ng-web-apis/common": "^2.0.1", "@ng-web-apis/common": "^2.1.0",
"@ng-web-apis/resize-observer": "^2.0.0", "@ng-web-apis/resize-observer": "^2.0.0",
"@ngui/common": "^1.0.0", "@ngui/common": "^1.0.0",
"@types/node": "^18.8.4", "@types/ackee-tracker": "^5.0.2",
"@types/node": "^18.11.17",
"@types/resize-observer-browser": "^0.1.7", "@types/resize-observer-browser": "^0.1.7",
"@types/validator": "^13.7.7", "@types/validator": "^13.7.10",
"bootstrap": "^5.2.2", "ackee-tracker": "^5.1.0",
"axios": "^1.2.1",
"bootstrap": "^5.2.3",
"caniuse-lite": "^1.0.30001441",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"moment": "^2.29.4", "moment": "^2.29.4",
@ -47,10 +51,11 @@
"ngx-moment": "^6.0.2", "ngx-moment": "^6.0.2",
"picsur-shared": "*", "picsur-shared": "*",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "~7.5.7", "rxjs": "^7.8.0",
"tslib": "^2.4.0", "tslib": "^2.4.1",
"typescript": "4.8.4", "typescript": "^4.9.4",
"zod": "^3.19.1", "webpack-bundle-analyzer": "^4.7.0",
"zone.js": "~0.11.8" "zod": "^3.20.2",
"zone.js": "^0.12.0"
} }
} }

View file

@ -1,6 +1,7 @@
<app-header <app-header
[enableHamburger]="hasSidebar && !isDesktop" [enableHamburger]="hasSidebar && !isDesktop"
(onHamburgerClick)="sidebar.toggle()" (onHamburgerClick)="sidebar.toggle()"
[loading]="loading"
></app-header> ></app-header>
<mat-sidenav-container class="grow-full"> <mat-sidenav-container class="grow-full">

View file

@ -5,11 +5,13 @@ import {
ActivatedRoute, ActivatedRoute,
NavigationEnd, NavigationEnd,
NavigationError, NavigationError,
NavigationStart,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { RouteTransitionAnimations } from './app.animation'; import { RouteTransitionAnimations } from './app.animation';
import { PRouteData } from './models/dto/picsur-routes.dto'; import { PRouteData } from './models/dto/picsur-routes.dto';
import { UsageService } from './services/usage/usage.service';
import { BootstrapService } from './util/bootstrap.service'; import { BootstrapService } from './util/bootstrap.service';
@Component({ @Component({
@ -23,6 +25,9 @@ export class AppComponent implements OnInit {
@ViewChild(MatSidenav) sidebar: MatSidenav; @ViewChild(MatSidenav) sidebar: MatSidenav;
loading: boolean = false;
private loadingTimeout: number | null = null;
wrapContentWithContainer: boolean = true; wrapContentWithContainer: boolean = true;
sidebarPortal: Portal<any> | undefined = undefined; sidebarPortal: Portal<any> | undefined = undefined;
@ -33,7 +38,10 @@ export class AppComponent implements OnInit {
private readonly router: Router, private readonly router: Router,
private readonly activatedRoute: ActivatedRoute, private readonly activatedRoute: ActivatedRoute,
private readonly bootstrapService: BootstrapService, private readonly bootstrapService: BootstrapService,
) {} usageService: UsageService,
) {
usageService;
}
public getRouteAnimData() { public getRouteAnimData() {
// Everyone is doing shit with the activated route // Everyone is doing shit with the activated route
@ -50,6 +58,12 @@ export class AppComponent implements OnInit {
@AutoUnsubscribe() @AutoUnsubscribe()
private subscribeRouter() { private subscribeRouter() {
return this.router.events.subscribe((event) => { return this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
this.loadingStart();
}
if (event instanceof NavigationEnd) {
this.loadingEnd();
}
if (event instanceof NavigationEnd) this.onNavigationEnd(event); if (event instanceof NavigationEnd) this.onNavigationEnd(event);
if (event instanceof NavigationError) this.onNavigationError(event); if (event instanceof NavigationError) this.onNavigationError(event);
}); });
@ -83,6 +97,21 @@ export class AppComponent implements OnInit {
this.updateSidebar(); this.updateSidebar();
} }
private loadingStart() {
if (this.loadingTimeout !== null) clearTimeout(this.loadingTimeout);
this.loadingTimeout = window.setTimeout(() => {
this.loading = true;
}, 500);
}
private loadingEnd() {
if (this.loadingTimeout !== null) clearTimeout(this.loadingTimeout);
this.loadingTimeout = null;
this.loading = false;
}
private updateSidebar() { private updateSidebar() {
if (!this.sidebar) return; if (!this.sidebar) return;

View file

@ -1,13 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { PRoutes } from './models/dto/picsur-routes.dto'; import { PRoutes } from './models/dto/picsur-routes.dto';
import { ErrorsRouteModule } from './routes/errors/errors.module';
import { ImagesRouteModule } from './routes/images/images.module';
import { ProcessingRouteModule } from './routes/processing/processing.module';
import { SettingsRouteModule } from './routes/settings/settings.module';
import { UploadRouteModule } from './routes/upload/upload.module';
import { UserRouteModule } from './routes/user/user.module';
import { ViewRouteModule } from './routes/view/view.module';
const routes: PRoutes = [ const routes: PRoutes = [
{ {
@ -17,31 +10,38 @@ const routes: PRoutes = [
}, },
{ {
path: 'upload', path: 'upload',
loadChildren: () => UploadRouteModule, loadChildren: () =>
import('./routes/upload/upload.module').then((m) => m.default),
}, },
{ {
path: 'processing', path: 'processing',
loadChildren: () => ProcessingRouteModule, loadChildren: () =>
import('./routes/processing/processing.module').then((m) => m.default),
}, },
{ {
path: 'view', path: 'view',
loadChildren: () => ViewRouteModule, loadChildren: () =>
import('./routes/view/view.module').then((m) => m.default),
}, },
{ {
path: 'user', path: 'user',
loadChildren: () => UserRouteModule, loadChildren: () =>
import('./routes/user/user.module').then((m) => m.default),
}, },
{ {
path: 'images', path: 'images',
loadChildren: () => ImagesRouteModule, loadChildren: () =>
import('./routes/images/images.module').then((m) => m.default),
}, },
{ {
path: 'settings', path: 'settings',
loadChildren: () => SettingsRouteModule, loadChildren: () =>
import('./routes/settings/settings.module').then((m) => m.default),
}, },
{ {
path: 'error', path: 'error',
loadChildren: () => ErrorsRouteModule, loadChildren: () =>
import('./routes/errors/errors.module').then((m) => m.default),
}, },
]; ];

View file

@ -1,4 +1,8 @@
<mat-form-field appearance="outline" color="accent"> <mat-form-field
[appearance]="appearance"
[color]="color"
[subscriptSizing]="subscriptSizing"
>
<mat-label>{{ label }}</mat-label> <mat-label>{{ label }}</mat-label>
<input <input
matInput matInput

View file

@ -1,5 +1,9 @@
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import {
MatFormFieldAppearance,
SubscriptSizing,
} from '@angular/material/form-field';
import { Fail, FT } from 'picsur-shared/dist/types'; import { Fail, FT } from 'picsur-shared/dist/types';
import { Logger } from 'src/app/services/logger/logger.service'; import { Logger } from 'src/app/services/logger/logger.service';
import { ErrorService } from 'src/app/util/error-manager/error.service'; import { ErrorService } from 'src/app/util/error-manager/error.service';
@ -19,6 +23,10 @@ export class CopyFieldComponent {
@Input() showHideButton: boolean = false; @Input() showHideButton: boolean = false;
@Input() hidden: boolean = false; @Input() hidden: boolean = false;
@Input() color: 'primary' | 'accent' | 'warn' = 'primary';
@Input() appearance: MatFormFieldAppearance = 'outline';
@Input() subscriptSizing: SubscriptSizing = 'fixed';
@Output('copy') onCopy = new EventEmitter<string>(); @Output('copy') onCopy = new EventEmitter<string>();
@Output('hide') onHide = new EventEmitter<boolean>(); @Output('hide') onHide = new EventEmitter<boolean>();

View file

@ -7,7 +7,7 @@ import { Component, Input } from '@angular/core';
export class FabComponent { export class FabComponent {
@Input('aria-label') ariaLabel: string = 'Floating Action Button'; @Input('aria-label') ariaLabel: string = 'Floating Action Button';
@Input() icon: string = 'add'; @Input() icon: string = 'add';
@Input() color: string = 'accent'; @Input() color: string = 'primary';
@Input('tooltip') tooltip: string; @Input('tooltip') tooltip: string;
@Input() onClick: () => void = () => {}; @Input() onClick: () => void = () => {};

View file

@ -1,5 +1,5 @@
import { Directive, Host, Optional } from '@angular/core'; import { Directive, Host, Optional } from '@angular/core';
import { MatButton } from '@angular/material/button'; import { MatMiniFabButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
@Directive({ @Directive({
@ -8,9 +8,9 @@ import { MatTooltip } from '@angular/material/tooltip';
export class SpeedDialOptionDirective { export class SpeedDialOptionDirective {
constructor( constructor(
@Host() @Optional() tooltip?: MatTooltip, @Host() @Optional() tooltip?: MatTooltip,
@Host() @Optional() button?: MatButton, @Host() @Optional() button?: MatMiniFabButton,
) { ) {
if (tooltip) tooltip.position = 'left'; if (tooltip) tooltip.position = 'left';
if (button) button.color = 'primary'; if (button) button.color = 'accent';
} }
} }

View file

@ -13,7 +13,7 @@ export class SpeedDialComponent {
@Input('icon') icon: string = 'add'; @Input('icon') icon: string = 'add';
@Input('icon-hover') iconHover: string = 'close'; @Input('icon-hover') iconHover: string = 'close';
@Input('color') color: string = 'accent'; @Input('color') color: string = 'primary';
@Input('open-on-hover') openOnHover: boolean = false; @Input('open-on-hover') openOnHover: boolean = false;
@Input('tooltip') tooltip: string; @Input('tooltip') tooltip: string;

View file

@ -3,7 +3,7 @@ footer {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-top: 16px; margin-top: 1em;
text-align: center; text-align: center;
} }

View file

@ -1,4 +1,11 @@
<mat-toolbar color="primary"> <mat-toolbar>
<mat-progress-bar
*ngIf="loading"
class="loading-bar"
mode="indeterminate"
color="accent"
></mat-progress-bar>
<button <button
*ngIf="_enableHamburger" *ngIf="_enableHamburger"
class="me-3" class="me-3"
@ -56,9 +63,9 @@
<mat-menu #menu="matMenu" xPosition="before"> <mat-menu #menu="matMenu" xPosition="before">
<ng-template matMenuContent> <ng-template matMenuContent>
<span mat-menu-item disabled> <span>
<div class="centered"> <div class="menu-username center-horizontally">
<h2>{{ user?.username }}</h2> <h2 class="mat-typography">{{ user?.username }}</h2>
</div> </div>
</span> </span>
<button *ngIf="canUpload" mat-menu-item (click)="doImages()"> <button *ngIf="canUpload" mat-menu-item (click)="doImages()">

View file

@ -28,8 +28,9 @@
} }
} }
.mat-menu-item[disabled] { .menu-username {
color: inherit; margin-top: 0.5rem;
margin-bottom: 1rem;
} }
h2 { h2 {
@ -54,3 +55,10 @@ mat-toolbar {
box-shadow: 0px 2px 5px -3px rgba(0, 0, 0, 0.2), box-shadow: 0px 2px 5px -3px rgba(0, 0, 0, 0.2),
0px 5px 8px 0px rgba(0, 0, 0, 0.14), 0px 1px 14px 0px rgba(0, 0, 0, 0.12); 0px 5px 8px 0px rgba(0, 0, 0, 0.14), 0px 1px 14px 0px rgba(0, 0, 0, 0.12);
} }
.loading-bar {
position: absolute;
top: 64px;
left: 0;
right: 0;
}

View file

@ -41,6 +41,8 @@ export class HeaderComponent implements OnInit {
public _enableHamburger: boolean = true; public _enableHamburger: boolean = true;
@Output('onHamburgerClick') onHamburgerClick = new EventEmitter<void>(); @Output('onHamburgerClick') onHamburgerClick = new EventEmitter<void>();
@Input('loading') public loading: boolean = false;
private currentUser: EUser | null = null; private currentUser: EUser | null = null;
public canLogIn: boolean = false; public canLogIn: boolean = false;

View file

@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@ -16,6 +17,7 @@ import { HeaderComponent } from './header.component';
MatToolbarModule, MatToolbarModule,
MatButtonModule, MatButtonModule,
MatProgressBarModule,
RouterModule, RouterModule,
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,

View file

@ -17,5 +17,4 @@
(nguiInview)="onInview($event)" (nguiInview)="onInview($event)"
(nguiOutview)="onOutview($event)" (nguiOutview)="onOutview($event)"
*ngIf="state === 'loading'" *ngIf="state === 'loading'"
color="accent"
></mat-spinner> ></mat-spinner>

View file

@ -85,7 +85,7 @@ export class PicsurImgComponent implements OnChanges {
this.state = PicsurImgState.Canvas; this.state = PicsurImgState.Canvas;
} else { } else {
const result = await this.apiService.getBuffer(url); const result = await this.apiService.getBuffer(url).result;
if (HasFailed(result)) return result; if (HasFailed(result)) return result;
const img = this.img.nativeElement; const img = this.img.nativeElement;
@ -99,12 +99,12 @@ export class PicsurImgComponent implements OnChanges {
} }
private async getFileType(url: string): AsyncFailable<FileType> { private async getFileType(url: string): AsyncFailable<FileType> {
const response = await this.apiService.head(url); const response = await this.apiService.head(url).result;
if (HasFailed(response)) { if (HasFailed(response)) {
return response; return response;
} }
const mimeHeader = response.get('content-type') ?? ''; const mimeHeader = response['content-type'] ?? '';
const mime = mimeHeader.split(';')[0]; const mime = mimeHeader.split(';')[0];
return ParseMime2FileType(mime); return ParseMime2FileType(mime);

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