Merge pull request #490 from gitroomhq/feat/public-api

Public API
This commit is contained in:
Nevo David 2024-12-14 17:31:34 +07:00 committed by GitHub
commit 1c877d2af2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 550 additions and 525 deletions

View File

@ -10,7 +10,6 @@ import handleR2Upload from '@gitroom/nestjs-libraries/upload/r2.uploader';
import { FileInterceptor } from '@nestjs/platform-express';
import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { basename } from 'path';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
@ApiTags('Media')

View File

@ -47,7 +47,7 @@ export class UsersController {
if (!organization) {
throw new HttpForbiddenException();
}
// @ts-ignore
return {
...user,
orgId: organization.id,
@ -61,6 +61,8 @@ export class UsersController {
isLifetime: !!organization?.subscription?.isLifetime,
admin: !!user.isSuperAdmin,
impersonate: !!req.cookies.impersonate,
// @ts-ignore
publicApi: (organization?.users[0]?.role === 'SUPERADMIN' || organization?.users[0]?.role === 'ADMIN') ? organization?.apiKey : '',
};
}

View File

@ -6,12 +6,31 @@ import { APP_GUARD } from '@nestjs/core';
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PluginModule } from '@gitroom/plugins/plugin.module';
import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
@Global()
@Module({
imports: [BullMqModule, DatabaseModule, ApiModule, PluginModule],
imports: [
BullMqModule,
DatabaseModule,
ApiModule,
PluginModule,
PublicApiModule,
ThrottlerModule.forRoot([
{
ttl: 3600000,
limit: 30,
},
]),
],
controllers: [],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerBehindProxyGuard,
},
{
provide: APP_GUARD,
useClass: PoliciesGuard,

View File

@ -0,0 +1,42 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
import { PermissionsService } from '@gitroom/backend/services/auth/permissions/permissions.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
import { PublicIntegrationsController } from '@gitroom/backend/public-api/routes/v1/public.integrations.controller';
import { PublicAuthMiddleware } from '@gitroom/backend/services/auth/public.auth.middleware';
const authenticatedController = [
PublicIntegrationsController
];
@Module({
imports: [
UploadModule,
],
controllers: [
...authenticatedController,
],
providers: [
AuthService,
StripeService,
OpenaiService,
ExtractContentService,
PoliciesGuard,
PermissionsService,
CodesService,
IntegrationManager,
],
get exports() {
return [...this.imports, ...this.providers];
},
})
export class PublicApiModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(PublicAuthMiddleware).forRoutes(...authenticatedController);
}
}

View File

@ -0,0 +1,98 @@
import {
Body,
Controller,
Get,
HttpException,
Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permissions.service';
import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
@ApiTags('Public API')
@Controller('/public/v1')
export class PublicIntegrationsController {
private storage = UploadFactory.createStorage();
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService,
private _mediaService: MediaService
) {}
@Post('/upload')
@UseInterceptors(FileInterceptor('file'))
async uploadSimple(
@GetOrgFromRequest() org: Organization,
@UploadedFile('file') file: Express.Multer.File
) {
if (!file) {
throw new HttpException({ msg: 'No file provided' }, 400);
}
const getFile = await this.storage.uploadFile(file);
return this._mediaService.saveFile(
org.id,
getFile.originalname,
getFile.path
);
}
@Get('/posts')
async getPosts(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsDto
) {
const posts = await this._postsService.getPosts(org.id, query);
return {
posts,
// comments,
};
}
@Post('/posts')
@CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH])
createPost(
@GetOrgFromRequest() org: Organization,
@Body() body: CreatePostDto
) {
console.log(JSON.stringify(body, null, 2));
return this._postsService.createPost(org.id, body);
}
@Get('/integrations')
async listIntegration(@GetOrgFromRequest() org: Organization) {
return (await this._integrationService.getIntegrationsList(org.id)).map(
(org) => ({
id: org.id,
name: org.name,
identifier: org.providerIdentifier,
picture: org.picture,
disabled: org.disabled,
profile: org.profile,
customer: org.customer
? {
id: org.customer.id,
name: org.customer.name,
}
: undefined,
})
);
}
}

View File

@ -81,6 +81,10 @@ export class AuthMiddleware implements NestMiddleware {
throw new HttpForbiddenException();
}
if (!setOrg.apiKey) {
await this._organizationService.updateApiKey(setOrg.id);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.user = user;

View File

@ -0,0 +1,35 @@
import { HttpStatus, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
@Injectable()
export class PublicAuthMiddleware implements NestMiddleware {
constructor(private _organizationService: OrganizationService) {}
async use(req: Request, res: Response, next: NextFunction) {
const auth = (req.headers.authorization ||
req.headers.Authorization) as string;
if (!auth) {
res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'No API Key found' });
return;
}
try {
const org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'Invalid API key' });
return ;
}
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'No subscription found' });
return ;
}
// @ts-ignore
req.org = {...org, users: [{users: {role: 'SUPERADMIN'}}]};
} catch (err) {
throw new HttpForbiddenException();
}
next();
}
}

View File

@ -18,6 +18,7 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { LogoutComponent } from '@gitroom/frontend/components/layout/logout.component';
import { useSearchParams } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { PublicComponent } from '@gitroom/frontend/components/public-api/public.component';
export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
const {isGeneral} = useVariables();
@ -195,6 +196,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
</div>
)}
{!!user?.tier?.team_members && isGeneral && <TeamsComponent />}
{!!user?.tier?.public_api && isGeneral && <PublicComponent />}
{showLogout && <LogoutComponent />}
</div>
</form>

View File

@ -12,6 +12,7 @@ export const UserContext = createContext<
| (User & {
orgId: string;
tier: PricingInnerInterface;
publicApi: string;
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
totalChannels: number;
isLifetime?: boolean;
@ -24,6 +25,7 @@ export const ContextWrapper: FC<{
orgId: string;
tier: 'FREE' | 'STANDARD' | 'PRO' | 'ULTIMATE' | 'TEAM';
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
publicApi: string;
totalChannels: number;
};
children: ReactNode;

View File

@ -0,0 +1,50 @@
'use client';
import { useState, useCallback } from 'react';
import { useUser } from '../layout/user.context';
import { Button } from '@gitroom/react/form/button';
import copy from 'copy-to-clipboard';
import { useToaster } from '@gitroom/react/toaster/toaster';
export const PublicComponent = () => {
const user = useUser();
const toaster = useToaster();
const [reveal, setReveal] = useState(false);
const copyToClipboard = useCallback(() => {
toaster.show('API Key copied to clipboard', 'success');
copy(user?.publicApi!);
}, [user]);
if (!user || !user.publicApi) {
return null;
}
return (
<div className="flex flex-col">
<h2 className="text-[24px]">Public API</h2>
<div className="text-customColor18 mt-[4px]">
Use Postiz API to integrate with your tools.
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth items-center border rounded-[4px] p-[24px] flex gap-[24px]">
<div className="flex items-center">
{reveal ? (
user.publicApi
) : (
<>
<div className="blur-sm">{user.publicApi.slice(0, -5)}</div>
<div>{user.publicApi.slice(-5)}</div>
</>
)}
</div>
<div>
{!reveal ? (
<Button onClick={() => setReveal(true)}>Reveal</Button>
) : (
<Button onClick={copyToClipboard}>Copy Key</Button>
)}
</div>
</div>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { Role, SubscriptionTier } from '@prisma/client';
import { Injectable } from '@nestjs/common';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
@Injectable()
export class OrganizationRepository {
@ -12,6 +13,23 @@ export class OrganizationRepository {
private _user: PrismaRepository<'user'>
) {}
getOrgByApiKey(api: string) {
return this._organization.model.organization.findFirst({
where: {
apiKey: api,
},
include: {
subscription: {
select: {
subscriptionTier: true,
totalChannels: true,
isLifetime: true,
},
},
},
});
}
getUserOrg(id: string) {
return this._userOrg.model.userOrganization.findFirst({
where: {
@ -83,6 +101,17 @@ export class OrganizationRepository {
});
}
updateApiKey(orgId: string) {
return this._organization.model.organization.update({
where: {
id: orgId,
},
data: {
apiKey: AuthService.fixedEncryption(makeId(20)),
},
});
}
async getOrgsByUserId(userId: string) {
return this._organization.model.organization.findMany({
where: {
@ -183,6 +212,7 @@ export class OrganizationRepository {
return this._organization.model.organization.create({
data: {
name: body.company,
apiKey: AuthService.fixedEncryption(makeId(20)),
users: {
create: {
role: Role.SUPERADMIN,

View File

@ -17,7 +17,10 @@ export class OrganizationService {
async createOrgAndUser(
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
) {
return this._organizationRepository.createOrgAndUser(body, this._notificationsService.hasEmailProvider());
return this._organizationRepository.createOrgAndUser(
body,
this._notificationsService.hasEmailProvider()
);
}
addUserToOrg(
@ -33,6 +36,10 @@ export class OrganizationService {
return this._organizationRepository.getOrgById(id);
}
getOrgByApiKey(api: string) {
return this._organizationRepository.getOrgByApiKey(api);
}
getUserOrg(id: string) {
return this._organizationRepository.getUserOrg(id);
}
@ -41,6 +48,10 @@ export class OrganizationService {
return this._organizationRepository.getOrgsByUserId(userId);
}
updateApiKey(orgId: string) {
return this._organizationRepository.updateApiKey(orgId);
}
getTeam(orgId: string) {
return this._organizationRepository.getTeam(orgId);
}
@ -82,6 +93,9 @@ export class OrganizationService {
}
disableOrEnableNonSuperAdminUsers(orgId: string, disable: boolean) {
return this._organizationRepository.disableOrEnableNonSuperAdminUsers(orgId, disable);
return this._organizationRepository.disableOrEnableNonSuperAdminUsers(
orgId,
disable
);
}
}

View File

@ -11,24 +11,25 @@ datasource db {
}
model Organization {
id String @id @default(uuid())
name String
description String?
users UserOrganization[]
media Media[]
paymentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
github GitHub[]
subscription Subscription?
Integration Integration[]
post Post[] @relation("organization")
submittedPost Post[] @relation("submittedForOrg")
Comments Comments[]
notifications Notifications[]
id String @id @default(uuid())
name String
description String?
apiKey String?
users UserOrganization[]
media Media[]
paymentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
github GitHub[]
subscription Subscription?
Integration Integration[]
post Post[] @relation("organization")
submittedPost Post[] @relation("submittedForOrg")
Comments Comments[]
notifications Notifications[]
buyerOrganization MessagesGroup[]
usedCodes UsedCodes[]
credits Credits[]
usedCodes UsedCodes[]
credits Credits[]
plugs Plugs[]
customers Customer[]
}

View File

@ -11,6 +11,7 @@ export interface PricingInnerInterface {
import_from_channels: boolean;
image_generator?: boolean;
image_generation_count: number;
public_api: boolean;
}
export interface PricingInterface {
[key: string]: PricingInnerInterface;
@ -29,6 +30,7 @@ export const pricing: PricingInterface = {
ai: false,
import_from_channels: false,
image_generator: false,
public_api: false,
},
STANDARD: {
current: 'STANDARD',
@ -43,6 +45,7 @@ export const pricing: PricingInterface = {
featured_by_gitroom: false,
import_from_channels: true,
image_generator: false,
public_api: true,
},
TEAM: {
current: 'TEAM',
@ -57,6 +60,7 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
PRO: {
current: 'PRO',
@ -71,6 +75,7 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
ULTIMATE: {
current: 'ULTIMATE',
@ -85,5 +90,6 @@ export const pricing: PricingInterface = {
ai: true,
import_from_channels: true,
image_generator: true,
public_api: true,
},
};

View File

@ -27,7 +27,7 @@ export class GetPostsDto {
@Type(() => Number)
@IsNumber()
@Max(52)
@Max(12)
@Min(1)
month: number;

View File

@ -0,0 +1,17 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
public override async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.switchToHttp().getRequest().url.includes('/public/v1')) {
return super.canActivate(context);
}
return true;
}
protected override async getTracker(req: Record<string, any>): Promise<string> {
return req.org.id;
}
}

707
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,7 @@
"@nestjs/platform-express": "^10.0.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^6.3.0",
"@nx/eslint": "19.7.2",
"@nx/eslint-plugin": "19.7.2",
"@nx/jest": "19.7.2",