diff --git a/.github/agents/ai-agents-cli.svg b/.github/agents/ai-agents-cli.svg
new file mode 100644
index 0000000000..620a2378ab
--- /dev/null
+++ b/.github/agents/ai-agents-cli.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/chatgpt.svg b/.github/agents/chatgpt.svg
new file mode 100644
index 0000000000..ae9dd30105
--- /dev/null
+++ b/.github/agents/chatgpt.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/claude-code.svg b/.github/agents/claude-code.svg
new file mode 100644
index 0000000000..7eff60377f
--- /dev/null
+++ b/.github/agents/claude-code.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/claude-cowork.svg b/.github/agents/claude-cowork.svg
new file mode 100644
index 0000000000..a56712dbe1
--- /dev/null
+++ b/.github/agents/claude-cowork.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/claude.svg b/.github/agents/claude.svg
new file mode 100644
index 0000000000..a56712dbe1
--- /dev/null
+++ b/.github/agents/claude.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/codex.svg b/.github/agents/codex.svg
new file mode 100644
index 0000000000..272e7dba4d
--- /dev/null
+++ b/.github/agents/codex.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/cursor.svg b/.github/agents/cursor.svg
new file mode 100644
index 0000000000..15f4fd625e
--- /dev/null
+++ b/.github/agents/cursor.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/grok-bot.svg b/.github/agents/grok-bot.svg
new file mode 100644
index 0000000000..711a07bcb5
--- /dev/null
+++ b/.github/agents/grok-bot.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/grok-build.svg b/.github/agents/grok-build.svg
new file mode 100644
index 0000000000..d7fe50c514
--- /dev/null
+++ b/.github/agents/grok-build.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/hermes-agent.svg b/.github/agents/hermes-agent.svg
new file mode 100644
index 0000000000..23e1afb212
--- /dev/null
+++ b/.github/agents/hermes-agent.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/mcp-server.svg b/.github/agents/mcp-server.svg
new file mode 100644
index 0000000000..f5b3f33d11
--- /dev/null
+++ b/.github/agents/mcp-server.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/muse.svg b/.github/agents/muse.svg
new file mode 100644
index 0000000000..40d5cd1d9c
--- /dev/null
+++ b/.github/agents/muse.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/nanoclaw.svg b/.github/agents/nanoclaw.svg
new file mode 100644
index 0000000000..a30d9658d6
--- /dev/null
+++ b/.github/agents/nanoclaw.svg
@@ -0,0 +1 @@
+n
diff --git a/.github/agents/openclaw.svg b/.github/agents/openclaw.svg
new file mode 100644
index 0000000000..7ec7b1da33
--- /dev/null
+++ b/.github/agents/openclaw.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/paperclip.svg b/.github/agents/paperclip.svg
new file mode 100644
index 0000000000..1a7a9bc4b2
--- /dev/null
+++ b/.github/agents/paperclip.svg
@@ -0,0 +1 @@
+
diff --git a/.github/agents/perplexity-computer.svg b/.github/agents/perplexity-computer.svg
new file mode 100644
index 0000000000..10599bbcd3
--- /dev/null
+++ b/.github/agents/perplexity-computer.svg
@@ -0,0 +1 @@
+
diff --git a/README.md b/README.md
index 5e47047bc9..7bfdd3fe11 100644
--- a/README.md
+++ b/README.md
@@ -13,31 +13,12 @@
-
- Your ultimate AI social media scheduling tool
- Postiz : An alternative to: Buffer.com, Hypefury, Twitter Hunter, etc...
+ Postiz is a social media management platform for scheduling, automating, and analyzing your content.
+
+ Use Postiz Cloud for a fully managed experience, or deploy the open-source edition on your own infrastructure.
- Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -65,7 +46,46 @@
Make.com integration
-
+
+
+Schedule posts to:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+With your favorite AI agent:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## 🔌 See the leading Postiz features
@@ -97,7 +117,6 @@
- Measure your work with analytics.
- Collaborate with other team members to exchange or buy posts.
- Invite your team members to collaborate, comment, and schedule posts.
-- At the moment, there is no difference between the hosted version and the self-hosted version
- Perfect for automation (API) with platforms like N8N, Make.com, Zapier, etc.
## Tech Stack
@@ -127,15 +146,53 @@ To have the project up and running, please follow the [Quick Start Guide](https:
## Sponsor Postiz
-We now give a few options to Sponsor Postiz:
+We now offer a few options to sponsor Postiz:
- Just a donation: You like what we are building, and want to buy us some coffee so we can build faster.
- Main repository: Get your logo with a backlink from the main Postiz repository. Postiz has over 7M downloads and 20k views per month.
Link: https://opencollective.com/postiz
+
+
+
+## Postiz Cloud vs. Open-source
+
+Choose [Postiz Cloud](https://postiz.com/) for a fully managed experience, or deploy Postiz Open-source on your own infrastructure. Both provide the same core Postiz product and features.
+
+We do not "gate" features or limit the license.
+
+The main difference is the infrastructure you need to own, approval from social media providers, and deployment that might be hard at times (let your LLM deploy it)
+
+| Area | Postiz Cloud | Postiz Open-source (self-hosted) |
+|---|---|---|
+| **Cost** | Subscription per plan, 7-day free trial | Free forever (AGPL-3.0); you pay only for your own infra |
+| **Setup time** | Sign up and connect channels in minutes | Deploy with Docker / Coolify / Railway / any VPS; you configure Postgres, Redis, storage and env vars |
+| **Hosting & data** | Hosted by Postiz; data stored in our infrastructure | Runs on your own server; data never leaves your environment |
+| **Social platform apps** | Pre-approved apps for every channel, ready to use | You create your own developer apps on each platform and go through their approval (Meta, YouTube, TikTok can take weeks) |
+| **Channels** | Limited by plan tier | Unlimited, every supported provider |
+| **Posts per month** | Limited by plan tier | Unlimited |
+| **Team members** | Limited by plan tier | Unlimited |
+| **Scheduling, calendar views, cross-posting, repeated posts, post comments & delays, sets, signatures** | Included | Included |
+| **Internal & Global Plugs, RSS auto-post, customer groups** | Included per plan | Included |
+| **Analytics** | Included per plan | Included (requires your own app credentials with analytics scopes) |
+| **AI Copilot, AI images, AI videos** | Included with monthly quotas per plan; keys managed by Postiz | Available if you bring your own OpenAI (and other provider) API keys; no quota, you pay the provider |
+| **AI video clipping** | Included with monthly clipping minutes per plan | Requires your own provider keys and extra configuration |
+| **Smart Agent** | Included per plan | Available with your own LLM key |
+| **Public API & webhooks** | Included per plan | Included |
+| **Agentic surfaces (MCP, CLI, Claude / ChatGPT / Codex / OpenClaw / Cursor connectors)** | Included, hosted MCP endpoint | Included, you point the MCP / CLI at your own instance |
+| **Custom integrations** | Included per plan | Included; you can also modify the code and add providers |
+| **Updates & maintenance** | Automatic, zero downtime for you | You pull new images and run migrations yourself |
+| **Uptime, backups, security patches** | Managed by Postiz | Your responsibility |
+| **Support** | Priority support via Discord / email per plan | Community support on Discord and GitHub |
+| **Source access & customization** | No (SaaS) | Full source code, fork and modify freely under AGPL |
+| **Compliance / data residency** | Postiz-controlled regions | Any region or air-gapped environment you choose |
+
+
+
+
## Postiz Compliance
-- Postiz is an open-source, self-hosted social media scheduling tool that supports platforms like X (formerly Twitter), Bluesky, Mastodon, Discord, and others.
+- This GitHub repository contains the open-source, self-hosted edition of Postiz. Postiz is also available as Postiz Cloud, a fully managed service at postiz.com.
- Postiz hosted service uses official, platform-approved OAuth flows.
- Postiz does not automate or scrape content from social media platforms.
- Postiz does not collect, store, or proxy API keys or access tokens from users.
diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts
index 5d6adf74ae..e334f9a3ac 100644
--- a/apps/backend/src/api/routes/auth.controller.ts
+++ b/apps/backend/src/api/routes/auth.controller.ts
@@ -25,7 +25,7 @@ import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { RealIP } from 'nestjs-real-ip';
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';
import { Provider } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import * as Sentry from '@sentry/nestjs';
import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social/farcaster.provider';
@@ -233,7 +233,7 @@ export class AuthController {
@Query() query: any,
@Res({ passthrough: true }) response: Response
) {
- const state = `login-${makeId(16)}`;
+ const state = `login-${makeSecureId(16)}`;
response.cookie('oauth_state', state, {
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
...(!process.env.NOT_SECURED
diff --git a/apps/backend/src/api/routes/enterprise.controller.ts b/apps/backend/src/api/routes/enterprise.controller.ts
index 5e9bbdc806..eebc630054 100644
--- a/apps/backend/src/api/routes/enterprise.controller.ts
+++ b/apps/backend/src/api/routes/enterprise.controller.ts
@@ -17,15 +17,34 @@ export class EnterpriseController {
private _postsService: PostsService
) {}
+ private verifyEnterpriseToken(params: string): T {
+ const payload = AuthService.verifyJWT(params) as any;
+ if (
+ !payload ||
+ typeof payload !== 'object' ||
+ 'providerName' in payload || // login token (full User row)
+ 'orgId' in payload || // team invite token
+ 'expires' in payload // password reset token
+ ) {
+ throw new Error('Invalid enterprise token');
+ }
+
+ return payload as T;
+ }
+
@Post('/create-user')
async createUser(@Body('params') params: string) {
try {
- const { id, name, saasName, email } = AuthService.verifyJWT(params) as {
+ const { id, name, saasName, email } = this.verifyEnterpriseToken<{
id: string;
name: string;
email: string;
saasName: string;
- };
+ }>(params);
+
+ if (!id || !saasName) {
+ return { success: false };
+ }
try {
return await this._organizationService.createMaxUser(
@@ -45,13 +64,13 @@ export class EnterpriseController {
@Post('/url')
async redirectParams(@Body('params') params: string) {
try {
- const load = AuthService.verifyJWT(params) as {
+ const load = this.verifyEnterpriseToken<{
redirectUrl: string;
apiKey: string;
refreshId?: string;
provider: string;
webhookUrl: string;
- };
+ }>(params);
if (!load || !load.redirectUrl || !load.apiKey || !load.provider) {
return;
@@ -94,10 +113,10 @@ export class EnterpriseController {
@Post('/delete-channel')
async deleteChannel(@Body('params') params: string) {
try {
- const load = AuthService.verifyJWT(params) as {
+ const load = this.verifyEnterpriseToken<{
apiKey: string;
id: string;
- };
+ }>(params);
if (!load || !load.apiKey || !load.id) {
return { success: false };
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
index 0258beb2cf..6a64c0752e 100644
--- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts
+++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts
@@ -33,6 +33,8 @@ import {
import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto';
import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto';
import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto';
+import { ClippingDto } from '@gitroom/nestjs-libraries/dtos/clipping/clipping.dto';
+import { ClippingService } from '@gitroom/nestjs-libraries/database/prisma/clipping/clipping.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto';
import * as Sentry from '@sentry/nestjs';
@@ -65,7 +67,8 @@ export class PublicIntegrationsController {
private _refreshIntegrationService: RefreshIntegrationService,
private _usersService: UsersService,
private _adminStatsService: AdminStatsService,
- private _organizationService: OrganizationService
+ private _organizationService: OrganizationService,
+ private _clippingService: ClippingService
) {}
@Post('/upload')
@@ -420,6 +423,30 @@ export class PublicIntegrationsController {
);
}
+ @Post('/clipping')
+ startClipping(
+ @GetOrgFromRequest() org: Organization,
+ @Body() body: ClippingDto
+ ) {
+ Sentry.metrics.count('public_api-request', 1);
+ return this._clippingService.startClipping(org, body);
+ }
+
+ @Get('/clipping')
+ getClippings(
+ @GetOrgFromRequest() org: Organization,
+ @Query('page') page: number
+ ) {
+ Sentry.metrics.count('public_api-request', 1);
+ return this._clippingService.getClippings(org.id, page);
+ }
+
+ @Get('/clipping/:id')
+ getClipping(@GetOrgFromRequest() org: Organization, @Param('id') id: string) {
+ Sentry.metrics.count('public_api-request', 1);
+ return this._clippingService.getClipping(org.id, id);
+ }
+
@Delete('/integrations/:id')
async deleteChannel(
@GetOrgFromRequest() org: Organization,
diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
index ed74769750..ae20e4836a 100644
--- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx
+++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
@@ -623,7 +623,10 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
{agent === apiTab ? (
apiSection
) : isChatOnlyMcpClient(agent) ? (
- chatSection
+ <>
+ {connectorSection}
+ {chatSection}
+ >
) : (
<>
{connectorSection}
diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx
index 5261826ceb..f9009ce23f 100644
--- a/apps/frontend/src/components/public-api/public.component.tsx
+++ b/apps/frontend/src/components/public-api/public.component.tsx
@@ -22,6 +22,11 @@ export const remoteMcpClients = {
'In ChatGPT go to Settings > Connectors > Create and paste this URL.',
} as const;
+// The upstream one-click connector directory URLs (claude.ai/directory,
+// chatgpt.com/plugins, cursor.com/marketplace) are deliberately NOT
+// reproduced here: they route this deployment's users to the upstream cloud.
+// Connector cards are brand-gated via brandConfig URLs instead.
+
// Clients with no MCP or CLI settings: you paste instructions into the chat,
// the agent installs the CLI itself and asks you for the API key.
// A function of the API base, because the upstream CLI defaults to the
diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json
index 6f8e45cbf2..f2f37513c0 100644
--- a/chatgpt-app-submission.json
+++ b/chatgpt-app-submission.json
@@ -199,6 +199,42 @@
"open_world_justification": "Reads internal Postiz data for the user's workspace only.",
"destructive_justification": "Read-only; it cannot change or delete anything."
}
+ },
+ "clippingTool": {
+ "annotations": {
+ "readOnlyHint": false,
+ "openWorldHint": true,
+ "destructiveHint": false
+ },
+ "justifications": {
+ "read_only_justification": "Starts a background clipping job that downloads a public YouTube video, renders short vertical clips with captions, consumes the user's clipping minutes, and saves every clip to the media library; when channels are passed it also creates draft posts.",
+ "open_world_justification": "Reads a public YouTube URL and calls external rendering and storage services to create media for the user's workspace.",
+ "destructive_justification": "Does not delete or overwrite existing media or posts, revoke access, or publish content; any posts it creates are drafts only."
+ }
+ },
+ "clippingStatusTool": {
+ "annotations": {
+ "readOnlyHint": true,
+ "openWorldHint": false,
+ "destructiveHint": false
+ },
+ "justifications": {
+ "read_only_justification": "Only reads the status of a clipping job the user started and returns the hosted clip URLs once it is done.",
+ "open_world_justification": "Does not write to public internet state or third-party systems.",
+ "destructive_justification": "Does not delete, overwrite, revoke access, or send content."
+ }
+ },
+ "clippingWidgetTicketTool": {
+ "annotations": {
+ "readOnlyHint": false,
+ "openWorldHint": false,
+ "destructiveHint": false
+ },
+ "justifications": {
+ "read_only_justification": "Creates a short-lived ticket for the clipping widget to read the progress of a clipping job; it is only callable by the widget, not by the model.",
+ "open_world_justification": "The ticket only allows reading the user's own clipping job inside Postiz; no public or third-party state changes.",
+ "destructive_justification": "Does not delete or overwrite anything, revoke access, or publish content."
+ }
}
},
"test_cases": [
diff --git a/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts b/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts
index 7304b741f9..3435eba5c4 100644
--- a/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts
+++ b/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts
@@ -38,6 +38,9 @@ export class ClippingTool implements AgentToolInterface {
description: `Turn a long YouTube video into short vertical clips with burned-in captions.
The best parts of the video are picked automatically, every clip is saved to the media library,
and when channels are passed a draft post is created for every clip on every channel (nothing is scheduled or published).
+ Before calling this tool, always ask the user how the horizontal video should fill the vertical clip, and wait for the answer:
+ "blur" keeps the whole picture over a blurred copy of itself, "crop" fills the clip with the middle of the picture and cuts the sides away.
+ Never pick one for the user, unless they already said which one they want in this conversation.
It uses the clipping minutes of the subscription: one minute for every minute of the source video.
Clipping takes several minutes, so this only starts it and returns a clippingId: tell the user it is running.
Some apps show a widget with the progress and report the finished clips in the conversation by themselves.
@@ -61,9 +64,8 @@ export class ClippingTool implements AgentToolInterface {
.describe('Maximum number of clips, 5 by default'),
fit: z
.enum(['crop', 'blur'])
- .optional()
.describe(
- 'How the horizontal video fills the vertical clip. "blur" (default) keeps the whole picture over a blurred copy of itself and is always safe. "crop" fills the clip with the middle of the picture and cuts the sides away: there is no face tracking, so a speaker who is not in the centre is cut out of the clip. Leave this empty unless the user explicitly asks for a cropped clip, and when they do, tell them that anything outside the centre of the picture will be lost.'
+ 'How the horizontal video fills the vertical clip, as answered by the user: ask them before calling this tool and never guess it. "blur" keeps the whole picture over a blurred copy of itself and is always safe. "crop" fills the clip with the middle of the picture and cuts the sides away: there is no face tracking, so a speaker who is not in the centre is cut out of the clip. When asking, tell the user that with "crop" anything outside the centre of the picture will be lost.'
),
}),
outputSchema: z.object({
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
index 39d11cbd7d..d7b670b2c8 100644
--- a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
@@ -110,7 +110,7 @@ If validation fails, the result contains output.errors describing what to fix; t
value: z
.any()
.describe(
- 'Value of the key, always prefer the id then label if possible'
+ 'Value of the key, always prefer the id then label if possible. When the settings schema says a field is an id, pass the id returned by the channel tools, never the display label'
),
})
)
diff --git a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts
index e3812b2203..99a95d3cb5 100644
--- a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts
+++ b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts
@@ -41,7 +41,7 @@ If validation fails, the result contains output.errors describing what to fix; t
value: z
.any()
.describe(
- 'New value of the key, always prefer the id then label if possible'
+ 'New value of the key, always prefer the id then label if possible. When the settings schema says a field is an id, pass the id returned by the channel tools, never the display label'
),
})
)
diff --git a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts
index 87f50f666c..428d3ac303 100644
--- a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts
@@ -3,7 +3,7 @@ import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth
import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto';
import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto';
import { RegisterClientDto } from '@gitroom/nestjs-libraries/dtos/oauth/register-client.dto';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { extractBearerToken } from '@gitroom/nestjs-libraries/chat/oauth-types';
import { createHash } from 'crypto';
@@ -54,8 +54,8 @@ export class OAuthService {
);
}
- const clientId = 'pca_' + makeId(32);
- const clientSecret = 'pcs_' + makeId(48);
+ const clientId = 'pca_' + makeSecureId(32);
+ const clientSecret = 'pcs_' + makeSecureId(48);
const encryptedSecret = AuthService.fixedEncryption(clientSecret);
const app = await this._oauthRepository.createApp(orgId, {
@@ -95,7 +95,7 @@ export class OAuthService {
throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND);
}
- const newSecret = 'pcs_' + makeId(48);
+ const newSecret = 'pcs_' + makeSecureId(48);
const encrypted = AuthService.fixedEncryption(newSecret);
await this._oauthRepository.updateClientSecret(orgId, encrypted);
return { clientSecret: newSecret };
@@ -187,8 +187,8 @@ export class OAuthService {
: dto.token_endpoint_auth_method === 'client_secret_basic'
? 'client_secret_basic'
: 'client_secret_post';
- const clientId = 'pcd_' + makeId(32);
- const clientSecret = isPublicClient ? undefined : 'pcs_' + makeId(48);
+ const clientId = 'pcd_' + makeSecureId(32);
+ const clientSecret = isPublicClient ? undefined : 'pcs_' + makeSecureId(48);
const app = await this._oauthRepository.createDynamicApp({
name: dto.client_name?.trim().slice(0, 100) || 'MCP Client',
@@ -310,7 +310,7 @@ export class OAuthService {
redirectUri?: string;
}
) {
- const code = makeId(32);
+ const code = makeSecureId(32);
const encryptedCode = AuthService.fixedEncryption(code);
const codeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
@@ -398,7 +398,7 @@ export class OAuthService {
);
}
- const token = 'pos_' + makeId(40);
+ const token = 'pos_' + makeSecureId(40);
const encryptedToken = AuthService.fixedEncryption(token);
const {
organizationId,
diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts
index b263134cbc..97d1f5c9b6 100644
--- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts
@@ -5,6 +5,7 @@ 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';
import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
@Injectable()
export class OrganizationRepository {
@@ -22,7 +23,7 @@ export class OrganizationRepository {
},
data: {
name: name ? `${name}###${id}` : `Unnamed User###${id}`,
- apiKey: AuthService.fixedEncryption(makeId(20)),
+ apiKey: AuthService.fixedEncryption(makeSecureId(20)),
isTrailing: false,
subscription: {
create: {
@@ -43,7 +44,7 @@ export class OrganizationRepository {
: `${saasName}+` + makeId(10) + '@postiz.com',
name: name ? `${name}###${id}` : `Unnamed User###${id}`,
providerName: 'LOCAL',
- password: AuthService.hashPassword(makeId(500)),
+ password: AuthService.hashPassword(makeSecureId(500)),
timezone: 0,
},
},
@@ -241,7 +242,7 @@ export class OrganizationRepository {
id: orgId,
},
data: {
- apiKey: AuthService.fixedEncryption(makeId(20)),
+ apiKey: AuthService.fixedEncryption(makeSecureId(20)),
},
});
}
@@ -468,7 +469,7 @@ export class OrganizationRepository {
data: {
...(body.orgId ? { id: body.orgId } : {}),
name: body.company,
- apiKey: AuthService.fixedEncryption(makeId(20)),
+ apiKey: AuthService.fixedEncryption(makeSecureId(20)),
allowTrial: true,
isTrailing: true,
users: {
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
index 1aa9e1538f..b6b3af92ed 100644
--- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
@@ -177,6 +177,11 @@ export class PostsRepository {
creationMethod: true,
settings: true,
tags: {
+ where: {
+ tag: {
+ deletedAt: null,
+ },
+ },
select: {
tag: true,
},
@@ -289,6 +294,11 @@ export class PostsRepository {
group: true,
creationMethod: true,
tags: {
+ where: {
+ tag: {
+ deletedAt: null,
+ },
+ },
select: {
tag: true,
},
@@ -348,6 +358,11 @@ export class PostsRepository {
include: {
integration: true,
tags: {
+ where: {
+ tag: {
+ deletedAt: null,
+ },
+ },
select: {
tag: true,
},
@@ -373,6 +388,11 @@ export class PostsRepository {
? {
integration: true,
tags: {
+ where: {
+ tag: {
+ deletedAt: null,
+ },
+ },
select: {
tag: true,
},
@@ -598,6 +618,7 @@ export class PostsRepository {
const tagsList = await this._tags.model.tags.findMany({
where: {
orgId: orgId,
+ deletedAt: null,
name: {
in: tags.map((tag) => tag.label).filter((f) => f),
},
@@ -909,8 +930,8 @@ export class PostsRepository {
});
}
- deleteTag(id: string, orgId: string) {
- return this._tags.model.tags.update({
+ async deleteTag(id: string, orgId: string) {
+ const tag = await this._tags.model.tags.update({
where: {
id,
orgId,
@@ -919,6 +940,14 @@ export class PostsRepository {
deletedAt: new Date(),
},
});
+
+ await this._tagsPosts.model.tagsPosts.deleteMany({
+ where: {
+ tagId: tag.id,
+ },
+ });
+
+ return tag;
}
createComment(
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
index aed79c8055..81779dfec9 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
@@ -1,5 +1,12 @@
import {
- IsDefined, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateIf
+ IsDefined,
+ IsOptional,
+ IsString,
+ IsUrl,
+ Matches,
+ MaxLength,
+ MinLength,
+ ValidateIf,
} from 'class-validator';
import { JSONSchema } from 'class-validator-jsonschema';
@@ -27,8 +34,13 @@ export class PinterestSettingsDto {
@MinLength(1, {
message: 'Board is required',
})
- @JSONSchema({
- description: 'board must be an id',
+ @Matches(/^\d+$/, {
+ message:
+ 'Board must be the numeric board id (use the boards list of the channel to find it), not the board name',
+ })
+ @JSONSchema({
+ description:
+ 'The numeric id of the board (from the boards list of the channel), not the board name',
})
board: string;
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
index 4efd91c26d..67521a3293 100644
--- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
@@ -5,7 +5,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { setHeartbeatDetails } from '@gitroom/nestjs-libraries/temporal/temporal.heartbeat';
import {
BadBody,
@@ -301,10 +301,10 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
index f0aee14a95..c80855135f 100644
--- a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
@@ -7,7 +7,7 @@ import {
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
@@ -24,10 +24,10 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
dto = DevToSettingsDto;
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
index 61608a56fc..3cc826fe8b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
@@ -61,14 +61,14 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: `https://discord.com/oauth2/authorize?client_id=${
process.env.DISCORD_CLIENT_ID
}&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/discord`
)}&integration_type=0&scope=bot+identify+guilds&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
index eab2e725aa..dc325e09b6 100644
--- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
@@ -5,9 +5,10 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import FormData from 'form-data';
import {
+ BadBody,
SocialAbstract,
ValidityMedia,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
@@ -109,14 +110,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: `https://dribbble.com/oauth/authorize?client_id=${
process.env.DRIBBBLE_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/dribbble`
)}&response_type=code&scope=${this.scopes.join('+')}&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
@@ -180,16 +181,31 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
formData.append('title', postDetails[0].settings.title);
formData.append('description', postDetails[0].message);
- const data2 = await this.getSsrfSafeAxios().post(
- 'https://api.dribbble.com/v2/shots',
- formData,
- {
- headers: {
- ...formData.getHeaders(),
- Authorization: `Bearer ${accessToken}`,
- },
+ let data2;
+ try {
+ data2 = await this.getSsrfSafeAxios().post(
+ 'https://api.dribbble.com/v2/shots',
+ formData,
+ {
+ headers: {
+ ...formData.getHeaders(),
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+ } catch (err: any) {
+ const status = err?.response?.status;
+ if (status >= 400 && status < 500 && status !== 429) {
+ throw new BadBody(
+ this.identifier,
+ JSON.stringify(err?.response?.data ?? {}),
+ '{}',
+ err?.response?.data?.message ||
+ `Dribbble rejected the shot with status ${status}`
+ );
}
- );
+ throw err;
+ }
const location = data2.headers['location'];
const newId = location.split('/').at(-1);
diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
index e009bd65b6..33c69599b7 100644
--- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
@@ -6,7 +6,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
import {
BadBody,
@@ -64,7 +64,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
status: number
):
| {
- type: 'refresh-token' | 'bad-body';
+ type: 'refresh-token' | 'bad-body' | 'retry';
value: string;
}
| undefined {
@@ -229,6 +229,43 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
value: 'Facebook return: No permission to publish the video',
};
}
+ if (/"error_subcode":459\b/.test(body)) {
+ return {
+ type: 'bad-body' as const,
+ value:
+ 'Facebook is asking you to resolve a security check. Log in at facebook.com, complete it, then try again',
+ };
+ }
+ if (/"error_subcode":492\b/.test(body)) {
+ return {
+ type: 'bad-body' as const,
+ value:
+ 'Your Facebook user no longer has a role on this Page. Ask a Page admin to grant you a role, then reconnect the channel',
+ };
+ }
+ if (body.indexOf('must be granted before impersonating') > -1) {
+ return {
+ type: 'refresh-token' as const,
+ value:
+ 'Facebook Page permissions are missing, please reconnect the channel and allow all permissions',
+ };
+ }
+ if (
+ /"error_subcode":33\b/.test(body) &&
+ body.indexOf('does not exist') > -1
+ ) {
+ return {
+ type: 'bad-body' as const,
+ value:
+ 'The Facebook Page or post this was targeting no longer exists, please reconnect the channel and schedule again',
+ };
+ }
+ if (body.indexOf('Sorry, something went wrong') > -1) {
+ return {
+ type: 'retry' as const,
+ value: 'Facebook is temporarily unavailable, please try again later',
+ };
+ }
if (body.indexOf('490') > -1) {
return {
type: 'refresh-token' as const,
@@ -260,7 +297,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url:
`https://www.facebook.com/${META_GRAPH_API_VERSION}/dialog/oauth` +
@@ -270,7 +307,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
)}` +
`&state=${state}` +
`&scope=${this.scopes.join(',')}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
@@ -985,12 +1022,18 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
// require Graph API v23.0+:
// - page_total_media_view_unique: total unique views on the page's media (reach)
// - page_media_view: total media views, broken down between paid and organic
- const { data } = await (
+ const { data, error } = await (
await fetch(
`https://graph.facebook.com/${META_GRAPH_API_VERSION}/${id}/insights?metric=page_total_media_view_unique,page_media_view,page_post_engagements,page_daily_follows&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
+ // Throw so checkAnalytics doesn't cache the empty result for an hour.
+ if (error) {
+ console.warn('Facebook page insights returned an error:', { id, error });
+ throw new Error(error.message);
+ }
+
// page_media_view returns paid/organic breakdowns as an object; sum them to
// keep the single-total UI working.
const sumValue = (value: any): number => {
@@ -1129,11 +1172,17 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
// - total_video_impressions: times the video was shown
// - total_video_views: 3s+ (or full, if shorter) plays
// - total_video_reactions_by_type_total: reactions object, keyed by type
+ // Reels never return the total_video_* metrics (the edge answers with an
+ // empty data array), only the reels ones, so both sets are requested at
+ // once and Graph simply omits the metrics that don't apply:
+ // - fb_reels_total_plays: plays including replays
+ // - post_video_likes_by_reaction_type: reactions object, keyed by type
+ // - post_video_social_actions: comments/shares object, keyed by type
// Use plain fetch (not this.fetch) so a `(#100) nonexisting field` / story
// response doesn't throw an ApplicationFailure — we want a quiet `[]` instead.
const { data, error } = await (
await fetch(
- `https://graph.facebook.com/${META_GRAPH_API_VERSION}/${videoId}/video_insights?metric=total_video_impressions,total_video_views,total_video_reactions_by_type_total&access_token=${accessToken}`
+ `https://graph.facebook.com/${META_GRAPH_API_VERSION}/${videoId}/video_insights?metric=total_video_impressions,total_video_views,total_video_reactions_by_type_total,fb_reels_total_plays,post_video_likes_by_reaction_type,post_video_social_actions&access_token=${accessToken}`
)
).json();
@@ -1171,7 +1220,12 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
label = 'Views';
total = String(value);
break;
+ case 'fb_reels_total_plays':
+ label = 'Plays';
+ total = String(value);
+ break;
case 'total_video_reactions_by_type_total':
+ case 'post_video_likes_by_reaction_type':
// This returns an object with reaction types
if (typeof value === 'object') {
const totalReactions = Object.values(
@@ -1181,6 +1235,16 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
total = String(totalReactions);
}
break;
+ case 'post_video_social_actions':
+ // This returns an object with action types (comments, shares)
+ if (typeof value === 'object') {
+ const totalActions = Object.values(
+ value as Record
+ ).reduce((sum: number, v: number) => sum + v, 0);
+ label = 'Engagement';
+ total = String(totalActions);
+ }
+ break;
}
if (label) {
diff --git a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
index c61a265ce4..dbbd137066 100644
--- a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
import {
SocialAbstract,
@@ -80,10 +80,10 @@ export class FarcasterProvider
}
async generateAuthUrl() {
- const state = makeId(17);
+ const state = makeSecureId(17);
return {
url: `${process.env.NEYNAR_CLIENT_ID}||${state}` || '',
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts
index 6f44d3f2c8..281c1d1bf3 100644
--- a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts
@@ -5,7 +5,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import {
@@ -157,7 +157,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(7);
+ const state = makeSecureId(7);
const { client } = clientAndGmb();
return {
url: client.generateAuthUrl({
@@ -167,7 +167,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/gmb`,
scope: this.scopes.slice(0),
}),
- codeVerifier: makeId(11),
+ codeVerifier: makeSecureId(11),
state,
};
}
@@ -508,8 +508,9 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
const postId = postData.name;
const locationId = id.split('/').pop();
- // GMB posts don't have direct URLs, but we can link to the business profile
- const releaseURL = `https://business.google.com/locations/${locationId}`;
+ const releaseURL =
+ postData.searchUrl ||
+ `https://business.google.com/locations/${locationId}`;
return [
{
diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
index 7eeadef06b..d2384db70e 100644
--- a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
@@ -4,13 +4,16 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import {
+ BadBody,
+ SocialAbstract,
+} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { tags } from '@gitroom/nestjs-libraries/integrations/social/hashnode.tags';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class HashnodeProvider extends SocialAbstract implements SocialProvider {
@@ -26,10 +29,10 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
dto = HashnodeSettingsDto;
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
@@ -69,7 +72,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
me: { name, id, profilePicture, username },
},
} = await (
- await fetch('https://gql.hashnode.com', {
+ await fetch('https://gql-beta.hashnode.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -122,7 +125,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
},
},
} = await (
- await fetch('https://gql.hashnode.com', {
+ await fetch('https://gql-beta.hashnode.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -174,17 +177,17 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
? { originalArticleURL: settings.canonical }
: {}),
contentMarkdown: postDetails?.[0].message,
- tags: settings.tags.map((tag: any) => ({ id: tag.value })),
+ tags: settings.tags.map((tag: any) => ({
+ slug: tags.find((t) => t.objectID === tag.value)?.slug,
+ })),
...(settings.subtitle ? { subtitle: settings.subtitle } : {}),
...(settings.main_image
? {
- coverImageOptions: {
- coverImageURL: `${
- settings?.main_image?.path?.indexOf('http') === -1
- ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}`
- : ``
- }${settings?.main_image?.path}`,
- },
+ coverImage: `${
+ settings?.main_image?.path?.indexOf('http') === -1
+ ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}`
+ : ``
+ }${settings?.main_image?.path}`,
}
: {}),
},
@@ -199,14 +202,8 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
{ pretty: true }
);
- const {
- data: {
- publishPost: {
- post: { id: postId, url },
- },
- },
- } = await (
- await this.fetch('https://gql.hashnode.com', {
+ const { data, errors } = await (
+ await this.fetch('https://gql-beta.hashnode.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -218,6 +215,21 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
})
).json();
+ if (errors?.length || !data?.publishPost?.post) {
+ throw new BadBody(
+ this.identifier,
+ JSON.stringify(errors || data || {}),
+ '{}',
+ errors?.[0]?.message || 'Hashnode could not publish the post'
+ );
+ }
+
+ const {
+ publishPost: {
+ post: { id: postId, url },
+ },
+ } = data;
+
return [
{
id: postDetails?.[0].id,
diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
index f8c9aa08cb..8800a5ce99 100644
--- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
@@ -6,7 +6,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import {
@@ -103,7 +103,7 @@ export class InstagramProvider
status: number
):
| {
- type: 'refresh-token' | 'bad-body' | 'retry';
+ type: 'refresh-token' | 'bad-body' | 'retry' | 'disconnect';
value: string;
}
| undefined {
@@ -330,6 +330,20 @@ export class InstagramProvider
};
}
+ // Meta put the account behind a checkpoint: the token is still valid, so a
+ // refresh cannot help and every post fails until the user logs in on
+ // Instagram and re-connects the channel.
+ if (
+ body.indexOf('You cannot access the app till you log in to') > -1 ||
+ body.indexOf('Session key is malformed') > -1
+ ) {
+ return {
+ type: 'disconnect' as const,
+ value:
+ 'Instagram requires you to log in at instagram.com and follow its instructions before posting can resume. After that, please reconnect this channel.',
+ };
+ }
+
if (body.indexOf('190,') > -1) {
return {
type: 'bad-body' as const,
@@ -423,7 +437,7 @@ export class InstagramProvider
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url:
`https://www.facebook.com/${META_GRAPH_API_VERSION}/dialog/oauth` +
@@ -433,7 +447,7 @@ export class InstagramProvider
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
index 5cbd5df80c..db023cdf58 100644
--- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
import {
SocialAbstract,
@@ -68,7 +68,10 @@ export class InstagramStandaloneProvider
body: string,
status: number
):
- | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
+ | {
+ type: 'refresh-token' | 'bad-body' | 'retry' | 'disconnect';
+ value: string;
+ }
| undefined {
return instagramProvider.handleErrors(body, status);
}
@@ -103,7 +106,7 @@ export class InstagramStandaloneProvider
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url:
`https://www.instagram.com/oauth/authorize?enable_fb_login=0&client_id=${
@@ -117,7 +120,7 @@ export class InstagramStandaloneProvider
)}&response_type=code&scope=${encodeURIComponent(
this.scopes.join(',')
)}` + `&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts
index 8c2e66ef01..a830cdb7b0 100644
--- a/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts
@@ -5,6 +5,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@@ -68,7 +69,7 @@ export class KickProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(32);
+ const state = makeSecureId(32);
const { codeVerifier, codeChallenge } = this.generatePKCE();
const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/kick`;
diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
index a46b38452b..726559491b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
@@ -4,8 +4,10 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import {
+ BadBody,
+ RefreshToken,
SocialAbstract,
ValidityMedia,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
@@ -28,6 +30,65 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
}
dto = LemmySettingsDto;
+ override handleErrors(
+ body: string,
+ status: number
+ ):
+ | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
+ | undefined {
+ if (body.includes('rate_limit_error')) {
+ return {
+ type: 'retry',
+ value: 'Lemmy rate limit reached, please try again later',
+ };
+ }
+
+ if (body.includes('not_logged_in') || body.includes('incorrect_login')) {
+ return {
+ type: 'refresh-token',
+ value: 'Lemmy session is no longer valid, please reconnect the channel',
+ };
+ }
+
+ if (body.includes('site_ban') || body.includes('"error":"banned"')) {
+ return {
+ type: 'bad-body',
+ value: 'This account is banned on the Lemmy instance',
+ };
+ }
+
+ if (body.includes('couldnt_find_community')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'The selected Lemmy community no longer exists, please pick another one',
+ };
+ }
+
+ if (body.includes('blocked_url')) {
+ return {
+ type: 'bad-body',
+ value: 'The Lemmy instance blocks the URL in this post',
+ };
+ }
+
+ if (body.includes('"error":"deleted"')) {
+ return {
+ type: 'bad-body',
+ value: 'The selected Lemmy community or post was deleted',
+ };
+ }
+
+ if (body.includes('"error":"locked"')) {
+ return {
+ type: 'bad-body',
+ value: 'This Lemmy post is locked, comments cannot be added',
+ };
+ }
+
+ return undefined;
+ }
+
override async checkValidity(
items: Array
): Promise {
@@ -81,10 +142,10 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
@@ -149,20 +210,46 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
- const { jwt } = await (
- await fetch(body.service + '/api/v3/user/login', {
- // @ts-ignore - undici-only option; blocks SSRF to internal IPs
- dispatcher: getSsrfSafeDispatcher(),
- body: JSON.stringify({
- username_or_email: body.identifier,
- password: body.password,
- }),
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- ).json();
+ const options = {
+ // @ts-ignore - undici-only option; blocks SSRF to internal IPs
+ dispatcher: getSsrfSafeDispatcher(),
+ body: JSON.stringify({
+ username_or_email: body.identifier,
+ password: body.password,
+ }),
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ let login: Response;
+ try {
+ login = await this.fetch(body.service + '/api/v3/user/login', options);
+ } catch (err) {
+ // The request body holds the stored password, so the failure is rebuilt
+ // without it before it reaches the Temporal history and the Errors table.
+ const json = (err as any).details?.[0]?.json || '{}';
+ if (err instanceof BadBody) {
+ throw new BadBody(
+ this.identifier,
+ json,
+ {} as BodyInit,
+ err.message || 'Unknown Error'
+ );
+ }
+ if (err instanceof RefreshToken) {
+ throw new RefreshToken(
+ this.identifier,
+ json,
+ {} as BodyInit,
+ err.message || 'Unknown Error'
+ );
+ }
+ throw err;
+ }
+
+ const { jwt } = await login.json();
return { jwt, service: body.service };
}
@@ -179,18 +266,8 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
const valueArray: PostResponse[] = [];
for (const lemmy of firstPost.settings.subreddit) {
- console.log({
- community_id: +lemmy.value.id,
- name: lemmy.value.title,
- body: firstPost.message,
- ...(lemmy.value.url ? { url: lemmy.value.url } : {}),
- ...(firstPost.media?.length
- ? { custom_thumbnail: firstPost.media[0].path }
- : {}),
- nsfw: false,
- });
const { post_view } = await (
- await fetch(service + '/api/v3/post', {
+ await this.fetch(service + '/api/v3/post', {
// @ts-ignore - undici-only option; blocks SSRF to internal IPs
dispatcher: getSsrfSafeDispatcher(),
body: JSON.stringify({
@@ -253,7 +330,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
for (const singlePostId of postIds) {
const { comment_view } = await (
- await fetch(service + '/api/v3/comment', {
+ await this.fetch(service + '/api/v3/comment', {
// @ts-ignore - undici-only option; blocks SSRF to internal IPs
dispatcher: getSsrfSafeDispatcher(),
body: JSON.stringify({
@@ -305,7 +382,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
const { jwt, service } = await this.getJwtAndService(integration);
const { communities } = await (
- await fetch(
+ await this.fetch(
service + `/api/v3/search?type_=Communities&sort=Active&q=${data.word}`,
{
// @ts-ignore - undici-only option; blocks SSRF to internal IPs
diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
index 0e933e1d97..607289ef0f 100644
--- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
@@ -5,7 +5,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@@ -121,8 +121,8 @@ export class LinkedinPageProvider
}
override async generateAuthUrl() {
- const state = makeId(6);
- const codeVerifier = makeId(30);
+ const state = makeSecureId(6);
+ const codeVerifier = makeSecureId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&prompt=none&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
index a4b6a9494f..0ec0b376a3 100644
--- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
@@ -6,6 +6,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import sharp from 'sharp';
import { lookup } from 'mime-types';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
@@ -178,8 +179,8 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
- const codeVerifier = makeId(30);
+ const state = makeSecureId(6);
+ const codeVerifier = makeSecureId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&prompt=none&redirect_uri=${encodeURIComponent(
diff --git a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts
index 371126c229..9366e50306 100644
--- a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts
@@ -1,4 +1,4 @@
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '../social.abstract';
import {
AuthTokenDetails,
@@ -63,10 +63,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
index 40b1c281cc..11e34e7456 100644
--- a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import { Integration } from '@prisma/client';
@@ -42,7 +42,7 @@ export class MastodonCustomProvider extends MastodonProvider {
refresh?: string,
external?: ClientInformation
) {
- const state = makeId(6);
+ const state = makeSecureId(6);
const url = this.generateUrlDynamic(
external?.instanceUrl!,
state,
@@ -53,7 +53,7 @@ export class MastodonCustomProvider extends MastodonProvider {
return {
url,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
index ee97692c95..c0ec3920f9 100644
--- a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
@@ -6,6 +6,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import {
BadBody,
RefreshToken,
@@ -96,7 +97,7 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
const url = this.generateUrlDynamic(
process.env.MASTODON_URL || 'https://mastodon.social',
state,
@@ -105,7 +106,7 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
);
return {
url,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
index 5aab52d644..9f46f6fb4f 100644
--- a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
@@ -7,7 +7,7 @@ import {
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
@@ -24,10 +24,10 @@ export class MediumProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts
index 51c51c5350..fddd1a94a1 100644
--- a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts
@@ -5,6 +5,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@@ -79,7 +80,7 @@ export class MeweProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url:
`${this.meweHost}/login` +
@@ -88,7 +89,7 @@ export class MeweProvider extends SocialAbstract implements SocialProvider {
`${process.env.FRONTEND_URL}/integrations/social/mewe`
)}` +
`&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts
index 2bc8db4e30..b4c145ba6d 100644
--- a/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@@ -37,10 +37,10 @@ export class MoltbookProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
index c89f21dd77..873adbcb2e 100644
--- a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { getPublicKey, Relay, finalizeEvent, SimplePool } from 'nostr-tools';
@@ -63,10 +63,10 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(17);
+ const state = makeSecureId(17);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
index 7de3797745..40251c71d2 100644
--- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
@@ -7,7 +7,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto';
import FormData from 'form-data';
import { timer } from '@gitroom/helpers/utils/timer';
@@ -128,7 +128,10 @@ export class PinterestProvider
'Pinterest was unable to reach the URL provided. Please check the link and try again.',
};
}
- if (body.indexOf(`does not match '^\\\\\\\\\\\\\\\\d+$'`) > -1) {
+ if (
+ body.indexOf("does not match '^") > -1 &&
+ body.indexOf("d+$'") > -1
+ ) {
return {
type: 'bad-body' as const,
value:
@@ -192,7 +195,7 @@ export class PinterestProvider
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: `https://www.pinterest.com/oauth/?client_id=${
process.env.PINTEREST_CLIENT_ID
@@ -201,7 +204,7 @@ export class PinterestProvider
)}&response_type=code&scope=${encodeURIComponent(
'boards:read,boards:write,pins:read,pins:write,user_accounts:read'
)}&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
index 0c4183828b..34f630d0ee 100644
--- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
@@ -5,11 +5,13 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto';
import { timer } from '@gitroom/helpers/utils/timer';
import {
BadBody,
+ Disconnect,
RefreshToken,
SocialAbstract,
ValidityMedia,
@@ -598,6 +600,14 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
// Reddit rejects submissions with a 200 and an errors array: surface the
// real reason instead of failing later with an unknown outcome.
if (all?.json?.errors?.length) {
+ // A rate limit is a refusal, nothing was submitted: disarm the marker so
+ // the next check re-arms this subreddit and submits it again once the
+ // window has passed, instead of failing the whole post.
+ if (all.json.errors.every((e: any[]) => e?.[0] === 'RATELIMIT')) {
+ data.armed = undefined;
+ return { status: 'pending', pendingData: data };
+ }
+
throw new BadBody(
this.identifier,
JSON.stringify(all),
@@ -766,11 +776,19 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
],
})
async subreddits(accessToken: string, data: any) {
+ // A pasted "r/name" or reddit.com/r/name URL: search by the name alone (the
+ // full URL matches nothing) and put that exact subreddit first if it exists.
+ const named = String(data.word || '').match(/(?:^|\/)r\/([A-Za-z0-9_]+)/);
+ const word = named ? named[1] : data.word;
+ const exact = named
+ ? await this.subredditByName(accessToken, named[1])
+ : [];
+
const {
data: { children },
} = await (
await this.fetch(
- `https://oauth.reddit.com/subreddits/search?show=public&q=${data.word}&sort=activity&show_users=false&limit=10`,
+ `https://oauth.reddit.com/subreddits/search?show=public&q=${word}&sort=activity&show_users=false&limit=10`,
{
method: 'GET',
headers: {
@@ -784,16 +802,59 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
)
).json();
- return children
- .filter(
- ({ data }: { data: any }) =>
- data.subreddit_type === 'public' && data.submission_type !== 'image'
- )
- .map(({ data: { title, url, id } }: any) => ({
- title,
- name: url,
- id,
- }));
+ return [
+ ...exact,
+ ...children
+ .filter(
+ ({ data }: { data: any }) =>
+ data.subreddit_type === 'public' &&
+ data.submission_type !== 'image' &&
+ !exact.some((e) => e.id === data.id)
+ )
+ .map(({ data: { title, url, id } }: any) => ({
+ title,
+ name: url,
+ id,
+ })),
+ ];
+ }
+
+ private async subredditByName(accessToken: string, name: string) {
+ let about: any;
+ try {
+ about = await (
+ await this.fetch(
+ `https://oauth.reddit.com/r/${name}/about`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ },
+ 'reddit',
+ 0,
+ false
+ )
+ ).json();
+ } catch (err) {
+ if (err instanceof RefreshToken || err instanceof Disconnect) {
+ throw err;
+ }
+ return [];
+ }
+
+ if (
+ about?.kind !== 't5' ||
+ about.data.subreddit_type !== 'public' ||
+ about.data.submission_type === 'image'
+ ) {
+ return [];
+ }
+
+ return [
+ { title: about.data.title, name: about.data.url, id: about.data.id },
+ ];
}
private getPermissions(submissionType: string, allow_images: string) {
diff --git a/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
index a0df90f76d..c116634f7a 100644
--- a/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts
@@ -1,4 +1,4 @@
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { BadBody, SocialAbstract } from '../social.abstract';
import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import {
@@ -81,10 +81,10 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
index bcbe01dbe8..481c19ff5b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
@@ -4,8 +4,13 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
-import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
+import {
+ BadBody,
+ RefreshToken,
+ SocialAbstract,
+ ValidityMedia,
+} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
@@ -27,6 +32,20 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
];
dto = SlackDto;
+ // Media goes out as Block Kit image blocks, which Slack only accepts for
+ // png / jpg / gif; an mp4 makes chat.postMessage reject the whole message.
+ override async checkValidity(
+ posts: Array
+ ): Promise {
+ const hasVideo = posts?.some((post) =>
+ post?.some((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1)
+ );
+ if (hasVideo) {
+ return 'No video support for Slack, only images';
+ }
+ return true;
+ }
+
maxLength() {
return 400000;
}
@@ -43,7 +62,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
};
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: `https://slack.com/oauth/v2/authorize?client_id=${
@@ -55,7 +74,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
: ''
}${process?.env?.FRONTEND_URL}/integrations/social/slack`
)}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
@@ -131,6 +150,32 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
}));
}
+ // Slack answers HTTP 200 with { ok: false, error } on failures, so the post
+ // used to be marked completed with no message in the channel.
+ private checkApiError(all: any) {
+ if (all?.ok !== false) {
+ return;
+ }
+ const json = JSON.stringify(all);
+ const message =
+ [all.error, ...(all.errors || [])].filter(Boolean).join(': ') ||
+ 'Slack rejected the request';
+ if (
+ [
+ 'invalid_auth',
+ 'token_revoked',
+ 'token_expired',
+ 'account_inactive',
+ ].includes(all.error)
+ ) {
+ throw new RefreshToken(this.identifier, json, Buffer.from('{}'), message);
+ }
+ if (all.error === 'ratelimited') {
+ throw new Error(message);
+ }
+ throw new BadBody(this.identifier, json, Buffer.from('{}'), message);
+ }
+
async post(
id: string,
accessToken: string,
@@ -153,7 +198,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
});
// Post the main message
- const { ts, channel: responseChannel } = await (
+ const posted = await (
await fetch(`https://slack.com/api/chat.postMessage`, {
method: 'POST',
headers: {
@@ -183,6 +228,8 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
}),
})
).json();
+ this.checkApiError(posted);
+ const { ts, channel: responseChannel } = posted;
// Get permalink for the message
const { permalink } = await (
@@ -220,7 +267,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
const threadTs = lastCommentId || postId;
// Post the threaded reply
- const { ts, channel: responseChannel } = await (
+ const posted = await (
await fetch(`https://slack.com/api/chat.postMessage`, {
method: 'POST',
headers: {
@@ -251,6 +298,8 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
}),
})
).json();
+ this.checkApiError(posted);
+ const { ts, channel: responseChannel } = posted;
// Get permalink for the comment
const { permalink } = await (
diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
index bcdc7a096c..c6bb19dc42 100644
--- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
@@ -4,7 +4,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
//@ts-ignore
@@ -43,10 +43,10 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(17);
+ const state = makeSecureId(17);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
index 08f8988979..cac4d40bc4 100644
--- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
@@ -6,7 +6,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import {
@@ -40,7 +40,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
override handleErrors(body: string):
| {
- type: 'refresh-token' | 'bad-body';
+ type: 'refresh-token' | 'bad-body' | 'retry';
value: string;
}
| undefined {
@@ -71,6 +71,13 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
"One of the media URLs is invalid or inaccessible, make sure it's being uploaded to Postiz first",
};
}
+ if (body.includes('4279009')) {
+ return {
+ type: 'retry',
+ value:
+ 'Threads could not find the media container yet, please try again in a few seconds',
+ };
+ }
if (body.includes('text must be at most 500 characters')) {
return {
type: 'bad-body',
@@ -104,7 +111,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url:
'https://www.threads.net/oauth/authorize' +
@@ -118,7 +125,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts
index 6eab54c392..3fdaeb3f7c 100644
--- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts
@@ -7,6 +7,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import dayjs from 'dayjs';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import {
BadBody,
Disconnect,
@@ -344,7 +345,7 @@ export class TiktokBusinessProvider
}
async generateAuthUrl() {
- const state = Math.random().toString(36).substring(2);
+ const state = makeSecureId(16);
return {
url:
diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
index 2ce190280f..63612dd2c3 100644
--- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
@@ -7,6 +7,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import dayjs from 'dayjs';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import {
BadBody,
Disconnect,
@@ -338,7 +339,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = Math.random().toString(36).substring(2);
+ const state = makeSecureId(16);
return {
url:
diff --git a/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
index 9761021c80..11353cdc60 100644
--- a/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts
@@ -9,7 +9,7 @@ import {
SocialAbstract,
ValidityMedia,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { TumblrDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tumblr.dto';
import { Integration } from '@prisma/client';
import FormDataUpload from 'form-data';
@@ -264,7 +264,7 @@ export class TumblrProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
const redirectUri = this.redirectUri();
const params = new URLSearchParams({
client_id: process.env.TUMBLR_CLIENT_ID!,
@@ -276,7 +276,7 @@ export class TumblrProvider extends SocialAbstract implements SocialProvider {
return {
url: `https://www.tumblr.com/oauth2/authorize?${params.toString()}`,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts
index c88480360f..37c6c2ae3a 100644
--- a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts
@@ -5,6 +5,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
import { TwitchDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/twitch.dto';
@@ -54,7 +55,7 @@ export class TwitchProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(32);
+ const state = makeSecureId(32);
const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/twitch`;
@@ -68,7 +69,7 @@ export class TwitchProvider extends SocialAbstract implements SocialProvider {
return {
url,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
index 26fa196af2..f9db95efff 100644
--- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
@@ -4,9 +4,13 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
-import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import {
+ BadBody,
+ RefreshToken,
+ SocialAbstract,
+} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { createHash, randomBytes } from 'crypto';
import FormDataNew from 'form-data';
import mime from 'mime-types';
@@ -40,7 +44,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
formData.append('refresh_token', oldRefreshToken);
formData.append('client_id', process.env.VK_ID!);
formData.append('device_id', device_id);
- formData.append('state', makeId(32));
+ formData.append('state', makeSecureId(32));
formData.append('scope', this.scopes.join(' '));
const { access_token, refresh_token, expires_in } = await (
@@ -75,7 +79,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(32);
+ const state = makeSecureId(32);
const codeVerifier = randomBytes(64).toString('base64url');
const challenge = Buffer.from(
createHash('sha256').update(codeVerifier).digest()
@@ -229,6 +233,23 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
);
}
+ // VK answers HTTP 200 with { error } instead of { response } on failures,
+ // so this.fetch never sees them and the post used to be marked completed.
+ private checkApiError(all: any) {
+ if (!all?.error) {
+ return;
+ }
+ const json = JSON.stringify(all);
+ const message = all.error.error_msg || 'VK rejected the request';
+ if (all.error.error_code === 5) {
+ throw new RefreshToken(this.identifier, json, Buffer.from('{}'), message);
+ }
+ if ([6, 9, 29].includes(all.error.error_code)) {
+ throw new Error(message);
+ }
+ throw new BadBody(this.identifier, json, Buffer.from('{}'), message);
+ }
+
async post(
userId: string,
accessToken: string,
@@ -249,7 +270,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
);
}
- const { response } = await (
+ const all = await (
await this.fetch(
`https://api.vk.com/method/wall.post?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`,
{
@@ -258,6 +279,8 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
}
)
).json();
+ this.checkApiError(all);
+ const { response } = all;
return [
{
@@ -293,7 +316,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
);
}
- const { response } = await (
+ const all = await (
await this.fetch(
`https://api.vk.com/method/wall.createComment?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`,
{
@@ -302,6 +325,8 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
}
)
).json();
+ this.checkApiError(all);
+ const { response } = all;
return [
{
diff --git a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts
index 86295a57ce..6130381d93 100644
--- a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts
@@ -6,7 +6,7 @@ import {
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { timer } from '@gitroom/helpers/utils/timer';
import {
BadBody,
@@ -103,10 +103,10 @@ export class WhopProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = this.generateCodeChallenge(codeVerifier);
- const nonce = makeId(16);
+ const nonce = makeSecureId(16);
return {
url:
diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
index d7ba3d7731..2f9b597847 100644
--- a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
@@ -7,7 +7,7 @@ import {
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto';
import slugify from 'slugify';
// import FormData from 'form-data';
@@ -31,10 +31,10 @@ export class WordpressProvider
}
async generateAuthUrl() {
- const state = makeId(6);
+ const state = makeSecureId(6);
return {
url: state,
- codeVerifier: makeId(10),
+ codeVerifier: makeSecureId(10),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
index 51512fbdf2..969f70d23c 100644
--- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
@@ -161,6 +161,12 @@ export class XProvider extends SocialAbstract implements SocialProvider {
value: 'X is currently unavailable, please try again later',
};
}
+ if (body.includes('Too Many Requests')) {
+ return {
+ type: 'retry',
+ value: 'X rate limit reached, please try again later',
+ };
+ }
if (body.includes('maximum of one cashtag')) {
return {
type: 'bad-body',
@@ -232,6 +238,57 @@ export class XProvider extends SocialAbstract implements SocialProvider {
'The video you are trying to post is longer than 2 minutes, which is not allowed for this account',
};
}
+ if (
+ body.includes(
+ 'This user is not allowed to post a video longer than 10 minutes'
+ )
+ ) {
+ return {
+ type: 'bad-body',
+ value:
+ 'The video you are trying to post is longer than 10 minutes, which is not allowed for this account',
+ };
+ }
+ if (body.includes('Your account is temporarily locked')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'Your X account is temporarily locked, log in to x.com to unlock it and then try again',
+ };
+ }
+ if (body.includes('Crypto addresses are prohibited')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'X does not allow crypto addresses in posts for the first 7 days after connecting the account',
+ };
+ }
+ if (body.includes('Your media IDs are invalid')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'X rejected the attached media, please re-upload the media and try again',
+ };
+ }
+ if (body.includes('not authorized to create or publish articles')) {
+ return {
+ type: 'bad-body',
+ value: 'Publishing articles on X requires an X Premium subscription',
+ };
+ }
+ if (body.includes('Please include either text or media in your Tweet')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'One of the posts in this thread has no text or media, please add some text or remove it',
+ };
+ }
+ if (body.includes('"title":"Unauthorized"')) {
+ return {
+ type: 'refresh-token',
+ value: 'X rejected the connected account, please reconnect your account',
+ };
+ }
return undefined;
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
index 2e9263bcae..7c5be707dd 100644
--- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
@@ -7,7 +7,7 @@ import {
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration } from '@prisma/client';
-import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto';
@@ -283,7 +283,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
}
async generateAuthUrl() {
- const state = makeId(7);
+ const state = makeSecureId(7);
const { client } = clientAndYoutube();
return {
url: client.generateAuthUrl({
@@ -293,7 +293,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
scope: this.scopes.slice(0),
}),
- codeVerifier: makeId(11),
+ codeVerifier: makeSecureId(11),
state,
};
}
diff --git a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts
index c59c598a4c..8c14ff3eba 100644
--- a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts
+++ b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts
@@ -67,8 +67,15 @@ export const initializeSentry = (appName: string, allowLogs = false) => {
const path = String(
normalizedRequest?.url || attributes?.['http.target'] || attributes?.['url.path'] || name || ''
);
+ const method = String(
+ normalizedRequest?.method || attributes?.['http.request.method'] || attributes?.['http.method'] || ''
+ );
+ // MCP stream GETs are declined with 405; never trace them
+ if (method === 'GET' && /^(https?:\/\/[^/]+)?\/mcp(\/|-oauth|\?|$)/.test(path)) {
+ return 0;
+ }
return inheritOrSampleWith(
- path.includes('/public/v1/analytics/') ? 0.01 : 0.2
+ path.includes('/public/v1/analytics/') ? 0.01 : 0.1
);
},
enableLogs: true,
diff --git a/libraries/nestjs-libraries/src/services/make.secure.id.ts b/libraries/nestjs-libraries/src/services/make.secure.id.ts
new file mode 100644
index 0000000000..2760b2aa7b
--- /dev/null
+++ b/libraries/nestjs-libraries/src/services/make.secure.id.ts
@@ -0,0 +1,17 @@
+import { randomInt } from 'crypto';
+
+// Same alphabet and shape as makeId, but every character comes from the
+// OS entropy pool instead of Math.random. Use this for anything that acts
+// as a credential: tokens, secrets, api keys, oauth state and PKCE verifiers.
+// makeId stays as it is because it is also imported by the frontend and by
+// Temporal workflow files, where the crypto module is not available.
+export const makeSecureId = (length: number) => {
+ let text = '';
+ const possible =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+ for (let i = 0; i < length; i += 1) {
+ text += possible.charAt(randomInt(possible.length));
+ }
+ return text;
+};
diff --git a/libraries/nestjs-libraries/src/upload/r2.uploader.ts b/libraries/nestjs-libraries/src/upload/r2.uploader.ts
index 3164d5ed81..f753c4967b 100644
--- a/libraries/nestjs-libraries/src/upload/r2.uploader.ts
+++ b/libraries/nestjs-libraries/src/upload/r2.uploader.ts
@@ -232,9 +232,15 @@ export async function completeMultipartUpload(req: Request, res: Response) {
const prefix = Buffer.concat(chunks);
const detected = await fileTypeFromBuffer(prefix);
- // a .mov with an ISO brand sniffs as video/mp4; the normalizer reads both
+ // .mov and .mp4 are the same ISO BMFF family: a .mov with an ISO brand
+ // sniffs as video/mp4 and a QuickTime-brand file is often named .mp4.
+ // The normalizer reads both and always writes an mp4, so accept both
+ // for either extension whenever it is on; without it .mp4 stays strict.
const acceptedMimes =
- safeExt === '.mov' ? ['video/quicktime', 'video/mp4'] : [expectedMime];
+ UploadFactory.processorEnabled() &&
+ (safeExt === '.mov' || safeExt === '.mp4')
+ ? ['video/quicktime', 'video/mp4']
+ : [expectedMime];
if (!detected || !acceptedMimes.includes(detected.mime)) {
await R2.send(
new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key })
diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
index cde3b58c26..a103b42959 100644
--- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
+++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
@@ -17,6 +17,7 @@ export const initializeSentryBasic = (
/^NetworkError when attempting to fetch resource\.$/i,
/^NetworkError when attempting to fetch resource\. .*/i,
/^Object captured as promise rejection with keys: code, message$/i,
+ /^Called on script loaded before session recording is available$/i,
];
// Browser wallet extensions (Phantom, MetaMask, etc.) reject with a plain
@@ -55,7 +56,7 @@ export const initializeSentryBasic = (
sendDefaultPii: true,
...extension,
debug: environment === 'development',
- tracesSampleRate: 0.2,
+ tracesSampleRate: 0.1,
// Server tracing starts before the proxy exchanges the launch ticket.
beforeSendTransaction(event) {