add api key management endpoints
This commit is contained in:
parent
a7981ce8ad
commit
c68360c81f
|
@ -1,11 +1,11 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { EApiKeyBackend } from '../../database/entities/apikey.entity';
|
import { EApiKeyBackend } from '../../database/entities/apikey.entity';
|
||||||
import { ApikeyDbService } from './apikey-db.service';
|
import { ApiKeyDbService } from './apikey-db.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([EApiKeyBackend])],
|
imports: [TypeOrmModule.forFeature([EApiKeyBackend])],
|
||||||
providers: [ApikeyDbService],
|
providers: [ApiKeyDbService],
|
||||||
exports: [ApikeyDbService],
|
exports: [ApiKeyDbService],
|
||||||
})
|
})
|
||||||
export class ApikeyDbModule {}
|
export class ApiKeyDbModule {}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
|
import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types';
|
||||||
import { FindResult } from 'picsur-shared/dist/types/find-result';
|
import { FindResult } from 'picsur-shared/dist/types/find-result';
|
||||||
|
@ -8,7 +8,9 @@ import { EApiKeyBackend } from '../../database/entities/apikey.entity';
|
||||||
import { EUserBackend } from '../../database/entities/user.entity';
|
import { EUserBackend } from '../../database/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApikeyDbService {
|
export class ApiKeyDbService {
|
||||||
|
private readonly logger = new Logger(ApiKeyDbService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(EApiKeyBackend)
|
@InjectRepository(EApiKeyBackend)
|
||||||
private readonly apikeyRepo: Repository<EApiKeyBackend>,
|
private readonly apikeyRepo: Repository<EApiKeyBackend>,
|
||||||
|
@ -32,28 +34,20 @@ export class ApikeyDbService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
|
||||||
key: string
|
|
||||||
): AsyncFailable<EApiKeyBackend<EUserBackend>> {
|
|
||||||
try {
|
|
||||||
const apikey = await this.apikeyRepo.findOne({
|
|
||||||
where: { key },
|
|
||||||
relations: ['user'],
|
|
||||||
});
|
|
||||||
if (!apikey) return Fail(FT.NotFound, 'API key not found');
|
|
||||||
return apikey as EApiKeyBackend<EUserBackend>;
|
|
||||||
} catch (e) {
|
|
||||||
return Fail(FT.Database, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(
|
async findOne(
|
||||||
key: string,
|
key: string,
|
||||||
userid: string | undefined,
|
userid: string | undefined,
|
||||||
): AsyncFailable<EApiKeyBackend<string>> {
|
): AsyncFailable<EApiKeyBackend<string>> {
|
||||||
try {
|
try {
|
||||||
const apikey = await this.apikeyRepo.findOne({
|
const apikey = await this.apikeyRepo.findOne({
|
||||||
where: { user: userid, key },
|
where: {
|
||||||
|
user:
|
||||||
|
userid !== undefined
|
||||||
|
? // This is stupid, but typeorm do typeorm
|
||||||
|
({ id: userid } as any)
|
||||||
|
: undefined,
|
||||||
|
key,
|
||||||
|
},
|
||||||
loadRelationIds: true,
|
loadRelationIds: true,
|
||||||
});
|
});
|
||||||
if (!apikey) return Fail(FT.NotFound, 'API key not found');
|
if (!apikey) return Fail(FT.NotFound, 'API key not found');
|
||||||
|
@ -73,7 +67,13 @@ export class ApikeyDbService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [apikeys, amount] = await this.apikeyRepo.findAndCount({
|
const [apikeys, amount] = await this.apikeyRepo.findAndCount({
|
||||||
where: { user: userid },
|
where: {
|
||||||
|
user:
|
||||||
|
userid !== undefined
|
||||||
|
? // This is stupid, but typeorm do typeorm
|
||||||
|
({ id: userid } as any)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
skip: count * page,
|
skip: count * page,
|
||||||
take: count,
|
take: count,
|
||||||
loadRelationIds: true,
|
loadRelationIds: true,
|
||||||
|
@ -97,12 +97,35 @@ export class ApikeyDbService {
|
||||||
const apikeyToDelete = await this.findOne(key, userid);
|
const apikeyToDelete = await this.findOne(key, userid);
|
||||||
if (HasFailed(apikeyToDelete)) return apikeyToDelete;
|
if (HasFailed(apikeyToDelete)) return apikeyToDelete;
|
||||||
|
|
||||||
|
const apiKeyCopy = { ...apikeyToDelete };
|
||||||
try {
|
try {
|
||||||
return (await this.apikeyRepo.remove(
|
await this.apikeyRepo.remove(apikeyToDelete);
|
||||||
apikeyToDelete,
|
return apiKeyCopy as EApiKeyBackend<string>;
|
||||||
)) as EApiKeyBackend<string>;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Fail(FT.Database, e);
|
return Fail(FT.Database, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolve(key: string): AsyncFailable<EApiKeyBackend<EUserBackend>> {
|
||||||
|
try {
|
||||||
|
const apikey = await this.apikeyRepo.findOne({
|
||||||
|
where: { key },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (!apikey) return Fail(FT.NotFound, 'API key not found');
|
||||||
|
|
||||||
|
this.updateLastUsed(apikey);
|
||||||
|
|
||||||
|
return apikey as EApiKeyBackend<EUserBackend>;
|
||||||
|
} catch (e) {
|
||||||
|
return Fail(FT.Database, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLastUsed(apikey: EApiKeyBackend) {
|
||||||
|
(async () => {
|
||||||
|
apikey.last_used = new Date();
|
||||||
|
this.apikeyRepo.save(apikey);
|
||||||
|
})().catch(this.logger.error.bind(this.logger));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { EApiKeySchema } from 'picsur-shared/dist/entities/apikey.entity';
|
import { EApiKeySchema } from 'picsur-shared/dist/entities/apikey.entity';
|
||||||
import { CreateDateColumn, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { EUserBackend } from './user.entity';
|
import { EUserBackend } from './user.entity';
|
||||||
|
|
||||||
|
@ -31,4 +37,9 @@ export class EApiKeyBackend<
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
created: Date;
|
created: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
last_used: Date;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApiKeysModule } from './apikeys/apikeys.module';
|
||||||
import { ExperimentModule } from './experiment/experiment.module';
|
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';
|
||||||
|
@ -12,6 +13,7 @@ import { UserApiModule } from './user/user.module';
|
||||||
ExperimentModule,
|
ExperimentModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
RolesApiModule,
|
RolesApiModule,
|
||||||
|
ApiKeysModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PicsurApiModule {}
|
export class PicsurApiModule {}
|
||||||
|
|
71
backend/src/routes/api/apikeys/apikeys.controller.ts
Normal file
71
backend/src/routes/api/apikeys/apikeys.controller.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiKeyCreateResponse,
|
||||||
|
ApiKeyDeleteRequest,
|
||||||
|
ApiKeyDeleteResponse,
|
||||||
|
ApiKeyInfoRequest,
|
||||||
|
ApiKeyInfoResponse,
|
||||||
|
ApiKeyListRequest,
|
||||||
|
ApiKeyListResponse
|
||||||
|
} from 'picsur-shared/dist/dto/api/apikeys.dto';
|
||||||
|
import { Permission } from 'picsur-shared/dist/dto/permissions.enum';
|
||||||
|
import { ThrowIfFailed } from 'picsur-shared/dist/types';
|
||||||
|
import { ApiKeyDbService } from '../../../collections/apikey-db/apikey-db.service';
|
||||||
|
import {
|
||||||
|
HasPermission,
|
||||||
|
RequiredPermissions
|
||||||
|
} from '../../../decorators/permissions.decorator';
|
||||||
|
import { ReqUserID } from '../../../decorators/request-user.decorator';
|
||||||
|
import { Returns } from '../../../decorators/returns.decorator';
|
||||||
|
|
||||||
|
@Controller('api/apikeys')
|
||||||
|
@RequiredPermissions(Permission.ApiKey)
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apikeyDB: ApiKeyDbService) {}
|
||||||
|
|
||||||
|
@Post('info')
|
||||||
|
@Returns(ApiKeyInfoResponse)
|
||||||
|
async getApiKeyInfo(
|
||||||
|
@ReqUserID() userid: string,
|
||||||
|
@Body() body: ApiKeyInfoRequest,
|
||||||
|
@HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean,
|
||||||
|
): Promise<ApiKeyInfoResponse> {
|
||||||
|
return ThrowIfFailed(
|
||||||
|
await this.apikeyDB.findOne(body.key, isAdmin ? undefined : userid),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('list')
|
||||||
|
@Returns(ApiKeyListResponse)
|
||||||
|
async listApiKeys(
|
||||||
|
@ReqUserID() userid: string,
|
||||||
|
@Body() body: ApiKeyListRequest,
|
||||||
|
@HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean,
|
||||||
|
): Promise<ApiKeyListResponse> {
|
||||||
|
if (!isAdmin) body.user_id = userid;
|
||||||
|
|
||||||
|
return ThrowIfFailed(
|
||||||
|
await this.apikeyDB.findMany(body.count, body.page, body.user_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
@Returns(ApiKeyCreateResponse)
|
||||||
|
async createApiKey(
|
||||||
|
@ReqUserID() userID: string,
|
||||||
|
): Promise<ApiKeyCreateResponse> {
|
||||||
|
return ThrowIfFailed(await this.apikeyDB.createApiKey(userID));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('delete')
|
||||||
|
@Returns(ApiKeyDeleteResponse)
|
||||||
|
async deleteApiKey(
|
||||||
|
@ReqUserID() userID: string,
|
||||||
|
@Body() body: ApiKeyDeleteRequest,
|
||||||
|
@HasPermission(Permission.ApiKeyAdmin) isAdmin: boolean,
|
||||||
|
): Promise<ApiKeyDeleteResponse> {
|
||||||
|
return ThrowIfFailed(
|
||||||
|
await this.apikeyDB.deleteApiKey(body.key, isAdmin ? undefined : userID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
9
backend/src/routes/api/apikeys/apikeys.module.ts
Normal file
9
backend/src/routes/api/apikeys/apikeys.module.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApiKeyDbModule } from '../../../collections/apikey-db/apikey-db.module';
|
||||||
|
import { ApiKeysController } from './apikeys.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ApiKeyDbModule],
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
|
@ -1,7 +1,7 @@
|
||||||
import { Controller, Get, Request } from '@nestjs/common';
|
import { Controller, Get, Request } from '@nestjs/common';
|
||||||
import { UserInfoResponse } from 'picsur-shared/dist/dto/api/user-manage.dto';
|
import { UserInfoResponse } from 'picsur-shared/dist/dto/api/user-manage.dto';
|
||||||
import { Permission } from 'picsur-shared/dist/dto/permissions.enum';
|
import { Permission } from 'picsur-shared/dist/dto/permissions.enum';
|
||||||
import { ApikeyDbService } from '../../../collections/apikey-db/apikey-db.service';
|
import { ApiKeyDbService } from '../../../collections/apikey-db/apikey-db.service';
|
||||||
import { RequiredPermissions } from '../../../decorators/permissions.decorator';
|
import { RequiredPermissions } from '../../../decorators/permissions.decorator';
|
||||||
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';
|
||||||
|
@ -11,7 +11,7 @@ import type AuthFasityRequest from '../../../models/interfaces/authrequest.dto';
|
||||||
@RequiredPermissions(Permission.SysPrefAdmin)
|
@RequiredPermissions(Permission.SysPrefAdmin)
|
||||||
export class ExperimentController {
|
export class ExperimentController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apikeyDB: ApikeyDbService,
|
private readonly apikeyDB: ApiKeyDbService,
|
||||||
){}
|
){}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ApikeyDbModule } from '../../../collections/apikey-db/apikey-db.module';
|
import { ApiKeyDbModule } from '../../../collections/apikey-db/apikey-db.module';
|
||||||
import { ExperimentController } from './experiment.controller';
|
import { ExperimentController } from './experiment.controller';
|
||||||
|
|
||||||
// This is comletely useless module, but is used for testing
|
// This is comletely useless module, but is used for testing
|
||||||
// TODO: remove when out of beta
|
// TODO: remove when out of beta
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApikeyDbModule],
|
imports: [ApiKeyDbModule],
|
||||||
controllers: [ExperimentController],
|
controllers: [ExperimentController],
|
||||||
})
|
})
|
||||||
export class ExperimentModule {}
|
export class ExperimentModule {}
|
||||||
|
|
|
@ -12,8 +12,11 @@ export const UIFriendlyPermissions: {
|
||||||
|
|
||||||
[Permission.Settings]: 'View settings',
|
[Permission.Settings]: 'View settings',
|
||||||
|
|
||||||
|
[Permission.ApiKey]: 'Use API keys',
|
||||||
|
|
||||||
[Permission.ImageAdmin]: 'Image Admin',
|
[Permission.ImageAdmin]: 'Image Admin',
|
||||||
[Permission.UserAdmin]: 'User Admin',
|
[Permission.UserAdmin]: 'User Admin',
|
||||||
[Permission.RoleAdmin]: 'Role Admin',
|
[Permission.RoleAdmin]: 'Role Admin',
|
||||||
|
[Permission.ApiKeyAdmin]: 'API Key Admin',
|
||||||
[Permission.SysPrefAdmin]: 'System Admin',
|
[Permission.SysPrefAdmin]: 'System Admin',
|
||||||
};
|
};
|
||||||
|
|
53
shared/src/dto/api/apikeys.dto.ts
Normal file
53
shared/src/dto/api/apikeys.dto.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { EApiKeySchema } from '../../entities/apikey.entity';
|
||||||
|
import { createZodDto } from '../../util/create-zod-dto';
|
||||||
|
import { IsPosInt } from '../../validators/positive-int.validator';
|
||||||
|
|
||||||
|
// ApiKeyInfo
|
||||||
|
export const ApiKeyInfoRequestSchema = EApiKeySchema.pick({
|
||||||
|
key: true,
|
||||||
|
});
|
||||||
|
export class ApiKeyInfoRequest extends createZodDto(ApiKeyInfoRequestSchema) {}
|
||||||
|
|
||||||
|
export const ApiKeyInfoResponseSchema = EApiKeySchema;
|
||||||
|
export class ApiKeyInfoResponse extends createZodDto(
|
||||||
|
ApiKeyInfoResponseSchema,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ApiKeyList
|
||||||
|
|
||||||
|
export const ApiKeyListRequestSchema = z.object({
|
||||||
|
count: IsPosInt(),
|
||||||
|
page: IsPosInt(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
export class ApiKeyListRequest extends createZodDto(ApiKeyListRequestSchema) {}
|
||||||
|
|
||||||
|
export const ApiKeyListResponseSchema = z.object({
|
||||||
|
results: z.array(EApiKeySchema),
|
||||||
|
total: IsPosInt(),
|
||||||
|
page: IsPosInt(),
|
||||||
|
pages: IsPosInt(),
|
||||||
|
});
|
||||||
|
export class ApiKeyListResponse extends createZodDto(
|
||||||
|
ApiKeyListResponseSchema,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ApiKeyCreate
|
||||||
|
export const ApiKeyCreateResponseSchema = EApiKeySchema;
|
||||||
|
export class ApiKeyCreateResponse extends createZodDto(
|
||||||
|
ApiKeyCreateResponseSchema,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ApiKeyDelete
|
||||||
|
export const ApiKeyDeleteRequestSchema = EApiKeySchema.pick({
|
||||||
|
key: true,
|
||||||
|
});
|
||||||
|
export class ApiKeyDeleteRequest extends createZodDto(
|
||||||
|
ApiKeyDeleteRequestSchema,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export const ApiKeyDeleteResponseSchema = EApiKeySchema;
|
||||||
|
export class ApiKeyDeleteResponse extends createZodDto(
|
||||||
|
ApiKeyDeleteResponseSchema,
|
||||||
|
) {}
|
|
@ -12,8 +12,11 @@ export enum Permission {
|
||||||
|
|
||||||
Settings = 'settings', // Ability to view (personal) settings
|
Settings = 'settings', // Ability to view (personal) settings
|
||||||
|
|
||||||
|
ApiKey = 'apikey', // Ability to create and remove your own api keys
|
||||||
|
|
||||||
ImageAdmin = 'image-admin', // Ability to manage everyones manage images
|
ImageAdmin = 'image-admin', // Ability to manage everyones manage images
|
||||||
UserAdmin = 'user-admin', // Allow modification of users
|
UserAdmin = 'user-admin', // Allow modification of users
|
||||||
RoleAdmin = 'role-admin', // Allow modification of roles
|
RoleAdmin = 'role-admin', // Allow modification of roles
|
||||||
|
ApiKeyAdmin = 'apikey-admin', // Allow modification of all api keys
|
||||||
SysPrefAdmin = 'syspref-admin',
|
SysPrefAdmin = 'syspref-admin',
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { IsApiKey } from '../validators/api-key.validator';
|
||||||
import { IsEntityID } from '../validators/entity-id.validator';
|
import { IsEntityID } from '../validators/entity-id.validator';
|
||||||
|
|
||||||
export const EApiKeySchema = z.object({
|
export const EApiKeySchema = z.object({
|
||||||
key: z.string(),
|
key: IsApiKey(),
|
||||||
user: IsEntityID(),
|
user: IsEntityID(),
|
||||||
created: z.preprocess((data: any) => new Date(data), z.date()),
|
created: z.date(),
|
||||||
|
last_used: z.date().nullable(),
|
||||||
});
|
});
|
||||||
export type EApiKey = z.infer<typeof EApiKeySchema>;
|
export type EApiKey = z.infer<typeof EApiKeySchema>;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { IsEntityID } from '../validators/entity-id.validator';
|
||||||
export const EImageSchema = z.object({
|
export const EImageSchema = z.object({
|
||||||
id: IsEntityID(),
|
id: IsEntityID(),
|
||||||
user_id: IsEntityID(),
|
user_id: IsEntityID(),
|
||||||
created: z.preprocess((data: any) => new Date(data), z.date()),
|
created: z.date(),
|
||||||
file_name: z.string(),
|
file_name: z.string(),
|
||||||
});
|
});
|
||||||
export type EImage = z.infer<typeof EImageSchema>;
|
export type EImage = z.infer<typeof EImageSchema>;
|
||||||
|
|
4
shared/src/validators/api-key.validator.ts
Normal file
4
shared/src/validators/api-key.validator.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const IsApiKey = () =>
|
||||||
|
z.string().regex(/^[a-zA-Z0-9]{32}$/, 'Invalid API key');
|
Loading…
Reference in a new issue