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:
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
spec: "@yarnpkg/plugin-version"
spec: '@yarnpkg/plugin-version'
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
spec: '@yarnpkg/plugin-workspace-tools'
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
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.
@ -57,9 +57,9 @@ Every featured marked here should work in the latest release.
- [x] Proper DB migrations
- [x] Show own images in list
- [x] Correct previews on chats
- [X] Expiring images
- [X] ShareX endpoint
- [X] Arm64 image
- [x] Expiring images
- [x] ShareX endpoint
- [x] ARM64 and AMD64 Docker image
- [ ] White mode
- [ ] 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
### Is this project maintained?
Yes it still is. If I were to stop maintaining it, I would archive the repository.
However I do not have a lot of time on my hands, so updates are not always as frequent as I would like them to be.
### Why do my images dissapear of the public instance?
The public instance is only a demo, and therefore only keeps images for 5 minutes. This is to prevent the server from running out of disk space, and to prevent people from using it to host questionable images.
@ -150,6 +156,10 @@ volumes:
picsur-data:
```
## Thanks
- @awg13 for donating 5$
## Api
Here is a usually up to date documentation of the api:

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { generateRandomString } from 'picsur-shared/dist/util/random';
import { In, LessThan, Repository } from 'typeorm';
import { EImageBackend } from '../../database/entities/image.entity';
import { EImageBackend } from '../../database/entities/images/image.entity';
@Injectable()
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(
id: string,
userid: string | undefined,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,17 +5,18 @@ import {
PrefValueType,
PrefValueTypeStrings,
} 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 { Repository } from 'typeorm';
import {
EUsrPreferenceBackend,
EUsrPreferenceSchema,
} from '../../database/entities/usr-preference.entity';
import {
UsrPreferenceList,
UsrPreferenceValueTypes,
} from '../../models/constants/usrpreferences.const';
} from '../../database/entities/system/usr-preference.entity';
import { MutexFallBack } from '../../util/mutex-fallback';
import { PreferenceCommonService } from './preference-common.service';
import { PreferenceDefaultsService } from './preference-defaults.service';
@ -91,7 +92,7 @@ export class UsrPreferenceDbService {
// Return
const unpacked = this.prefCommon.DecodePref(
result.data,
result.data as any,
UsrPreference,
UsrPreferenceValueTypes,
);
@ -175,7 +176,7 @@ export class UsrPreferenceDbService {
return this.setPreference(
userid,
key,
this.defaultsService.usrDefaults[key](),
this.defaultsService.getUsrDefault(key),
);
}
@ -192,6 +193,12 @@ export class UsrPreferenceDbService {
);
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();
verifySysPreference.key = validated.key;
verifySysPreference.value = validated.value;

View File

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

View File

@ -10,7 +10,7 @@ import {
} from 'picsur-shared/dist/types';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { In, Repository } from 'typeorm';
import { ERoleBackend } from '../../database/entities/role.entity';
import { ERoleBackend } from '../../database/entities/users/role.entity';
import { Permissions } from '../../models/constants/permissions.const';
import {
ImmutableRolesList,

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 { AuthConfigService } from '../../config/early/auth.config.service';
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 { RoleDbModule } from '../role-db/role-db.module';
import { UserDbService } from './user-db.service';

View File

@ -12,7 +12,7 @@ import {
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { Repository } from 'typeorm';
import { EUserBackend } from '../../database/entities/user.entity';
import { EUserBackend } from '../../database/entities/users/user.entity';
import { Permissions } from '../../models/constants/permissions.const';
import {
DefaultRolesList,
@ -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> {
return HasSuccess(await this.findByUsername(username));
}

View File

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

View File

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

View File

@ -16,8 +16,15 @@ export class HostConfigService {
this.logger.log('Verbose: ' + this.isVerbose());
this.logger.log('Host: ' + this.getHost());
this.logger.log('Port: ' + this.getPort());
this.logger.log('Demo: ' + this.isDemo());
this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's');
if (this.isDemo()) {
this.logger.log('Running in demo mode');
this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's');
}
if (!this.isTelemetry()) {
this.logger.log('Telemetry disabled');
}
}
public getHost(): string {
@ -47,6 +54,10 @@ export class HostConfigService {
return ParseBool(this.configService.get(`${EnvPrefix}VERBOSE`), false);
}
public isTelemetry() {
return ParseBool(this.configService.get(`${EnvPrefix}TELEMETRY`), true);
}
public getVersion() {
return ParseString(this.configService.get(`npm_package_version`), '0.0.0');
}

View File

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

View File

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

View File

@ -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'),
);
let milliseconds = ms(expiresIn);
if (milliseconds === undefined) {
let milliseconds = ms(expiresIn as any);
if (isNaN(milliseconds)) {
milliseconds = 1000 * 60 * 60 * 24; // 1 day
}

View File

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

View File

@ -1,11 +1,12 @@
import { EApiKeyBackend } from './apikey.entity';
import { EImageDerivativeBackend } from './image-derivative.entity';
import { EImageFileBackend } from './image-file.entity';
import { EImageBackend } from './image.entity';
import { ERoleBackend } from './role.entity';
import { ESysPreferenceBackend } from './sys-preference.entity';
import { EUserBackend } from './user.entity';
import { EUsrPreferenceBackend } from './usr-preference.entity';
import { EImageDerivativeBackend } from './images/image-derivative.entity';
import { EImageFileBackend } from './images/image-file.entity';
import { EImageBackend } from './images/image.entity';
import { ESysPreferenceBackend } from './system/sys-preference.entity';
import { ESystemStateBackend } from './system/system-state.entity';
import { EUsrPreferenceBackend } from './system/usr-preference.entity';
import { ERoleBackend } from './users/role.entity';
import { EUserBackend } from './users/user.entity';
export const EntityList = [
EImageBackend,
@ -16,4 +17,5 @@ export const EntityList = [
ESysPreferenceBackend,
EUsrPreferenceBackend,
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,
} from 'typeorm';
import z from 'zod';
import { EUserBackend } from './user.entity';
import { EUserBackend } from '../users/user.entity';
export const EUsrPreferenceSchema = z.object({
id: IsEntityID().optional(),

View File

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

View File

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

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 { V040C1662535484200 } from './1662535484200-V_0_4_0_c';
import { V040D1662728275448 } from './1662728275448-V_0_4_0_d';
import { V050A1672154027079 } from './1672154027079-V_0_5_0_a';
export const MigrationList: Function[] = [
V030A1661692206479,
@ -12,4 +13,5 @@ export const MigrationList: Function[] = [
V040B1662485374471,
V040C1662535484200,
V040D1662728275448,
V050A1672154027079,
];

View File

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

View File

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

View File

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

View File

@ -1,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 { FastifyRequest } from 'fastify';
import { Fail, FT } from 'picsur-shared/dist/types';
@ -12,11 +12,11 @@ export class PostFilePipe implements PipeTransform {
private readonly multipartConfigService: MultipartConfigService,
) {}
async transform({ req }: { req: FastifyRequest }) {
if (!req.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
async transform({ request, data }: { data: any; request: FastifyRequest }) {
if (!request.isMultipart()) throw Fail(FT.UsrValidation, 'Invalid file');
// Only one file is allowed
const file = await req.file({
const file = await request.file({
limits: {
...this.multipartConfigService.getLimits(),
files: 1,
@ -30,13 +30,15 @@ export class PostFilePipe implements PipeTransform {
) as any;
// 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');
// Return a buffer of the file
try {
return await files[0]?.toBuffer();
return await files[0].toBuffer();
} catch (e) {
this.logger.warn(e);
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> {
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 type = exception.getType();
const message = exception.getReason();
const logmessage =
message +
(exception.getDebugMessage() ? ' - ' + exception.getDebugMessage() : '');
if (exception.isImportant()) {
MainExceptionFilter.logger.error(
`${traceString} ${exception.getName()}: ${logmessage}`,
);
if (exception.getStack()) {
MainExceptionFilter.logger.debug(exception.getStack());
}
} else {
MainExceptionFilter.logger.warn(
`${traceString} ${exception.getName()}: ${logmessage}`,
);
}
exception.print(MainExceptionFilter.logger, { prefix: traceString });
const toSend: ApiErrorResponse = {
success: false,
@ -65,7 +49,7 @@ export class MainExceptionFilter implements ExceptionFilter {
data: {
type,
message,
message: exception.getReason(),
},
};

View File

@ -46,10 +46,29 @@ export class SuccessInterceptor<T> implements NestInterceptor {
return data;
}
}),
map((data) => {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse<FastifyReply>();
const traceString = `(${request.ip} -> ${request.method} ${request.url})`;
this.logger.verbose(
`Handled ${traceString} with ${response.statusCode} in ${Math.ceil(
response.getResponseTime(),
)}ms`,
SuccessInterceptor.name,
);
return data;
}),
);
}
private validate(context: ExecutionContext, data: unknown): unknown {
const canReturnAnything =
(this.reflector.get('noreturns', context.getHandler()) ?? false) === true;
if (canReturnAnything) return data;
const schemaStatic = this.reflector.get<ZodDtoStatic>(
'returns',
context.getHandler(),

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

View File

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

View File

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

View File

@ -57,7 +57,8 @@ export class ImageConverterService {
if (HasFailed(memLimit) || HasFailed(timeLimit)) {
return Fail(FT.Internal, 'Failed to get conversion limits');
}
const timeLimitMS = ms(timeLimit);
let timeLimitMS = ms(timeLimit as any);
if (isNaN(timeLimitMS) || timeLimitMS === 0) timeLimitMS = 15 * 1000; // 15 seconds
const sharpWrapper = new SharpWrapper(timeLimitMS, memLimit);
const sharpOptions: SharpOptions = {
@ -121,16 +122,4 @@ export class ImageConverterService {
filetype: targetFiletype.identifier,
};
}
private async convertAnimation(
image: Buffer,
targetFiletype: FileType,
options: ImageRequestParams,
): AsyncFailable<ImageResult> {
// Apng and gif are stored as is for now
return {
image: image,
filetype: targetFiletype.identifier,
};
}
}

View File

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

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 { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service';
import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service';
import { EImageDerivativeBackend } from '../../database/entities/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/image-file.entity';
import { EImageBackend } from '../../database/entities/image.entity';
import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity';
import { EImageFileBackend } from '../../database/entities/images/image-file.entity';
import { EImageBackend } from '../../database/entities/images/image.entity';
import { MutexFallBack } from '../../util/mutex-fallback';
import { ImageConverterService } from './image-converter.service';
import { ImageProcessorService } from './image-processor.service';
@ -145,19 +145,15 @@ export class ImageManagerService {
const converted_key = this.getConvertHash({ mime: fileType, ...options });
const [save_derivatives, allow_editing] = await Promise.all([
this.sysPref.getBooleanPreference(SysPreference.SaveDerivatives),
this.sysPref.getBooleanPreference(SysPreference.AllowEditing),
]);
if (HasFailed(save_derivatives)) return save_derivatives;
const allow_editing = await this.sysPref.getBooleanPreference(
SysPreference.AllowEditing,
);
if (HasFailed(allow_editing)) return allow_editing;
return MutexFallBack(
converted_key,
() => {
if (save_derivatives)
return this.imageFilesService.getDerivative(imageId, converted_key);
else return Promise.resolve(null);
return this.imageFilesService.getDerivative(imageId, converted_key);
},
async () => {
const masterImage = await this.getMaster(imageId);
@ -181,21 +177,12 @@ export class ImageManagerService {
} in ${Date.now() - startTime}ms`,
);
if (save_derivatives) {
return await this.imageFilesService.addDerivative(
imageId,
converted_key,
convertResult.filetype,
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;
}
return await this.imageFilesService.addDerivative(
imageId,
converted_key,
convertResult.filetype,
convertResult.image,
);
},
);
}
@ -256,7 +243,7 @@ export class ImageManagerService {
let mime: string | undefined;
if (filetypeResult === undefined) {
if (IsQOI(image)) mime = 'image/qoi';
if (IsQOI(image)) mime = 'image/x-qoi';
} else {
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 { EUserBackend } from '../../database/entities/user.entity';
import { EUserBackend } from '../../database/entities/users/user.entity';
export function EUserBackend2EUser(eUser: EUserBackend): 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 { PrefModule } from './pref/pref.module';
import { RolesApiModule } from './roles/roles.module';
import { UsageApiModule } from './usage/usage.module';
import { UserApiModule } from './user/user.module';
@Module({
@ -14,6 +15,7 @@ import { UserApiModule } from './user/user.module';
InfoModule,
RolesApiModule,
ApiKeysModule,
UsageApiModule,
],
})
export class PicsurApiModule {}

View File

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

View File

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

View File

@ -10,7 +10,11 @@ import {
SupportedAnimFileTypes,
SupportedImageFileTypes,
} 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 { InfoConfigService } from '../../../config/late/info.config.service';
import { UsageConfigService } from '../../../config/late/usage.config.service';
import { NoPermissions } from '../../../decorators/permissions.decorator';
import { Returns } from '../../../decorators/returns.decorator';
import { PermissionsList } from '../../../models/constants/permissions.const';
@ -18,15 +22,29 @@ import { PermissionsList } from '../../../models/constants/permissions.const';
@Controller('api/info')
@NoPermissions()
export class InfoController {
constructor(private readonly hostConfig: HostConfigService) {}
constructor(
private readonly hostConfig: HostConfigService,
private readonly infoConfig: InfoConfigService,
private readonly usageService: UsageConfigService,
) {}
@Get()
@Returns(InfoResponse)
async getInfo(): Promise<InfoResponse> {
const trackingID =
FallbackIfFailed(await this.usageService.getTrackingID(), null) ??
undefined;
const hostOverride = await this.infoConfig.getHostnameOverride();
return {
demo: this.hostConfig.isDemo(),
production: this.hostConfig.isProduction(),
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 { EarlyConfigModule } from '../../../config/early/early-config.module';
import { LateConfigModule } from '../../../config/late/late-config.module';
import { InfoController } from './info.controller';
@Module({
imports: [EarlyConfigModule],
imports: [LateConfigModule],
controllers: [InfoController],
})
export class InfoModule {}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { Body, Controller, Get, Logger, Post } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import {
RoleCreateRequest,
RoleCreateResponse,
@ -56,6 +57,7 @@ export class RolesController {
@Post('update')
@Returns(RoleUpdateResponse)
@Throttle(20)
async updateRole(
@Body() body: RoleUpdateRequest,
): Promise<RoleUpdateResponse> {
@ -73,6 +75,7 @@ export class RolesController {
@Post('create')
@Returns(RoleCreateResponse)
@Throttle(10)
async createRole(
@Body() role: RoleCreateRequest,
): 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 { Throttle } from '@nestjs/throttler';
import {
GetSpecialUsersResponse,
UserCreateRequest,
@ -46,6 +47,7 @@ export class UserAdminController {
@Post('create')
@Returns(UserCreateResponse)
@Throttle(10)
async register(
@Body() create: UserCreateRequest,
): Promise<UserCreateResponse> {
@ -78,6 +80,7 @@ export class UserAdminController {
@Post('update')
@Returns(UserUpdateResponse)
@Throttle(20)
async setPermissions(
@Body() body: UserUpdateRequest,
): Promise<UserUpdateResponse> {

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { UserDbModule } from '../../collections/user-db/user-db.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 { 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": [],
"allowedCommonJsDependencies": [
"ngx-auto-unsubscribe-decorator",
"moment"
"moment",
"platform",
"form-data"
],
"optimization": true,
"webWorkerTsConfig": "tsconfig.worker.json",
@ -54,7 +56,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1500kb",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
@ -102,6 +104,5 @@
}
}
}
},
"defaultProject": "picsur-frontend"
}
}

View File

@ -1,5 +1,9 @@
import webpack from 'webpack';
// import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
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",
"version": "0.4.1",
"version": "0.5.0",
"description": "Frontend for Picsur",
"license": "GPL-3.0",
"repository": "https://github.com/rubikscraft/Picsur",
@ -14,30 +14,34 @@
"purge": "rm -rf dist && rm -rf node_modules && rm -rf .angular"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^14.0.1",
"@angular-devkit/build-angular": "14.2.5",
"@angular/animations": "^14.2.5",
"@angular/cdk": "^14.2.4",
"@angular/cli": "^14.2.5",
"@angular/common": "^14.2.5",
"@angular/compiler": "^14.2.5",
"@angular/compiler-cli": "^14.2.5",
"@angular/core": "^14.2.5",
"@angular/forms": "^14.2.5",
"@angular/material": "^14.2.4",
"@angular/platform-browser": "^14.2.5",
"@angular/platform-browser-dynamic": "^14.2.5",
"@angular/router": "^14.2.5",
"@angular-builders/custom-webpack": "^15.0.0",
"@angular-devkit/build-angular": "^15.0.4",
"@angular/animations": "^15.0.4",
"@angular/cdk": "^15.0.3",
"@angular/cli": "^15.0.4",
"@angular/common": "^15.0.4",
"@angular/compiler": "^15.0.4",
"@angular/compiler-cli": "^15.0.4",
"@angular/core": "^15.0.4",
"@angular/forms": "^15.0.4",
"@angular/material": "^15.0.3",
"@angular/platform-browser": "^15.0.4",
"@angular/platform-browser-dynamic": "^15.0.4",
"@angular/router": "^15.0.4",
"@fontsource/material-icons": "^4.5.4",
"@fontsource/material-icons-outlined": "^4.5.4",
"@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",
"@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/validator": "^13.7.7",
"bootstrap": "^5.2.2",
"@types/validator": "^13.7.10",
"ackee-tracker": "^5.1.0",
"axios": "^1.2.1",
"bootstrap": "^5.2.3",
"caniuse-lite": "^1.0.30001441",
"fuse.js": "^6.6.2",
"jwt-decode": "^3.1.2",
"moment": "^2.29.4",
@ -47,10 +51,11 @@
"ngx-moment": "^6.0.2",
"picsur-shared": "*",
"reflect-metadata": "^0.1.13",
"rxjs": "~7.5.7",
"tslib": "^2.4.0",
"typescript": "4.8.4",
"zod": "^3.19.1",
"zone.js": "~0.11.8"
"rxjs": "^7.8.0",
"tslib": "^2.4.1",
"typescript": "^4.9.4",
"webpack-bundle-analyzer": "^4.7.0",
"zod": "^3.20.2",
"zone.js": "^0.12.0"
}
}

View File

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

View File

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

View File

@ -1,13 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
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 = [
{
@ -17,31 +10,38 @@ const routes: PRoutes = [
},
{
path: 'upload',
loadChildren: () => UploadRouteModule,
loadChildren: () =>
import('./routes/upload/upload.module').then((m) => m.default),
},
{
path: 'processing',
loadChildren: () => ProcessingRouteModule,
loadChildren: () =>
import('./routes/processing/processing.module').then((m) => m.default),
},
{
path: 'view',
loadChildren: () => ViewRouteModule,
loadChildren: () =>
import('./routes/view/view.module').then((m) => m.default),
},
{
path: 'user',
loadChildren: () => UserRouteModule,
loadChildren: () =>
import('./routes/user/user.module').then((m) => m.default),
},
{
path: 'images',
loadChildren: () => ImagesRouteModule,
loadChildren: () =>
import('./routes/images/images.module').then((m) => m.default),
},
{
path: 'settings',
loadChildren: () => SettingsRouteModule,
loadChildren: () =>
import('./routes/settings/settings.module').then((m) => m.default),
},
{
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>
<input
matInput

View File

@ -1,5 +1,9 @@
import { Clipboard } from '@angular/cdk/clipboard';
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 { Logger } from 'src/app/services/logger/logger.service';
import { ErrorService } from 'src/app/util/error-manager/error.service';
@ -19,6 +23,10 @@ export class CopyFieldComponent {
@Input() showHideButton: 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('hide') onHide = new EventEmitter<boolean>();

View File

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

View File

@ -1,5 +1,5 @@
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';
@Directive({
@ -8,9 +8,9 @@ import { MatTooltip } from '@angular/material/tooltip';
export class SpeedDialOptionDirective {
constructor(
@Host() @Optional() tooltip?: MatTooltip,
@Host() @Optional() button?: MatButton,
@Host() @Optional() button?: MatMiniFabButton,
) {
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-hover') iconHover: string = 'close';
@Input('color') color: string = 'accent';
@Input('color') color: string = 'primary';
@Input('open-on-hover') openOnHover: boolean = false;
@Input('tooltip') tooltip: string;

View File

@ -3,7 +3,7 @@ footer {
flex-direction: column;
align-items: center;
margin-top: 16px;
margin-top: 1em;
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
*ngIf="_enableHamburger"
class="me-3"
@ -56,9 +63,9 @@
<mat-menu #menu="matMenu" xPosition="before">
<ng-template matMenuContent>
<span mat-menu-item disabled>
<div class="centered">
<h2>{{ user?.username }}</h2>
<span>
<div class="menu-username center-horizontally">
<h2 class="mat-typography">{{ user?.username }}</h2>
</div>
</span>
<button *ngIf="canUpload" mat-menu-item (click)="doImages()">

View File

@ -28,8 +28,9 @@
}
}
.mat-menu-item[disabled] {
color: inherit;
.menu-username {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
h2 {
@ -54,3 +55,10 @@ mat-toolbar {
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);
}
.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;
@Output('onHamburgerClick') onHamburgerClick = new EventEmitter<void>();
@Input('loading') public loading: boolean = false;
private currentUser: EUser | null = null;
public canLogIn: boolean = false;

View File

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

View File

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

View File

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

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