diff --git a/.env.example b/.env.example index 8cbb4a1..6ccf450 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,20 @@ APP_DOMAIN=app.quorlasocial.com # Let's Encrypt sends certificate notices here ACME_EMAIL=support@quorlasocial.com +# ---------- Other platforms (LinkedIn, Google, TikTok, Pinterest, Threads, Reddit) ---------- +# App keys are normally entered in the dashboard (Connections → Set up) and stored encrypted in +# data/connections.json, using DASHBOARD_SECRET as the key (or QUORLA_SECRET_KEY if set). +# Changing that secret means every platform must be reconnected. +# Public address of this install, used for sign-in redirects and media links (TikTok photos, Threads, +# Google Business Profile). Defaults to https://APP_DOMAIN when APP_DOMAIN is set. +PUBLIC_URL= +# LinkedIn API version (YYYYMM). LinkedIn retires versions after about a year. +LINKEDIN_API_VERSION=202606 +# true while the Pinterest app only has Trial access (Pins go to Pinterest's sandbox) +PINTEREST_SANDBOX=false +# Reddit requires a descriptive User-Agent: web:: (by /u/) +REDDIT_USER_AGENT=web:com.quorlasocial.app:1.0 (by /u/YOUR_REDDIT_USERNAME) + # ---------- Dashboard ---------- DASHBOARD_PORT=4310 # 127.0.0.1 behind nginx (recommended); 0.0.0.0 to listen on all interfaces diff --git a/CLAUDE.md b/CLAUDE.md index 567d2de..e355851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ You run many client accounts at once. Every client is different: never reuse one - DMs: `direct_messages` reads; `send_direct_message` only replies to someone who messaged first (Meta's 24-hour window), with approval. - Research: `competitor_lookup` (any public business/creator IG account), `audience_demographics`, `instagram_mentions`. - Every write is logged to `data/publish-log.jsonl`. +- **Other platforms** (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit): an admin connects each client's account in the dashboard (Connections). Call `list_connections` first; only plan posts for platforms that are connected, and pass each platform's required fields in `options` (e.g. YouTube `title`, Pinterest `boardId`, TikTok `privacy`, Reddit `subreddit` + `title`). Write a platform-specific `caption` in `options` when the main caption doesn't fit (LinkedIn: professional; Threads: ≤500 chars; GBP: local offer + button). Same approval rules apply. Reddit: only where the subreddit's rules allow it; never disguise promotion. ## Reports & branding - Every report we hand over (audits, weekly reports) is exported as a Metatroncube-branded PDF: `node tools/report-pdf/build.mjs ` (first time: `cd tools/report-pdf && npm install`). Logos and styles live in `branding/`. diff --git a/connector/src/library.js b/connector/src/library.js index 60aaef7..c1561a8 100644 --- a/connector/src/library.js +++ b/connector/src/library.js @@ -98,11 +98,11 @@ export function archiveItem(id, by = "team") { } /** Turn an approved item into a DRAFT post in the queue (still needs approval there to publish). */ -export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], type }, by = "team") { +export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], options = {}, type }, by = "team") { const i = getItem(id); if (!["approved", "scheduled"].includes(i.status)) throw err("Only approved work can be scheduled. Review it first."); const post = sched.createPost({ - client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, localTime, + client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, options, localTime, createdBy: by, notes: `From library item ${i.id} "${i.title}" by ${i.uploadedBy}`, }); mutate(id, (x) => { x.status = "scheduled"; x.postIds.push(post.id); x.history.push({ at: now(), by, action: `scheduled ${post.localTime}`, postId: post.id }); }); diff --git a/connector/src/platforms/gbp.js b/connector/src/platforms/gbp.js new file mode 100644 index 0000000..5a1c453 --- /dev/null +++ b/connector/src/platforms/gbp.js @@ -0,0 +1,63 @@ +// Google Business Profile: "updates" (local posts) on a business location, with an optional button. +// Access to the Business Profile APIs must be requested from Google (approval form) before calls work. +import { request, isVideo } from "./http.js"; +import { googleAuth, googleSetup } from "./google.js"; +import { publicMediaUrl } from "./media.js"; + +const CTA = [["", "No button"], ["LEARN_MORE", "Learn more"], ["BOOK", "Book"], ["ORDER", "Order online"], ["SHOP", "Buy"], ["SIGN_UP", "Sign up"], ["CALL", "Call now"]]; + +export default { + id: "gbp", + name: "Google Business Profile", + color: "#4285f4", + setup: googleSetup("Business Profile API access is approved separately: request it with Google's “GBP API access” form for the same project.", [ + "APIs & Services → Library → enable “My Business Account Management API”, “My Business Business Information API” and “Google My Business API”.", + "Request Business Profile API access (developers.google.com/my-business → Request access). Calls fail with a quota of 0 until Google approves.", + ]), + auth: googleAuth(["openid", "email", "https://www.googleapis.com/auth/business.manage"]), + formats: { TEXT: true, IMAGE: true }, + limits: { caption: 1500 }, + targetLabel: "Business location", + fields: [ + { key: "cta", label: "Button", type: "select", options: CTA, default: "" }, + { key: "ctaUrl", label: "Button link", type: "text", hint: "Needed for every button except Call now. e.g. booking page." }, + ], + + async identify(tokens) { + const { data } = await request("Google", "https://openidconnect.googleapis.com/v1/userinfo", { token: tokens.accessToken }); + return { id: data.sub, name: data.email || data.name, username: null, avatar: data.picture || null, url: null }; + }, + + async targets(tokens) { + const out = []; + const { data } = await request("Google Business Profile", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", { token: tokens.accessToken }); + for (const acc of data.accounts || []) { + let pageToken; + do { + const r = await request("Google Business Profile", `https://mybusinessbusinessinformation.googleapis.com/v1/${acc.name}/locations`, { + token: tokens.accessToken, query: { readMask: "name,title,storefrontAddress", pageSize: 100, pageToken }, + }); + for (const loc of r.data.locations || []) { + const addr = loc.storefrontAddress ? [loc.storefrontAddress.addressLines?.[0], loc.storefrontAddress.locality].filter(Boolean).join(", ") : ""; + out.push({ id: `${acc.name}/${loc.name}`, name: addr ? `${loc.title} · ${addr}` : loc.title }); + } + pageToken = r.data.nextPageToken; + } while (pageToken); + } + return out; + }, + + async publish({ tokens, conn, post, opts, caption }) { + const location = conn.target?.id; + if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first."); + const body = { languageCode: "en", summary: String(caption || "").slice(0, 1500), topicType: "STANDARD" }; + if (opts.cta) { + if (opts.cta !== "CALL" && !opts.ctaUrl) throw new Error("Google Business Profile: add a link for the button."); + body.callToAction = { actionType: opts.cta, ...(opts.cta !== "CALL" ? { url: opts.ctaUrl } : {}) }; + } + const img = (post.media || []).find((m) => !isVideo(m)); + if (img) body.media = [{ mediaFormat: "PHOTO", sourceUrl: /^https?:/.test(img) ? img : publicMediaUrl(img) }]; + const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${location}/localPosts`, { token: tokens.accessToken, method: "POST", json: body }); + return { id: data.name, permalink: data.searchUrl || null, state: data.state }; + }, +}; diff --git a/connector/src/platforms/google.js b/connector/src/platforms/google.js new file mode 100644 index 0000000..093d8c2 --- /dev/null +++ b/connector/src/platforms/google.js @@ -0,0 +1,20 @@ +// Shared Google sign-in for YouTube and Google Business Profile (one Google Cloud OAuth client covers both). +export const googleAuth = (scopes) => ({ + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scopes, + pkce: true, + extraAuthParams: { access_type: "offline", prompt: "consent", include_granted_scopes: "true" }, +}); + +export const googleSetup = (product, extraSteps = []) => ({ + console: "https://console.cloud.google.com/apis/credentials", + steps: [ + "In Google Cloud Console create (or pick) a project for Quorla.", + ...extraSteps, + "APIs & Services → OAuth consent screen: External, app name Quorla, your support email, and add the scopes Quorla asks for.", + "Credentials → Create credentials → OAuth client ID → Web application. Add the Redirect URI shown below.", + `Copy the Client ID and Client secret here. YouTube and Google Business Profile can share the same client: enter it under each.`, + ], + note: `Until Google verifies the consent screen, add each client's Google account as a Test user (max 100). ${product}`, +}); diff --git a/connector/src/platforms/http.js b/connector/src/platforms/http.js new file mode 100644 index 0000000..c90b7f0 --- /dev/null +++ b/connector/src/platforms/http.js @@ -0,0 +1,48 @@ +// Small HTTP helpers shared by the platform adapters. +import fs from "node:fs"; +import path from "node:path"; + +export class PlatformError extends Error { + constructor(platform, message, { status, body } = {}) { + super(`${platform}: ${message}`); + this.platform = platform; this.status = status; this.body = body; + } +} + +/** fetch → JSON, throwing a readable PlatformError on HTTP or API errors. */ +export async function request(platform, url, { method = "GET", headers = {}, json, form, body, token, query } = {}) { + const u = new URL(url); + for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null) u.searchParams.set(k, String(v)); + const h = { Accept: "application/json", ...headers }; + if (token) h.Authorization = `Bearer ${token}`; + let payload = body; + if (json !== undefined) { h["Content-Type"] = "application/json"; payload = JSON.stringify(json); } + if (form !== undefined) { h["Content-Type"] = "application/x-www-form-urlencoded"; payload = new URLSearchParams(Object.entries(form).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)])).toString(); } + const res = await fetch(u, { method, headers: h, body: payload }); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = text; } + const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message); + if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) { + throw new PlatformError(platform, apiError || `${res.status} ${res.statusText}`, { status: res.status, body: data }); + } + return { data, headers: res.headers, status: res.status }; +} + +export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, ""); +export const isUrl = (s) => /^https?:\/\//i.test(String(s || "")); +export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]); +export const mimeOf = (p) => ({ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".m4v": "video/mp4", ".webm": "video/webm" })[path.extname(String(p).split("?")[0]).toLowerCase()] || "application/octet-stream"; + +/** Bytes of a local file or a URL. */ +export async function bytesOf(src) { + if (isUrl(src)) { + const r = await fetch(src); + if (!r.ok) throw new Error(`Couldn't download ${src}: ${r.status}`); + return Buffer.from(await r.arrayBuffer()); + } + return fs.readFileSync(src); +} + +export const sizeOf = async (src) => (isUrl(src) ? (await bytesOf(src)).length : fs.statSync(src).size); +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js new file mode 100644 index 0000000..fc5b98f --- /dev/null +++ b/connector/src/platforms/index.js @@ -0,0 +1,136 @@ +// Platform registry: everything beyond Facebook/Instagram (which use the Meta system user). +// Each client connects its own account per platform (OAuth); tokens live encrypted in data/connections.json. +import linkedin from "./linkedin.js"; +import youtube from "./youtube.js"; +import gbp from "./gbp.js"; +import tiktok from "./tiktok.js"; +import pinterest from "./pinterest.js"; +import threads from "./threads.js"; +import reddit from "./reddit.js"; +import * as vault from "./vault.js"; +import * as oauth from "./oauth.js"; + +export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit }; +export const PLATFORM_IDS = Object.keys(ADAPTERS); +export { vault }; + +const err = (m, status = 400) => Object.assign(new Error(m), { status }); +const FORMAT_WORD = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" }; + +export function adapter(id) { + const a = ADAPTERS[id]; + if (!a) throw err(`Unknown platform: ${id}`, 404); + return a; +} + +/** Public description of every platform for the Connections page (no secrets). */ +export function describePlatforms() { + return PLATFORM_IDS.map((id) => { + const a = ADAPTERS[id]; + const app = vault.appCredentials(id); + return { + id, name: a.name, color: a.color, setup: a.setup, formats: Object.keys(a.formats), fields: a.fields || [], + targetLabel: a.targetLabel || null, hasTargets: Boolean(a.targets), limits: a.limits || {}, + defaultScopes: a.auth.scopes, + app: app ? { configured: true, source: app.source, clientId: app.clientId, scopes: app.scopes || [], updatedAt: app.updatedAt || null } : { configured: false }, + }; + }); +} + +// ---------- sign-in ---------- +export function startConnect({ platform, client, redirectUri, user }) { + const a = adapter(platform); + const app = vault.appCredentials(platform); + if (!app) throw err(`${a.name} isn't set up yet. An admin adds the app's client ID and secret first.`); + const p = a.auth.pkce ? oauth.pkce() : {}; + const state = vault.createState({ platform, client, redirectUri, verifier: p.verifier, userId: user.id, by: user.name || user.email }); + return oauth.authorizeUrl(a, app, { redirectUri, state, challenge: p.challenge }); +} + +export async function finishConnect({ platform, code, state, user }) { + const s = vault.takeState(state); + if (s.platform !== platform) throw err("This sign-in doesn't match the platform. Start again."); + if (s.userId !== user.id) throw err("Finish the sign-in in the same Quorla session that started it."); + const a = adapter(platform); + const app = vault.appCredentials(platform); + const tokens = await oauth.exchangeCode(a, app, { code, redirectUri: s.redirectUri, verifier: s.verifier }); + const account = await a.identify(tokens); + const conn = vault.saveConnection({ client: s.client, platform, account, tokens, scopes: (tokens.scope || "").split(/[\s,]+/).filter(Boolean), by: s.by }); + // Pick the only target automatically (e.g. one business location). + if (a.targets) { + try { + const t = await a.targets(tokens, account); + if (t.length === 1) vault.updateConnection(conn.id, { target: t[0] }); + } catch { /* chosen later on the Connections page */ } + } + return { ...vault.getConnection(conn.id), client: s.client }; +} + +/** Fresh tokens for a connection, refreshing (and saving) them when they expire within 5 minutes. */ +export async function tokensFor(connId) { + const conn = vault.getConnection(connId, { withTokens: true }); + if (!conn.tokens) throw err(`${adapter(conn.platform).name}: the stored sign-in can't be read (the server secret changed). Reconnect it.`); + const soon = conn.tokens.expiresAt && new Date(conn.tokens.expiresAt) - Date.now() < 5 * 60000; + if (!soon) return { conn, tokens: conn.tokens }; + try { + const tokens = await oauth.refreshTokens(adapter(conn.platform), vault.appCredentials(conn.platform), conn.tokens); + vault.updateConnection(conn.id, { tokens, status: "connected", lastError: null }); + return { conn, tokens }; + } catch (e) { + vault.updateConnection(conn.id, { status: "reconnect", lastError: e.message }); + throw err(`${adapter(conn.platform).name}: the sign-in expired. Reconnect it on the Connections page. (${e.message})`); + } +} + +export async function targetsFor(connId) { + const { conn, tokens } = await tokensFor(connId); + const a = adapter(conn.platform); + return a.targets ? a.targets(tokens, conn.account) : []; +} + +export async function setTarget(connId, targetId) { + const list = await targetsFor(connId); + const t = list.find((x) => x.id === targetId); + if (!t) throw err("That option isn't available for this account any more."); + return vault.updateConnection(connId, { target: t }); +} + +/** Re-read the account (name, followers) to check the connection still works. */ +export async function checkConnection(connId) { + const { conn, tokens } = await tokensFor(connId); + try { + const account = await adapter(conn.platform).identify(tokens); + return vault.updateConnection(connId, { account, status: "connected", lastError: null }); + } catch (e) { + vault.updateConnection(connId, { status: "error", lastError: e.message }); + throw e; + } +} + +// ---------- posting ---------- +/** Problems that stop a post going to this platform (checked when the draft is saved). */ +export function validate(platform, client, post) { + const a = adapter(platform); + const conn = vault.connectionFor(client, platform); + if (!conn) throw err(`${a.name} isn't connected for this client. Connect it on the Connections page.`); + if (!a.formats[post.type]) throw err(`${a.name} doesn't take ${FORMAT_WORD[post.type] || post.type.toLowerCase()} posts. It takes: ${Object.keys(a.formats).map((f) => FORMAT_WORD[f]).join(", ")}.`); + if (a.targetLabel && a.targets && !conn.target && platform !== "linkedin") throw err(`${a.name}: choose the ${a.targetLabel.toLowerCase()} on the Connections page first.`); + const opts = post.options?.[platform] || {}; + for (const f of a.fields || []) { + if (f.onlyFor && !f.onlyFor.includes(post.type)) continue; + if (f.required && (opts[f.key] === undefined || opts[f.key] === "")) throw err(`${a.name}: ${f.label.toLowerCase()} is required.`); + if (f.max && String(opts[f.key] || "").length > f.max) throw err(`${a.name}: ${f.label.toLowerCase()} is longer than ${f.max} characters.`); + } + const cap = a.limits?.caption; + const caption = opts.caption ?? post.caption ?? ""; + if (cap && caption.length > cap) throw err(`${a.name}: the caption is ${caption.length} characters; the limit is ${cap}. Shorten it or add a ${a.name} caption.`); +} + +export async function publish(platform, client, post) { + const a = adapter(platform); + const c = vault.connectionFor(client, platform); + if (!c) throw err(`${a.name} isn't connected for this client.`); + const { conn, tokens } = await tokensFor(c.id); + const opts = { ...Object.fromEntries((a.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default])), ...(post.options?.[platform] || {}) }; + return a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" }); +} diff --git a/connector/src/platforms/linkedin.js b/connector/src/platforms/linkedin.js new file mode 100644 index 0000000..20c039e --- /dev/null +++ b/connector/src/platforms/linkedin.js @@ -0,0 +1,113 @@ +// LinkedIn: post to a person's profile or to a company Page they administer. +// Company Pages need the Community Management API product on the LinkedIn app (approval required). +import { request, bytesOf, isVideo, sizeOf } from "./http.js"; + +const API = "https://api.linkedin.com"; +const version = () => process.env.LINKEDIN_API_VERSION || "202606"; +const rest = (token) => ({ token, headers: { "LinkedIn-Version": version(), "X-Restli-Protocol-Version": "2.0.0" } }); + +// LinkedIn's "little text" format: reserved characters must be escaped; hashtags use a template. +export function littleText(s) { + const text = String(s || ""); + const esc = (t) => t.replace(/[\\|{}@[\]()<>#*_~]/g, (c) => `\\${c}`); + const re = /(^|\s)#([\p{L}\p{N}_]+)/gu; + let out = "", last = 0, m; + while ((m = re.exec(text))) { + out += esc(text.slice(last, m.index)) + m[1] + `{hashtag|\\#|${m[2]}}`; + last = m.index + m[0].length; + } + return out + esc(text.slice(last)); +} + +async function uploadImage(token, owner, src) { + const { data } = await request("LinkedIn", `${API}/rest/images?action=initializeUpload`, { ...rest(token), method: "POST", json: { initializeUploadRequest: { owner } } }); + const { uploadUrl, image } = data.value; + const put = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: await bytesOf(src) }); + if (!put.ok) throw new Error(`LinkedIn: image upload failed (${put.status}).`); + return image; +} + +async function uploadVideo(token, owner, src) { + const bytes = await bytesOf(src); + const { data } = await request("LinkedIn", `${API}/rest/videos?action=initializeUpload`, { + ...rest(token), method: "POST", + json: { initializeUploadRequest: { owner, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } }, + }); + const { video, uploadInstructions, uploadToken } = data.value; + const etags = []; + for (const part of uploadInstructions) { + const r = await fetch(part.uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes.subarray(part.firstByte, part.lastByte + 1) }); + if (!r.ok) throw new Error(`LinkedIn: video upload failed (${r.status}).`); + etags.push(r.headers.get("etag")); + } + await request("LinkedIn", `${API}/rest/videos?action=finalizeUpload`, { + ...rest(token), method: "POST", json: { finalizeUploadRequest: { video, uploadToken: uploadToken || "", uploadedPartIds: etags } }, + }); + return video; +} + +export default { + id: "linkedin", + name: "LinkedIn", + color: "#0a66c2", + setup: { + console: "https://www.linkedin.com/developers/apps", + steps: [ + "Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).", + "Products tab: add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn” (instant).", + "For posting to clients' company Pages, also request “Community Management API” (LinkedIn reviews it; your company Page must be verified).", + "Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.", + ], + note: "Until Community Management API is approved, set Scopes to “openid profile w_member_social” so you can post to personal profiles only.", + }, + auth: { + authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization", + tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken", + scopes: ["openid", "profile", "w_member_social", "r_organization_admin", "w_organization_social", "r_organization_social"], + }, + formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true }, + limits: { caption: 3000, images: 20 }, + targetLabel: "Post as", + + async identify(tokens) { + const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken }); + return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null }; + }, + + /** The person, plus every company Page they administer (needs Community Management API). */ + async targets(tokens, account) { + const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }]; + try { + const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } }); + for (const e of data.elements || []) { + const orgId = String(e.organization).split(":").pop(); + let name = `Company ${orgId}`; + try { name = (await request("LinkedIn", `${API}/rest/organizations/${orgId}`, rest(tokens.accessToken))).data.localizedName || name; } catch { /* keep id */ } + out.push({ id: e.organization, name: `${name} (company Page)` }); + } + } catch { /* no organization scopes: personal profile only */ } + return out; + }, + + async publish({ tokens, conn, post, caption }) { + const author = conn.target?.id || `urn:li:person:${conn.account.id}`; + const body = { + author, commentary: littleText(caption), visibility: "PUBLIC", lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: false, + distribution: { feedDistribution: "MAIN_FEED", targetEntities: [], thirdPartyDistributionChannels: [] }, + }; + const media = post.media || []; + if (post.type === "REELS" || (media.length === 1 && isVideo(media[0]))) { + if ((await sizeOf(media[0])) > 5 * 1024 ** 3) throw new Error("LinkedIn: videos must be under 5 GB."); + body.content = { media: { id: await uploadVideo(tokens.accessToken, author, media[0]) } }; + } else if (media.length > 1) { + const images = []; + for (const m of media.slice(0, 20)) images.push({ id: await uploadImage(tokens.accessToken, author, m) }); + body.content = { multiImage: { images } }; + } else if (media.length === 1) { + body.content = { media: { id: await uploadImage(tokens.accessToken, author, media[0]) } }; + } + const { headers } = await request("LinkedIn", `${API}/rest/posts`, { ...rest(tokens.accessToken), method: "POST", json: body }); + const id = headers.get("x-restli-id") || headers.get("x-linkedin-id"); + return { id, permalink: id ? `https://www.linkedin.com/feed/update/${id}/` : null }; + }, +}; diff --git a/connector/src/platforms/media.js b/connector/src/platforms/media.js new file mode 100644 index 0000000..fb84c1b --- /dev/null +++ b/connector/src/platforms/media.js @@ -0,0 +1,50 @@ +// Short-lived signed public links to uploaded media, for platforms that fetch media from a URL +// (TikTok photos, Threads, Google Business Profile). Served by the dashboard at /m//. +// Only files under data/library and data/schedule/media can be linked, and links expire. +import path from "node:path"; +import crypto from "node:crypto"; +import { PROJECT_ROOT, loadEnv } from "../env.js"; + +loadEnv(); +const DATA = path.join(PROJECT_ROOT, "data"); +const ROOTS = [path.join(DATA, "library"), path.join(DATA, "schedule", "media")].map((p) => path.resolve(p)); +const err = (m, status = 400) => Object.assign(new Error(m), { status }); + +function secret() { + const s = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET; + if (!s) throw err("Set DASHBOARD_SECRET in .env to create media links.", 500); + return crypto.createHash("sha256").update(`quorla-media:${s}`).digest(); +} + +/** The public HTTPS address of this install, e.g. https://app.quorlasocial.com */ +export function publicBaseUrl() { + const u = process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : ""); + return u.replace(/\/$/, ""); +} + +const allowed = (f) => ROOTS.some((r) => f === r || f.startsWith(r + path.sep)); + +/** Signed URL for a local media file, valid for `hours`. */ +export function publicMediaUrl(filePath, { hours = 6 } = {}) { + const base = publicBaseUrl(); + if (!base) throw err("This platform fetches media from a public link. Set PUBLIC_URL (e.g. https://app.quorlasocial.com) in .env."); + const f = path.resolve(filePath); + if (!allowed(f)) throw err("Only files from the content library or Publishing uploads can be shared with platforms."); + const rel = path.relative(DATA, f).split(path.sep).join("/"); + const body = Buffer.from(JSON.stringify({ p: rel, e: Date.now() + hours * 3600000 })).toString("base64url"); + const sig = crypto.createHmac("sha256", secret()).update(body).digest("base64url"); + return `${base}/m/${body}.${sig}/${encodeURIComponent(path.basename(f).replace(/^\d+-[a-z0-9]{4}-/i, ""))}`; +} + +/** Verify a token from /m//… and return the absolute file path, or null. */ +export function resolveMediaToken(token) { + const [body, sig] = String(token || "").split("."); + if (!body || !sig) return null; + const expected = crypto.createHmac("sha256", secret()).update(body).digest("base64url"); + if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; + let p; + try { p = JSON.parse(Buffer.from(body, "base64url").toString()); } catch { return null; } + if (!p?.p || p.e < Date.now()) return null; + const f = path.resolve(DATA, p.p); + return allowed(f) ? f : null; +} diff --git a/connector/src/platforms/oauth.js b/connector/src/platforms/oauth.js new file mode 100644 index 0000000..862afed --- /dev/null +++ b/connector/src/platforms/oauth.js @@ -0,0 +1,69 @@ +// Generic OAuth 2.0 authorization-code flow used by every platform adapter: +// build the sign-in URL (with PKCE where the platform supports it), exchange the code, refresh tokens. +import crypto from "node:crypto"; +import { request } from "./http.js"; + +/** PKCE verifier + challenge. TikTok's desktop flavour uses hex; everyone else uses base64url (RFC 7636). */ +export function pkce(style = "S256") { + const verifier = crypto.randomBytes(48).toString("base64url"); + const hash = crypto.createHash("sha256").update(verifier); + const challenge = style === "hex" ? hash.digest("hex") : hash.digest("base64url"); + return { verifier, challenge }; +} + +export function authorizeUrl(adapter, app, { redirectUri, state, challenge }) { + const a = adapter.auth; + const u = new URL(a.authorizeUrl); + u.searchParams.set(a.clientIdParam || "client_id", app.clientId); + u.searchParams.set("redirect_uri", redirectUri); + u.searchParams.set("response_type", "code"); + u.searchParams.set("scope", (app.scopes?.length ? app.scopes : a.scopes).join(a.scopeSeparator || " ")); + u.searchParams.set("state", state); + if (a.pkce && challenge) { u.searchParams.set("code_challenge", challenge); u.searchParams.set("code_challenge_method", "S256"); } + for (const [k, v] of Object.entries(a.extraAuthParams || {})) u.searchParams.set(k, v); + return u.toString(); +} + +/** Normalise a token response into { accessToken, refreshToken, expiresAt, refreshExpiresAt, raw-ish extras }. */ +export function normaliseTokens(t, prev = {}) { + const now = Date.now(); + return { + accessToken: t.access_token, + refreshToken: t.refresh_token || prev.refreshToken || null, + tokenType: t.token_type || "bearer", + expiresAt: t.expires_in ? new Date(now + Number(t.expires_in) * 1000).toISOString() : prev.expiresAt || null, + refreshExpiresAt: t.refresh_expires_in || t.refresh_token_expires_in + ? new Date(now + Number(t.refresh_expires_in || t.refresh_token_expires_in) * 1000).toISOString() + : prev.refreshExpiresAt || null, + userId: t.user_id ? String(t.user_id) : t.open_id || prev.userId || null, + scope: t.scope || prev.scope || null, + }; +} + +async function tokenRequest(adapter, app, form) { + const a = adapter.auth; + const headers = {}; + const body = { ...form }; + if (a.clientAuth === "basic") headers.Authorization = `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`; + else { body[a.clientIdParam || "client_id"] = app.clientId; body.client_secret = app.clientSecret; } + if (a.tokenHeaders) Object.assign(headers, a.tokenHeaders); + const { data } = await request(adapter.name, a.tokenUrl, { method: "POST", headers, form: body }); + if (!data?.access_token) throw new Error(`${adapter.name}: no access token in the response.`); + return data; +} + +export async function exchangeCode(adapter, app, { code, redirectUri, verifier }) { + const raw = await tokenRequest(adapter, app, { + grant_type: "authorization_code", code, redirect_uri: redirectUri, ...(adapter.auth.pkce && verifier ? { code_verifier: verifier } : {}), + }); + let tokens = normaliseTokens(raw); + if (adapter.afterExchange) tokens = await adapter.afterExchange(tokens, app); + return tokens; +} + +export async function refreshTokens(adapter, app, prev) { + if (adapter.refresh) return adapter.refresh(prev, app); // platforms with their own refresh scheme (Threads) + if (!prev.refreshToken) throw new Error(`${adapter.name}: the sign-in expired and can't be renewed automatically. Reconnect it.`); + const raw = await tokenRequest(adapter, app, { grant_type: "refresh_token", refresh_token: prev.refreshToken }); + return normaliseTokens(raw, prev); +} diff --git a/connector/src/platforms/pinterest.js b/connector/src/platforms/pinterest.js new file mode 100644 index 0000000..ecf3ea4 --- /dev/null +++ b/connector/src/platforms/pinterest.js @@ -0,0 +1,87 @@ +// Pinterest API v5: image, multi-image (carousel) and video Pins on a chosen board. +// Trial-access apps can only create Pins in Pinterest's sandbox: set PINTEREST_SANDBOX=true until upgraded. +import { request, bytesOf, mimeOf, isVideo, sleep } from "./http.js"; + +const api = () => (process.env.PINTEREST_SANDBOX === "true" ? "https://api-sandbox.pinterest.com/v5" : "https://api.pinterest.com/v5"); + +async function uploadVideo(token, src) { + const { data } = await request("Pinterest", `${api()}/media`, { token, method: "POST", json: { media_type: "video" } }); + const form = new FormData(); + for (const [k, v] of Object.entries(data.upload_parameters || {})) form.set(k, v); + form.set("file", new Blob([await bytesOf(src)], { type: mimeOf(src) }), "video.mp4"); + const up = await fetch(data.upload_url, { method: "POST", body: form }); + if (!up.ok) throw new Error(`Pinterest: video upload failed (${up.status}).`); + for (let i = 0; i < 60; i++) { + const s = (await request("Pinterest", `${api()}/media/${data.media_id}`, { token })).data; + if (s.status === "succeeded") return data.media_id; + if (s.status === "failed") throw new Error("Pinterest couldn't process the video."); + await sleep(5000); + } + throw new Error("Pinterest is still processing the video after 5 minutes."); +} + +export default { + id: "pinterest", + name: "Pinterest", + color: "#e60023", + setup: { + console: "https://developers.pinterest.com/apps/", + steps: [ + "Use a Pinterest Business account. Go to developers.pinterest.com → My apps → Connect app, and fill in the app details (quorlasocial.com, privacy policy).", + "Add the Redirect URI shown below in the app's settings.", + "Copy the App ID and App secret key here.", + "Request Standard access (Pinterest asks for a short demo video of the Connect and Pin flow).", + ], + note: "With Trial access, Pins can only be created in Pinterest's sandbox. Set PINTEREST_SANDBOX=true in .env until Standard access is approved.", + }, + auth: { + authorizeUrl: "https://www.pinterest.com/oauth/", + tokenUrl: "https://api.pinterest.com/v5/oauth/token", + clientAuth: "basic", + scopes: ["boards:read", "pins:read", "pins:write", "user_accounts:read"], + scopeSeparator: ",", + }, + formats: { IMAGE: true, CAROUSEL: true, REELS: true }, + limits: { caption: 500, title: 100, images: 5 }, + fields: [ + { key: "boardId", label: "Board", type: "select", optionsFrom: "targets", required: true }, + { key: "title", label: "Pin title", type: "text", max: 100 }, + { key: "link", label: "Destination link", type: "text", hint: "Where the Pin leads, e.g. the product or booking page." }, + ], + + async identify(tokens) { + const { data } = await request("Pinterest", `${api()}/user_account`, { token: tokens.accessToken }); + return { id: data.id || data.username, name: data.business_name || data.username, username: data.username, avatar: data.profile_image || null, url: `https://www.pinterest.com/${data.username}/`, followers: data.follower_count ?? null }; + }, + + async targets(tokens) { + const out = []; + let bookmark; + do { + const { data } = await request("Pinterest", `${api()}/boards`, { token: tokens.accessToken, query: { page_size: 100, bookmark } }); + for (const b of data.items || []) out.push({ id: b.id, name: b.name }); + bookmark = data.bookmark; + } while (bookmark); + return out; + }, + + async publish({ tokens, post, opts, caption }) { + if (!opts.boardId) throw new Error("Pinterest: pick a board."); + const media = post.media || []; + let media_source; + if (post.type === "REELS" || isVideo(media[0])) { + media_source = { source_type: "video_id", media_id: await uploadVideo(tokens.accessToken, media[0]), cover_image_key_frame_time: 1 }; + } else if (media.length > 1) { + const items = []; + for (const m of media.slice(0, 5)) items.push({ content_type: mimeOf(m), data: (await bytesOf(m)).toString("base64") }); + media_source = { source_type: "multiple_image_base64", items }; + } else { + media_source = { source_type: "image_base64", content_type: mimeOf(media[0]), data: (await bytesOf(media[0])).toString("base64") }; + } + const { data } = await request("Pinterest", `${api()}/pins`, { + token: tokens.accessToken, method: "POST", + json: { board_id: opts.boardId, title: opts.title ? String(opts.title).slice(0, 100) : undefined, description: String(caption || "").slice(0, 500), link: opts.link || undefined, media_source }, + }); + return { id: data.id, permalink: `https://www.pinterest.com/pin/${data.id}/` }; + }, +}; diff --git a/connector/src/platforms/reddit.js b/connector/src/platforms/reddit.js new file mode 100644 index 0000000..f1625d2 --- /dev/null +++ b/connector/src/platforms/reddit.js @@ -0,0 +1,53 @@ +// Reddit: text and link posts to a subreddit. Reddit communities often remove brand self-promotion: +// check each subreddit's rules, and prefer genuine participation over ads. +import { request } from "./http.js"; + +const UA = () => process.env.REDDIT_USER_AGENT || "web:com.quorlasocial.app:1.0 (by /u/quorla)"; + +export default { + id: "reddit", + name: "Reddit", + color: "#ff4500", + setup: { + console: "https://www.reddit.com/prefs/apps", + steps: [ + "Signed in as the agency's Reddit account, open reddit.com/prefs/apps → “create another app…” → type “web app”.", + "Set the redirect uri to the address shown below.", + "Copy the client ID (under the app name) and the secret here.", + "Commercial use of Reddit's API needs Reddit's approval: register via Reddit's Data API request form before using it for clients.", + ], + note: "Set REDDIT_USER_AGENT in .env to something like web:com.quorlasocial.app:1.0 (by /u/yourusername), as Reddit requires.", + }, + auth: { + authorizeUrl: "https://www.reddit.com/api/v1/authorize", + tokenUrl: "https://www.reddit.com/api/v1/access_token", + clientAuth: "basic", + scopes: ["identity", "submit", "read", "mysubreddits", "flair"], + extraAuthParams: { duration: "permanent" }, + get tokenHeaders() { return { "User-Agent": UA() }; }, + }, + formats: { TEXT: true }, + limits: { caption: 40000, title: 300 }, + fields: [ + { key: "subreddit", label: "Subreddit", type: "text", required: true, hint: "Without r/, e.g. waterloo. Read the subreddit's rules first." }, + { key: "title", label: "Post title", type: "text", required: true, max: 300 }, + { key: "link", label: "Link (optional)", type: "text", hint: "Makes it a link post; the caption is then not used." }, + { key: "flairId", label: "Flair ID (optional)", type: "text", hint: "Some subreddits require a flair." }, + ], + + async identify(tokens) { + const { data } = await request("Reddit", "https://oauth.reddit.com/api/v1/me", { token: tokens.accessToken, headers: { "User-Agent": UA() } }); + return { id: data.id, name: `u/${data.name}`, username: data.name, avatar: (data.icon_img || "").split("?")[0] || null, url: `https://www.reddit.com/user/${data.name}`, followers: data.total_karma ?? null }; + }, + + async publish({ tokens, opts, caption }) { + const sr = String(opts.subreddit || "").replace(/^\/?r\//i, "").trim(); + if (!sr || !opts.title) throw new Error("Reddit: add the subreddit and a title."); + const form = { sr, title: String(opts.title).slice(0, 300), api_type: "json", resubmit: "true", ...(opts.flairId ? { flair_id: opts.flairId } : {}) }; + if (opts.link) Object.assign(form, { kind: "link", url: opts.link }); else Object.assign(form, { kind: "self", text: caption || "" }); + const { data } = await request("Reddit", "https://oauth.reddit.com/api/submit", { token: tokens.accessToken, method: "POST", headers: { "User-Agent": UA() }, form }); + const errors = data?.json?.errors || []; + if (errors.length) throw new Error(`Reddit: ${errors.map((e) => e.slice(1).join(" ")).join("; ")}`); + return { id: data.json.data.name || data.json.data.id, permalink: data.json.data.url }; + }, +}; diff --git a/connector/src/platforms/threads.js b/connector/src/platforms/threads.js new file mode 100644 index 0000000..0a5c0b5 --- /dev/null +++ b/connector/src/platforms/threads.js @@ -0,0 +1,93 @@ +// Threads (Meta): text, image, video and carousel posts. Media is fetched by Threads from a public link. +// Uses its own Threads app credentials (Meta app with the Threads use case), separate from the Meta system user. +import { request, isVideo, sleep } from "./http.js"; +import { publicMediaUrl } from "./media.js"; + +const G = "https://graph.threads.net"; +const url = (m) => (/^https?:/.test(m) ? m : publicMediaUrl(m)); + +async function waitFinished(token, id) { + for (let i = 0; i < 60; i++) { + const { data } = await request("Threads", `${G}/v1.0/${id}`, { query: { fields: "status,error_message", access_token: token } }); + if (data.status === "FINISHED") return; + if (data.status === "ERROR" || data.status === "EXPIRED") throw new Error(`Threads couldn't process the media: ${data.error_message || data.status}`); + await sleep(5000); + } + throw new Error("Threads is still processing the media after 5 minutes."); +} + +async function container(token, uid, params) { + const { data } = await request("Threads", `${G}/v1.0/${uid}/threads`, { method: "POST", query: { ...params, access_token: token } }); + return data.id; +} + +export default { + id: "threads", + name: "Threads", + color: "#000000", + setup: { + console: "https://developers.facebook.com/apps/", + steps: [ + "In Meta for Developers, create an app with the “Access the Threads API” use case (it can be a separate app from the Facebook/Instagram one).", + "Use case → Settings: add the Redirect URI shown below as a Redirect Callback URL (also fill Uninstall and Delete callback URLs with your site).", + "Add permissions threads_basic, threads_content_publish, threads_manage_replies, threads_read_replies, threads_manage_insights.", + "App settings → Basic: copy the Threads App ID and Threads App secret here.", + ], + note: "Until Meta approves the app, only Threads accounts added under App roles → Roles (as Threads testers, who accept the invite in Threads) can connect.", + }, + auth: { + authorizeUrl: "https://threads.net/oauth/authorize", + tokenUrl: `${G}/oauth/access_token`, + scopes: ["threads_basic", "threads_content_publish", "threads_manage_replies", "threads_read_replies", "threads_manage_insights"], + scopeSeparator: ",", + }, + formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true }, + limits: { caption: 500, images: 20 }, + + /** Swap the 1-hour token for a 60-day one. */ + async afterExchange(tokens, app) { + const { data } = await request("Threads", `${G}/access_token`, { query: { grant_type: "th_exchange_token", client_secret: app.clientSecret, access_token: tokens.accessToken } }); + return { ...tokens, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString(), refreshToken: null }; + }, + /** Long-lived Threads tokens are refreshed with themselves (any time after 24 h, before expiry). */ + async refresh(prev) { + const { data } = await request("Threads", `${G}/refresh_access_token`, { query: { grant_type: "th_refresh_token", access_token: prev.accessToken } }); + return { ...prev, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString() }; + }, + + async identify(tokens) { + const { data } = await request("Threads", `${G}/v1.0/me`, { query: { fields: "id,username,name,threads_profile_picture_url", access_token: tokens.accessToken } }); + let followers = null; + try { + const ins = await request("Threads", `${G}/v1.0/${data.id}/threads_insights`, { query: { metric: "followers_count", access_token: tokens.accessToken } }); + followers = ins.data.data?.[0]?.total_value?.value ?? null; + } catch { /* insights permission not granted */ } + return { id: data.id, name: data.name || data.username, username: data.username, avatar: data.threads_profile_picture_url || null, url: `https://www.threads.net/@${data.username}`, followers }; + }, + + async publish({ tokens, conn, post, caption }) { + const token = tokens.accessToken, uid = conn.account.id; + const text = String(caption || "").slice(0, 500); + const media = post.media || []; + let id; + if (post.type === "TEXT" || !media.length) { + id = await container(token, uid, { media_type: "TEXT", text }); + } else if (media.length > 1) { + const children = []; + for (const m of media.slice(0, 20)) { + const c = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), is_carousel_item: true } : { media_type: "IMAGE", image_url: url(m), is_carousel_item: true }); + await waitFinished(token, c); + children.push(c); + } + id = await container(token, uid, { media_type: "CAROUSEL", children: children.join(","), text }); + } else { + const m = media[0]; + id = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), text } : { media_type: "IMAGE", image_url: url(m), text }); + } + await waitFinished(token, id); + const { data } = await request("Threads", `${G}/v1.0/${uid}/threads_publish`, { method: "POST", query: { creation_id: id, access_token: token } }); + let permalink = null; + try { permalink = (await request("Threads", `${G}/v1.0/${data.id}`, { query: { fields: "permalink", access_token: token } })).data.permalink; } catch { /* optional */ } + return { id: data.id, permalink }; + }, +}; diff --git a/connector/src/platforms/tiktok.js b/connector/src/platforms/tiktok.js new file mode 100644 index 0000000..709f2a3 --- /dev/null +++ b/connector/src/platforms/tiktok.js @@ -0,0 +1,108 @@ +// TikTok Content Posting API: direct-post videos (uploaded in chunks) and photo posts (pulled from a URL). +// Until TikTok audits the app, posts can only be private (SELF_ONLY) and the account must be set to private. +import { request, bytesOf, isVideo, sleep } from "./http.js"; +import { publicMediaUrl } from "./media.js"; + +const API = "https://open.tiktokapis.com/v2"; +const PRIVACY = [["PUBLIC_TO_EVERYONE", "Everyone"], ["MUTUAL_FOLLOW_FRIENDS", "Friends"], ["FOLLOWER_OF_CREATOR", "Followers"], ["SELF_ONLY", "Only me"]]; + +/** TikTok chunk rules: whole file if ≤ 64 MB, else 10 MB chunks with the remainder in the last one. */ +export function chunkPlan(size) { + if (size <= 64 * 1024 * 1024) return { chunkSize: size, count: 1 }; + const chunkSize = 10 * 1024 * 1024; + return { chunkSize, count: Math.floor(size / chunkSize) }; +} + +async function waitForPost(token, publishId) { + for (let i = 0; i < 60; i++) { + const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } }); + const s = data.data?.status; + if (s === "PUBLISH_COMPLETE") return data.data; + if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`); + await sleep(5000); + } + return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done +} + +export default { + id: "tiktok", + name: "TikTok", + color: "#010101", + setup: { + console: "https://developers.tiktok.com/apps", + steps: [ + "Create an app at developers.tiktok.com (Manage apps → Connect an app). Add a website URL, terms and privacy policy links (quorlasocial.com).", + "Add products “Login Kit” and “Content Posting API”; in Content Posting API turn on Direct Post.", + "Login Kit → Redirect URI: add the address shown below.", + "For photo posts: URL properties → verify the domain of your Quorla address (e.g. app.quorlasocial.com).", + "Copy the Client key and Client secret here, then submit the app for review.", + ], + note: "Before TikTok approves the app, posts are private (“Only me”) and only work for accounts added as Sandbox target users.", + }, + auth: { + authorizeUrl: "https://www.tiktok.com/v2/auth/authorize/", + tokenUrl: `${API}/oauth/token/`, + clientIdParam: "client_key", + scopes: ["user.info.basic", "user.info.profile", "user.info.stats", "video.publish", "video.upload"], + scopeSeparator: ",", + }, + formats: { REELS: true, IMAGE: true, CAROUSEL: true }, + limits: { caption: 2200, images: 35 }, + fields: [ + { key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." }, + { key: "allowComments", label: "Allow comments", type: "checkbox", default: true }, + { key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] }, + { key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] }, + { key: "title", label: "Photo post title", type: "text", max: 90, onlyFor: ["IMAGE", "CAROUSEL"], hint: "Optional, up to 90 characters. The caption becomes the description." }, + ], + + async identify(tokens) { + const { data } = await request("TikTok", `${API}/user/info/`, { token: tokens.accessToken, query: { fields: "open_id,avatar_url,display_name,username,follower_count,profile_deep_link" } }); + const u = data.data?.user || {}; + return { id: u.open_id || tokens.userId, name: u.display_name, username: u.username || null, avatar: u.avatar_url || null, url: u.username ? `https://www.tiktok.com/@${u.username}` : u.profile_deep_link || null, followers: u.follower_count ?? null }; + }, + + async publish({ tokens, conn, post, opts, caption }) { + const token = tokens.accessToken; + if (!opts.privacy) throw new Error("TikTok: choose who can view the post."); + const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {}; + if (creator.privacy_level_options && !creator.privacy_level_options.includes(opts.privacy)) { + throw new Error(`TikTok: this account can't post as “${opts.privacy}”. Allowed: ${creator.privacy_level_options.join(", ")}.`); + } + const media = post.media || []; + let publishId; + if (post.type === "REELS" || isVideo(media[0])) { + const bytes = await bytesOf(media[0]); + const plan = chunkPlan(bytes.length); + const { data } = await request("TikTok", `${API}/post/publish/video/init/`, { + token, method: "POST", + json: { + post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, disable_duet: !opts.allowDuet, disable_stitch: !opts.allowStitch }, + source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count }, + }, + }); + publishId = data.data.publish_id; + for (let i = 0; i < plan.count; i++) { + const start = i * plan.chunkSize; + const end = i === plan.count - 1 ? bytes.length : start + plan.chunkSize; + const r = await fetch(data.data.upload_url, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Range": `bytes ${start}-${end - 1}/${bytes.length}` }, body: bytes.subarray(start, end) }); + if (!r.ok) throw new Error(`TikTok: video upload failed on part ${i + 1} (${r.status}).`); + } + } else { + const urls = media.slice(0, 35).map((m) => (/^https?:/.test(m) ? m : publicMediaUrl(m))); + const { data } = await request("TikTok", `${API}/post/publish/content/init/`, { + token, method: "POST", + json: { + post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, auto_add_music: true }, + source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls }, + post_mode: "DIRECT_POST", media_type: "PHOTO", + }, + }); + publishId = data.data.publish_id; + } + const done = await waitForPost(token, publishId); + const postId = done.publicaly_available_post_id?.[0]; + const user = conn.account?.username; + return { id: postId ? String(postId) : publishId, publishId, status: done.status, permalink: postId && user ? `https://www.tiktok.com/@${user}/video/${postId}` : null }; + }, +}; diff --git a/connector/src/platforms/vault.js b/connector/src/platforms/vault.js new file mode 100644 index 0000000..8cc0a36 --- /dev/null +++ b/connector/src/platforms/vault.js @@ -0,0 +1,152 @@ +// Encrypted store for platform app credentials (client ID/secret per platform) and each client's +// connected accounts (OAuth tokens). Lives in data/connections.json; secrets are AES-256-GCM encrypted +// with a key derived from QUORLA_SECRET_KEY (or DASHBOARD_SECRET). Tokens never leave the server. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { PROJECT_ROOT, loadEnv } from "../env.js"; + +loadEnv(); +const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests +const err = (message, status = 400) => Object.assign(new Error(message), { status }); +const now = () => new Date().toISOString(); + +function key() { + const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET; + if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500); + return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest(); +} + +export function seal(value) { + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv("aes-256-gcm", key(), iv); + const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]); + return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`; +} + +export function open(sealed) { + const [v, iv, tag, body] = String(sealed || "").split("."); + if (v !== "v1") throw err("Unreadable secret.", 500); + const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url")); + d.setAuthTag(Buffer.from(tag, "base64url")); + return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8")); +} + +function read() { + try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; } +} +function write(db) { + fs.mkdirSync(path.dirname(FILE), { recursive: true }); + const tmp = `${FILE}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(db, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, FILE); +} + +// ---------- platform apps (client ID / secret) ---------- +/** App credentials for a platform: from the vault, else from .env (_CLIENT_ID / _CLIENT_SECRET). */ +export function appCredentials(platform) { + const rec = read().apps[platform]; + if (rec) { + try { return { ...open(rec.sealed), source: "settings", updatedAt: rec.updatedAt }; } catch { /* key changed */ } + } + const P = platform.toUpperCase(); + const clientId = process.env[`${P}_CLIENT_ID`], clientSecret = process.env[`${P}_CLIENT_SECRET`]; + return clientId ? { clientId, clientSecret, source: "env" } : null; +} + +/** Save a platform's app credentials. Leave the secret empty to keep the stored one. `scopes` overrides the defaults. */ +export function saveApp(platform, { clientId, clientSecret, scopes }, by) { + if (!String(clientId || "").trim()) throw err("Enter the app's client ID (or client key)."); + const db = read(); + const prev = db.apps[platform] ? (() => { try { return open(db.apps[platform].sealed); } catch { return {}; } })() : {}; + const secret = String(clientSecret || "").trim() || prev.clientSecret; + if (!secret) throw err("Enter the app's client secret."); + const scopeList = String(scopes ?? "").split(/[\s,]+/).filter(Boolean); + db.apps[platform] = { sealed: seal({ clientId: String(clientId).trim(), clientSecret: secret, scopes: scopeList }), updatedAt: now(), updatedBy: by }; + write(db); +} + +export function removeApp(platform) { + const db = read(); delete db.apps[platform]; write(db); +} + +// ---------- connected accounts ---------- +const publicConn = (c) => { + const { sealed, ...rest } = c; + return rest; +}; + +export function listConnections({ client, platform } = {}) { + return read().connections.filter((c) => (!client || c.client === client) && (!platform || c.platform === platform)).map(publicConn); +} + +export function getConnection(id, { withTokens = false } = {}) { + const c = read().connections.find((x) => x.id === id); + if (!c) throw err("Connection not found.", 404); + if (!withTokens) return publicConn(c); + let tokens = null; + try { tokens = open(c.sealed); } catch { /* key changed: reconnect */ } + return { ...publicConn(c), tokens }; +} + +/** The client's connection for a platform (one account per platform per client). */ +export function connectionFor(client, platform, opts) { + const c = read().connections.find((x) => x.client === client && x.platform === platform); + return c ? getConnection(c.id, opts) : null; +} + +/** Create or replace the client's connection for a platform. */ +export function saveConnection({ client, platform, account, tokens, scopes, by }) { + const db = read(); + const existing = db.connections.find((x) => x.client === client && x.platform === platform); + const rec = { + id: existing?.id || crypto.randomUUID().slice(0, 8), + client, platform, + account, // { id, name, username, url, avatar, followers } + target: existing && existing.account?.id === account?.id ? existing.target || null : null, + scopes: scopes || [], + expiresAt: tokens.expiresAt || null, + refreshExpiresAt: tokens.refreshExpiresAt || null, + status: "connected", lastError: null, + connectedAt: now(), connectedBy: by, updatedAt: now(), + sealed: seal(tokens), + }; + db.connections = db.connections.filter((x) => x !== existing); + db.connections.push(rec); + write(db); + return publicConn(rec); +} + +export function updateConnection(id, patch) { + const db = read(); + const c = db.connections.find((x) => x.id === id); + if (!c) throw err("Connection not found.", 404); + if (patch.tokens) { c.sealed = seal(patch.tokens); c.expiresAt = patch.tokens.expiresAt || null; c.refreshExpiresAt = patch.tokens.refreshExpiresAt || c.refreshExpiresAt || null; } + for (const k of ["target", "status", "lastError", "account"]) if (k in patch) c[k] = patch[k]; + c.updatedAt = now(); + write(db); + return publicConn(c); +} + +export function removeConnection(id) { + const db = read(); + const before = db.connections.length; + db.connections = db.connections.filter((x) => x.id !== id); + if (db.connections.length === before) throw err("Connection not found.", 404); + write(db); +} + +// ---------- pending OAuth sign-ins (state → who/what, PKCE verifier) ---------- +const pending = new Map(); +export function createState(data) { + const state = crypto.randomBytes(24).toString("base64url"); + pending.set(state, { ...data, at: Date.now() }); + for (const [k, v] of pending) if (Date.now() - v.at > 15 * 60000) pending.delete(k); + return state; +} +export function takeState(state) { + const v = pending.get(String(state || "")); + pending.delete(String(state || "")); + if (!v || Date.now() - v.at > 15 * 60000) throw err("This sign-in link expired or was already used. Start again from Connections."); + return v; +} diff --git a/connector/src/platforms/youtube.js b/connector/src/platforms/youtube.js new file mode 100644 index 0000000..255cf9c --- /dev/null +++ b/connector/src/platforms/youtube.js @@ -0,0 +1,57 @@ +// YouTube: upload videos (vertical videos under 3 minutes show as Shorts). +// Unverified Google apps can only upload as Private until Google audits the app. +import { request, bytesOf, mimeOf, isVideo } from "./http.js"; +import { googleAuth, googleSetup } from "./google.js"; + +const API = "https://www.googleapis.com/youtube/v3"; + +export default { + id: "youtube", + name: "YouTube", + color: "#ff0000", + setup: googleSetup("YouTube API audit (Google's form) lifts the private-only upload limit and the low daily quota.", [ + "APIs & Services → Library → enable “YouTube Data API v3”.", + ]), + auth: googleAuth(["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube.readonly"]), + formats: { REELS: true }, + limits: { caption: 5000, title: 100 }, + fields: [ + { key: "title", label: "Video title", type: "text", required: true, max: 100, hint: "Shown on YouTube. Up to 100 characters. The caption becomes the description." }, + { key: "privacy", label: "Visibility", type: "select", options: [["public", "Public"], ["unlisted", "Unlisted"], ["private", "Private"]], default: "public" }, + { key: "madeForKids", label: "Made for kids?", type: "select", options: [["no", "No"], ["yes", "Yes"]], default: "no", hint: "Required by YouTube (COPPA)." }, + ], + + async identify(tokens) { + const { data } = await request("YouTube", `${API}/channels`, { token: tokens.accessToken, query: { part: "snippet,statistics", mine: "true" } }); + const ch = data.items?.[0]; + if (!ch) throw new Error("YouTube: this Google account has no YouTube channel. Create one first, or sign in with the brand account."); + return { + id: ch.id, name: ch.snippet.title, username: ch.snippet.customUrl || null, + avatar: ch.snippet.thumbnails?.default?.url || null, url: `https://www.youtube.com/channel/${ch.id}`, + followers: Number(ch.statistics?.subscriberCount ?? 0) || null, + }; + }, + + async publish({ tokens, post, opts, caption }) { + const src = (post.media || [])[0]; + if (!src || !isVideo(src)) throw new Error("YouTube needs one video file."); + const title = String(opts.title || "").trim(); + if (!title) throw new Error("YouTube needs a video title."); + const bytes = await bytesOf(src); + const meta = { + snippet: { title: title.slice(0, 100), description: String(caption || "").slice(0, 5000), categoryId: "22" }, + status: { privacyStatus: opts.privacy || "public", selfDeclaredMadeForKids: opts.madeForKids === "yes" }, + }; + const init = await fetch("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", { + method: "POST", + headers: { Authorization: `Bearer ${tokens.accessToken}`, "Content-Type": "application/json; charset=UTF-8", "X-Upload-Content-Type": mimeOf(src), "X-Upload-Content-Length": String(bytes.length) }, + body: JSON.stringify(meta), + }); + if (!init.ok) throw new Error(`YouTube: couldn't start the upload (${init.status}): ${(await init.text()).slice(0, 300)}`); + const location = init.headers.get("location"); + const up = await fetch(location, { method: "PUT", headers: { "Content-Type": mimeOf(src), "Content-Length": String(bytes.length) }, body: bytes }); + const video = await up.json().catch(() => ({})); + if (!up.ok || !video.id) throw new Error(`YouTube: upload failed (${up.status}): ${video.error?.message || ""}`); + return { id: video.id, permalink: `https://www.youtube.com/shorts/${video.id}`, privacy: video.status?.privacyStatus }; + }, +}; diff --git a/connector/src/schedule.js b/connector/src/schedule.js index c6114d1..e882612 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -6,6 +6,10 @@ import path from "node:path"; import crypto from "node:crypto"; import { PROJECT_ROOT } from "./env.js"; import * as meta from "./meta.js"; +import * as extra from "./platforms/index.js"; + +const META_PLATFORMS = ["instagram", "facebook"]; +export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS]; const DIR = path.join(PROJECT_ROOT, "data", "schedule"); const FILE = path.join(DIR, "queue.json"); @@ -77,24 +81,27 @@ export function getPost(id) { * Add a post to the queue as a DRAFT. Nothing is published until a person approves it. * localTime is in the client's time zone, e.g. "2026-10-20 19:00". */ -export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], localTime, createdBy = "agent", notes } = {}) { +export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], options = {}, localTime, createdBy = "agent", notes } = {}) { const c = clientInfo(client); if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`); - platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook"); - if (!platforms.length) throw err("Pick Instagram, Facebook or both."); - // Instagram-only clients (no Facebook Page): drop Facebook when Instagram is also picked. - if (!c.pageId && c.igId) { - if (!platforms.includes("instagram")) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`); - platforms = ["instagram"]; + platforms = [...new Set(platforms)].filter((p) => ALL_PLATFORMS.includes(p)); + if (!platforms.length) throw err("Pick at least one platform."); + // Instagram-only clients (no Facebook Page): drop Facebook when something else is also picked. + if (!c.pageId && c.igId && platforms.includes("facebook")) { + if (platforms.length === 1) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`); + platforms = platforms.filter((p) => p !== "facebook"); } + if (platforms.some((p) => META_PLATFORMS.includes(p)) && !c.pageId && !c.igId) throw err(`${c.name} isn't linked to a Facebook Page or Instagram account, so it can't post there.`); if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts."); if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL)."); + options = Object.fromEntries(Object.entries(options || {}).filter(([k]) => platforms.includes(k))); + for (const p of platforms) if (!META_PLATFORMS.includes(p)) extra.validate(p, c.slug, { type, caption, media, options }); for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`); const publishAt = zonedToUtc(localTime, c.timezone); if (publishAt < Date.now() - 60000) throw err(`That time has already passed in ${c.timezone}.`); const post = { id: crypto.randomUUID().slice(0, 8), client: c.slug, clientName: c.name, platforms, type, media, caption, - fbCaption: fbCaption || null, collaborators, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone), + fbCaption: fbCaption || null, collaborators, options, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone), publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null, results: {}, errors: {}, }; @@ -106,7 +113,8 @@ export function updatePost(id, patch, by = "team") { const items = read(); const p = items.find((x) => x.id === id); if (!p) throw err("Post not found.", 404); if (!["draft", "failed"].includes(p.status)) throw err("Only drafts or failed posts can be edited. Cancel it first."); - for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes"]) if (patch[k] !== undefined) p[k] = patch[k]; + for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes", "options"]) if (patch[k] !== undefined) p[k] = patch[k]; + for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p); if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); } p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString(); write(items); return p; @@ -143,13 +151,16 @@ export async function cancelPost(id, by = "team") { } async function publishNow(p) { - const a = await accountFor(p.client); + let a = null; for (const platform of p.platforms) { if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively) try { + if (META_PLATFORMS.includes(platform)) a ||= await accountFor(p.client); p.results[platform] = platform === "instagram" ? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators }) - : await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media }); + : platform === "facebook" + ? await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media }) + : await extra.publish(platform, p.client, p); delete p.errors[platform]; log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] }); } catch (e) { p.errors[platform] = e.message; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); } diff --git a/connector/src/server.js b/connector/src/server.js index 550cca4..5358acd 100644 --- a/connector/src/server.js +++ b/connector/src/server.js @@ -10,6 +10,7 @@ import { PROJECT_ROOT } from "./env.js"; import * as meta from "./meta.js"; import * as sched from "./schedule.js"; import * as lib from "./library.js"; +import * as extra from "./platforms/index.js"; const server = new McpServer({ name: "social-manager", version: "0.1.0" }); @@ -139,11 +140,25 @@ server.tool("audience_demographics", "Follower demographics (top cities, countri // ---------- Scheduling (queue → human approval → auto-publish at the client's local time) ---------- const slug = z.string().describe("Client folder slug, e.g. maison-de-treats"); -server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs.", - { client: slug, platforms: z.array(z.enum(["instagram", "facebook"])).min(1), type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]), +const platformList = z.array(z.enum(sched.ALL_PLATFORMS)).min(1); +const options = z.record(z.string(), z.record(z.string(), z.any())).default({}) + .describe("Per-platform settings, e.g. {\"youtube\":{\"title\":\"…\",\"privacy\":\"public\"},\"pinterest\":{\"boardId\":\"…\"},\"linkedin\":{\"caption\":\"…\"}}. Any platform can take its own \"caption\". See list_connections for each platform's fields."); + +server.tool("list_connections", "Which platforms beyond Facebook/Instagram (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit) are connected for a client, the account, and the per-post fields each platform needs.", + { client: slug }, + wrap(async ({ client }) => { + const conns = extra.vault.listConnections({ client }); + return extra.describePlatforms().map((p) => { + const c = conns.find((x) => x.platform === p.id); + return { platform: p.id, name: p.name, connected: Boolean(c), status: c?.status || null, account: c?.account?.name || null, target: c?.target?.name || null, formats: p.formats, fields: p.fields.map((f) => ({ key: f.key, label: f.label, required: Boolean(f.required), options: f.options?.map((o) => o[0]), onlyFor: f.onlyFor })), captionLimit: p.limits.caption || null }; + }); + })); + +server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs. Platforms other than instagram/facebook must be connected for the client first (list_connections).", + { client: slug, platforms: platformList, type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]), options, media: z.array(z.string()).default([]), caption: z.string().default(""), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]).describe("Instagram usernames to invite as Collab"), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), notes: z.string().optional() }, - wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, localTime: a.local_time, notes: a.notes, createdBy: "agent" }))); + wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options, localTime: a.local_time, notes: a.notes, createdBy: "agent" }))); server.tool("list_scheduled", "The content queue (drafts, approved, published, failed), optionally for one client.", { client: z.string().optional(), status: z.enum(["draft", "approved", "publishing", "published", "partial", "failed", "cancelled"]).optional() }, @@ -165,8 +180,8 @@ server.tool("content_library", "Finished work the team uploaded for a client (de wrap(async ({ client, status }) => lib.listItems({ client, status }).map((i) => ({ id: i.id, client: i.client, title: i.title, format: i.format, files: i.files, captionIdea: i.captionIdea, notes: i.notes, forDate: i.forDate, taskRef: i.taskRef, status: i.status, version: i.version, uploadedBy: i.uploadedBy, reviewNote: i.reviewNote, updatedAt: i.updatedAt, postIds: i.postIds })))); server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT post in the queue (never publishes; a person still approves the post). local_time is in the client's time zone. Leave caption empty to use the uploader's caption idea, but normally write a finished caption.", - { id: z.string(), platforms: z.array(z.enum(["instagram", "facebook"])).min(1).default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), - caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]) }, - wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators }, "agent"))); + { id: z.string(), platforms: platformList.default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), + caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options }, + wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent"))); await server.connect(new StdioServerTransport()); diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs new file mode 100644 index 0000000..c641363 --- /dev/null +++ b/connector/test/platforms.test.mjs @@ -0,0 +1,301 @@ +// Offline tests for the platform adapters: every platform API is simulated by a fake fetch that records +// what Quorla sends. Run: node --test connector/test/ (no network, no real accounts touched) +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-")); +process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json"); +process.env.DASHBOARD_SECRET = "test-secret-not-real"; +process.env.PUBLIC_URL = "https://app.example.test"; + +const { PROJECT_ROOT } = await import("../src/env.js"); +const P = await import("../src/platforms/index.js"); +const { vault } = P; +const media = await import("../src/platforms/media.js"); +const { littleText } = await import("../src/platforms/linkedin.js"); +const { chunkPlan } = await import("../src/platforms/tiktok.js"); + +// Test media inside data/library (the only folders media links may expose). +const LIB = path.join(PROJECT_ROOT, "data", "library", "_quorla_test"); +const IMG = path.join(LIB, "1700000000000-abcd-photo.jpg"); +const IMG2 = path.join(LIB, "1700000000001-abcd-photo2.png"); +const VID = path.join(LIB, "1700000000002-abcd-clip.mp4"); + +// ---------- fake fetch ---------- +let calls = []; +let routes = []; +const realFetch = globalThis.fetch; +function route(method, match, reply) { routes.push({ method, match, reply }); } +function reset() { calls = []; routes = []; } +const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }); +async function fakeFetch(input, init = {}) { + const url = String(input instanceof URL ? input : input?.url || input); + const method = (init.method || "GET").toUpperCase(); + let body = init.body; + if (body instanceof URLSearchParams) body = body.toString(); + calls.push({ method, url, headers: init.headers || {}, body }); + const r = routes.find((x) => x.method === method && (typeof x.match === "string" ? url.startsWith(x.match) : x.match.test(url))); + if (!r) return json({ error: { message: `no fake route for ${method} ${url}` } }, 599); + return typeof r.reply === "function" ? r.reply({ url, method, body, headers: init.headers || {} }) : r.reply.clone(); +} +const sent = (re) => calls.filter((c) => re.test(c.url)); +const bodyJson = (c) => JSON.parse(c.body); +const form = (c) => Object.fromEntries(new URLSearchParams(c.body)); + +const user = { id: "u1", name: "Tester" }; +async function connect(platform, client, { tokenReply, identify }) { + vault.saveApp(platform, { clientId: `${platform}-id`, clientSecret: `${platform}-secret` }, "test"); + const url = new URL(P.startConnect({ platform, client, redirectUri: `https://app.example.test/oauth/${platform}/callback`, user })); + const state = url.searchParams.get("state"); + route("POST", P.adapter(platform).auth.tokenUrl, tokenReply || json({ access_token: `${platform}-at`, refresh_token: `${platform}-rt`, expires_in: 3600, scope: "a b" })); + for (const [m, match, reply] of identify) route(m, match, reply); + const conn = await P.finishConnect({ platform, code: "the-code", state, user }); + return { url, conn }; +} + +before(() => { + fs.mkdirSync(LIB, { recursive: true }); + fs.writeFileSync(IMG, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3])); + fs.writeFileSync(IMG2, Buffer.from([0x89, 0x50, 0x4e, 0x47, 4, 5])); + fs.writeFileSync(VID, Buffer.alloc(1024, 7)); + globalThis.fetch = fakeFetch; +}); +after(() => { + globalThis.fetch = realFetch; + fs.rmSync(LIB, { recursive: true, force: true }); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +// ---------- vault ---------- +test("vault: secrets are encrypted at rest and never listed", () => { + vault.saveApp("linkedin", { clientId: "cid", clientSecret: "super-secret-value" }, "t"); + const raw = fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8"); + assert.ok(!raw.includes("super-secret-value") && !raw.includes("cid"), "app secret must not be stored in plain text"); + assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value"); + vault.saveApp("linkedin", { clientId: "cid2", clientSecret: "" }, "t"); + assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value", "empty secret keeps the stored one"); + const c = vault.saveConnection({ client: "acme", platform: "linkedin", account: { id: "1", name: "A" }, tokens: { accessToken: "tok-123" }, by: "t" }); + assert.ok(!JSON.stringify(vault.listConnections()).includes("tok-123")); + assert.ok(!fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8").includes("tok-123")); + assert.equal(vault.getConnection(c.id, { withTokens: true }).tokens.accessToken, "tok-123"); + vault.removeConnection(c.id); +}); + +test("vault: a sign-in state works once and only for the same platform/user", () => { + const s = vault.createState({ platform: "x", userId: "u1" }); + assert.equal(vault.takeState(s).platform, "x"); + assert.throws(() => vault.takeState(s), /expired or was already used/); +}); + +// ---------- media links ---------- +test("media links: signed, expiring, limited to library/uploads", () => { + const u = media.publicMediaUrl(IMG); + assert.match(u, /^https:\/\/app\.example\.test\/m\/[^/]+\/photo\.jpg$/); + const token = u.split("/m/")[1].split("/")[0]; + assert.equal(media.resolveMediaToken(token), path.resolve(IMG)); + assert.equal(media.resolveMediaToken(token.slice(0, -2) + "xx"), null, "tampered signature rejected"); + assert.throws(() => media.publicMediaUrl(path.join(PROJECT_ROOT, ".env")), /Only files from the content library/); + const old = media.publicMediaUrl(IMG, { hours: -1 }).split("/m/")[1].split("/")[0]; + assert.equal(media.resolveMediaToken(old), null, "expired link rejected"); +}); + +// ---------- helpers ---------- +test("LinkedIn little-text escaping keeps hashtags as hashtags", () => { + assert.equal(littleText("Hi (team) #Waterloo_Eats, see [x]!"), "Hi \\(team\\) {hashtag|\\#|Waterloo_Eats}, see \\[x\\]!"); + assert.equal(littleText("C# 100%"), "C\\# 100%"); +}); + +test("TikTok chunk plan follows TikTok's rules", () => { + assert.deepEqual(chunkPlan(3 * 1024 * 1024), { chunkSize: 3 * 1024 * 1024, count: 1 }); + const big = 150 * 1024 * 1024; + const p = chunkPlan(big); + assert.equal(p.chunkSize, 10 * 1024 * 1024); + assert.equal(p.count, 15); + assert.ok(big - (p.count - 1) * p.chunkSize <= 128 * 1024 * 1024, "last chunk stays under 128 MB"); +}); + +// ---------- sign-in URLs ---------- +test("sign-in URLs carry the right client id, scopes and PKCE per platform", () => { + for (const id of P.PLATFORM_IDS) vault.saveApp(id, { clientId: `${id}-id`, clientSecret: "s" }, "t"); + const u = (id) => new URL(P.startConnect({ platform: id, client: "acme", redirectUri: `https://app.example.test/oauth/${id}/callback`, user })); + const tt = u("tiktok"); + assert.equal(tt.searchParams.get("client_key"), "tiktok-id"); + assert.match(tt.searchParams.get("scope"), /video\.publish,video\.upload/); + const yt = u("youtube"); + assert.equal(yt.searchParams.get("access_type"), "offline"); + assert.equal(yt.searchParams.get("code_challenge_method"), "S256"); + assert.ok(yt.searchParams.get("code_challenge")); + assert.equal(u("reddit").searchParams.get("duration"), "permanent"); + assert.equal(u("linkedin").searchParams.get("redirect_uri"), "https://app.example.test/oauth/linkedin/callback"); + vault.saveApp("linkedin", { clientId: "x", clientSecret: "s", scopes: "openid profile w_member_social" }, "t"); + assert.equal(u("linkedin").searchParams.get("scope"), "openid profile w_member_social", "scope override is used"); +}); + +// ---------- each platform: connect + publish ---------- +test("LinkedIn: connect, list company Pages, post image to a company Page", async () => { + reset(); + const { conn } = await connect("linkedin", "acme", { identify: [ + ["GET", "https://api.linkedin.com/v2/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })], + ["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })], + ["GET", "https://api.linkedin.com/rest/organizations/42", json({ localizedName: "Acme Co" })], + ] }); + assert.equal(conn.account.name, "Pat Lee"); + const targets = await P.targetsFor(conn.id); + assert.deepEqual(targets.map((t) => t.id), ["urn:li:person:abc", "urn:li:organization:42"]); + await P.setTarget(conn.id, "urn:li:organization:42"); + route("POST", "https://api.linkedin.com/rest/images?action=initializeUpload", json({ value: { uploadUrl: "https://upload.linkedin.test/1", image: "urn:li:image:9" } })); + route("PUT", "https://upload.linkedin.test/1", new Response("", { status: 201 })); + route("POST", "https://api.linkedin.com/rest/posts", new Response("", { status: 201, headers: { "x-restli-id": "urn:li:share:77" } })); + const r = await P.publish("linkedin", "acme", { type: "IMAGE", media: [IMG], caption: "New menu #Waterloo" }); + assert.equal(r.id, "urn:li:share:77"); + const post = bodyJson(sent(/rest\/posts/)[0]); + assert.equal(post.author, "urn:li:organization:42"); + assert.equal(post.content.media.id, "urn:li:image:9"); + assert.match(post.commentary, /\{hashtag\|\\#\|Waterloo\}/); + assert.ok(sent(/rest\/posts/)[0].headers["LinkedIn-Version"]); +}); + +test("YouTube: connect a channel, upload a Short with title and privacy", async () => { + reset(); + const { conn } = await connect("youtube", "acme", { identify: [ + ["GET", "https://www.googleapis.com/youtube/v3/channels", json({ items: [{ id: "UC1", snippet: { title: "Acme TV", customUrl: "@acmetv", thumbnails: { default: { url: "t" } } }, statistics: { subscriberCount: "1200" } }] })], + ] }); + assert.equal(conn.account.followers, 1200); + route("POST", "https://www.googleapis.com/upload/youtube/v3/videos", new Response("", { status: 200, headers: { location: "https://upload.youtube.test/s1" } })); + route("PUT", "https://upload.youtube.test/s1", json({ id: "vid1", status: { privacyStatus: "unlisted" } })); + P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: { youtube: { title: "Behind the scenes" } } }); + assert.throws(() => P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /video title is required/); + assert.throws(() => P.validate("youtube", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: { youtube: { title: "t" } } }), /doesn't take image posts/); + const r = await P.publish("youtube", "acme", { type: "REELS", media: [VID], caption: "Desc", options: { youtube: { title: "Behind the scenes", privacy: "unlisted" } } }); + assert.equal(r.permalink, "https://www.youtube.com/shorts/vid1"); + const meta = JSON.parse(sent(/upload\/youtube/)[0].body); + assert.equal(meta.snippet.title, "Behind the scenes"); + assert.equal(meta.status.privacyStatus, "unlisted"); + assert.equal(meta.status.selfDeclaredMadeForKids, false); +}); + +test("Google Business Profile: auto-picks the only location, posts with a button and photo link", async () => { + reset(); + const { conn } = await connect("gbp", "acme", { identify: [ + ["GET", "https://openidconnect.googleapis.com/v1/userinfo", json({ sub: "g1", email: "owner@acme.test" })], + ["GET", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", json({ accounts: [{ name: "accounts/1" }] })], + ["GET", "https://mybusinessbusinessinformation.googleapis.com/v1/accounts/1/locations", json({ locations: [{ name: "locations/5", title: "Acme Bakery", storefrontAddress: { addressLines: ["1 King St"], locality: "Waterloo" } }] })], + ] }); + assert.equal(vault.getConnection(conn.id).target.id, "accounts/1/locations/5"); + route("POST", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/localPosts", json({ name: "accounts/1/locations/5/localPosts/9", searchUrl: "https://g.test/p" })); + assert.throws(() => P.validate("gbp", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /doesn't take video/); + await assert.rejects(P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER" } } }), /add a link for the button/); + const r = await P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER", ctaUrl: "https://acme.test/order" } } }); + assert.equal(r.permalink, "https://g.test/p"); + const b = bodyJson(sent(/localPosts/).at(-1)); + assert.deepEqual(b.callToAction, { actionType: "ORDER", url: "https://acme.test/order" }); + assert.match(b.media[0].sourceUrl, /^https:\/\/app\.example\.test\/m\//); +}); + +test("TikTok: connect, privacy is enforced, video uploads and photo posts pull from signed links", async () => { + reset(); + const { conn } = await connect("tiktok", "acme", { + tokenReply: json({ access_token: "tt-at", refresh_token: "tt-rt", expires_in: 86400, open_id: "op1", scope: "video.publish" }), + identify: [["GET", "https://open.tiktokapis.com/v2/user/info/", json({ data: { user: { open_id: "op1", display_name: "Acme", username: "acme", follower_count: 50 } }, error: { code: "ok" } })]], + }); + assert.equal(conn.account.username, "acme"); + assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /who can view is required/); + route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } })); + await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE" } } }), /can't post as/); + route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ data: { publish_id: "pub1", upload_url: "https://upload.tiktok.test/u1" }, error: { code: "ok" } })); + route("PUT", "https://upload.tiktok.test/u1", new Response("", { status: 201 })); + route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "PUBLISH_COMPLETE", publicaly_available_post_id: [7001] }, error: { code: "ok" } })); + const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Our kitchen", options: { tiktok: { privacy: "SELF_ONLY" } } }); + assert.equal(r.permalink, "https://www.tiktok.com/@acme/video/7001"); + const init = bodyJson(sent(/video\/init/)[0]); + assert.equal(init.source_info.total_chunk_count, 1); + assert.equal(init.post_info.privacy_level, "SELF_ONLY"); + assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024"); + route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } })); + await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } }); + const photo = bodyJson(sent(/content\/init/)[0]); + assert.equal(photo.media_type, "PHOTO"); + assert.equal(photo.source_info.photo_images.length, 2); + assert.ok(photo.source_info.photo_images.every((u) => u.startsWith("https://app.example.test/m/"))); +}); + +test("Pinterest: boards, base64 image Pin to the chosen board", async () => { + reset(); + const { conn } = await connect("pinterest", "acme", { identify: [ + ["GET", "https://api.pinterest.com/v5/user_account", json({ username: "acmepins", business_name: "Acme", follower_count: 300 })], + ["GET", "https://api.pinterest.com/v5/boards", json({ items: [{ id: "b1", name: "Cakes" }, { id: "b2", name: "Bread" }] })], + ] }); + assert.equal(calls.find((c) => c.url.startsWith("https://api.pinterest.com/v5/oauth/token")).headers.Authorization.startsWith("Basic "), true, "Pinterest uses Basic auth for tokens"); + assert.equal(vault.getConnection(conn.id).target, null, "two boards: nothing auto-picked"); + assert.throws(() => P.validate("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: {} }), /board is required/); + route("POST", "https://api.pinterest.com/v5/pins", json({ id: "pin9" })); + const r = await P.publish("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "Birthday cakes", options: { pinterest: { boardId: "b1", title: "Cakes", link: "https://acme.test" } } }); + assert.equal(r.permalink, "https://www.pinterest.com/pin/pin9/"); + const pin = bodyJson(sent(/v5\/pins/)[0]); + assert.equal(pin.board_id, "b1"); + assert.equal(pin.media_source.source_type, "image_base64"); + assert.equal(pin.media_source.data, fs.readFileSync(IMG).toString("base64")); +}); + +test("Threads: long-lived token exchange, text and image posts", async () => { + reset(); + route("GET", "https://graph.threads.net/access_token", json({ access_token: "th-long", expires_in: 5184000 })); + const { conn } = await connect("threads", "acme", { + tokenReply: json({ access_token: "th-short", user_id: 555 }), + identify: [ + ["GET", "https://graph.threads.net/v1.0/me", json({ id: "555", username: "acme" })], + ["GET", "https://graph.threads.net/v1.0/555/threads_insights", json({ data: [{ total_value: { value: 88 } }] })], + ], + }); + assert.equal(vault.getConnection(conn.id, { withTokens: true }).tokens.accessToken, "th-long"); + assert.equal(conn.account.followers, 88); + route("POST", "https://graph.threads.net/v1.0/555/threads?", json({ id: "c1" })); + route("GET", "https://graph.threads.net/v1.0/c1", json({ status: "FINISHED" })); + route("POST", "https://graph.threads.net/v1.0/555/threads_publish", json({ id: "t1" })); + route("GET", "https://graph.threads.net/v1.0/t1", json({ permalink: "https://threads.net/@acme/post/t1" })); + const r = await P.publish("threads", "acme", { type: "IMAGE", media: [IMG], caption: "Hello" }); + assert.equal(r.permalink, "https://threads.net/@acme/post/t1"); + const c = new URL(sent(/555\/threads\?/)[0].url); + assert.equal(c.searchParams.get("media_type"), "IMAGE"); + assert.match(c.searchParams.get("image_url"), /^https:\/\/app\.example\.test\/m\//); + assert.throws(() => P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: {} }), /limit is 500/); + P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: { threads: { caption: "short" } } }); +}); + +test("Reddit: text post needs subreddit and title, sends User-Agent", async () => { + reset(); + await connect("reddit", "acme", { identify: [["GET", "https://oauth.reddit.com/api/v1/me", json({ id: "r1", name: "acme_bakery", total_karma: 12 })]] }); + assert.throws(() => P.validate("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "waterloo" } } }), /post title is required/); + route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [], data: { name: "t3_abc", url: "https://reddit.com/r/waterloo/abc" } } })); + const r = await P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "We're open late", options: { reddit: { subreddit: "r/waterloo", title: "Late hours" } } }); + assert.equal(r.permalink, "https://reddit.com/r/waterloo/abc"); + const sub = sent(/api\/submit/)[0]; + assert.deepEqual({ sr: form(sub).sr, kind: form(sub).kind, text: form(sub).text }, { sr: "waterloo", kind: "self", text: "We're open late" }); + assert.ok(sub.headers["User-Agent"]); + route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [["SUBREDDIT_NOTALLOWED", "you aren't allowed to post there.", "sr"]] } })); + routes = routes.filter((x, i, a) => !(x.match === "https://oauth.reddit.com/api/submit" && a.findIndex((y) => y.match === x.match) === i)); + await assert.rejects(P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "x", title: "t" } } }), /aren't allowed to post/); +}); + +test("expired tokens are refreshed and saved; failed refresh asks to reconnect", async () => { + reset(); + const c = vault.connectionFor("acme", "youtube"); + vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "yt-rt", expiresAt: new Date(Date.now() - 1000).toISOString() } }); + route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "new-at", expires_in: 3600 })); + const { tokens } = await P.tokensFor(c.id); + assert.equal(tokens.accessToken, "new-at"); + assert.equal(tokens.refreshToken, "yt-rt", "refresh token kept when the platform doesn't send a new one"); + assert.equal(form(sent(/oauth2\.googleapis/)[0]).grant_type, "refresh_token"); + reset(); + vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "bad", expiresAt: new Date(Date.now() - 1000).toISOString() } }); + route("POST", "https://oauth2.googleapis.com/token", json({ error: "invalid_grant", error_description: "Token has been revoked." }, 400)); + await assert.rejects(P.tokensFor(c.id), /Reconnect it/); + assert.equal(vault.getConnection(c.id).status, "reconnect"); +}); + +test("posting to an unconnected platform is refused clearly", () => { + assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index cccc01b..2df5dbf 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -13,6 +13,8 @@ import * as docs from "./docs.js"; import * as meta from "../../connector/src/meta.js"; import * as sched from "../../connector/src/schedule.js"; import * as lib from "../../connector/src/library.js"; +import * as extra from "../../connector/src/platforms/index.js"; +import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; const app = express(); @@ -47,6 +49,31 @@ app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png"))); app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png"))); +// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from. +app.get("/m/:token/:name", (req, res) => { + const f = resolveMediaToken(req.params.token); + if (!f || !fs.existsSync(f)) return res.status(404).send("Not found"); + res.setHeader("Cache-Control", "private, max-age=3600"); + res.sendFile(f); +}); + +// OAuth: the platform sends the person back here after they approve Quorla. +const baseUrl = (req) => publicBaseUrl() || `${req.protocol}://${req.get("host")}`; +const redirectUri = (req, platform) => `${baseUrl(req)}/oauth/${platform}/callback`; +app.get("/oauth/:platform/callback", async (req, res) => { + const back = (q) => res.redirect(`/connections?${new URLSearchParams(q)}`); + const user = currentUser(req); + if (!user) return back({ error: "Your Quorla session ended. Sign in and connect again." }); + if (req.query.error) return back({ error: `${req.params.platform}: ${String(req.query.error_description || req.query.error).slice(0, 300)}` }); + try { + const conn = await extra.finishConnect({ platform: req.params.platform, code: String(req.query.code || ""), state: String(req.query.state || ""), user }); + back({ client: conn.client, connected: req.params.platform }); + } catch (e) { + console.error("[oauth]", req.params.platform, e.message); + back({ error: String(e.message).slice(0, 300) }); + } +}); + // ---------- everything below needs a session ---------- app.use("/api", requireAuth); @@ -58,6 +85,24 @@ app.post("/api/me/password", h(async (req, res) => { return { user }; })); +// ---------- platforms beyond Facebook/Instagram ---------- +app.get("/api/platforms", h(async (req) => ({ + platforms: extra.describePlatforms().map((p) => ({ ...p, redirectUri: redirectUri(req, p.id), app: req.user.role === "admin" ? p.app : { configured: p.app.configured } })), + publicUrl: baseUrl(req), mediaLinks: Boolean(publicBaseUrl()), +}))); +app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; })); +app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; })); +app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) }))); +app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => { + const client = String(req.query.client || ""); + if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 }); + return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) }; +})); +app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) }))); +app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) }))); +app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) }))); +app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; })); + // ---------- team members (admins) ---------- app.get("/api/users", requireAdmin, h(async () => ({ users: users.listUsers() }))); app.post("/api/users", requireAdmin, h(async (req) => users.createUser(req.body || {}, req.user.name || req.user.email))); diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index fbd7fd9..699fab2 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx"; import Publishing from "./pages/Publishing.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; +import Connections from "./pages/Connections.jsx"; const AuthCtx = createContext(null); export const useAuth = () => useContext(AuthCtx); @@ -21,6 +22,7 @@ const NAV = [ { to: "/library", label: "Content library", icon: FolderUp }, { to: "/publishing", label: "Publishing", icon: CalendarClock }, { to: "/inbox", label: "Inbox", icon: MessageCircle }, + { to: "/connections", label: "Connections", icon: Plug }, { to: "/team", label: "Team", icon: Users2 }, { to: "/runs", label: "AI runs", icon: Sparkles }, { to: "/settings", label: "Settings", icon: Settings2 }, @@ -117,6 +119,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/web/src/PlatformPicker.jsx b/dashboard/web/src/PlatformPicker.jsx new file mode 100644 index 0000000..2e5d0ec --- /dev/null +++ b/dashboard/web/src/PlatformPicker.jsx @@ -0,0 +1,128 @@ +// "Post to" picker shared by New post (Publishing) and Schedule (Content library): +// Instagram/Facebook from the Meta link, plus every platform connected for the client, each with its own fields. +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "./api.js"; +import { cx, Field } from "./ui.jsx"; + +const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" }; + +/** Platforms the client can post to: [{ id, name, meta, formats, fields, limits, conn }] */ +export function useClientPlatforms(client) { + const [state, setState] = useState({ loading: true, list: [] }); + useEffect(() => { + if (!client) { setState({ loading: false, list: [] }); return; } + let live = true; + setState((s) => ({ ...s, loading: true })); + Promise.all([api(`/api/clients/${client}`), api("/api/platforms"), api(`/api/connections?client=${encodeURIComponent(client)}`)]) + .then(([c, p, k]) => { + if (!live) return; + const info = c.client; + const list = []; + if (info.igId) list.push({ id: "instagram", name: "Instagram", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY"], fields: [], limits: { caption: 2200 } }); + if (info.pageId) list.push({ id: "facebook", name: "Facebook", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"], fields: [], limits: {} }); + for (const pl of p.platforms) { + const conn = k.connections.find((x) => x.platform === pl.id && x.status === "connected"); + if (conn) list.push({ ...pl, conn }); + } + setState({ loading: false, list }); + }) + .catch(() => live && setState({ loading: false, list: [] })); + return () => { live = false; }; + }, [client]); + return state; +} + +/** Default selection for a client: its Meta platforms. */ +export const defaultPlatforms = (list) => list.filter((p) => p.meta).map((p) => p.id); + +function TargetSelect({ conn, value, onChange, id }) { + const [opts, setOpts] = useState(null); + useEffect(() => { api(`/api/connections/${conn.id}/targets`).then((r) => setOpts(r.targets)).catch(() => setOpts([])); }, [conn.id]); + return ( + + ); +} + +function PlatformFields({ platform, type, options, setOption, caption }) { + const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type)); + const own = options.caption; + const limit = platform.limits?.caption; + const used = (own ?? caption ?? "").length; + return ( +
+
+
+
+ {fields.map((f) => { + const id = `opt-${platform.id}-${f.key}`; + const v = options[f.key] ?? f.default ?? ""; + if (f.type === "checkbox") { + return ; + } + return ( +
+ + {f.type === "select" && f.optionsFrom === "targets" ? setOption(f.key, x)} /> + : f.type === "select" ? ( + + ) : setOption(f.key, e.target.value)} />} + +
+ ); + })} +
+ +