feat: platforms with a chrome extension

This commit is contained in:
Nevo David 2026-02-08 20:53:28 +07:00
parent f72d63bd43
commit e3c3854840
73 changed files with 1358 additions and 878 deletions

View file

@ -76,6 +76,9 @@ MASTODON_URL="https://mastodon.social"
MASTODON_CLIENT_ID=""
MASTODON_CLIENT_SECRET=""
# Chrome Extension Settings (for cookie-based platform integrations like Skool)
EXTENSION_ID=""
# Misc Settings
OPENAI_API_KEY=""
NEXT_PUBLIC_DISCORD_SUPPORT=""

View file

@ -232,6 +232,10 @@ export class NoAuthIntegrationsController {
? AuthService.fixedEncryption(
Buffer.from(body.code, 'base64').toString()
)
: integrationProvider.isChromeExtension
? AuthService.signJWT(
JSON.parse(Buffer.from(body.code, 'base64').toString())
)
: undefined
);
@ -284,11 +288,21 @@ export class NoAuthIntegrationsController {
await ioRedis.del(`redirect:${body.state}`);
}
const extensionToken = integrationProvider.isChromeExtension
? AuthService.signJWT({
integrationId: createUpdate.id,
organizationId: org.id,
internalId: String(id),
provider: integration,
})
: undefined;
return {
...createUpdate,
onboarding: onboarding === 'true',
pages,
...(returnURL ? { returnURL } : {}),
...(extensionToken ? { extensionToken } : {}),
};
}
@ -307,4 +321,75 @@ export class NoAuthIntegrationsController {
return this._integrationService.saveProviderPage(org.id, id, body);
}
@Post('/extension-refresh')
async extensionRefreshCookies(
@Body() body: { jwt: string; cookies: string }
) {
let payload: any;
try {
payload = AuthService.verifyJWT(body.jwt);
} catch {
throw new HttpException('Invalid token', 401);
}
const { integrationId, organizationId, internalId, provider } = payload;
if (!integrationId || !organizationId || !internalId || !provider) {
throw new HttpException('Invalid token payload', 400);
}
const integration = await this._integrationService.getIntegrationById(
organizationId,
integrationId
);
if (!integration || integration.internalId !== internalId) {
throw new HttpException('Integration not found', 404);
}
const integrationProvider =
this._integrationManager.getSocialIntegration(provider);
if (!integrationProvider?.isChromeExtension) {
throw new HttpException('Not a Chrome extension integration', 400);
}
const authResult = await integrationProvider.authenticate({
code: body.cookies,
codeVerifier: '',
});
if (typeof authResult === 'string') {
throw new HttpException(authResult, 400);
}
if (String(authResult.id) !== String(integration.internalId)) {
await this._integrationService.refreshNeeded(
organizationId,
integrationId
);
return { success: false, reason: 'account_mismatch' };
}
await this._integrationService.createOrUpdateIntegration(
undefined,
false,
organizationId,
integration.name,
undefined,
'social',
integration.internalId,
integration.providerIdentifier,
authResult.accessToken,
'',
authResult.expiresIn,
undefined,
false,
undefined,
undefined,
AuthService.signJWT(
JSON.parse(Buffer.from(body.cookies, 'base64').toString())
)
);
return { success: true };
}
}

37
apps/extension/manifest.dev.json Executable file → Normal file
View file

@ -1,15 +1,30 @@
{
"action": {
"default_icon": "public/dev-icon-32.png",
"default_popup": "src/pages/popup/index.html"
},
"manifest_version": 3,
"name": "Postiz",
"version": "2.0.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtH6qclAsfFf6qbUKfPmhBbfycGrt13+0h6ti/olniCGnjQjhkVVTnURfLFz+v+842Ee+pAS5HBEXo57dQ9xUtwFGXnavVR+myjN+Un9NIfFyYmYEBvLrinclsMJBwWMM8JkhxKuaOagxp1hqGgNAO4C0bzE3YN/SPoTjNpGU8TGm/ENZ/TDUneZyyVM5HEEmOTZEmjmy9FJaxbzGmZ2rixNO45pkjXMFp8+/XrFSNiCqNZt6LQNIqL5SfVIRUKGBjE3OG/gtahVToBdlXi5yzP1uYE0Qs4grJ/T1rUUzTXFAQa7heWA9mskf0xAMEtTSED4N9bZ4sF8cf5J+SGGlwIDAQAB",
"description": "Postiz browser extension for social media scheduling",
"icons": {
"128": "public/dev-icon-128.png"
"32": "icon-32.png",
"128": "icon-128.png"
},
"web_accessible_resources": [
{
"resources": ["contentStyle.css", "dev-icon-128.png", "dev-icon-32.png"],
"matches": []
}
]
"permissions": [
"cookies",
"alarms",
"storage"
],
"host_permissions": [
"*://*.skool.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"externally_connectable": {
"matches": [
"http://localhost/*",
"https://localhost/*",
"https://*.postiz.com/*"
]
}
}

View file

@ -1,31 +1,29 @@
{
"manifest_version": 3,
"name": "Postiz",
"description": "Your ultimate social media scheduling tool",
"options_ui": {
"page": "src/pages/options/index.html"
},
"action": {
"default_popup": "src/pages/popup/index.html",
"default_icon": {
"32": "icon-32.png"
}
},
"version": "2.0.0",
"description": "Postiz browser extension for social media scheduling",
"icons": {
"32": "icon-32.png",
"128": "icon-128.png"
},
"permissions": ["activeTab", "cookies", "tabs"],
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"js": ["src/pages/content/index.tsx"],
"css": ["contentStyle.css"]
}
"permissions": [
"cookies",
"alarms",
"storage"
],
"web_accessible_resources": [
{
"resources": ["contentStyle.css", "icon-128.png", "icon-32.png"],
"matches": []
}
]
"host_permissions": [
"*://*.skool.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"externally_connectable": {
"matches": [
"http://localhost/*",
"https://localhost/*",
"https://*.postiz.com/*"
]
}
}

View file

@ -1,16 +0,0 @@
{
"env": {
"__DEV__": "true"
},
"watch": [
"src",
"utils",
"vite.config.base.ts",
"vite.config.chrome.ts",
"manifest.json",
"manifest.dev.json"
],
"ext": "tsx,css,html,ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "vite build --config vite.config.chrome.ts --mode development"
}

View file

@ -1,16 +0,0 @@
{
"env": {
"__DEV__": "true"
},
"watch": [
"src",
"utils",
"vite.config.base.ts",
"vite.config.firefox.ts",
"manifest.json",
"manifest.dev.json"
],
"ext": "tsx,css,html,ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "vite build --config vite.config.firefox.ts --mode development"
}

View file

@ -1,14 +1,10 @@
{
"name": "postiz-extension",
"version": "1.0.3",
"description": "A simple chrome & firefox extension template with Vite, React, TypeScript and Tailwind CSS.",
"version": "2.0.0",
"description": "Postiz browser extension for cookie-based platform authentication",
"scripts": {
"build": "rm -rf dist && vite build --config vite.config.chrome.ts && zip -r extension.zip dist",
"build:chrome": "vite build --config vite.config.chrome.ts",
"build:firefox": "vite build --config vite.config.firefox.ts",
"dev": "rm -rf dist && dotenv -e ../../.env -- vite build --config vite.config.chrome.ts --mode development --watch",
"dev:chrome": "nodemon --config nodemon.chrome.json",
"dev:firefox": "nodemon --config nodemon.firefox.json"
"build": "rm -rf dist && vite build && cp manifest.json dist/manifest.json && cd dist && zip -r ../extension.zip .",
"dev": "rm -rf dist && HOT_RELOAD_EXTENSION_VITE_PORT=8081 NODE_ENV=development dotenv -e ../../.env -- vite build --config vite.config.chrome.ts --mode development --watch"
},
"type": "module"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -1,13 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@theme {
--animate-spin-slow: spin 20s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}

View file

@ -0,0 +1,209 @@
import { ExtensionRequest, GetCookiesResponse, ProviderInfo, StoredRefreshEntry } from './types/messages';
import { getAllProviders, getProvider } from './providers/provider.registry';
import { CookieProvider } from './providers/cookie-provider.interface';
const EXTENSION_VERSION = '2.0.0';
const REFRESH_ALARM_NAME = 'cookie-refresh';
const STORAGE_KEY = 'refreshEntries';
const ALLOWED_ORIGIN_PATTERNS = [
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/([a-z0-9-]+\.)*postiz\.com$/,
];
function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return false;
return ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
}
async function extractCookies(provider: CookieProvider): Promise<GetCookiesResponse> {
const allCookies = await chrome.cookies.getAll({ url: provider.url });
const extracted: Record<string, string> = {};
const missingRequired: string[] = [];
for (const def of provider.cookies) {
const found = allCookies.find((c) => c.name === def.name);
if (found) {
extracted[def.name] = found.value;
} else if (def.required) {
missingRequired.push(def.name);
}
}
if (missingRequired.length > 0) {
return {
success: false,
provider: provider.identifier,
error: `Missing required cookies: ${missingRequired.join(', ')}. User may need to log in to ${provider.name}.`,
missingCookies: missingRequired,
};
}
return {
success: true,
provider: provider.identifier,
cookies: extracted,
};
}
// --- Refresh Token Storage Helpers ---
async function getStoredEntries(): Promise<Record<string, StoredRefreshEntry>> {
const result = await chrome.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] || {};
}
async function setStoredEntries(entries: Record<string, StoredRefreshEntry>): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: entries });
}
async function ensureAlarm(): Promise<void> {
const existing = await chrome.alarms.get(REFRESH_ALARM_NAME);
if (!existing) {
chrome.alarms.create(REFRESH_ALARM_NAME, { periodInMinutes: 1440 });
}
}
async function clearAlarmIfEmpty(): Promise<void> {
const entries = await getStoredEntries();
if (Object.keys(entries).length === 0) {
await chrome.alarms.clear(REFRESH_ALARM_NAME);
}
}
// --- Background Cookie Refresh ---
async function refreshAllCookies(): Promise<void> {
const entries = await getStoredEntries();
for (const [integrationId, entry] of Object.entries(entries)) {
try {
const provider = getProvider(entry.provider);
if (!provider) continue;
const cookieResult = await extractCookies(provider);
if (!cookieResult.success) continue;
const base64Cookies = btoa(JSON.stringify(cookieResult.cookies));
await fetch(`${entry.backendUrl}/integrations/extension-refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jwt: entry.jwt, cookies: base64Cookies }),
});
} catch {
// Silently skip — will retry next cycle
}
}
}
// --- Alarm Listener ---
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === REFRESH_ALARM_NAME) {
refreshAllCookies();
}
});
// --- Ensure alarm on startup ---
(async () => {
const entries = await getStoredEntries();
if (Object.keys(entries).length > 0) {
await ensureAlarm();
}
})();
// --- Message Listener ---
chrome.runtime.onMessageExternal.addListener(
(
message: ExtensionRequest,
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
) => {
const origin = sender.origin ?? sender.url;
if (!isOriginAllowed(origin)) {
sendResponse({ error: 'Unauthorized origin' });
return true;
}
switch (message.type) {
case 'PING': {
sendResponse({ status: 'ok', version: EXTENSION_VERSION });
break;
}
case 'GET_PROVIDERS': {
const providers = getAllProviders();
const providerInfos: ProviderInfo[] = providers.map((p) => ({
identifier: p.identifier,
name: p.name,
url: p.url,
cookieNames: p.cookies.map((c) => c.name),
}));
sendResponse({ providers: providerInfos });
break;
}
case 'GET_COOKIES': {
const provider = getProvider(message.provider);
if (!provider) {
sendResponse({
success: false,
provider: message.provider,
error: `Unknown provider: ${message.provider}`,
});
break;
}
extractCookies(provider)
.then((result) => sendResponse(result))
.catch((err) =>
sendResponse({
success: false,
provider: message.provider,
error: `Failed to extract cookies: ${err.message}`,
})
);
return true;
}
case 'STORE_REFRESH_TOKEN': {
(async () => {
const entries = await getStoredEntries();
entries[message.integrationId] = {
jwt: message.jwt,
backendUrl: message.backendUrl,
provider: message.provider,
};
await setStoredEntries(entries);
await ensureAlarm();
sendResponse({ success: true });
})().catch(() => sendResponse({ success: false }));
return true;
}
case 'REMOVE_REFRESH_TOKEN': {
(async () => {
const entries = await getStoredEntries();
delete entries[message.integrationId];
await setStoredEntries(entries);
await clearAlarmIfEmpty();
sendResponse({ success: true });
})().catch(() => sendResponse({ success: false }));
return true;
}
default: {
sendResponse({ error: `Unknown message type: ${(message as any).type}` });
break;
}
}
return true;
}
);

View file

@ -1,11 +0,0 @@
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
declare module '*.json' {
const content: string;
export default content;
}

View file

@ -1,10 +0,0 @@
{
"extName": {
"message": "name in src/locales/en/messages.json",
"description": "Extension name"
},
"extDescription": {
"message": "description in src/locales/en/messages.json",
"description": "Extension description"
}
}

View file

@ -1,37 +0,0 @@
import { fetchRequestUtil } from '@gitroom/extension/utils/request.util';
const isDevelopment = process.env.NODE_ENV === 'development';
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === 'makeHttpRequest') {
fetchRequestUtil(request).then((response) => {
sendResponse(response);
});
}
if (request.action === 'loadStorage') {
chrome.storage.local.get([request.key], function (storage) {
sendResponse(storage[request.key]);
});
}
if (request.action === 'saveStorage') {
chrome.storage.local.set({ [request.key]: request.value }, function () {
sendResponse({ success: true });
});
}
if (request.action === 'loadCookie') {
chrome.cookies.get(
{
url: import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL,
name: request.cookieName,
},
function (cookies) {
sendResponse(cookies?.value);
}
);
}
return true;
});

View file

@ -1,115 +0,0 @@
import { FC, memo, useCallback, useEffect, useState } from 'react';
import { ProviderInterface } from '@gitroom/extension/providers/provider.interface';
import { fetchCookie } from '@gitroom/extension/utils/load.cookie';
const Comp: FC<{ removeModal: () => void; platform: string; style: string }> = (
props
) => {
const load = async () => {
const cookie = await fetchCookie(`auth`);
if (document.querySelector('iframe#modal-postiz')) {
return;
}
const div = document.createElement('div');
div.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
div.style.position = 'fixed';
div.style.top = '0';
div.style.left = '0';
div.style.zIndex = '9999';
div.style.width = '100%';
div.style.height = '100%';
div.style.border = 'none';
div.style.overflow = 'hidden';
document.body.appendChild(div);
const iframe = document.createElement('iframe');
iframe.style.backgroundColor = 'transparent';
// @ts-ignore
iframe.allowTransparency = 'true';
iframe.src =
(import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL) +
`/modal/${props.style}/${props.platform}?loggedAuth=${cookie}`;
iframe.id = 'modal-postiz';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.position = 'fixed';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.zIndex = '9999';
iframe.style.border = 'none';
div.appendChild(iframe);
window.addEventListener('message', (event) => {
if (event.data.action === 'closeIframe') {
const iframe = document.querySelector('iframe#modal-postiz');
if (iframe) {
props.removeModal();
div.remove();
}
}
});
};
useEffect(() => {
load();
}, []);
return <></>;
};
export const ActionComponent: FC<{
target: Node;
keyIndex: number;
actionType: string;
provider: ProviderInterface;
wrap: boolean;
selector: string;
}> = memo((props) => {
const { wrap, provider, selector, target, actionType } = props;
const [modal, showModal] = useState(false);
const handle = useCallback(async (e: any) => {
showModal(true);
e.preventDefault();
e.stopPropagation();
}, []);
useEffect(() => {
const blockingDiv = document.createElement('div');
if (document.querySelector(`.${selector}`)) {
console.log('already exists');
return;
}
setTimeout(() => {
// @ts-ignore
const targetInformation = target.getBoundingClientRect();
blockingDiv.style.position = 'absolute';
blockingDiv.id = 'blockingDiv';
blockingDiv.style.cursor = 'pointer';
blockingDiv.style.top = `${targetInformation.top}px`;
blockingDiv.style.left = `${targetInformation.left}px`;
blockingDiv.style.width = `${targetInformation.width}px`;
blockingDiv.style.height = `${targetInformation.height}px`;
blockingDiv.style.zIndex = '9999';
blockingDiv.className = selector;
document.body.appendChild(blockingDiv);
blockingDiv.addEventListener('click', handle);
}, 1000);
return () => {
blockingDiv.removeEventListener('click', handle);
blockingDiv.remove();
};
}, []);
return (
<div className="g-wrapper" style={{ position: 'relative' }}>
<div className="absolute start-0 top-0 z-[9999] w-full h-full" />
{modal && (
<Comp
platform={provider.identifier}
style={provider.style}
removeModal={() => showModal(false)}
/>
)}
</div>
);
});

View file

@ -1,11 +0,0 @@
import { createRoot } from 'react-dom/client';
import './style.css';
import { MainContent } from '@gitroom/extension/pages/content/main.content';
const div = document.createElement('div');
div.id = '__root';
document.body.appendChild(div);
const rootContainer = document.querySelector('#__root');
if (!rootContainer) throw new Error("Can't find Content root element");
const root = createRoot(rootContainer);
root.render(<MainContent />);

View file

@ -1,191 +0,0 @@
import {
FC,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ProviderList } from '@gitroom/extension/providers/provider.list';
import { createPortal } from 'react-dom';
import { ActionComponent } from '@gitroom/extension/pages/content/elements/action.component';
// Define a type to track elements with their action types
interface ActionElement {
element: HTMLElement;
actionType: string;
}
export const MainContent: FC = () => {
return <MainContentInner />;
};
export const MainContentInner: FC = (props) => {
const [actionElements, setActionElements] = useState<ActionElement[]>([]);
const actionSetRef = useRef(new Map<HTMLElement, string>());
const provider = useMemo(() => {
return ProviderList.find((p) => {
return p.baseUrl.indexOf(new URL(window.location.href).hostname) > -1;
});
}, []);
useEffect(() => {
if (!provider) return;
// Helper to scan DOM for existing matching elements
const scanDOMForExistingMatches = () => {
const action = { selector: provider.element, type: 'post' };
const matches = document.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (!actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.set(htmlMatch, action.type);
}
});
// Update state
const elements: ActionElement[] = [];
actionSetRef.current.forEach((actionType, element) => {
elements.push({ element, actionType });
});
setActionElements(elements);
};
// Initial scan before observing
scanDOMForExistingMatches();
const observer = new MutationObserver((mutationsList) => {
let addedSomething = false;
let removedSomething = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const action = { selector: provider.element, type: 'post' };
if (
el.matches?.(action.selector) &&
!actionSetRef.current.has(el)
) {
actionSetRef.current.set(el, action.type);
addedSomething = true;
}
if (el.querySelectorAll) {
const matches = el.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (!actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.set(htmlMatch, action.type);
addedSomething = true;
}
});
}
}
}
for (const node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (actionSetRef.current.has(el)) {
actionSetRef.current.delete(el);
removedSomething = true;
}
const action = { selector: provider.element, type: 'post' };
if (el.querySelectorAll) {
const matches = el.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.delete(htmlMatch);
removedSomething = true;
}
});
}
}
}
}
if (mutation.type === 'attributes') {
const el = mutation.target;
if (el instanceof HTMLElement) {
const action = { selector: provider.element, type: 'post' };
const matchesNow = el.matches(action.selector);
const wasTracked = actionSetRef.current.has(el);
if (matchesNow && !wasTracked) {
actionSetRef.current.set(el, action.type);
addedSomething = true;
} else if (!matchesNow && wasTracked) {
actionSetRef.current.delete(el);
removedSomething = true;
}
}
}
}
if (addedSomething || removedSomething) {
const elements: ActionElement[] = [];
actionSetRef.current.forEach((actionType, element) => {
elements.push({ element, actionType });
});
setActionElements(elements);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
});
return () => observer.disconnect();
}, []);
return actionElements.map((actionEl, index) => (
<Fragment key={index}>
{createPortal(
<ActionComponent
target={actionEl.element}
keyIndex={index}
actionType={actionEl.actionType}
provider={provider}
wrap={true}
selector={stringToABC(
provider.element
.split(',')
.map((z) => z.trim())
.find((p) => actionEl.element.matches(p)) || ''
)}
/>,
actionEl.element
)}
</Fragment>
));
};
function stringToABC(text: string, length = 8) {
// Simple DJB2-like hash (non-cryptographic!)
let hash = 5381;
for (let i = 0; i < text.length; i++) {
hash = (hash * 33) ^ text.charCodeAt(i);
}
hash = Math.abs(hash);
// Convert to base-26 string using az
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
let result = '';
while (result.length < length) {
result = alphabet[hash % 26] + result;
hash = Math.floor(hash / 26);
}
return result;
}

View file

@ -1,27 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.my-wrapper {
left: 0 !important;
top: 0 !important;
position: fixed !important;
width: 100% !important;
height: 100% !important;
z-index: 999999 !important;
display: flex !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.5) !important;
}
.my-wrapper > div {
background: white !important;
width: 600px !important;
height: 300px !important;
border-radius: 10px !important;
display: flex !important;
flex-direction: column !important;
justify-items: center !important;
margin-top: 100px !important;
color: black !important;
}

View file

@ -1,8 +0,0 @@
.container {
width: 100%;
height: 50vh;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -1,6 +0,0 @@
import React from 'react';
import '@gitroom/extension/pages/options/Options.css';
export default function Options() {
return <div className="container">Options</div>;
}

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Options</title>
</head>
<body>
<div id="__root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -1,13 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '@gitroom/extension/pages/options/index.css';
import Options from '@gitroom/extension/pages/options/Options';
function init() {
const rootContainer = document.querySelector('#__root');
if (!rootContainer) throw new Error("Can't find Options root element");
const root = createRoot(rootContainer);
root.render(<Options />);
}
init();

View file

@ -1,7 +0,0 @@
body {
background-color: #242424;
}
.container {
color: #ffffff;
}

View file

@ -1,10 +0,0 @@
import React from 'react';
import '@pages/panel/Panel.css';
export default function Panel() {
return (
<div className="container">
<h1>Side Panel</h1>
</div>
);
}

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Devtools Panel</title>
</head>
<body>
<div id="__root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -1,14 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Panel from '@pages/panel/Panel';
import '@pages/panel/index.css';
import '@assets/styles/tailwind.css';
function init() {
const rootContainer = document.querySelector('#__root');
if (!rootContainer) throw new Error("Can't find Panel root element");
const root = createRoot(rootContainer);
root.render(<Panel />);
}
init();

View file

@ -1,77 +0,0 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { ProviderList } from '@gitroom/extension/providers/provider.list';
import { fetchCookie } from '@gitroom/extension/utils/load.cookie';
export const PopupContainerContainer: FC = () => {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
setUrl(tabs[0]?.url);
});
}, []);
if (!url) {
return (
<div className="text-4xl">This website is not supported by Postiz</div>
);
}
return <PopupContainer url={url} />;
};
export const PopupContainer: FC<{ url: string }> = (props) => {
const { url } = props;
const [isLoggedIn, setIsLoggedIn] = useState<false | string>(false);
const [isLoading, setIsLoading] = useState(true);
const provider = useMemo(() => {
return ProviderList.find((p) => {
return p.baseUrl.indexOf(new URL(url).hostname) > -1;
});
}, [url]);
const loadCookie = useCallback(async () => {
try {
if (!provider) {
setIsLoading(false);
return;
}
const auth = await fetchCookie(`auth`);
if (auth) {
setIsLoggedIn(auth);
}
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadCookie();
}, []);
if (isLoading) {
return null;
}
if (!provider) {
return (
<div className="text-4xl">This website is not supported by Postiz</div>
);
}
if (!isLoggedIn) {
return <div className="text-4xl">You are not logged in to Postiz</div>;
}
return <div />;
};
export default function Popup() {
return (
<div className="flex justify-center items-center h-screen">
<PopupContainerContainer />
</div>
);
}

View file

@ -1,16 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
width: 300px;
height: 260px;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
}

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Popup</title>
</head>
<body>
<div id="__root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -1,14 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import '@gitroom/extension/assets/styles/tailwind.css';
import Popup from '@gitroom/extension/pages/popup/Popup';
function init() {
const rootContainer = document.querySelector('#__root');
if (!rootContainer) throw new Error("Can't find Popup root element");
const root = createRoot(rootContainer);
root.render(<Popup />);
}
init();

View file

@ -0,0 +1,19 @@
export interface CookieDefinition {
/** The cookie name to extract, e.g., 'client_id' */
name: string;
/** Whether this cookie must exist for the extraction to be considered successful */
required: boolean;
}
export interface CookieProvider {
/** Unique identifier used in messages, e.g., 'skool' */
identifier: string;
/** Human-readable name, e.g., 'Skool' */
name: string;
/** URL to query cookies for, e.g., 'https://www.skool.com' — passed to chrome.cookies.getAll({ url }) */
url: string;
/** URL pattern for host_permissions in manifest, e.g., '*://*.skool.com/*' */
hostPermission: string;
/** List of cookies to extract from this site */
cookies: CookieDefinition[];
}

View file

@ -1,12 +0,0 @@
import { ProviderInterface } from '@gitroom/extension/providers/provider.interface';
export class LinkedinProvider implements ProviderInterface {
identifier = 'linkedin';
baseUrl = 'https://www.linkedin.com';
element = `.share-box-feed-entry__closed-share-box`;
attachTo = `[role="main"]`;
style = 'light' as 'light';
findIdentifier = (element: HTMLElement) => {
return element.closest('[data-urn]').getAttribute('data-urn');
};
}

View file

@ -0,0 +1,12 @@
import { CookieProvider } from '../cookie-provider.interface';
export const skoolProvider: CookieProvider = {
identifier: 'skool',
name: 'Skool',
url: 'https://www.skool.com',
hostPermission: '*://*.skool.com/*',
cookies: [
{ name: 'client_id', required: true },
{ name: 'auth_token', required: true },
],
};

View file

@ -1,24 +0,0 @@
import { ProviderInterface } from '@gitroom/extension/providers/provider.interface';
export class XProvider implements ProviderInterface {
identifier = 'x';
baseUrl = 'https://x.com';
element = `[data-testid="primaryColumn"]:has([data-testid="toolBar"]) [data-testid="tweetTextarea_0_label"], [data-testid="SideNav_NewTweet_Button"]`;
attachTo = `#react-root`;
style = 'dark' as 'dark';
findIdentifier = (element: HTMLElement) => {
return (
Array.from(
(
element?.closest('article') ||
element?.closest(`[aria-labelledby="modal-header"]`)
)?.querySelectorAll('a') || []
)
?.find((p) => {
return p?.getAttribute('href')?.includes('/status/');
})
?.getAttribute('href')
?.split('/status/')?.[1] || window.location.href.split('/status/')?.[1]
);
};
}

View file

@ -1,8 +0,0 @@
export interface ProviderInterface {
identifier: string;
baseUrl: string;
element: string;
findIdentifier: (element: HTMLElement) => string;
attachTo: string;
style: 'dark' | 'light';
}

View file

@ -1,8 +0,0 @@
import { XProvider } from './list/x.provider';
import { ProviderInterface } from './provider.interface';
import { LinkedinProvider } from './list/linkedin.provider';
export const ProviderList = [
new XProvider(),
new LinkedinProvider(),
] satisfies ProviderInterface[] as ProviderInterface[];

View file

@ -0,0 +1,18 @@
import { CookieProvider } from './cookie-provider.interface';
import { skoolProvider } from './list/skool.provider';
export const providers: CookieProvider[] = [
skoolProvider,
];
const providerMap = new Map<string, CookieProvider>(
providers.map((p) => [p.identifier, p])
);
export function getAllProviders(): CookieProvider[] {
return providers;
}
export function getProvider(identifier: string): CookieProvider | undefined {
return providerMap.get(identifier);
}

View file

@ -0,0 +1,85 @@
// ---- Request Types ----
export interface PingRequest {
type: 'PING';
}
export interface GetProvidersRequest {
type: 'GET_PROVIDERS';
}
export interface GetCookiesRequest {
type: 'GET_COOKIES';
provider: string;
}
export interface StoreRefreshTokenRequest {
type: 'STORE_REFRESH_TOKEN';
provider: string;
integrationId: string;
jwt: string;
backendUrl: string;
}
export interface RemoveRefreshTokenRequest {
type: 'REMOVE_REFRESH_TOKEN';
integrationId: string;
}
export type ExtensionRequest =
| PingRequest
| GetProvidersRequest
| GetCookiesRequest
| StoreRefreshTokenRequest
| RemoveRefreshTokenRequest;
// ---- Response Types ----
export interface PingResponse {
status: 'ok';
version: string;
}
export interface ProviderInfo {
identifier: string;
name: string;
url: string;
cookieNames: string[];
}
export interface GetProvidersResponse {
providers: ProviderInfo[];
}
export interface GetCookiesSuccessResponse {
success: true;
provider: string;
cookies: Record<string, string>;
}
export interface GetCookiesErrorResponse {
success: false;
provider: string;
error: string;
missingCookies?: string[];
}
export type GetCookiesResponse =
| GetCookiesSuccessResponse
| GetCookiesErrorResponse;
export interface StoredRefreshEntry {
jwt: string;
backendUrl: string;
provider: string;
}
export interface ErrorResponse {
error: string;
}
export type ExtensionResponse =
| PingResponse
| GetProvidersResponse
| GetCookiesResponse
| ErrorResponse;

View file

@ -1,13 +0,0 @@
export const fetchCookie = (cookieName: string) => {
return chrome.runtime.sendMessage({
action: 'loadCookie',
cookieName,
});
};
export const getCookie = async (
cookies: chrome.cookies.Cookie[],
cookie: string
) => {
// return cookies.find((c) => c.name === cookie).value;
};

View file

@ -1,6 +0,0 @@
export const fetchStorage = (key: string) => {
return chrome.runtime.sendMessage({
action: 'loadStorage',
key,
});
};

View file

@ -1,33 +0,0 @@
const isDev = process.env.NODE_ENV === 'development';
export const sendRequest = (
auth: string,
url: string,
method: 'GET' | 'POST',
body?: string
) => {
return chrome.runtime.sendMessage({
action: 'makeHttpRequest',
url,
method,
body,
auth,
});
};
export const fetchRequestUtil = async (request: any) => {
return (
await fetch(
(import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL) +
request.url,
{
method: request.method || 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: request.auth,
// Add any auth headers here if needed
},
...(request.body ? { body: request.body } : {}),
}
)
).json();
};

View file

@ -1,7 +0,0 @@
export const saveStorage = (key: string, value: any) => {
return chrome.runtime.sendMessage({
action: 'saveStorage',
key,
value,
});
};

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -21,7 +21,6 @@
"src",
"utils",
"vite.config.base.ts",
"vite.config.chrome.ts",
"vite.config.firefox.ts"
"vite.config.chrome.ts"
]
}

View file

@ -1,38 +1,28 @@
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { ManifestV3Export } from '@crxjs/vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, BuildOptions } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { stripDevIcons, crxI18n } from './custom-vite-plugins';
import manifest from './manifest.json';
import devManifest from './manifest.dev.json';
import pkg from './package.json';
import { ProviderList } from './src/providers/provider.list';
import { providers } from './src/providers/provider.registry';
const isDev = process.env.NODE_ENV === 'development';
// set this flag to true, if you want localization support
const localize = false;
const merge = isDev ? devManifest : ({} as ManifestV3Export);
const { matches, ...rest } = manifest?.content_scripts?.[0] || {};
export const baseManifest = {
...manifest,
host_permissions: [
...ProviderList.map((p) => p.baseUrl + '/'),
import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL + '/*',
(import.meta.env?.NEXT_PUBLIC_BACKEND_URL || process?.env?.NEXT_PUBLIC_BACKEND_URL || '') + '/*',
...providers.map(p => p.hostPermission)
],
permissions: [...(manifest.permissions || [])],
content_scripts: [
{
matches: ProviderList.reduce(
(all, p) => [...all, p.baseUrl + '/*'],
[import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL + '/*']
),
...rest,
},
],
version: pkg.version,
...merge,
...(localize
@ -50,9 +40,8 @@ export const baseBuildOptions: BuildOptions = {
};
export default defineConfig({
envPrefix: ['NEXT_PUBLIC_', 'FRONTEND_URL'],
envPrefix: ['NEXT_PUBLIC_', 'FRONTEND_URL', 'NEXT_PUBLIC_BACKEND_URL'],
plugins: [
tailwindcss(),
tsconfigPaths(),
react(),
stripDevIcons(isDev),

View file

@ -15,7 +15,7 @@ export default mergeConfig(
manifest: {
...baseManifest,
background: {
service_worker: 'src/pages/background/index.ts',
service_worker: 'src/background.ts',
type: 'module',
},
} as ManifestV3Export,
@ -28,7 +28,7 @@ export default mergeConfig(
? [
hotReloadExtension({
log: true,
backgroundPath: 'src/pages/background/index.ts',
backgroundPath: 'src/background.ts',
}),
]
: []),

View file

@ -1,31 +0,0 @@
import { resolve } from 'path';
import { mergeConfig, defineConfig } from 'vite';
import { crx, ManifestV3Export } from '@crxjs/vite-plugin';
import baseConfig, { baseManifest, baseBuildOptions } from './vite.config.base';
const outDir = resolve(__dirname, 'dist_firefox');
export default mergeConfig(
baseConfig,
defineConfig({
plugins: [
crx({
manifest: {
...baseManifest,
background: {
scripts: ['src/pages/background/index.ts'],
},
} as ManifestV3Export,
browser: 'firefox',
contentScripts: {
injectCss: true,
},
}),
],
build: {
...baseBuildOptions,
outDir,
},
publicDir: resolve(__dirname, 'public'),
})
);

View file

@ -0,0 +1,23 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/background.ts'),
formats: ['es'],
fileName: () => 'background.js',
},
rollupOptions: {
output: {
entryFileNames: 'background.js',
},
},
target: 'esnext',
minify: false,
sourcemap: process.env.NODE_ENV === 'development',
},
publicDir: resolve(__dirname, 'public'),
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -78,6 +78,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
disableImageCompression={!!process.env.DISABLE_IMAGE_COMPRESSION}
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
extensionId={process.env.EXTENSION_ID || ''}
language={allHeaders.get(headerName)}
transloadit={
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE

View file

@ -49,6 +49,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
disableImageCompression={!!process.env.DISABLE_IMAGE_COMPRESSION}
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
extensionId={process.env.EXTENSION_ID || ''}
transloadit={
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE
? [

15
apps/frontend/src/chrome.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
/**
* Minimal Chrome extension API types for externally_connectable messaging.
* Web pages listed in an extension's externally_connectable can use
* chrome.runtime.sendMessage to communicate with the extension.
*/
declare namespace chrome {
namespace runtime {
const lastError: { message?: string } | undefined;
function sendMessage(
extensionId: string,
message: any,
callback: (response: any) => void
): void;
}
}

View file

@ -251,6 +251,109 @@ export const CustomVariables: FC<{
</div>
);
};
const ExtensionNotFound: FC = () => {
const modals = useModals();
const t = useT();
return (
<div className="flex flex-col gap-[16px] pt-[8px]">
<p className="text-[14px] text-textColor/80">
{t(
'extension_not_available',
'The Postiz browser extension is not installed. You need to install it before connecting this channel.'
)}
</p>
<div className="flex gap-[10px]">
<Button
type="button"
className="flex-1"
onClick={() => {
window.open(
'https://chromewebstore.google.com/detail/postiz/cidhffagahknaeodkplfbcpfeielnkjl?hl=en',
'_blank'
);
modals.closeCurrent();
}}
>
{t('install_extension', 'Install Extension')}
</Button>
<Button
type="button"
className="flex-1 !bg-transparent border border-tableBorder text-textColor"
onClick={() => modals.closeCurrent()}
>
{t('cancel', 'Cancel')}
</Button>
</div>
</div>
);
};
const ChromeExtensionWarning: FC<{
onConfirm: () => void;
onCancel: () => void;
}> = ({ onConfirm, onCancel }) => {
const modals = useModals();
const t = useT();
return (
<div className="flex flex-col gap-[16px] pt-[8px]">
<p className="text-[14px] text-textColor/80">
{t(
'chrome_extension_warning_intro',
'This channel connects via the browser extension. Please be aware of the following:'
)}
</p>
<ul className="flex flex-col gap-[8px] list-disc ps-[20px] text-[14px] text-textColor/80">
<li>
{t(
'chrome_extension_warning_tos',
'Using a browser extension to interact with a platform may violate its terms of service and could result in your account being suspended or banned.'
)}
</li>
<li>
{t(
'chrome_extension_warning_unstable',
'This method is not as reliable as native integrations and may experience random disconnections.'
)}
</li>
<li>
{t(
'chrome_extension_warning_reconnect',
'You may need to reconnect periodically if the session expires.'
)}
</li>
<li>
We will store your cookies securely to facilitate the connection.
</li>
<li>
Postiz does not take responsibility for any issues arising or account termination due to the use of this method.
</li>
</ul>
<div className="flex gap-[10px] mt-[8px]">
<Button
type="button"
className="flex-1"
onClick={() => {
modals.closeCurrent();
onConfirm();
}}
>
{t('i_understand_continue', 'I understand, continue')}
</Button>
<Button
type="button"
className="flex-1 !bg-transparent border border-tableBorder text-textColor"
onClick={() => {
modals.closeCurrent();
onCancel();
}}
>
{t('cancel', 'Cancel')}
</Button>
</div>
</div>
);
};
export const AddProviderComponent: FC<{
social: Array<{
identifier: string;
@ -258,6 +361,11 @@ export const AddProviderComponent: FC<{
toolTip?: string;
isExternal: boolean;
isWeb3: boolean;
isChromeExtension?: boolean;
extensionCookies?: Array<{
name: string;
domain: string;
}>;
customFields?: Array<{
key: string;
label: string;
@ -274,7 +382,7 @@ export const AddProviderComponent: FC<{
onboarding?: boolean;
}> = (props) => {
const { update, social, article, onboarding } = props;
const { isGeneral } = useVariables();
const { isGeneral, extensionId } = useVariables();
const toaster = useToaster();
const router = useRouter();
const fetch = useFetch();
@ -285,6 +393,7 @@ export const AddProviderComponent: FC<{
identifier: string,
isExternal: boolean,
isWeb3: boolean,
isChromeExtension?: boolean,
customFields?: Array<{
key: string;
label: string;
@ -364,6 +473,106 @@ export const AddProviderComponent: FC<{
openWeb3();
return;
}
if (isChromeExtension) {
const confirmed = await new Promise<boolean>((resolve) => {
modal.openModal({
title: t('chrome_extension_notice', 'Browser Extension Notice'),
withCloseButton: true,
onClose: () => resolve(false),
children: (
<ChromeExtensionWarning
onConfirm={() => {
resolve(true);
}}
onCancel={() => {
resolve(false);
}}
/>
),
});
});
if (!confirmed) {
return;
}
if (!extensionId || !chrome?.runtime?.sendMessage) {
modal.openModal({
title: t('extension_not_available_title', 'Extension Not Found'),
withCloseButton: true,
children: <ExtensionNotFound />,
});
return;
}
try {
await new Promise<void>((resolve, reject) => {
chrome.runtime.sendMessage(
extensionId,
{ type: 'PING' },
(response: any) => {
if (chrome.runtime.lastError || !response?.status) {
reject(new Error('Extension not reachable'));
} else {
resolve();
}
}
);
});
} catch {
toaster.show(
t(
'extension_not_installed',
'Postiz browser extension is not installed or not reachable.'
),
'warning'
);
return;
}
try {
const cookieResponse = await new Promise<any>((resolve, reject) => {
chrome.runtime.sendMessage(
extensionId,
{ type: 'GET_COOKIES', provider: identifier },
(response: any) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
}
);
});
if (!cookieResponse.success) {
toaster.show(
cookieResponse.error ||
t(
'extension_cookies_missing',
'Could not get cookies. Please log in to the platform first.'
),
'warning'
);
return;
}
const { url } = await (
await fetch(
`/integrations/social/${identifier}${
onboarding ? '?onboarding=true' : ''
}`
)
).json();
modal.closeAll();
window.location.href = `/integrations/social/${identifier}?state=${url}&code=${Buffer.from(
JSON.stringify(cookieResponse.cookies)
).toString('base64')}${onboarding ? '&onboarding=true' : ''}`;
} catch {
toaster.show(
t(
'extension_communication_error',
'Failed to communicate with the browser extension.'
),
'warning'
);
}
return;
}
if (isExternal) {
modal.openModal({
title: 'URL',
@ -415,7 +624,12 @@ export const AddProviderComponent: FC<{
return true;
}
return !item.isExternal && !item.isWeb3 && !item.customFields;
return (
!item.isExternal &&
!item.isWeb3 &&
!item.isChromeExtension &&
!item.customFields
);
})
.map((item) => (
<div
@ -425,6 +639,7 @@ export const AddProviderComponent: FC<{
item.identifier,
item.isExternal,
item.isWeb3,
item.isChromeExtension,
item.customFields
)}
{...(!!item.toolTip

View file

@ -84,7 +84,7 @@ export interface Integrations {
id: string;
disabled?: boolean;
inBetweenSteps: boolean;
editor: 'normal' | 'markdown' | 'html';
editor: 'none' | 'normal' | 'markdown' | 'html';
display: string;
identifier: string;
type: string;

View file

@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
import { useVariables } from '@gitroom/react/helpers/variable.context';
interface TwoStepState {
integrationId: string;
@ -31,6 +32,7 @@ export const ContinueIntegration: FC<{
const { push } = useRouter();
const t = useT();
const fetch = useFetch();
const { extensionId, backendUrl } = useVariables();
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [twoStepState, setTwoStepState] = useState<TwoStepState | null>(null);
@ -135,9 +137,34 @@ export const ContinueIntegration: FC<{
onboarding: resOnboarding,
pages,
returnURL,
extensionToken,
} = await data.json();
const onboarding = resOnboarding || searchParams.onboarding === 'true';
// Store refresh token in extension for background cookie refresh
if (
extensionToken &&
extensionId &&
typeof chrome !== 'undefined' &&
chrome?.runtime?.sendMessage
) {
try {
chrome.runtime.sendMessage(
extensionId,
{
type: 'STORE_REFRESH_TOKEN',
provider,
integrationId: id,
jwt: extensionToken,
backendUrl,
},
() => {}
);
} catch {
// Silently ignore — extension may not be available
}
}
// If it's a two-step provider, show the selection UI inline
if (inBetweenSteps && !searchParams.refresh) {
setTwoStepState({

View file

@ -25,6 +25,7 @@ import { Integration } from '@prisma/client';
import { SettingsModal } from '@gitroom/frontend/components/launches/settings.modal';
import { CustomVariables } from '@gitroom/frontend/components/launches/add.provider.component';
import { useRouter } from 'next/navigation';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal';
import dayjs from 'dayjs';
@ -59,6 +60,7 @@ export const Menu: FC<{
const fetch = useFetch();
const router = useRouter();
const { extensionId } = useVariables();
const { integrations, reloadCalendarView } = useCalendar();
const toast = useToaster();
const modal = useModals();
@ -146,10 +148,26 @@ export const Menu: FC<{
);
return;
}
// Clean up extension refresh token if applicable
if (
extensionId &&
typeof chrome !== 'undefined' &&
chrome?.runtime?.sendMessage
) {
try {
chrome.runtime.sendMessage(
extensionId,
{ type: 'REMOVE_REFRESH_TOKEN', integrationId: id },
() => {}
);
} catch {
// Silently ignore
}
}
toast.show(t('channel_deleted', 'Channel Deleted'), 'success');
setShow(false);
onChange(true);
}, [t]);
}, [t, extensionId, id]);
const enableChannel = useCallback(async () => {
await fetch('/integrations/enable', {

View file

@ -526,7 +526,7 @@ export const EditorWrapper: FC<{
};
export const Editor: FC<{
editorType?: 'normal' | 'markdown' | 'html';
editorType?: 'none' | 'normal' | 'markdown' | 'html';
totalPosts: number;
value: string;
num?: number;
@ -777,14 +777,18 @@ export const Editor: FC<{
toolBar={
<div className="flex gap-[5px]">
<SignatureBox editor={editorRef?.current?.editor} />
<UText
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
<BoldText
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
{editorType !== 'none' && (
<>
<UText
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
<BoldText
editor={editorRef?.current?.editor}
currentValue={props.value!}
/>
</>
)}
{(editorType === 'markdown' || editorType === 'html') &&
identifier !== 'telegram' && (
<>
@ -854,7 +858,7 @@ export const Editor: FC<{
export const OnlyEditor = forwardRef<
any,
{
editorType: 'normal' | 'markdown' | 'html';
editorType: 'none' | 'normal' | 'markdown' | 'html';
value: string;
onChange: (value: string) => void;
paste?: (event: ClipboardEvent | File[]) => void;

View file

@ -36,6 +36,7 @@ import WordpressProvider from '@gitroom/frontend/components/new-launch/providers
import ListmonkProvider from '@gitroom/frontend/components/new-launch/providers/listmonk/listmonk.provider';
import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/gmb.provider';
import MoltbookProvider from '@gitroom/frontend/components/new-launch/providers/moltbook/moltbook.provider';
import SkoolProvider from '@gitroom/frontend/components/new-launch/providers/skool/skool.provider';
export const Providers = [
{
@ -157,6 +158,10 @@ export const Providers = [
{
identifier: 'moltbook',
component: MoltbookProvider,
},
{
identifier: 'skool',
component: SkoolProvider,
}
];
export const ShowAllProviders = forwardRef((props, ref) => {

View file

@ -0,0 +1,57 @@
'use client';
import { FC, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { Select } from '@gitroom/react/form/select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const SkoolGroupSelect: FC<{
name: string;
onChange: (event: {
target: {
value: string;
name: string;
};
}) => void;
}> = (props) => {
const { onChange, name } = props;
const t = useT();
const customFunc = useCustomProviderFunction();
const [groups, setGroups] = useState([]);
const { getValues } = useSettings();
const [currentGroup, setCurrentGroup] = useState<string | undefined>();
const onChangeInner = (event: {
target: {
value: string;
name: string;
};
}) => {
setCurrentGroup(event.target.value);
onChange(event);
};
useEffect(() => {
customFunc.get('groups').then((data) => setGroups(data));
const settings = getValues()[props.name];
if (settings) {
setCurrentGroup(settings);
}
}, []);
if (!groups.length) {
return null;
}
return (
<Select
name={name}
label="Select Group"
onChange={onChangeInner}
value={currentGroup}
>
<option value="">{t('select_1', '--Select--')}</option>
{groups.map((group: any) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
);
};

View file

@ -0,0 +1,65 @@
'use client';
import { FC, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { Select } from '@gitroom/react/form/select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const SkoolLabelSelect: FC<{
name: string;
groupId: string | undefined;
onChange: (event: {
target: {
value: string;
name: string;
};
}) => void;
}> = (props) => {
const { onChange, name, groupId } = props;
const t = useT();
const customFunc = useCustomProviderFunction();
const [labels, setLabels] = useState([]);
const { getValues } = useSettings();
const [currentLabel, setCurrentLabel] = useState<string | undefined>();
const onChangeInner = (event: {
target: {
value: string;
name: string;
};
}) => {
setCurrentLabel(event.target.value);
onChange(event);
};
useEffect(() => {
if (!groupId) {
setLabels([]);
setCurrentLabel(undefined);
return;
}
customFunc.get('label', { id: groupId }).then((data) => setLabels(data));
}, [groupId]);
useEffect(() => {
const settings = getValues()[name];
if (settings) {
setCurrentLabel(settings);
}
}, []);
if (!groupId || !labels.length) {
return null;
}
return (
<Select
name={name}
label="Select Label"
onChange={onChangeInner}
value={currentLabel}
>
<option value="">{t('select_1', '--Select--')}</option>
{labels.map((label: any) => (
<option key={label.id} value={label.id}>
{label.name}
</option>
))}
</Select>
);
};

View file

@ -0,0 +1,37 @@
'use client';
import {
withProvider,
} from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import { FC, useState } from 'react';
import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto';
import { SkoolGroupSelect } from '@gitroom/frontend/components/new-launch/providers/skool/skool.group.select';
import { SkoolLabelSelect } from '@gitroom/frontend/components/new-launch/providers/skool/skool.label.select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { Input } from '@gitroom/react/form/input';
const SkoolComponent: FC = () => {
const form = useSettings();
const [selectedGroup, setSelectedGroup] = useState<string | undefined>(
form.getValues().group
);
const groupRegister = form.register('group');
const onGroupChange = (event: { target: { value: string; name: string } }) => {
setSelectedGroup(event.target.value);
groupRegister.onChange(event);
};
return (
<div>
<Input label="Title" {...form.register('title')} />
<SkoolGroupSelect {...groupRegister} onChange={onGroupChange} />
<SkoolLabelSelect {...form.register('label')} groupId={selectedGroup} />
</div>
);
};
export default withProvider({
minimumCharacters: [],
SettingsComponent: SkoolComponent,
CustomPreviewComponent: undefined,
dto: SkoolDto,
checkValidity: undefined,
maximumCharacters: 5000,
});

View file

@ -26,7 +26,7 @@ export interface SelectedIntegrations {
}
interface StoreState {
editor: undefined | 'normal' | 'markdown' | 'html';
editor: undefined | 'none' | 'normal' | 'markdown' | 'html';
loaded: boolean;
date: dayjs.Dayjs;
postComment: PostComment;
@ -128,7 +128,7 @@ interface StoreState {
setPostComment: (postComment: PostComment) => void;
setActivateExitButton?: (activateExitButton: boolean) => void;
setDummy: (dummy: boolean) => void;
setEditor: (editor: 'normal' | 'markdown' | 'html') => void;
setEditor: (editor: 'none' | 'normal' | 'markdown' | 'html') => void;
setLoaded?: (loaded: boolean) => void;
setChars: (id: string, chars: number) => void;
chars: Record<string, number>;
@ -613,7 +613,7 @@ export const useLaunchStore = create<StoreState>()((set) => ({
set((state) => ({
dummy,
})),
setEditor: (editor: 'normal' | 'markdown' | 'html') =>
setEditor: (editor: 'none' | 'normal' | 'markdown' | 'html') =>
set((state) => ({
editor,
})),

View file

@ -145,6 +145,16 @@ export const stripHtmlValidation = (
const value = serialize(parseFragment(val));
if (type === 'none') {
return striptags(value)
.replace(/&gt;/gi, '>')
.replace(/&lt;/gi, '<')
.replace(/&amp;/gi, '&')
.replace(/&nbsp;/gi, ' ')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'");
}
if (type === 'html') {
return striptags(convertMention(value, convertMentionFunction), [
'ul',

View file

@ -21,6 +21,7 @@ import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-s
import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto';
import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto';
import { MoltbookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/moltbook.dto';
import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto';
export type ProviderExtension<T extends string, M> = { __type: T } & M;
export type AllProvidersSettings =
@ -53,7 +54,8 @@ export type AllProvidersSettings =
| ProviderExtension<'telegram', None>
| ProviderExtension<'nostr', None>
| ProviderExtension<'moltbook', MoltbookDto>
| ProviderExtension<'vk', None>;
| ProviderExtension<'vk', None>
| ProviderExtension<'skool', SkoolDto>;
type None = NonNullable<unknown>;
@ -89,6 +91,7 @@ export const allProviders = (setEmpty?: any) => {
{ value: setEmpty, name: 'nostr' },
{ value: setEmpty, name: 'vk' },
{ value: MoltbookDto, name: 'moltbook' },
{ value: SkoolDto, name: 'skool' },
].filter((f) => f.value);
};

View file

@ -0,0 +1,28 @@
import { IsDefined, IsString, MinLength } from 'class-validator';
import { JSONSchema } from 'class-validator-jsonschema';
export class SkoolDto {
@MinLength(1)
@IsDefined()
@IsString()
@JSONSchema({
description: 'Group must be an id',
})
group: string;
@MinLength(1)
@IsDefined()
@IsString()
@JSONSchema({
description: 'Label must be an id',
})
label: string;
@MinLength(1)
@IsDefined()
@IsString()
@JSONSchema({
description: 'Title of the post',
})
title: string;
}

View file

@ -33,6 +33,7 @@ import { KickProvider } from '@gitroom/nestjs-libraries/integrations/social/kick
import { TwitchProvider } from '@gitroom/nestjs-libraries/integrations/social/twitch.provider';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { MoltbookProvider } from '@gitroom/nestjs-libraries/integrations/social/moltbook.provider';
import { SkoolProvider } from '@gitroom/nestjs-libraries/integrations/social/skool.provider';
export const socialIntegrationList: Array<SocialAbstract & SocialProvider> = [
new XProvider(),
@ -65,6 +66,7 @@ export const socialIntegrationList: Array<SocialAbstract & SocialProvider> = [
new WordpressProvider(),
new ListmonkProvider(),
new MoltbookProvider(),
new SkoolProvider(),
// new MastodonCustomProvider(),
];
@ -80,6 +82,8 @@ export class IntegrationManager {
editor: p.editor,
isExternal: !!p.externalUrl,
isWeb3: !!p.isWeb3,
isChromeExtension: !!p.isChromeExtension,
...(p.extensionCookies ? { extensionCookies: p.extensionCookies } : {}),
...(p.customFields ? { customFields: await p.customFields() } : {}),
}))
),

View file

@ -0,0 +1,333 @@
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { SocialAbstract } from '../social.abstract';
import {
AuthTokenDetails,
MediaContent,
PostDetails,
PostResponse,
SocialProvider,
} from './social.integrations.interface';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
export class SkoolProvider extends SocialAbstract implements SocialProvider {
identifier = 'skool';
name = 'Skool';
isBetweenSteps = false;
isChromeExtension = true;
scopes = [] as string[];
editor = 'normal' as const;
dto = SkoolDto;
extensionCookies = [
{ name: 'client_id', domain: '.skool.com' },
{ name: 'auth_token', domain: '.skool.com' },
];
private getCookies(integration: Integration): {
client_id: string;
auth_token: string;
} {
return AuthService.verifyJWT(integration.customInstanceDetails!) as {
client_id: string;
auth_token: string;
};
}
override handleErrors(
body: string
):
| { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
| undefined {
if (body.includes('must be admin or level')) {
return { type: 'bad-body', value: 'You can\'t post to this channel' };
}
if (body.includes('cannot post to this label')) {
return { type: 'bad-body', value: 'Cannot post to this label' };
}
return undefined;
}
maxLength() {
return 5000;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
expiresIn: 0,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
};
}
async generateAuthUrl() {
const state = makeId(6);
return {
url: state,
codeVerifier: makeId(10),
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
try {
const cookies: Record<string, string> = JSON.parse(
Buffer.from(params.code, 'base64').toString()
);
const missing = this.extensionCookies
.map((c) => c.name)
.filter((name) => !cookies[name]);
if (missing.length > 0) {
return `Missing required cookies: ${missing.join(', ')}`;
}
const data = await (
await fetch('https://api2.skool.com/self', {
method: 'GET',
headers: {
Cookie: `auth_token=${cookies.auth_token}; client_id=${cookies.client_id}`,
},
})
).json();
return {
refreshToken: '',
expiresIn: dayjs().add(100, 'year').unix() - dayjs().unix(),
accessToken: AuthService.signJWT(cookies),
id: data.id,
name: data.first_name + ' ' + data.last_name,
picture: data.metadata.picture_profile || '',
username: data.name,
};
} catch (e) {
return 'Invalid cookie data';
}
}
@Tool({ description: 'Groups', dataSchema: [] })
async groups(accessToken: string, params: any, id: string, integration: Integration) {
try {
const { client_id, auth_token } = this.getCookies(integration);
const { groups } = await (
await fetch(
`https://api2.skool.com/users/${id}/groups?offset=0&limit=30`,
{
headers: {
Cookie: `auth_token=${auth_token}; client_id=${client_id}`,
},
}
)
).json();
return groups.map((p: any) => ({
id: String(p.id),
name: p.metadata.display_name,
}));
} catch (err) {
return [];
}
}
@Tool({ description: 'Label', dataSchema: [] })
async label(accessToken: string, params: any, id: string, integration: Integration) {
try {
const { client_id, auth_token } = this.getCookies(integration);
const { metadata } = await (
await this.fetch(`https://api2.skool.com/groups/${params.id}`, {
headers: {
Cookie: `auth_token=${auth_token}; client_id=${client_id}`,
},
})
).json();
if (!metadata.labels || metadata.labels.length === 0) {
return [{ id: 'none', name: 'Default Label' }];
}
const labels = metadata.labels.split(',');
if (labels.length === 0) {
return [{ id: 'none', name: 'Default Label' }];
}
const labelInformation = await Promise.all(
labels.map(async (labelId: string) => {
return (
await this.fetch(`https://api2.skool.com/labels/${labelId}`, {
headers: {
Cookie: `auth_token=${auth_token}; client_id=${client_id}`,
},
})
).json();
})
);
return labelInformation.map((p: any) => ({
id: String(p.id),
name: p.metadata.display_name,
}));
} catch (err) {
return [];
}
}
private async uploadMediaToSkool(
media: MediaContent[],
userId: string,
cookies: { client_id: string; auth_token: string }
): Promise<string> {
if (!media || media.length === 0) return '';
const fileIds: string[] = [];
for (const item of media) {
const fileResponse = await fetch(item.path);
const fileBuffer = await fileResponse.arrayBuffer();
const contentType =
fileResponse.headers.get('content-type') || 'application/octet-stream';
const fileName = item.path.split('/').pop() || 'file';
const createFileResponse = await (
await this.fetch('https://api2.skool.com/files', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `auth_token=${cookies.auth_token}; client_id=${cookies.client_id}`,
},
body: JSON.stringify({
file_name: fileName,
content_type: contentType,
content_length: fileBuffer.byteLength,
content_disposition: '',
ref: '',
owner_id: userId,
large_thumbnail: false,
}),
}, 'create file record')
).json();
await fetch(createFileResponse.write_url, {
method: 'PUT',
headers: {
'Content-Type': createFileResponse.content_type,
'x-amz-acl': createFileResponse.acl,
},
body: fileBuffer,
});
fileIds.push(createFileResponse.file.id);
}
return fileIds.join(',');
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const { client_id, auth_token } = this.getCookies(integration);
const [post] = postDetails;
const attachments = await this.uploadMediaToSkool(
post.media || [],
id,
{ client_id, auth_token }
);
const { id: postId, name } = await (
await this.fetch('https://api2.skool.com/posts?follow=true', {
method: 'POST',
headers: {
Cookie: `auth_token=${auth_token}; client_id=${client_id}`,
},
body: JSON.stringify({
post_type: 'generic',
group_id: post.settings.group,
metadata: {
title: post.settings.title,
content: post.message,
attachments,
...(post.settings.label && post.settings.label !== 'none'
? { labels: post.settings.label }
: {}),
action: 0,
video_ids: '',
},
}),
})
).json();
return [
{
id: String(postId),
postId,
releaseURL: `https://www.skool.com/${post.settings.group}/${name}`,
status: 'success',
},
];
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const { client_id, auth_token } = this.getCookies(integration);
const [post] = postDetails;
const attachments = await this.uploadMediaToSkool(
post.media || [],
id,
{ client_id, auth_token }
);
const { id: postIdFinal, name } = await (
await this.fetch('https://api2.skool.com/posts?follow=true', {
method: 'POST',
headers: {
Cookie: `auth_token=${auth_token}; client_id=${client_id}`,
},
body: JSON.stringify({
post_type: 'comment',
group_id: post.settings.group,
root_id: postId,
parent_id: lastCommentId || postId,
metadata: {
title: '',
content: post.message,
attachments,
action: 0,
video_ids: '',
},
}),
})
).json();
return [
{
id: String(id),
postId: postIdFinal,
releaseURL: `https://www.skool.com/${post.settings.group}/${name}`,
status: 'success',
},
];
}
}

View file

@ -141,7 +141,9 @@ export interface SocialProvider
dto?: any;
maxLength: (additionalSettings?: any) => number;
isWeb3?: boolean;
editor: 'normal' | 'markdown' | 'html';
isChromeExtension?: boolean;
extensionCookies?: { name: string; domain: string }[];
editor: 'none' | 'normal' | 'markdown' | 'html';
customFields?: () => Promise<
{
key: string;

View file

@ -25,6 +25,7 @@ interface VariableContextInterface {
dub: boolean;
transloadit: string[];
sentryDsn: string;
extensionId: string;
}
const VariableContext = createContext({
stripeClient: '',
@ -49,6 +50,7 @@ const VariableContext = createContext({
dub: false,
transloadit: [],
sentryDsn: '',
extensionId: '',
} as VariableContextInterface);
export const VariableContextComponent: FC<
VariableContextInterface & {