diff --git a/.env.example b/.env.example index 7ca10d13b..d1144f664 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,11 @@ # Configuration reference: http://docs.postiz.com/configuration/reference -# === Required Settings +# === Required Settings DATABASE_URL="postgresql://postiz-user:postiz-password@localhost:5432/postiz-db-local" REDIS_URL="redis://localhost:6379" JWT_SECRET="random string for your JWT secret, make it long" + +# === This needs to be exactly the URL you're accessing Postiz on. FRONTEND_URL="http://localhost:4200" NEXT_PUBLIC_BACKEND_URL="http://localhost:3000" BACKEND_INTERNAL_URL="http://localhost:3000" @@ -77,6 +79,7 @@ MASTODON_CLIENT_SECRET="" OPENAI_API_KEY="" NEXT_PUBLIC_DISCORD_SUPPORT="" NEXT_PUBLIC_POLOTNO="" +NOT_SECURED=false # Payment settings FEE_AMOUNT=0.05 diff --git a/.gitignore b/.gitignore index 3d97dd0e5..304585422 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ node_modules *.launch .settings/ *.sublime-workspace +.vscode/* # IDE - VSCode .vscode/* diff --git a/SECURITY.md b/SECURITY.md index e37fd76a3..79ff527f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,8 +9,7 @@ The Postiz app is committed to ensuring the security and integrity of our users' If you discover a security vulnerability in the Postiz app, please report it to us privately via email to one of the maintainers: * @nevo-david -* @jamesread ([email](mailto:contact@jread.com)) -* @jonathan-irvin ([email](mailto:offendingcommit@gmail.com)) +* @egelhaus ([email](mailto:gelhausenno@outlook.de)) When reporting a security vulnerability, please provide as much detail as possible, including: diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index e2999098b..f623162ab 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -86,30 +86,37 @@ export class IntegrationsController { @Get('/list') async getIntegrationList(@GetOrgFromRequest() org: Organization) { return { - integrations: ( - await this._integrationService.getIntegrationsList(org.id) - ).map((p) => { - const findIntegration = this._integrationManager.getSocialIntegration( - p.providerIdentifier - ); - return { - name: p.name, - id: p.id, - internalId: p.internalId, - disabled: p.disabled, - picture: p.picture || '/no-picture.jpg', - identifier: p.providerIdentifier, - inBetweenSteps: p.inBetweenSteps, - refreshNeeded: p.refreshNeeded, - display: p.profile, - type: p.type, - time: JSON.parse(p.postingTimes), - changeProfilePicture: !!findIntegration?.changeProfilePicture, - changeNickName: !!findIntegration?.changeNickname, - customer: p.customer, - additionalSettings: p.additionalSettings || '[]', - }; - }), + integrations: await Promise.all( + (await this._integrationService.getIntegrationsList(org.id)).map( + async (p) => { + const findIntegration = + this._integrationManager.getSocialIntegration( + p.providerIdentifier + ); + return { + name: p.name, + id: p.id, + internalId: p.internalId, + disabled: p.disabled, + picture: p.picture || '/no-picture.jpg', + identifier: p.providerIdentifier, + inBetweenSteps: p.inBetweenSteps, + refreshNeeded: p.refreshNeeded, + isCustomFields: !!findIntegration.customFields, + ...(findIntegration.customFields + ? { customFields: await findIntegration.customFields() } + : {}), + display: p.profile, + type: p.type, + time: JSON.parse(p.postingTimes), + changeProfilePicture: !!findIntegration?.changeProfilePicture, + changeNickName: !!findIntegration?.changeNickname, + customer: p.customer, + additionalSettings: p.additionalSettings || '[]', + }; + } + ) + ), }; } @@ -612,9 +619,7 @@ export class IntegrationsController { } @Get('/telegram/updates') - async getUpdates( - @Query() query: { word: string; id?: number }, - ) { + async getUpdates(@Query() query: { word: string; id?: number }) { return new TelegramProvider().getBotId(query); } } diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index e2b9bf16f..358232ae0 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -60,7 +60,7 @@ export class UsersController { ...user, orgId: organization.id, // @ts-ignore - totalChannels: organization?.subscription?.totalChannels || pricing.FREE.channel, + totalChannels: !process.env.STRIPE_PUBLISHABLE_KEY ? 10000 : organization?.subscription?.totalChannels || pricing.FREE.channel, // @ts-ignore tier: organization?.subscription?.subscriptionTier || (!process.env.STRIPE_PUBLISHABLE_KEY ? 'ULTIMATE' : 'FREE'), diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index 0b8d8036c..2b287e5cd 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -1,4 +1,10 @@ -import { FC, MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { + FC, + MouseEventHandler, + useCallback, + useMemo, + useState, +} from 'react'; import { useClickOutside } from '@mantine/hooks'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; @@ -11,6 +17,9 @@ import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture'; import { CustomerModal } from '@gitroom/frontend/components/launches/customer.modal'; import { Integration } from '@prisma/client'; import { SettingsModal } from '@gitroom/frontend/components/launches/settings.modal'; +import { string } from 'yup'; +import { CustomVariables } from '@gitroom/frontend/components/launches/add.provider.component'; +import { useRouter } from 'next/navigation'; export const Menu: FC<{ canEnable: boolean; @@ -35,6 +44,7 @@ export const Menu: FC<{ refreshChannel, } = props; const fetch = useFetch(); + const router = useRouter(); const { integrations } = useCalendar(); const toast = useToaster(); const modal = useModals(); @@ -209,6 +219,23 @@ export const Menu: FC<{ setShow(false); }, [integrations]); + const updateCredentials = useCallback(() => { + modal.openModal({ + title: '', + withCloseButton: false, + classNames: { + modal: 'bg-transparent text-textColor', + }, + children: ( + router.push(url)} + variables={findIntegration.customFields} + /> + ), + }); + }, []); + return (
e.stopPropagation()} className={`absolute top-[100%] left-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder ${interClass} text-nowrap`} > - {canDisable && findIntegration?.refreshNeeded && ( + {canDisable && + findIntegration?.refreshNeeded && + !findIntegration.customFields && ( +
+
+ + + +
+
Reconnect channel
+
+ )} + {!!findIntegration?.isCustomFields && (
-
Reconnect channel
+
Update Credentials
)} {findIntegration?.additionalSettings !== '[]' && ( diff --git a/apps/frontend/src/components/launches/providers/bluesky/bluesky.provider.tsx b/apps/frontend/src/components/launches/providers/bluesky/bluesky.provider.tsx index c505774fa..cfc1f98cf 100644 --- a/apps/frontend/src/components/launches/providers/bluesky/bluesky.provider.tsx +++ b/apps/frontend/src/components/launches/providers/bluesky/bluesky.provider.tsx @@ -1,6 +1,9 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; export default withProvider(null, undefined, undefined, async (posts) => { + if (posts.some((p) => p.some(a => a.path.indexOf('mp4') > -1))) { + return 'At the moment BlueSky does not support video posts.'; + } if (posts.some((p) => p.length > 4)) { return 'There can be maximum 4 pictures in a post.'; } diff --git a/apps/frontend/src/components/launches/providers/mastodon/mastodon.provider.tsx b/apps/frontend/src/components/launches/providers/mastodon/mastodon.provider.tsx index 08680e28b..538c72c9b 100644 --- a/apps/frontend/src/components/launches/providers/mastodon/mastodon.provider.tsx +++ b/apps/frontend/src/components/launches/providers/mastodon/mastodon.provider.tsx @@ -1,3 +1,3 @@ import { FC } from 'react'; import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; -export default withProvider(null, undefined, undefined, undefined); +export default withProvider(null, undefined, undefined, undefined, 500); diff --git a/apps/frontend/src/components/launches/providers/tiktok/tiktok.provider.tsx b/apps/frontend/src/components/launches/providers/tiktok/tiktok.provider.tsx index 6f2ca47cc..58d0db423 100644 --- a/apps/frontend/src/components/launches/providers/tiktok/tiktok.provider.tsx +++ b/apps/frontend/src/components/launches/providers/tiktok/tiktok.provider.tsx @@ -33,6 +33,17 @@ const privacyLevel = [ }, ]; +const contentPostingMethod = [ + { + value: 'DIRECT_POST', + label: 'Post content directly to TikTok', + }, + { + value: 'UPLOAD', + label: 'Upload content to TikTok without posting it', + }, +]; + const yesNo = [ { value: 'true', @@ -109,12 +120,16 @@ const TikTokSettings: FC<{ values?: any }> = (props) => { const disclose = watch('disclose'); const brand_organic_toggle = watch('brand_organic_toggle'); const brand_content_toggle = watch('brand_content_toggle'); +const content_posting_method = watch('content_posting_method'); + + const isUploadMode = content_posting_method === 'UPLOAD'; return (
+
+ {`Choose upload without posting if you want to review and edit your content within TikTok's app before publishing. + This gives you access to TikTok's built-in editing tools and lets you make final adjustments before posting.`} +
+
Allow User To:
= (props) => { = (props) => { = (props) => { = (props) => {
)}
- Turn on to disclose that this video promotes good or services in + Turn on to disclose that this video promotes goods or services in exchange for something of value. You video could promote yourself, a third party, or both.
@@ -193,6 +230,7 @@ const TikTokSettings: FC<{ values?: any }> = (props) => { = (props) => { { const fetch = useFetch(); @@ -43,6 +46,33 @@ export const ConnectChannels: FC = () => { // [] // ); + const openWeb3 = async (id: string) => { + const { component: Web3Providers } = web3List.find( + (item) => item.identifier === id + )!; + + const { url } = await (await fetch(`/integrations/social/${id}`)).json(); + + setShowCustom( + { + if (await deleteDialog('Connection found, should we continue?', 'Continue')) { + window.open( + `/integrations/social/${id}?code=${code}&state=${newState}`, + 'Social Connect', + 'width=700,height=700' + ); + return ; + } + + setShowCustom(undefined); + }} + nonce={url} + /> + ); + return; + }; + const refreshChannel = useCallback( (integration: Integration & { identifier: string }) => async () => { const { url } = await ( @@ -82,6 +112,7 @@ export const ConnectChannels: FC = () => { ( identifier: string, isExternal: boolean, + isWeb3: boolean, customFields?: Array<{ key: string; label: string; @@ -124,6 +155,11 @@ export const ConnectChannels: FC = () => { // return; // } + if (isWeb3) { + openWeb3(identifier); + return; + } + if (customFields) { setShowCustom( { onClick={getSocialLink( social.identifier, social.isExternal, + social.isWeb3, social.customFields )} className="h-[96px] bg-input flex flex-col justify-center items-center gap-[10px] cursor-pointer" diff --git a/apps/workers/src/app/posts.controller.ts b/apps/workers/src/app/posts.controller.ts index a4f33a5c8..f644dc32c 100644 --- a/apps/workers/src/app/posts.controller.ts +++ b/apps/workers/src/app/posts.controller.ts @@ -15,4 +15,9 @@ export class PostsController { async payout(data: { id: string; releaseURL: string }) { return this._postsService.payout(data.id, data.releaseURL); } + + @EventPattern('sendDigestEmail', Transport.REDIS) + async sendDigestEmail(data: { subject: string, org: string; since: string }) { + return this._postsService.sendDigestEmail(data.subject, data.org, data.since); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index d8e92687e..b16a74d8a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -170,6 +170,10 @@ export class IntegrationRepository { : '[]', }, update: { + ...(additionalSettings + ? { additionalSettings: JSON.stringify(additionalSettings) } + : {}), + ...(customInstanceDetails ? { customInstanceDetails } : {}), type: type as any, ...(!refresh ? { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 2985469f3..3512d00f1 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -210,7 +210,7 @@ export class IntegrationService { const integrations = ( await this._integrationRepository.getIntegrationsList(org) ).filter((f) => !f.disabled); - if (integrations.length >= totalChannels) { + if (!!process.env.STRIPE_PUBLISHABLE_KEY && integrations.length >= totalChannels) { throw new Error('You have reached the maximum number of channels'); } diff --git a/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts b/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts index af007660d..c100e8321 100644 --- a/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts @@ -2,13 +2,17 @@ import { Injectable } from '@nestjs/common'; import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository'; import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; +import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import dayjs from 'dayjs'; @Injectable() export class NotificationService { constructor( private _notificationRepository: NotificationsRepository, private _emailService: EmailService, - private _organizationRepository: OrganizationRepository + private _organizationRepository: OrganizationRepository, + private _workerServiceProducer: BullMqClient ) {} getMainPageCount(organizationId: string, userId: string) { @@ -25,12 +29,58 @@ export class NotificationService { ); } - async inAppNotification(orgId: string, subject: string, message: string, sendEmail = false) { + getNotificationsSince(organizationId: string, since: string) { + return this._notificationRepository.getNotificationsSince( + organizationId, + since + ); + } + + async inAppNotification( + orgId: string, + subject: string, + message: string, + sendEmail = false, + digest = false + ) { + const date = new Date().toISOString(); await this._notificationRepository.createNotification(orgId, message); if (!sendEmail) { return; } + if (digest) { + await ioRedis.watch('digest_' + orgId); + const value = await ioRedis.get('digest_' + orgId); + if (value) { + return; + } + + await ioRedis + .multi() + .set('digest_' + orgId, date) + .expire('digest_' + orgId, 60) + .exec(); + + this._workerServiceProducer.emit('sendDigestEmail', { + id: 'digest_' + orgId, + options: { + delay: 60000, + }, + payload: { + subject, + org: orgId, + since: date, + }, + }); + + return; + } + + await this.sendEmailsToOrg(orgId, subject, message); + } + + async sendEmailsToOrg(orgId: string, subject: string, message: string) { const userOrg = await this._organizationRepository.getAllUsersOrgs(orgId); for (const user of userOrg?.users || []) { await this.sendEmail(user.user.email, subject, message); diff --git a/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts b/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts index 2ac87cc50..86cb37ceb 100644 --- a/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts @@ -45,6 +45,17 @@ export class NotificationsRepository { }); } + async getNotificationsSince(organizationId: string, since: string) { + return this._notifications.model.notifications.findMany({ + where: { + organizationId, + createdAt: { + gte: new Date(since), + }, + }, + }); + } + async getNotifications(organizationId: string, userId: string) { const { lastReadNotifications } = (await this.getLastReadNotification( userId diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 30c588027..89d7390f1 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -46,11 +46,13 @@ export class PostsService { async getStatistics(orgId: string, id: string) { const getPost = await this.getPostsRecursively(id, true, orgId, true); const content = getPost.map((p) => p.content); - const shortLinksTracking = await this._shortLinkService.getStatistics(content); + const shortLinksTracking = await this._shortLinkService.getStatistics( + content + ); return { - clicks: shortLinksTracking - } + clicks: shortLinksTracking, + }; } async getPostsRecursively( @@ -363,7 +365,10 @@ export class PostsService { `Your post has been published on ${capitalize( integration.providerIdentifier )}`, - `Your post has been published at ${publishedPosts[0].releaseURL}`, + `Your post has been published on ${capitalize( + integration.providerIdentifier + )} at ${publishedPosts[0].releaseURL}`, + true, true ); @@ -517,10 +522,10 @@ export class PostsService { const post = await this._postRepository.deletePost(orgId, group); if (post?.id) { await this._workerServiceProducer.delete('post', post.id); - return {id: post.id}; + return { id: post.id }; } - return {error: true}; + return { error: true }; } async countPostsFromDay(orgId: string, date: Date) { @@ -566,8 +571,10 @@ export class PostsService { async createPost(orgId: string, body: CreatePostDto) { const postList = []; for (const post of body.posts) { - const messages = post.value.map(p => p.content); - const updateContent = !body.shortLink ? messages : await this._shortLinkService.convertTextToShortLinks(orgId, messages); + const messages = post.value.map((p) => p.content); + const updateContent = !body.shortLink + ? messages + : await this._shortLinkService.convertTextToShortLinks(orgId, messages); post.value = post.value.map((p, i) => ({ ...p, @@ -624,7 +631,7 @@ export class PostsService { postList.push({ postId: posts[0].id, integration: post.integration.id, - }) + }); } return postList; @@ -878,4 +885,23 @@ export class PostsService { ) { return this._postRepository.createComment(orgId, userId, postId, comment); } + + async sendDigestEmail(subject: string, orgId: string, since: string) { + const getNotificationsForOrgSince = + await this._notificationService.getNotificationsSince(orgId, since); + if (getNotificationsForOrgSince.length === 0) { + return; + } + + const message = getNotificationsForOrgSince + .map((p) => p.content) + .join('
'); + await this._notificationService.sendEmailsToOrg( + orgId, + getNotificationsForOrgSince.length === 1 + ? subject + : '[Postiz] Your latest notifications', + message + ); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index e7bb98fa4..a8645cc2e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -75,6 +75,10 @@ export class SubscriptionService { totalChannels: number, billing: 'FREE' | 'STANDARD' | 'PRO' ) { + if (!customerId) { + return false; + } + const getOrgByCustomerId = await this._subscriptionRepository.getOrganizationByCustomerId( customerId @@ -84,6 +88,11 @@ export class SubscriptionService { (await this._subscriptionRepository.getSubscriptionByCustomerId( customerId ))!; + + if (getCurrentSubscription && getCurrentSubscription?.isLifetime) { + return false; + } + const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE']; const to = pricing[billing]; @@ -114,6 +123,8 @@ export class SubscriptionService { ); } + return true; + // if (to.faq < from.faq) { // await this._faqRepository.deleteFAQs(getCurrentSubscription?.organizationId, from.faq - to.faq); // } @@ -143,7 +154,18 @@ export class SubscriptionService { org?: string ) { if (!code) { - await this.modifySubscription(customerId, totalChannels, billing); + try { + const load = await this.modifySubscription( + customerId, + totalChannels, + billing + ); + if (!load) { + return {}; + } + } catch (e) { + return {}; + } } return this._subscriptionRepository.createOrUpdateSubscription( identifier, diff --git a/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts index 0505e2b00..25f633a94 100644 --- a/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts @@ -1,29 +1,31 @@ -import {IsDefined, IsEmail, IsString, MinLength, ValidateIf} from "class-validator"; +import {IsDefined, IsEmail, IsString, MaxLength, MinLength, ValidateIf} from "class-validator"; import {Provider} from '@prisma/client'; export class CreateOrgUserDto { - @IsString() - @MinLength(3) - @IsDefined() - @ValidateIf(o => !o.providerToken) - password: string; + @IsString() + @MinLength(3) + @MaxLength(64) + @IsDefined() + @ValidateIf((o) => !o.providerToken) + password: string; - @IsString() - @IsDefined() - provider: Provider; + @IsString() + @IsDefined() + provider: Provider; - @IsString() - @IsDefined() - @ValidateIf(o => !o.password) - providerToken: string; + @IsString() + @IsDefined() + @ValidateIf((o) => !o.password) + providerToken: string; - @IsEmail() - @IsDefined() - @ValidateIf(o => !o.providerToken) - email: string; + @IsEmail() + @IsDefined() + @ValidateIf((o) => !o.providerToken) + email: string; - @IsString() - @IsDefined() - @MinLength(3) - company: string; + @IsString() + @IsDefined() + @MinLength(3) + @MaxLength(128) + company: string; } diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts index 28c49024f..91eece6cc 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts @@ -32,4 +32,8 @@ export class TikTokDto { @IsIn(['true']) @IsDefined() isValidVideo: boolean; + + @IsIn(['DIRECT_POST', 'UPLOAD']) + @IsString() + content_posting_method: 'DIRECT_POST' | 'UPLOAD'; } diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts index 361949101..f2d01a743 100644 --- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -6,7 +6,7 @@ import { } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { - NotEnoughScopes, + RefreshToken, SocialAbstract, } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { BskyAgent, RichText } from '@atproto/api'; @@ -69,7 +69,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider { { key: 'identifier', label: 'Identifier', - validation: `/^.{3,}$/`, + validation: `/^.+$/`, type: 'text' as const, }, { @@ -152,21 +152,27 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider { service: body.service, }); - await agent.login({ - identifier: body.identifier, - password: body.password, - }); + try { + await agent.login({ + identifier: body.identifier, + password: body.password, + }); + } catch (err) { + throw new RefreshToken('bluesky', JSON.stringify(err), {} as BodyInit); + } let loadCid = ''; let loadUri = ''; const cidUrl = [] as { cid: string; url: string; rev: string }[]; for (const post of postDetails) { const images = await Promise.all( - post.media?.map(async (p) => { - return await agent.uploadBlob( - new Blob([await reduceImageBySize(p.url)]) - ); - }) || [] + post.media + ?.filter((p) => p.url.indexOf('mp4') === -1) + .map(async (p) => { + return await agent.uploadBlob( + new Blob([await reduceImageBySize(p.url)]) + ); + }) || [] ); const rt = new RichText({ diff --git a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts index 21833c1d5..6c97e547a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts @@ -117,7 +117,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider { ).json(); return list - .filter((p: any) => p.type === 0 || p.type === 15) + .filter((p: any) => p.type === 0 || p.type === 5 || p.type === 15) .map((p: any) => ({ id: String(p.id), name: p.name, diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts index e43da8091..79c310763 100644 --- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -7,10 +7,15 @@ import { import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import dayjs from 'dayjs'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +//@ts-ignore +import mime from 'mime'; import TelegramBot from 'node-telegram-bot-api'; import { Integration } from '@prisma/client'; const telegramBot = new TelegramBot(process.env.TELEGRAM_TOKEN!); +// Added to support local storage posting +const frontendURL = process.env.FRONTEND_URL || 'http://localhost:5000'; +const mediaStorage = process.env.STORAGE_PROVIDER || 'local'; export class TelegramProvider extends SocialAbstract implements SocialProvider { identifier = 'telegram'; @@ -47,7 +52,7 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { }) { const chat = await telegramBot.getChat(params.code); - console.log(JSON.stringify(chat)) + console.log(JSON.stringify(chat)); if (!chat?.id) { return 'No chat found'; } @@ -55,8 +60,10 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { const photo = !chat?.photo?.big_file_id ? '' : await telegramBot.getFileLink(chat.photo.big_file_id); + + // Modified id to work with chat.username (public groups/channels) or chat.id (private groups/channels) when chat.username is not available return { - id: String(chat.username), + id: String(chat.username ? chat.username : chat.id), name: chat.title!, accessToken: String(chat.id), refreshToken: '', @@ -67,21 +74,62 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { } async getBotId(query: { id?: number; word: string }) { + // Added allowed_updates Ensure only necessary updates are fetched const res = await telegramBot.getUpdates({ ...(query.id ? { offset: query.id } : {}), + allowed_updates: ['message', 'channel_post'], }); + //message.text is for groups, channel_post.text is for channels + const match = res.find( + (p) => + (p?.message?.text === `/connect ${query.word}` && + p?.message?.chat?.id) || + (p?.channel_post?.text === `/connect ${query.word}` && + p?.channel_post?.chat?.id) + ); + // get correct chatId based on the channel type + const chatId = match?.message?.chat?.id || match?.channel_post?.chat?.id; - const chatId = res?.find( - (p) => p?.message?.text === `/connect ${query.word}` - )?.message?.chat?.id; + // prevents the code from running while chatId is still undefined to avoid the error 'ETELEGRAM: 400 Bad Request: chat_id is empty'. the code would still work eventually but console spam is not pretty + if (chatId) { + //get the numberic ID of the bot + const botId = (await telegramBot.getMe()).id; + // check if the bot is an admin in the chat + const isAdmin = await this.botIsAdmin(chatId, botId); + // get the messageId of the message that triggered the connection + const connectMessageId = + match?.message?.message_id || match?.channel_post?.message_id; - return chatId - ? { + if (!isAdmin) { + // alternatively you can replace this with a console.log if you do not want to inform the user of the bot's admin status + telegramBot.sendMessage( chatId, - } + "Connection Successful. I don't have admin privileges to delete these messages, please go ahead and remove them yourself." + ); + } else { + // Delete the message that triggered the connection + await telegramBot.deleteMessage(chatId, connectMessageId); + // Send success message to the chat + const successMessage = await telegramBot.sendMessage( + chatId, + 'Connection Successful. Message will be deleted in 10 seconds.' + ); + // Delete the success message after 10 seconds + setTimeout(async () => { + await telegramBot.deleteMessage(chatId, successMessage.message_id); + console.log('Success message deleted.'); + }, 10000); + } + } + + // modified lastChatId to work with any type of channel (private/public groups/channels) + return chatId + ? { chatId } : res.length > 0 ? { - lastChatId: res?.[res.length - 1]?.message?.chat?.id, + lastChatId: + res?.[res.length - 1]?.message?.chat?.id || + res?.[res.length - 1]?.channel_post?.chat?.id, } : {}; } @@ -92,50 +140,130 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { postDetails: PostDetails[] ): Promise { const ids: PostResponse[] = []; + for (const message of postDetails) { - if ( - (message?.media?.length || 0) === 1 - ) { - const [{ message_id }] = await telegramBot.sendMediaGroup( - accessToken, - message?.media?.map((m) => ({ - type: m.url.indexOf('mp4') > -1 ? 'video' : 'photo', - caption: message.message, - media: m.url, - })) || [] - ); + let messageId: number | null = null; + const mediaFiles = message.media || []; + const text = message.message || ''; + // check if media is local to modify url + const processedMedia = mediaFiles.map((media) => { + let mediaUrl = media.url; + if (mediaStorage === 'local' && mediaUrl.startsWith(frontendURL)) { + mediaUrl = mediaUrl.replace(frontendURL, ''); + } + //get mime type to pass contentType to telegram api. + //some photos and videos might not pass telegram api restrictions, so they are sent as documents instead of returning errors + const mimeType = mime.getType(mediaUrl); // Detect MIME type + let mediaType: 'photo' | 'video' | 'document'; - ids.push({ - id: message.id, - postId: String(message_id), - releaseURL: `https://t.me/${id}/${message_id}`, - status: 'completed', - }); - } else { - const { message_id } = await telegramBot.sendMessage( - accessToken, - message.message - ); + if (mimeType?.startsWith('image/')) { + mediaType = 'photo'; + } else if (mimeType?.startsWith('video/')) { + mediaType = 'video'; + } else { + mediaType = 'document'; + } - ids.push({ - id: message.id, - postId: String(message_id), - releaseURL: `https://t.me/${id}/${message_id}`, - status: 'completed', - }); + return { + type: mediaType, + media: mediaUrl, + fileOptions: { + filename: media.url.split('/').pop(), + contentType: mimeType || 'application/octet-stream', + }, + }; + }); + // if there's no media, bot sends a text message only + if (processedMedia.length === 0) { + const response = await telegramBot.sendMessage(accessToken, text); + messageId = response.message_id; + } + // if there's only one media, bot sends the media with the text message as caption + else if (processedMedia.length === 1) { + const media = processedMedia[0]; + const response = + media.type === 'video' + ? await telegramBot.sendVideo( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ) + : media.type === 'photo' + ? await telegramBot.sendPhoto( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ) + : await telegramBot.sendDocument( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ); + messageId = response.message_id; + } + // if there are multiple media, bot sends them as a media group - max 10 media per group - with the text as a caption (if there are more than 1 group, the caption will only be sent with the first group) + else { + const mediaGroups = this.chunkMedia(processedMedia, 10); + for (let i = 0; i < mediaGroups.length; i++) { + const mediaGroup = mediaGroups[i].map((m, index) => ({ + type: m.type === 'document' ? 'document' : m.type, // Documents are not allowed in media groups + media: m.media, + caption: i === 0 && index === 0 ? text : undefined, + })); - if ((message?.media?.length || 0) > 0) { - await telegramBot.sendMediaGroup( + const response = await telegramBot.sendMediaGroup( accessToken, - message?.media?.map((m) => ({ - type: m.url.indexOf('mp4') > -1 ? 'video' : 'photo', - media: m.url, - })) || [] + mediaGroup ); + if (i === 0) { + messageId = response[0].message_id; + } } } + // for private groups/channels message.id is undefined so the link generated by Postiz will be unusable "https://t.me/c/undefined/16" + // to avoid that, we use accessToken instead of message.id and we generate the link manually removing the -100 from the start. + if (messageId) { + ids.push({ + id: message.id, + postId: String(messageId), + releaseURL: `https://t.me/${ + id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` + }/${messageId}`, + status: 'completed', + }); + } } return ids; } + // chunkMedia is used to split media into groups of "size". 10 is used here because telegram api allows a maximum of 10 media per group + private chunkMedia(media: { type: string; media: string }[], size: number) { + const result = []; + for (let i = 0; i < media.length; i += size) { + result.push(media.slice(i, i + size)); + } + return result; + } + + async botIsAdmin(chatId: number, botId: number): Promise { + try { + const chatMember = await telegramBot.getChatMember(chatId, botId); + + if ( + chatMember.status === 'administrator' || + chatMember.status === 'creator' + ) { + const permissions = chatMember.can_delete_messages; + return !!permissions; // Return true if bot can delete messages + } + + return false; + } catch (error) { + console.error('Error checking bot privileges:', error); + return false; + } + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index aa299b419..14b4b0c5a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -218,6 +218,16 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { } } + private postingMethod(method: TikTokDto["content_posting_method"]): string { + switch (method) { + case 'UPLOAD': + return '/inbox/video/init/'; + case 'DIRECT_POST': + default: + return '/video/init/'; + } + } + async post( id: string, accessToken: string, @@ -225,12 +235,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { integration: Integration ): Promise { const [firstPost, ...comments] = postDetails; - const { data: { publish_id }, } = await ( await this.fetch( - 'https://open.tiktokapis.com/v2/post/publish/video/init/', + `https://open.tiktokapis.com/v2/post/publish${this.postingMethod(firstPost.settings.content_posting_method)}`, { method: 'POST', headers: { @@ -238,15 +247,17 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - post_info: { - title: firstPost.message, - privacy_level: firstPost.settings.privacy_level, - disable_duet: !firstPost.settings.duet, - disable_comment: !firstPost.settings.comment, - disable_stitch: !firstPost.settings.stitch, - brand_content_toggle: firstPost.settings.brand_content_toggle, - brand_organic_toggle: firstPost.settings.brand_organic_toggle, - }, + ...(firstPost.settings.content_posting_method === 'DIRECT_POST' ? { + post_info: { + title: firstPost.message, + privacy_level: firstPost.settings.privacy_level, + disable_duet: !firstPost.settings.duet, + disable_comment: !firstPost.settings.comment, + disable_stitch: !firstPost.settings.stitch, + brand_content_toggle: firstPost.settings.brand_content_toggle, + brand_organic_toggle: firstPost.settings.brand_organic_toggle, + } + } : {}), source_info: { source: 'PULL_FROM_URL', video_url: firstPost?.media?.[0]?.url!, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index b17257f84..ece85b8dc 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -104,7 +104,9 @@ export class StripeService { try { await stripe.paymentMethods.detach(paymentMethods.data[0].id); await stripe.subscriptions.cancel(event.data.object.id as string); - } catch (err) {/*dont do anything*/} + } catch (err) { + /*dont do anything*/ + } return false; } } @@ -122,8 +124,12 @@ export class StripeService { uniqueId: string; } = event.data.object.metadata; - const check = await this.checkValidCard(event); - if (!check) { + try { + const check = await this.checkValidCard(event); + if (!check) { + return { ok: false }; + } + } catch (err) { return { ok: false }; } diff --git a/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts b/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts new file mode 100644 index 000000000..585d73dc4 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts @@ -0,0 +1,91 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + + +const options = { + headers: { + Authorization: `Bearer ${process.env.SHORT_IO_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, +}; + +export class ShortIo implements ShortLinking { + shortLinkDomain = 'short.io'; + + async linksStatistics ( links: string[] ) { + return Promise.all( + links.map(async (link) => { + const url = `https://api.short.io/links/expand?domain=${this.shortLinkDomain}&path=${link.split('/').pop()}`; + const response = await ( fetch( url, options ) + .then( res => res.json() ) ); + + const linkStatisticsUrl = `https://statistics.short.io/statistics/link/${response.id}?period=last30&tz=UTC`; + + const statResponse = await ( fetch( linkStatisticsUrl, options ).then( ( res ) => res.json() ) ); + + return { + short: response.shortURL, + original: response.originalURL, + clicks: statResponse.totalClicks, + }; + }) + ); + } + + async convertLinkToShortLink ( id: string, link: string ) { + const response = await fetch( `https://api.short.io/links`, { + ...options, + method: 'POST', + body: JSON.stringify( { + url: link, + tenantId: id, + domain: this.shortLinkDomain, + originalURL: link + + } ), + } ).then( ( res ) => res.json() ); + + return response.shortURL; + } + + async convertShortLinkToLink(shortLink: string) { + return await( + await( + await fetch( + `https://api.short.io/links/expand?domain=${this.shortLinkDomain}&path=${shortLink.split('/').pop()}`, + options + ) + ).json() + ).originalURL; + } + + // recursive functions that gets maximum 100 links per request if there are less than 100 links stop the recursion + async getAllLinksStatistics( + id: string, + page = 1 + ): Promise<{ short: string; original: string; clicks: string }[]> { + const response = await ( + await fetch( + `https://api.short.io/api/links?domain_id=${id}&limit=150`, + options + ) + ).json(); + + const mapLinks = response.links.map(async ( link: any ) => { + const linkStatisticsUrl = `https://statistics.short.io/statistics/link/${response.id}?period=last30&tz=UTC`; + + const statResponse = await(fetch(linkStatisticsUrl, options).then((res) => res.json())); + + return { + short: link, + original: response.url, + clicks: statResponse.totalClicks, + }; + } ); + + if (mapLinks.length < 100) { + return mapLinks; + } + + return [...mapLinks, ...(await this.getAllLinksStatistics(id, page + 1))]; + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/short.link.service.ts b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts index cdff93ce0..4815e7f27 100644 --- a/libraries/nestjs-libraries/src/short-linking/short.link.service.ts +++ b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts @@ -2,12 +2,17 @@ import { Dub } from '@gitroom/nestjs-libraries/short-linking/providers/dub'; import { Empty } from '@gitroom/nestjs-libraries/short-linking/providers/empty'; import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; import { Injectable } from '@nestjs/common'; +import { ShortIo } from './providers/short.io'; const getProvider = (): ShortLinking => { if (process.env.DUB_TOKEN) { return new Dub(); } + if ( process.env.SHORT_IO_SECRET_KEY ) { + return new ShortIo(); + } + return new Empty(); };