diff --git a/.github/ISSUE_TEMPLATE/01_installation.prolem.yml b/.github/ISSUE_TEMPLATE/01_installation.prolem.yml new file mode 100644 index 00000000..6348c629 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_installation.prolem.yml @@ -0,0 +1,19 @@ +name: "🙏🏻 Installation Problem" +description: "Report an issue with installation" +title: "Installation Problem" +labels: ["type: installation"] +body: + - type: markdown + attributes: + value: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance. + - type: textarea + id: feature-description + validations: + required: true + attributes: + label: For installation issues, please visit our https://discord.postiz.com for assistance. + description: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance. + placeholder: | + For installation issues, please visit our https://discord.postiz.com for assistance. + Please do not save this issue - do not submit installation issues on GitHub. + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/02_bug_report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/02_bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/03_feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/03_feature_request.yml diff --git a/README.md b/README.md index 37a882c3..17878c53 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ +
+
+
+
+
-
-
We are sending a t-shirt for every merged PR! (max 1 per person)
-Rules:
-diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index e66a13fd..65928b20 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Req, Res } from '@nestjs/common'; +import { Logger, Controller, Get, Post, Req, Res } from '@nestjs/common'; import { CopilotRuntime, OpenAIAdapter, @@ -13,6 +13,11 @@ export class CopilotController { constructor(private _subscriptionService: SubscriptionService) {} @Post('/chat') chat(@Req() req: Request, @Res() res: Response) { + if (process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '') { + Logger.warn('OpenAI API key not set, chat functionality will not work'); + return + } + const copilotRuntimeHandler = copilotRuntimeNestEndpoint({ endpoint: '/copilot/chat', runtime: new CopilotRuntime(), diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index f7c4ad74..ae587603 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -1,12 +1,5 @@ import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, - UseFilters, + Body, Controller, Delete, Get, Param, Post, Put, Query, UseFilters } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; @@ -29,6 +22,12 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; +import { + NotEnoughScopes, + RefreshToken, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { timer } from '@gitroom/helpers/utils/timer'; @ApiTags('Integrations') @Controller('/integrations') @@ -43,6 +42,37 @@ export class IntegrationsController { return this._integrationManager.getAllIntegrations(); } + @Get('/customers') + getCustomers(@GetOrgFromRequest() org: Organization) { + return this._integrationService.customers(org.id); + } + + @Put('/:id/group') + async updateIntegrationGroup( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { group: string } + ) { + return this._integrationService.updateIntegrationGroup( + org.id, + id, + body.group + ); + } + + @Put('/:id/customer-name') + async updateOnCustomerName( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { name: string } + ) { + return this._integrationService.updateOnCustomerName( + org.id, + id, + body.name + ); + } + @Get('/list') async getIntegrationList(@GetOrgFromRequest() org: Organization) { return { @@ -57,7 +87,7 @@ export class IntegrationsController { id: p.id, internalId: p.internalId, disabled: p.disabled, - picture: p.picture, + picture: p.picture || '/no-picture.jpg', identifier: p.providerIdentifier, inBetweenSteps: p.inBetweenSteps, refreshNeeded: p.refreshNeeded, @@ -66,6 +96,7 @@ export class IntegrationsController { time: JSON.parse(p.postingTimes), changeProfilePicture: !!findIntegration?.changeProfilePicture, changeNickName: !!findIntegration?.changeNickname, + customer: p.customer, }; }), }; @@ -207,11 +238,51 @@ export class IntegrationsController { } if (integrationProvider[body.name]) { - return integrationProvider[body.name]( - getIntegration.token, - body.data, - getIntegration.internalId - ); + try { + const load = await integrationProvider[body.name]( + getIntegration.token, + body.data, + getIntegration.internalId + ); + + return load; + } catch (err) { + if (err instanceof RefreshToken) { + const { accessToken, refreshToken, expiresIn } = + await integrationProvider.refreshToken( + getIntegration.refreshToken + ); + + if (accessToken) { + await this._integrationService.createOrUpdateIntegration( + getIntegration.organizationId, + getIntegration.name, + getIntegration.picture!, + 'social', + getIntegration.internalId, + getIntegration.providerIdentifier, + accessToken, + refreshToken, + expiresIn + ); + + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + return this.functionIntegration(org, body); + } else { + await this._integrationService.disconnectChannel( + org.id, + getIntegration + ); + return false; + } + } + + return false; + } } throw new Error('Function not found'); } @@ -323,6 +394,7 @@ export class IntegrationsController { } const { + error, accessToken, expiresIn, refreshToken, @@ -341,6 +413,17 @@ export class IntegrationsController { details ? JSON.parse(details) : undefined ); + if (typeof auth === 'string') { + return res({ + error: auth, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }); + } + if (refresh && integrationProvider.reConnect) { const newAuth = await integrationProvider.reConnect( auth.id, @@ -353,13 +436,31 @@ export class IntegrationsController { return res(auth); }); - if (!id) { - throw new Error('Invalid api key'); + if (error) { + throw new NotEnoughScopes(error); } + if (!id) { + throw new NotEnoughScopes('Invalid API key'); + } + + if (refresh && id !== refresh) { + throw new NotEnoughScopes( + 'Please refresh the channel that needs to be refreshed' + ); + } + + let validName = name; + if (!validName) { + if (username) { + validName = username.split('.')[0] ?? username; + } else { + validName = `Channel_${String(id).slice(0, 8)}`; + } + } return this._integrationService.createOrUpdateIntegration( org.id, - name, + validName.trim(), picture, 'social', String(id), @@ -446,4 +547,35 @@ export class IntegrationsController { return this._integrationService.deleteChannel(org.id, id); } + + @Get('/plug/list') + async getPlugList() { + return { plugs: this._integrationManager.getAllPlugs() }; + } + + @Get('/:id/plugs') + async getPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.getPlugsByIntegrationId(org.id, id); + } + + @Post('/:id/plugs') + async postPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body() body: PlugDto + ) { + return this._integrationService.createOrUpdatePlug(org.id, id, body); + } + + @Put('/plugs/:id/activate') + async changePlugActivation( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body('status') status: boolean + ) { + return this._integrationService.changePlugActivation(org.id, id, status); + } } diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index 9daccbef..b28619f4 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -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') diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 85311535..2a63ca8c 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -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 : '', }; } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 154dff84..2d3139d1 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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: 20, + }, + ]), + ], controllers: [], providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerBehindProxyGuard, + }, { provide: APP_GUARD, useClass: PoliciesGuard, diff --git a/apps/backend/src/public-api/public.api.module.ts b/apps/backend/src/public-api/public.api.module.ts new file mode 100644 index 00000000..ac4389d0 --- /dev/null +++ b/apps/backend/src/public-api/public.api.module.ts @@ -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); + } +} diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts new file mode 100644 index 00000000..af9913e7 --- /dev/null +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -0,0 +1,77 @@ +import { + Body, Controller, Get, HttpException, Post, 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'; + +@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 + ); + } + + @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, + }) + ); + } +} diff --git a/apps/backend/src/services/auth/public.auth.middleware.ts b/apps/backend/src/services/auth/public.auth.middleware.ts new file mode 100644 index 00000000..0c32fd32 --- /dev/null +++ b/apps/backend/src/services/auth/public.auth.middleware.ts @@ -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(); + } +} diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico old mode 100644 new mode 100755 index 317ebcb2..5b622e17 Binary files a/apps/frontend/public/favicon.ico and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/no-picture.jpg b/apps/frontend/public/no-picture.jpg new file mode 100644 index 00000000..7a8a8432 Binary files /dev/null and b/apps/frontend/public/no-picture.jpg differ diff --git a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx index 8a06e542..5beb10e0 100644 --- a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx +++ b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx @@ -28,7 +28,8 @@ export default async function Page({ }); if (data.status === HttpStatusCode.NotAcceptable) { - return redirect(`/launches?scope=missing`); + const { msg } = await data.json(); + return redirect(`/launches?msg=${msg}`); } if ( @@ -53,5 +54,5 @@ export default async function Page({ return redirect(`/launches?added=${provider}&continue=${id}`); } - return redirect(`/launches?added=${provider}`); + return redirect(`/launches?added=${provider}&msg=Channel Updated`); } diff --git a/apps/frontend/src/app/(site)/plugs/page.tsx b/apps/frontend/src/app/(site)/plugs/page.tsx new file mode 100644 index 00000000..64a417e3 --- /dev/null +++ b/apps/frontend/src/app/(site)/plugs/page.tsx @@ -0,0 +1,19 @@ +import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; + +export const dynamic = 'force-dynamic'; + +import { Metadata } from 'next'; +import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; + +export const metadata: Metadata = { + title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Plugs`, + description: '', +}; + +export default async function Index() { + return ( + <> +