also looking at this
feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter
#30
28 files
+1810
-109
| @@ -3,7 +3,7 @@ import type { RequestInstance } from '@stacksjs/types' | ||
| 3 | 3 | import { Action } from '@stacksjs/actions' |
| 4 | 4 | import { response } from '@stacksjs/router' |
| 5 | 5 | import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService' |
| 6 | import { readUploadedImage } from '../../Support/Social/uploads' | |
| 6 | import { persistTempMedia, readUploadedImage, removeTempMedia } from '../../Support/Social/uploads' | |
| 7 | 7 | |
| 8 | 8 | export default new Action({ |
| 9 | 9 | name: 'Postline Crosspost Publish', |
| @@ -35,9 +35,17 @@ export default new Action({ | ||
| 35 | 35 | const imageAlt = String(request.get('image_alt') || '').trim() |
| 36 | 36 | |
| 37 | 37 | const media: NonNullable<Parameters<typeof crosspost.publish>[2]>['media'] = [] |
| 38 | let tempMediaFile: string | null = null | |
| 38 | 39 | const uploadedImage = await readUploadedImage(request.file?.('image')) |
| 39 | if (uploadedImage) | |
| 40 | media.push({ ...uploadedImage, altText: imageAlt || undefined }) | |
| 40 | if (uploadedImage) { | |
| 41 | // Persist briefly so URL-only providers (Instagram/Threads) can fetch the | |
| 42 | // upload server-side; byte-upload providers use the bytes. Removed once | |
| 43 | // publishing finishes (Meta downloads during container creation, so the | |
| 44 | // file is safe to delete by then). | |
| 45 | const temp = await persistTempMedia(uploadedImage.bytes, uploadedImage.mimeType) | |
| 46 | tempMediaFile = temp?.filename ?? null | |
| 47 | media.push({ ...uploadedImage, url: temp?.url, altText: imageAlt || undefined }) | |
| 48 | } | |
| 41 | 49 | if (imageUrl) |
| 42 | 50 | media.push({ url: imageUrl, altText: imageAlt || undefined }) |
| 43 | 51 | |
| @@ -66,5 +74,8 @@ export default new Action({ | ||
| 66 | 74 | error: error instanceof Error ? error.message : String(error), |
| 67 | 75 | }, { status: 422 }) |
| 68 | 76 | } |
| 77 | finally { | |
| 78 | if (tempMediaFile) await removeTempMedia(tempMediaFile) | |
| 79 | } | |
| 69 | 80 | }, |
| 70 | 81 | }) |
| @@ -0,0 +1,45 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { Action } from '@stacksjs/actions' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { isSafeMediaFilename, MEDIA_DIR, mediaContentType } from '../../Support/Social/uploads' | |
| 6 | ||
| 7 | /** | |
| 8 | * Serve a stored post image so a remote platform can fetch it server-side. | |
| 9 | * | |
| 10 | * Instagram and Threads publish an image by handing the Meta Graph API a public | |
| 11 | * `image_url` it downloads itself — an uploaded file sitting on local disk is | |
| 12 | * unreachable to them. This route exposes those files at a stable URL. | |
| 13 | * | |
| 14 | * It is intentionally UNAUTHENTICATED (Meta fetches it with no session), so it | |
| 15 | * is deliberately narrow: only a strictly-validated basename is accepted | |
| 16 | * (`isSafeMediaFilename` rejects any path separator or traversal), the filename | |
| 17 | * is an unguessable per-upload UUID, and nothing outside the media directory | |
| 18 | * can be reached. | |
| 19 | */ | |
| 20 | export default new Action({ | |
| 21 | name: 'Postline Media Serve', | |
| 22 | description: 'Serve a stored post image for remote platforms (Instagram/Threads) to fetch.', | |
| 23 | method: 'GET', | |
| 24 | ||
| 25 | async handle(request: RequestInstance) { | |
| 26 | const file = String(request.get('file') || '').trim() | |
| 27 | if (!isSafeMediaFilename(file)) | |
| 28 | return response.json({ ok: false, error: 'Invalid media reference.' }, { status: 400 }) | |
| 29 | ||
| 30 | const stored = Bun.file(join(MEDIA_DIR, file)) | |
| 31 | if (!(await stored.exists())) | |
| 32 | return response.json({ ok: false, error: 'Media not found.' }, { status: 404 }) | |
| 33 | ||
| 34 | const bytes = new Uint8Array(await stored.arrayBuffer()) | |
| 35 | return new Response(bytes as unknown as BodyInit, { | |
| 36 | status: 200, | |
| 37 | headers: { | |
| 38 | 'content-type': mediaContentType(file), | |
| 39 | // Filenames are unique per upload, so the bytes never change — let a | |
| 40 | // platform re-fetching the same URL hit its cache instead of the app. | |
| 41 | 'cache-control': 'public, max-age=31536000, immutable', | |
| 42 | }, | |
| 43 | }) | |
| 44 | }, | |
| 45 | }) | |
| @@ -6,6 +6,7 @@ import { instagram } from '../../Services/Social/InstagramService' | ||
| 6 | 6 | import { linkedin } from '../../Services/Social/LinkedInService' |
| 7 | 7 | import { mastodon } from '../../Services/Social/MastodonService' |
| 8 | 8 | import { threads } from '../../Services/Social/ThreadsService' |
| 9 | import { twitter } from '../../Services/Social/TwitterService' | |
| 9 | 10 | |
| 10 | 11 | export default new Action({ |
| 11 | 12 | name: 'Postline Providers Status', |
| @@ -16,6 +17,7 @@ export default new Action({ | ||
| 16 | 17 | try { |
| 17 | 18 | const providers = await Promise.all([ |
| 18 | 19 | bluesky.status(), |
| 20 | twitter.status(), | |
| 19 | 21 | linkedin.status(), |
| 20 | 22 | instagram.status(), |
| 21 | 23 | threads.status(), |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Auth', | |
| 7 | description: 'Redirect the user to X/Twitter for OAuth consent.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | return response.redirect(await twitter.getAuthUrl()) | |
| 13 | } | |
| 14 | catch (error) { | |
| 15 | const message = error instanceof Error ? error.message : String(error) | |
| 16 | return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(message)}`) | |
| 17 | } | |
| 18 | }, | |
| 19 | }) | |
| @@ -0,0 +1,30 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { twitter } from '../../Services/Social/TwitterService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Twitter Callback', | |
| 8 | description: 'Handle the X/Twitter OAuth redirect and store the access token.', | |
| 9 | method: 'GET', | |
| 10 | ||
| 11 | async handle(request: RequestInstance) { | |
| 12 | const code = String(request.get('code') || '') | |
| 13 | const state = String(request.get('state') || '') | |
| 14 | const oauthError = String(request.get('error') || '') | |
| 15 | ||
| 16 | if (oauthError) { | |
| 17 | const description = String(request.get('error_description') || oauthError) | |
| 18 | return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(description)}`) | |
| 19 | } | |
| 20 | ||
| 21 | try { | |
| 22 | await twitter.handleCallback(code, state) | |
| 23 | return response.redirect('/accounts?twitter=connected') | |
| 24 | } | |
| 25 | catch (error) { | |
| 26 | const message = error instanceof Error ? error.message : String(error) | |
| 27 | return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(message)}`) | |
| 28 | } | |
| 29 | }, | |
| 30 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Connect', | |
| 7 | description: 'Connect X/Twitter from a pre-obtained access token in the environment.', | |
| 8 | method: 'POST', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | const data = await twitter.connectFromEnv() | |
| 13 | return response.json({ ok: true, data }) | |
| 14 | } | |
| 15 | catch (error) { | |
| 16 | return response.json({ | |
| 17 | ok: false, | |
| 18 | error: error instanceof Error ? error.message : String(error), | |
| 19 | }, { status: 422 }) | |
| 20 | } | |
| 21 | }, | |
| 22 | }) | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Status', | |
| 7 | description: 'Return the current X/Twitter connection state.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | return response.json({ ok: true, data: await twitter.status() }) | |
| 13 | } | |
| 14 | catch (error) { | |
| 15 | return response.json({ | |
| 16 | ok: false, | |
| 17 | error: error instanceof Error ? error.message : String(error), | |
| 18 | }, { status: 500 }) | |
| 19 | } | |
| 20 | }, | |
| 21 | }) | |
| @@ -7,6 +7,7 @@ import { instagram } from './InstagramService' | ||
| 7 | 7 | import { linkedin } from './LinkedInService' |
| 8 | 8 | import { mastodon } from './MastodonService' |
| 9 | 9 | import { threads } from './ThreadsService' |
| 10 | import { twitter } from './TwitterService' | |
| 10 | 11 | import { ensureAccount, now, uuid } from './support' |
| 11 | 12 | |
| 12 | 13 | const database = db as any |
| @@ -18,6 +19,7 @@ interface ProviderPublisher { | ||
| 18 | 19 | // Each provider owns its own connection/token handling behind `publishToPost`. |
| 19 | 20 | const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = { |
| 20 | 21 | bluesky, |
| 22 | twitter, | |
| 21 | 23 | linkedin, |
| 22 | 24 | instagram, |
| 23 | 25 | threads, |
| @@ -4,9 +4,11 @@ import { BlueskyDriver } from './Drivers/BlueskyDriver' | ||
| 4 | 4 | import { InstagramDriver } from './Drivers/InstagramDriver' |
| 5 | 5 | import { LinkedInDriver } from './Drivers/LinkedInDriver' |
| 6 | 6 | import { ThreadsDriver } from './Drivers/ThreadsDriver' |
| 7 | import { TwitterDriver } from './Drivers/TwitterDriver' | |
| 7 | 8 | |
| 8 | 9 | const drivers: Partial<Record<SocialProvider, SocialDriver>> = { |
| 9 | 10 | bluesky: new BlueskyDriver(), |
| 11 | twitter: new TwitterDriver(), | |
| 10 | 12 | linkedin: new LinkedInDriver({ apiVersion: String(env.LINKEDIN_API_VERSION || '202405') }), |
| 11 | 13 | instagram: new InstagramDriver({ graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0') }), |
| 12 | 14 | threads: new ThreadsDriver({ graphVersion: String(env.THREADS_GRAPH_VERSION || 'v1.0') }), |
| @@ -1,1 +1,42 @@ | ||
| 1 | export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials' | |
| 1 | import { InstagramApiError, InstagramPublishingDriver } from '@stacksjs/socials' | |
| 2 | ||
| 3 | export { InstagramApiError } | |
| 4 | ||
| 5 | export interface InstagramLongLivedToken { | |
| 6 | accessToken: string | |
| 7 | expiresIn?: number | |
| 8 | } | |
| 9 | ||
| 10 | /** | |
| 11 | * App-owned Instagram driver. Extends the vendored `@stacksjs/socials` driver | |
| 12 | * with the Facebook long-lived-token exchange. Kept in `app/` so a framework | |
| 13 | * upgrade can't clobber it; publishing and account resolution are inherited. | |
| 14 | * | |
| 15 | * Instagram publishes with a Page access token derived from a user token. A | |
| 16 | * Page token minted from a *long-lived* user token does not expire on its own, | |
| 17 | * so the "keep the connection alive" step for Instagram is exchanging the | |
| 18 | * short-lived user token for a long-lived one before resolving the account — | |
| 19 | * there is no per-publish refresh grant the way Threads/LinkedIn have. | |
| 20 | */ | |
| 21 | export class InstagramDriver extends InstagramPublishingDriver { | |
| 22 | /** Exchange a short-lived user token for a long-lived (~60-day) one. */ | |
| 23 | async exchangeLongLivedUserToken(clientId: string, clientSecret: string, shortLivedToken: string): Promise<InstagramLongLivedToken> { | |
| 24 | const query = new URLSearchParams({ | |
| 25 | grant_type: 'fb_exchange_token', | |
| 26 | client_id: clientId, | |
| 27 | client_secret: clientSecret, | |
| 28 | fb_exchange_token: shortLivedToken, | |
| 29 | }) | |
| 30 | ||
| 31 | const payload = await this.graph<{ access_token?: string, expires_in?: number }>( | |
| 32 | `/oauth/access_token?${query.toString()}`, | |
| 33 | { method: 'GET' }, | |
| 34 | ) | |
| 35 | ||
| 36 | if (!payload.access_token) { | |
| 37 | throw new InstagramApiError('Facebook did not return a long-lived token.', 400, JSON.stringify(payload)) | |
| 38 | } | |
| 39 | ||
| 40 | return { accessToken: payload.access_token, expiresIn: payload.expires_in } | |
| 41 | } | |
| 42 | } | |
| @@ -1,1 +1,262 @@ | ||
| 1 | export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials' | |
| 1 | import type { LinkedInTokenExchangeInput, PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 2 | import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials' | |
| 3 | ||
| 4 | export { LinkedInApiError } | |
| 5 | ||
| 6 | interface InitializeUploadResponse { | |
| 7 | value?: { uploadUrl?: string, image?: string } | |
| 8 | } | |
| 9 | ||
| 10 | export interface LinkedInRefreshInput { | |
| 11 | clientId: string | |
| 12 | clientSecret: string | |
| 13 | refreshToken: string | |
| 14 | } | |
| 15 | ||
| 16 | export interface LinkedInRefreshedToken { | |
| 17 | accessToken: string | |
| 18 | expiresIn?: number | |
| 19 | /** Present only when the app is enrolled in LinkedIn's refresh-token program. */ | |
| 20 | refreshToken?: string | |
| 21 | refreshTokenExpiresIn?: number | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * App-owned LinkedIn driver. Extends the vendored `@stacksjs/socials` driver to | |
| 26 | * add image posting — the base driver only supports text and article link | |
| 27 | * cards. Kept here in `app/` (not patched into `storage/framework/`) so a | |
| 28 | * framework upgrade can't clobber it, the same "re-home into app code" rule the | |
| 29 | * app-owned Mastodon driver and the auth bridge follow. | |
| 30 | * | |
| 31 | * Everything else (OAuth `getAuthUrl`/`exchangeCode`/`getProfile`, the versioned | |
| 32 | * REST plumbing) is inherited unchanged. Only `publish` is overridden, and a | |
| 33 | * single `uploadImage` step is added. | |
| 34 | */ | |
| 35 | export class LinkedInDriver extends LinkedInPublishingDriver { | |
| 36 | /** | |
| 37 | * Exchange an authorization code for a token, additionally surfacing the | |
| 38 | * `refresh_token` the base driver drops. LinkedIn only returns one when the | |
| 39 | * app is enrolled in the refresh-token program; when absent, the field is | |
| 40 | * simply undefined and the connection falls back to reconnect-on-expiry. | |
| 41 | */ | |
| 42 | async exchangeCode(input: LinkedInTokenExchangeInput): Promise<LinkedInRefreshedToken & { scope?: string }> { | |
| 43 | const body = new URLSearchParams({ | |
| 44 | grant_type: 'authorization_code', | |
| 45 | code: input.code, | |
| 46 | redirect_uri: input.redirectUrl, | |
| 47 | client_id: input.clientId, | |
| 48 | client_secret: input.clientSecret, | |
| 49 | }) | |
| 50 | ||
| 51 | const payload = await this.request<{ | |
| 52 | access_token: string | |
| 53 | expires_in?: number | |
| 54 | scope?: string | |
| 55 | refresh_token?: string | |
| 56 | refresh_token_expires_in?: number | |
| 57 | }>(`${this.authBase}/oauth/v2/accessToken`, { | |
| 58 | method: 'POST', | |
| 59 | headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| 60 | body: body.toString(), | |
| 61 | }) | |
| 62 | ||
| 63 | if (!payload.access_token) { | |
| 64 | throw new LinkedInApiError('LinkedIn did not return an access token.', 400, JSON.stringify(payload)) | |
| 65 | } | |
| 66 | ||
| 67 | return { | |
| 68 | accessToken: payload.access_token, | |
| 69 | expiresIn: payload.expires_in, | |
| 70 | scope: payload.scope, | |
| 71 | refreshToken: payload.refresh_token, | |
| 72 | refreshTokenExpiresIn: payload.refresh_token_expires_in, | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | /** Mint a fresh access token from a stored refresh token. */ | |
| 77 | async refreshAccessToken(input: LinkedInRefreshInput): Promise<LinkedInRefreshedToken> { | |
| 78 | const body = new URLSearchParams({ | |
| 79 | grant_type: 'refresh_token', | |
| 80 | refresh_token: input.refreshToken, | |
| 81 | client_id: input.clientId, | |
| 82 | client_secret: input.clientSecret, | |
| 83 | }) | |
| 84 | ||
| 85 | const payload = await this.request<{ | |
| 86 | access_token: string | |
| 87 | expires_in?: number | |
| 88 | refresh_token?: string | |
| 89 | refresh_token_expires_in?: number | |
| 90 | }>(`${this.authBase}/oauth/v2/accessToken`, { | |
| 91 | method: 'POST', | |
| 92 | headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| 93 | body: body.toString(), | |
| 94 | }) | |
| 95 | ||
| 96 | if (!payload.access_token) { | |
| 97 | throw new LinkedInApiError('LinkedIn did not return a refreshed access token.', 400, JSON.stringify(payload)) | |
| 98 | } | |
| 99 | ||
| 100 | return { | |
| 101 | accessToken: payload.access_token, | |
| 102 | expiresIn: payload.expires_in, | |
| 103 | refreshToken: payload.refresh_token, | |
| 104 | refreshTokenExpiresIn: payload.refresh_token_expires_in, | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Register and upload one image via LinkedIn's versioned Images API, then | |
| 110 | * return its `urn:li:image:...` id for attachment. | |
| 111 | * | |
| 112 | * 1. POST /rest/images?action=initializeUpload → { uploadUrl, image URN } | |
| 113 | * 2. PUT the raw bytes to `uploadUrl` | |
| 114 | * | |
| 115 | * `owner` is the author URN (`urn:li:person:{sub}`) that owns the asset. | |
| 116 | */ | |
| 117 | async uploadImage(accessToken: string, owner: string, bytes: Uint8Array, mimeType?: string): Promise<string> { | |
| 118 | const initialized = await this.request<InitializeUploadResponse>( | |
| 119 | `${this.apiBase}/rest/images?action=initializeUpload`, | |
| 120 | { | |
| 121 | method: 'POST', | |
| 122 | headers: { | |
| 123 | 'authorization': `Bearer ${accessToken}`, | |
| 124 | 'content-type': 'application/json', | |
| 125 | 'linkedin-version': this.apiVersion, | |
| 126 | 'x-restli-protocol-version': '2.0.0', | |
| 127 | }, | |
| 128 | body: JSON.stringify({ initializeUploadRequest: { owner } }), | |
| 129 | }, | |
| 130 | ) | |
| 131 | ||
| 132 | const uploadUrl = initialized.value?.uploadUrl | |
| 133 | const image = initialized.value?.image | |
| 134 | if (!uploadUrl || !image) { | |
| 135 | throw new LinkedInApiError('LinkedIn did not return an image upload URL.', 400, JSON.stringify(initialized)) | |
| 136 | } | |
| 137 | ||
| 138 | const upload = await fetch(uploadUrl, { | |
| 139 | method: 'PUT', | |
| 140 | headers: { | |
| 141 | 'authorization': `Bearer ${accessToken}`, | |
| 142 | 'content-type': mimeType || 'image/jpeg', | |
| 143 | }, | |
| 144 | body: bytes as unknown as BodyInit, | |
| 145 | }) | |
| 146 | ||
| 147 | if (!upload.ok) { | |
| 148 | const text = await upload.text().catch(() => '') | |
| 149 | throw new LinkedInApiError( | |
| 150 | `LinkedIn image upload failed (${upload.status}): ${text || upload.statusText}`, | |
| 151 | upload.status, | |
| 152 | text, | |
| 153 | ) | |
| 154 | } | |
| 155 | ||
| 156 | return image | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Publish a member share. Extends the base text/article flow with a single | |
| 161 | * attached image (`post.media[0]`): the image is uploaded first, then | |
| 162 | * referenced under `content.media`. LinkedIn's `content` is a one-of, so an | |
| 163 | * image supersedes an article link card when both are present (matching how | |
| 164 | * Bluesky lets an image override the link card). | |
| 165 | * | |
| 166 | * The record shape is duplicated from the base driver by necessity — the base | |
| 167 | * `publish` builds it inline with no seam to inject media — and is pinned by | |
| 168 | * tests/unit/linkedin-driver.test.ts so upgrade drift surfaces immediately. | |
| 169 | */ | |
| 170 | async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 171 | if (!identity.accessToken) throw new Error('LinkedIn access token is missing for this identity.') | |
| 172 | ||
| 173 | // `did` carries the member URN (urn:li:person:{sub}) for LinkedIn identities. | |
| 174 | const author = identity.did | |
| 175 | if (!author) throw new Error('LinkedIn member URN is required to publish.') | |
| 176 | ||
| 177 | if (post.text.length > this.characterLimit) { | |
| 178 | throw new Error(`LinkedIn posts must be ${this.characterLimit} characters or fewer.`) | |
| 179 | } | |
| 180 | ||
| 181 | // Resolve an attached image to bytes — uploaded files arrive as `bytes`; | |
| 182 | // a bare `url` is fetched first (same handling as the Mastodon driver). | |
| 183 | const media = post.media?.[0] | |
| 184 | let imageUrn: string | undefined | |
| 185 | if (media) { | |
| 186 | let bytes = media.bytes | |
| 187 | let mimeType = media.mimeType | |
| 188 | if (!bytes?.length && media.url) { | |
| 189 | const response = await fetch(media.url) | |
| 190 | if (response.ok) { | |
| 191 | bytes = new Uint8Array(await response.arrayBuffer()) | |
| 192 | mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg' | |
| 193 | } | |
| 194 | } | |
| 195 | if (bytes?.length) { | |
| 196 | imageUrn = await this.uploadImage(identity.accessToken, author, bytes, mimeType) | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | const record: Record<string, unknown> = { | |
| 201 | author, | |
| 202 | commentary: escapeLinkedInText(post.text), | |
| 203 | visibility: 'PUBLIC', | |
| 204 | distribution: { | |
| 205 | feedDistribution: 'MAIN_FEED', | |
| 206 | targetEntities: [], | |
| 207 | thirdPartyDistributionChannels: [], | |
| 208 | }, | |
| 209 | lifecycleState: 'PUBLISHED', | |
| 210 | isReshareDisabledByAuthor: false, | |
| 211 | } | |
| 212 | ||
| 213 | if (imageUrn) { | |
| 214 | record.content = { | |
| 215 | media: { | |
| 216 | id: imageUrn, | |
| 217 | ...(media?.altText ? { altText: media.altText } : {}), | |
| 218 | }, | |
| 219 | } | |
| 220 | } | |
| 221 | else if (post.external) { | |
| 222 | record.content = { | |
| 223 | article: { | |
| 224 | source: post.external.uri, | |
| 225 | title: post.external.title, | |
| 226 | description: post.external.description || '', | |
| 227 | }, | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | const response = await fetch(`${this.apiBase}/rest/posts`, { | |
| 232 | method: 'POST', | |
| 233 | headers: { | |
| 234 | 'authorization': `Bearer ${identity.accessToken}`, | |
| 235 | 'content-type': 'application/json', | |
| 236 | 'linkedin-version': this.apiVersion, | |
| 237 | 'x-restli-protocol-version': '2.0.0', | |
| 238 | }, | |
| 239 | body: JSON.stringify(record), | |
| 240 | }) | |
| 241 | ||
| 242 | const text = await response.text() | |
| 243 | if (!response.ok) { | |
| 244 | throw new LinkedInApiError( | |
| 245 | `LinkedIn API failed (${response.status}): ${text || response.statusText}`, | |
| 246 | response.status, | |
| 247 | text, | |
| 248 | ) | |
| 249 | } | |
| 250 | ||
| 251 | // The created post URN comes back in a response header, not the body. | |
| 252 | const uri = response.headers.get('x-restli-id') | |
| 253 | || response.headers.get('x-linkedin-id') | |
| 254 | || '' | |
| 255 | ||
| 256 | return { | |
| 257 | provider: this.provider, | |
| 258 | uri, | |
| 259 | url: uri ? `https://www.linkedin.com/feed/update/${uri}` : undefined, | |
| 260 | } | |
| 261 | } | |
| 262 | } | |
| @@ -1,1 +1,66 @@ | ||
| 1 | export { ThreadsApiError, ThreadsPublishingDriver as ThreadsDriver } from '@stacksjs/socials' | |
| 1 | import { ThreadsApiError, ThreadsPublishingDriver } from '@stacksjs/socials' | |
| 2 | ||
| 3 | export { ThreadsApiError } | |
| 4 | ||
| 5 | export interface ThreadsLongLivedToken { | |
| 6 | accessToken: string | |
| 7 | expiresIn?: number | |
| 8 | } | |
| 9 | ||
| 10 | /** | |
| 11 | * App-owned Threads driver. Extends the vendored `@stacksjs/socials` driver with | |
| 12 | * long-lived-token exchange and refresh — the base driver's `exchangeCode` only | |
| 13 | * mints a short-lived (~1h) token. Kept in `app/` so a framework upgrade can't | |
| 14 | * clobber it; publishing and timeline are inherited unchanged. | |
| 15 | * | |
| 16 | * Both token endpoints are unversioned (no `/vX` prefix), so they can't go | |
| 17 | * through the base `graph()` helper — they use a small local `tokenRequest`. | |
| 18 | */ | |
| 19 | export class ThreadsDriver extends ThreadsPublishingDriver { | |
| 20 | /** | |
| 21 | * Exchange a short-lived token for a long-lived (~60-day) one. Requires the | |
| 22 | * app's client secret. Called once right after the OAuth code exchange. | |
| 23 | */ | |
| 24 | async exchangeLongLivedToken(clientSecret: string, shortLivedToken: string): Promise<ThreadsLongLivedToken> { | |
| 25 | const query = new URLSearchParams({ | |
| 26 | grant_type: 'th_exchange_token', | |
| 27 | client_secret: clientSecret, | |
| 28 | access_token: shortLivedToken, | |
| 29 | }) | |
| 30 | const payload = await this.tokenRequest(`${this.graphBase}/access_token?${query.toString()}`) | |
| 31 | return { accessToken: payload.access_token, expiresIn: payload.expires_in } | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Refresh an unexpired long-lived token, extending it another ~60 days. | |
| 36 | * Threads requires the token be at least 24h old and not yet expired — both | |
| 37 | * hold when the proactive refresh fires inside the pre-expiry window. | |
| 38 | */ | |
| 39 | async refreshLongLivedToken(accessToken: string): Promise<ThreadsLongLivedToken> { | |
| 40 | const query = new URLSearchParams({ | |
| 41 | grant_type: 'th_refresh_token', | |
| 42 | access_token: accessToken, | |
| 43 | }) | |
| 44 | const payload = await this.tokenRequest(`${this.graphBase}/refresh_access_token?${query.toString()}`) | |
| 45 | return { accessToken: payload.access_token, expiresIn: payload.expires_in } | |
| 46 | } | |
| 47 | ||
| 48 | private async tokenRequest(url: string): Promise<{ access_token: string, expires_in?: number }> { | |
| 49 | const response = await fetch(url, { method: 'GET' }) | |
| 50 | const text = await response.text() | |
| 51 | let json: any = {} | |
| 52 | try { | |
| 53 | json = text ? JSON.parse(text) : {} | |
| 54 | } | |
| 55 | catch { | |
| 56 | json = {} | |
| 57 | } | |
| 58 | ||
| 59 | if (!response.ok || json?.error || !json?.access_token) { | |
| 60 | const message = json?.error?.message || json?.error_message || text || response.statusText | |
| 61 | throw new ThreadsApiError(`Threads token request failed (${response.status}): ${message}`, response.status, text) | |
| 62 | } | |
| 63 | ||
| 64 | return json | |
| 65 | } | |
| 66 | } | |
| @@ -0,0 +1,261 @@ | ||
| 1 | import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 2 | ||
| 3 | /** | |
| 4 | * Full app-owned X/Twitter publishing driver. The published `@stacksjs/socials` | |
| 5 | * ships only a *sign-in* provider for Twitter (getAuthUrl/getAccessToken/ | |
| 6 | * getUserByToken, no `publish`), so — like the Mastodon driver — this is a | |
| 7 | * complete local implementation rather than a subclass. | |
| 8 | * | |
| 9 | * Auth is OAuth 2.0 with PKCE (X's user-context flow). Posting is `POST | |
| 10 | * /2/tweets`; images go through `POST /2/media/upload` first and attach by | |
| 11 | * media id. Threads chain via `reply.in_reply_to_tweet_id`. | |
| 12 | * | |
| 13 | * NOTE: posting on X requires a paid API tier — this is exercised only against | |
| 14 | * mocked endpoints in tests; the request shapes follow X's documented v2 API. | |
| 15 | */ | |
| 16 | ||
| 17 | export class TwitterApiError extends Error { | |
| 18 | constructor( | |
| 19 | message: string, | |
| 20 | public status: number, | |
| 21 | public body: string, | |
| 22 | ) { | |
| 23 | super(message) | |
| 24 | this.name = 'TwitterApiError' | |
| 25 | } | |
| 26 | ||
| 27 | get isAuthError(): boolean { | |
| 28 | return this.status === 401 || this.status === 403 | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export interface TwitterDriverOptions { | |
| 33 | apiBase?: string | |
| 34 | authorizeBase?: string | |
| 35 | } | |
| 36 | ||
| 37 | export interface TwitterAuthUrlInput { | |
| 38 | clientId: string | |
| 39 | redirectUrl: string | |
| 40 | scopes: string[] | |
| 41 | state: string | |
| 42 | } | |
| 43 | ||
| 44 | export interface TwitterTokenExchangeInput { | |
| 45 | clientId: string | |
| 46 | clientSecret?: string | |
| 47 | redirectUrl: string | |
| 48 | code: string | |
| 49 | codeVerifier: string | |
| 50 | } | |
| 51 | ||
| 52 | export interface TwitterRefreshInput { | |
| 53 | clientId: string | |
| 54 | clientSecret?: string | |
| 55 | refreshToken: string | |
| 56 | } | |
| 57 | ||
| 58 | export interface TwitterToken { | |
| 59 | accessToken: string | |
| 60 | refreshToken?: string | |
| 61 | expiresIn?: number | |
| 62 | scope?: string | |
| 63 | } | |
| 64 | ||
| 65 | export interface TwitterProfile { | |
| 66 | id: string | |
| 67 | username: string | |
| 68 | name?: string | |
| 69 | } | |
| 70 | ||
| 71 | /** base64url with padding stripped — the encoding OAuth2/PKCE expects. */ | |
| 72 | function base64Url(bytes: Uint8Array): string { | |
| 73 | let binary = '' | |
| 74 | for (const byte of bytes) binary += String.fromCharCode(byte) | |
| 75 | return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') | |
| 76 | } | |
| 77 | ||
| 78 | export class TwitterPublishingDriver { | |
| 79 | readonly provider: 'twitter' = 'twitter' | |
| 80 | characterLimit = 280 | |
| 81 | ||
| 82 | protected apiBase: string | |
| 83 | protected authorizeBase: string | |
| 84 | ||
| 85 | constructor(options: TwitterDriverOptions = {}) { | |
| 86 | this.apiBase = options.apiBase || 'https://api.twitter.com' | |
| 87 | this.authorizeBase = options.authorizeBase || 'https://twitter.com' | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Build the OAuth 2.0 consent URL with a PKCE challenge, returning both the | |
| 92 | * URL and the `code_verifier` the caller must stash for the token exchange. | |
| 93 | */ | |
| 94 | async createAuthorization(input: TwitterAuthUrlInput): Promise<{ url: string, codeVerifier: string }> { | |
| 95 | const codeVerifier = base64Url(crypto.getRandomValues(new Uint8Array(32))) | |
| 96 | const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) | |
| 97 | const codeChallenge = base64Url(new Uint8Array(digest)) | |
| 98 | ||
| 99 | const query = new URLSearchParams({ | |
| 100 | response_type: 'code', | |
| 101 | client_id: input.clientId, | |
| 102 | redirect_uri: input.redirectUrl, | |
| 103 | scope: input.scopes.join(' '), | |
| 104 | state: input.state, | |
| 105 | code_challenge: codeChallenge, | |
| 106 | code_challenge_method: 'S256', | |
| 107 | }) | |
| 108 | ||
| 109 | return { url: `${this.authorizeBase}/i/oauth2/authorize?${query.toString()}`, codeVerifier } | |
| 110 | } | |
| 111 | ||
| 112 | /** Exchange an authorization code (+ PKCE verifier) for user tokens. */ | |
| 113 | async exchangeCode(input: TwitterTokenExchangeInput): Promise<TwitterToken> { | |
| 114 | return this.tokenRequest(new URLSearchParams({ | |
| 115 | grant_type: 'authorization_code', | |
| 116 | code: input.code, | |
| 117 | redirect_uri: input.redirectUrl, | |
| 118 | code_verifier: input.codeVerifier, | |
| 119 | client_id: input.clientId, | |
| 120 | }), input.clientId, input.clientSecret) | |
| 121 | } | |
| 122 | ||
| 123 | /** Mint a fresh access token from a stored refresh token. */ | |
| 124 | async refreshAccessToken(input: TwitterRefreshInput): Promise<TwitterToken> { | |
| 125 | return this.tokenRequest(new URLSearchParams({ | |
| 126 | grant_type: 'refresh_token', | |
| 127 | refresh_token: input.refreshToken, | |
| 128 | client_id: input.clientId, | |
| 129 | }), input.clientId, input.clientSecret) | |
| 130 | } | |
| 131 | ||
| 132 | /** Resolve the authenticated account (id + handle). */ | |
| 133 | async getProfile(accessToken: string): Promise<TwitterProfile> { | |
| 134 | const payload = await this.request<{ data?: { id: string, username: string, name?: string } }>( | |
| 135 | `${this.apiBase}/2/users/me?user.fields=username,name`, | |
| 136 | { headers: { authorization: `Bearer ${accessToken}` } }, | |
| 137 | ) | |
| 138 | if (!payload.data?.id || !payload.data.username) { | |
| 139 | throw new TwitterApiError('Twitter did not return the authenticated user.', 400, JSON.stringify(payload)) | |
| 140 | } | |
| 141 | return { id: payload.data.id, username: payload.data.username, name: payload.data.name } | |
| 142 | } | |
| 143 | ||
| 144 | /** Upload one image and return its media id for attachment. */ | |
| 145 | async uploadMedia(accessToken: string, bytes: Uint8Array, mimeType: string): Promise<string> { | |
| 146 | const form = new FormData() | |
| 147 | form.set('media', new Blob([bytes as unknown as BlobPart], { type: mimeType || 'image/jpeg' })) | |
| 148 | form.set('media_category', 'tweet_image') | |
| 149 | ||
| 150 | const payload = await this.request<{ data?: { id?: string }, media_id_string?: string, id?: string }>( | |
| 151 | `${this.apiBase}/2/media/upload`, | |
| 152 | { method: 'POST', headers: { authorization: `Bearer ${accessToken}` }, body: form }, | |
| 153 | ) | |
| 154 | ||
| 155 | const mediaId = payload.data?.id || payload.media_id_string || payload.id | |
| 156 | if (!mediaId) { | |
| 157 | throw new TwitterApiError('Twitter did not return a media id.', 400, JSON.stringify(payload)) | |
| 158 | } | |
| 159 | return mediaId | |
| 160 | } | |
| 161 | ||
| 162 | async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 163 | if (!identity.accessToken) throw new Error('Twitter access token is missing for this identity.') | |
| 164 | if (post.text.length > this.characterLimit) { | |
| 165 | throw new Error(`Twitter posts must be ${this.characterLimit} characters or fewer.`) | |
| 166 | } | |
| 167 | ||
| 168 | // Resolve an attached image to bytes (uploaded files arrive as bytes; a bare | |
| 169 | // URL is fetched first) and upload it for its media id. | |
| 170 | const mediaIds: string[] = [] | |
| 171 | const media = post.media?.[0] | |
| 172 | if (media) { | |
| 173 | let bytes = media.bytes | |
| 174 | let mimeType = media.mimeType | |
| 175 | if (!bytes?.length && media.url) { | |
| 176 | const response = await fetch(media.url) | |
| 177 | if (response.ok) { | |
| 178 | bytes = new Uint8Array(await response.arrayBuffer()) | |
| 179 | mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg' | |
| 180 | } | |
| 181 | } | |
| 182 | if (bytes?.length) mediaIds.push(await this.uploadMedia(identity.accessToken, bytes, mimeType || 'image/jpeg')) | |
| 183 | } | |
| 184 | ||
| 185 | const body: Record<string, unknown> = { text: post.text } | |
| 186 | if (mediaIds.length) body.media = { media_ids: mediaIds } | |
| 187 | // Threads chain through the parent tweet id, carried in reply.parent.uri. | |
| 188 | if (post.reply?.parent?.uri) body.reply = { in_reply_to_tweet_id: post.reply.parent.uri } | |
| 189 | ||
| 190 | const payload = await this.request<{ data?: { id?: string } }>( | |
| 191 | `${this.apiBase}/2/tweets`, | |
| 192 | { | |
| 193 | method: 'POST', | |
| 194 | headers: { 'authorization': `Bearer ${identity.accessToken}`, 'content-type': 'application/json' }, | |
| 195 | body: JSON.stringify(body), | |
| 196 | }, | |
| 197 | ) | |
| 198 | ||
| 199 | const id = payload.data?.id | |
| 200 | if (!id) throw new TwitterApiError('Twitter did not return a tweet id.', 400, JSON.stringify(payload)) | |
| 201 | ||
| 202 | return { | |
| 203 | provider: this.provider, | |
| 204 | // Expose the tweet id as both uri and cid so the crosspost thread-chaining | |
| 205 | // logic (which needs a cid) can reply-chain subsequent segments. | |
| 206 | uri: id, | |
| 207 | cid: id, | |
| 208 | url: identity.handle | |
| 209 | ? `https://x.com/${identity.handle}/status/${id}` | |
| 210 | : `https://x.com/i/web/status/${id}`, | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | // X's home timeline needs elevated access we don't assume here. | |
| 215 | async timeline(): Promise<{ items: [] }> { | |
| 216 | return { items: [] } | |
| 217 | } | |
| 218 | ||
| 219 | protected async tokenRequest(body: URLSearchParams, clientId: string, clientSecret?: string): Promise<TwitterToken> { | |
| 220 | const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' } | |
| 221 | // Confidential clients authenticate the token endpoint with HTTP Basic; | |
| 222 | // public (PKCE-only) clients send just the client_id in the body. | |
| 223 | if (clientSecret) headers.authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}` | |
| 224 | ||
| 225 | const payload = await this.request<{ | |
| 226 | access_token?: string | |
| 227 | refresh_token?: string | |
| 228 | expires_in?: number | |
| 229 | scope?: string | |
| 230 | }>(`${this.apiBase}/2/oauth2/token`, { method: 'POST', headers, body: body.toString() }) | |
| 231 | ||
| 232 | if (!payload.access_token) { | |
| 233 | throw new TwitterApiError('Twitter did not return an access token.', 400, JSON.stringify(payload)) | |
| 234 | } | |
| 235 | ||
| 236 | return { | |
| 237 | accessToken: payload.access_token, | |
| 238 | refreshToken: payload.refresh_token, | |
| 239 | expiresIn: payload.expires_in, | |
| 240 | scope: payload.scope, | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | protected async request<T>(url: string, init: RequestInit): Promise<T> { | |
| 245 | const response = await fetch(url, init) | |
| 246 | const text = await response.text() | |
| 247 | ||
| 248 | if (!response.ok) { | |
| 249 | throw new TwitterApiError( | |
| 250 | `Twitter API failed (${response.status}): ${text || response.statusText}`, | |
| 251 | response.status, | |
| 252 | text, | |
| 253 | ) | |
| 254 | } | |
| 255 | ||
| 256 | return text ? JSON.parse(text) as T : {} as T | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | // Consumers import this as `TwitterDriver`, matching the sibling drivers. | |
| 261 | export { TwitterPublishingDriver as TwitterDriver } | |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -118,11 +119,25 @@ export class InstagramService { | ||
| 118 | 119 | code, |
| 119 | 120 | }) |
| 120 | 121 | |
| 121 | const account = await this.driver.resolveAccount(token.accessToken) | |
| 122 | // Exchange the short-lived user token for a long-lived (~60-day) one before | |
| 123 | // resolving the Page — a Page token minted from a long-lived user token does | |
| 124 | // not expire on its own, so there's no per-publish refresh grant to run. | |
| 125 | // Best-effort: fall back to the short-lived token if the exchange fails. | |
| 126 | let userToken = token.accessToken | |
| 127 | let expiresIn: number | undefined | |
| 128 | try { | |
| 129 | const longLived = await this.driver.exchangeLongLivedUserToken(cfg.clientId, cfg.clientSecret, token.accessToken) | |
| 130 | userToken = longLived.accessToken | |
| 131 | expiresIn = longLived.expiresIn | |
| 132 | } | |
| 133 | catch {} | |
| 134 | ||
| 135 | const account = await this.driver.resolveAccount(userToken) | |
| 122 | 136 | const identity = await this.saveSession({ |
| 123 | 137 | accessToken: account.pageAccessToken, |
| 124 | 138 | igUserId: account.igUserId, |
| 125 | 139 | username: account.username, |
| 140 | expiresIn, | |
| 126 | 141 | }) |
| 127 | 142 | return this.publicIdentity(identity) |
| 128 | 143 | } |
| @@ -260,7 +275,7 @@ export class InstagramService { | ||
| 260 | 275 | .executeTakeFirst() |
| 261 | 276 | } |
| 262 | 277 | |
| 263 | private async saveSession(input: { accessToken: string, igUserId: string, username?: string }): Promise<SocialIdentityRow> { | |
| 278 | private async saveSession(input: { accessToken: string, igUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> { | |
| 264 | 279 | const accountId = await ensureAccount() |
| 265 | 280 | const driver = await this.ensureDriver() |
| 266 | 281 | const existing = await this.findIdentity() |
| @@ -275,6 +290,7 @@ export class InstagramService { | ||
| 275 | 290 | auth_status: 'connected', |
| 276 | 291 | access_token: input.accessToken, |
| 277 | 292 | refresh_token: null, |
| 293 | token_expires_at: expiresAt(input.expiresIn), | |
| 278 | 294 | account_id: accountId, |
| 279 | 295 | social_driver_id: driver.id, |
| 280 | 296 | updated_at: savedAt, |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -125,6 +126,8 @@ export class LinkedInService { | ||
| 125 | 126 | |
| 126 | 127 | const identity = await this.saveSession({ |
| 127 | 128 | accessToken: token.accessToken, |
| 129 | refreshToken: token.refreshToken, | |
| 130 | expiresIn: token.expiresIn, | |
| 128 | 131 | authorUrn, |
| 129 | 132 | name: profile?.name, |
| 130 | 133 | }) |
| @@ -204,6 +207,7 @@ export class LinkedInService { | ||
| 204 | 207 | }, { |
| 205 | 208 | text: post.body, |
| 206 | 209 | ...(content?.external ? { external: content.external } : {}), |
| 210 | ...(content?.media?.length ? { media: content.media } : {}), | |
| 207 | 211 | }) |
| 208 | 212 | |
| 209 | 213 | await database.updateTable('post_targets').set({ |
| @@ -238,7 +242,9 @@ export class LinkedInService { | ||
| 238 | 242 | |
| 239 | 243 | private async requireIdentity(): Promise<SocialIdentityRow> { |
| 240 | 244 | const existing = await this.findIdentity() |
| 241 | if (existing?.access_token && existing.auth_status === 'connected') return existing | |
| 245 | if (existing?.access_token && existing.auth_status === 'connected') { | |
| 246 | return await this.refreshIfExpiring(existing) | |
| 247 | } | |
| 242 | 248 | |
| 243 | 249 | // Fall back to an env-configured token if one is available. |
| 244 | 250 | if (this.config().accessToken) { |
| @@ -250,6 +256,41 @@ export class LinkedInService { | ||
| 250 | 256 | throw new Error('Connect LinkedIn before publishing.') |
| 251 | 257 | } |
| 252 | 258 | |
| 259 | /** | |
| 260 | * Refresh the access token when it's within its pre-expiry window and a | |
| 261 | * refresh token is on file (LinkedIn only issues one when the app is enrolled | |
| 262 | * in the refresh-token program). Best-effort: any failure leaves the current | |
| 263 | * token in place so a real auth failure still surfaces at publish time. | |
| 264 | */ | |
| 265 | private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> { | |
| 266 | const cfg = this.config() | |
| 267 | if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity | |
| 268 | if (!cfg.clientId || !cfg.clientSecret) return identity | |
| 269 | ||
| 270 | try { | |
| 271 | const refreshed = await this.driver.refreshAccessToken({ | |
| 272 | clientId: cfg.clientId, | |
| 273 | clientSecret: cfg.clientSecret, | |
| 274 | refreshToken: identity.refresh_token, | |
| 275 | }) | |
| 276 | const nextExpiry = expiresAt(refreshed.expiresIn) | |
| 277 | // LinkedIn rotates the refresh token on some grants — keep the new one. | |
| 278 | const nextRefresh = refreshed.refreshToken ?? identity.refresh_token | |
| 279 | await database.updateTable('social_identities').set({ | |
| 280 | access_token: refreshed.accessToken, | |
| 281 | refresh_token: nextRefresh, | |
| 282 | token_expires_at: nextExpiry, | |
| 283 | auth_status: 'connected', | |
| 284 | updated_at: now(), | |
| 285 | }).where('id', '=', identity.id).execute() | |
| 286 | ||
| 287 | return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry } | |
| 288 | } | |
| 289 | catch { | |
| 290 | return identity | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 253 | 294 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { |
| 254 | 295 | return await database |
| 255 | 296 | .selectFrom('social_identities') |
| @@ -259,7 +300,7 @@ export class LinkedInService { | ||
| 259 | 300 | .executeTakeFirst() |
| 260 | 301 | } |
| 261 | 302 | |
| 262 | private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> { | |
| 303 | private async saveSession(input: { accessToken: string, authorUrn: string, name?: string, refreshToken?: string, expiresIn?: number }): Promise<SocialIdentityRow> { | |
| 263 | 304 | const accountId = await ensureAccount() |
| 264 | 305 | const driver = await this.ensureDriver() |
| 265 | 306 | const existing = await this.findIdentity() |
| @@ -273,7 +314,8 @@ export class LinkedInService { | ||
| 273 | 314 | external_id: input.authorUrn, |
| 274 | 315 | auth_status: 'connected', |
| 275 | 316 | access_token: input.accessToken, |
| 276 | refresh_token: null, | |
| 317 | refresh_token: input.refreshToken ?? null, | |
| 318 | token_expires_at: expiresAt(input.expiresIn), | |
| 277 | 319 | account_id: accountId, |
| 278 | 320 | social_driver_id: driver.id, |
| 279 | 321 | updated_at: savedAt, |
| @@ -1,17 +1,14 @@ | ||
| 1 | 1 | import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types' |
| 2 | 2 | import { mkdir, unlink } from 'node:fs/promises' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import process from 'node:process' | |
| 5 | 4 | import { db } from '@stacksjs/database' |
| 6 | 5 | import { env } from '@stacksjs/env' |
| 6 | import { MEDIA_DIR, publicMediaUrl } from '../../Support/Social/uploads' | |
| 7 | 7 | import { crosspost, crosspostProviders } from './CrosspostService' |
| 8 | 8 | import { ensureAccount, now, uuid } from './support' |
| 9 | 9 | |
| 10 | 10 | const database = db as any |
| 11 | 11 | |
| 12 | /** Uploaded images for queued posts live here until publish. */ | |
| 13 | const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media') | |
| 14 | ||
| 15 | 12 | /** Serialized into posts.content — everything beyond the text itself. */ |
| 16 | 13 | interface StoredContent { |
| 17 | 14 | /** Only set when the composer provided an explicit title. */ |
| @@ -379,6 +376,11 @@ export class QueueService { | ||
| 379 | 376 | if (await file.exists()) { |
| 380 | 377 | media.push({ |
| 381 | 378 | bytes: new Uint8Array(await file.arrayBuffer()), |
| 379 | // Also expose a public URL so URL-only providers (Instagram, | |
| 380 | // Threads) can publish the same uploaded file; byte-upload | |
| 381 | // providers (Bluesky, LinkedIn, Mastodon) keep using `bytes`. | |
| 382 | // Null when no public base is configured — then it's bytes-only. | |
| 383 | url: publicMediaUrl(item.path) ?? undefined, | |
| 382 | 384 | mimeType: item.mimeType, |
| 383 | 385 | altText: item.altText, |
| 384 | 386 | }) |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { ThreadsApiError, ThreadsDriver } from './Drivers/ThreadsDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -118,11 +119,25 @@ export class ThreadsService { | ||
| 118 | 119 | code, |
| 119 | 120 | }) |
| 120 | 121 | |
| 121 | const account = await this.driver.resolveAccount(token.accessToken) | |
| 122 | // Upgrade the short-lived (~1h) token to a long-lived (~60-day) one so | |
| 123 | // scheduled posts keep working, then resolve the account against it. If the | |
| 124 | // exchange fails, fall back to the short-lived token — publishing still | |
| 125 | // works, just briefly. | |
| 126 | let accessToken = token.accessToken | |
| 127 | let expiresIn = token.expiresIn | |
| 128 | try { | |
| 129 | const longLived = await this.driver.exchangeLongLivedToken(cfg.clientSecret, token.accessToken) | |
| 130 | accessToken = longLived.accessToken | |
| 131 | expiresIn = longLived.expiresIn | |
| 132 | } | |
| 133 | catch {} | |
| 134 | ||
| 135 | const account = await this.driver.resolveAccount(accessToken) | |
| 122 | 136 | const identity = await this.saveSession({ |
| 123 | 137 | accessToken: account.accessToken, |
| 124 | 138 | threadsUserId: account.threadsUserId, |
| 125 | 139 | username: account.username, |
| 140 | expiresIn, | |
| 126 | 141 | }) |
| 127 | 142 | return this.publicIdentity(identity) |
| 128 | 143 | } |
| @@ -235,7 +250,9 @@ export class ThreadsService { | ||
| 235 | 250 | |
| 236 | 251 | private async requireIdentity(): Promise<SocialIdentityRow> { |
| 237 | 252 | const existing = await this.findIdentity() |
| 238 | if (existing?.access_token && existing.auth_status === 'connected') return existing | |
| 253 | if (existing?.access_token && existing.auth_status === 'connected') { | |
| 254 | return await this.refreshIfExpiring(existing) | |
| 255 | } | |
| 239 | 256 | |
| 240 | 257 | if (this.config().accessToken) { |
| 241 | 258 | await this.connectFromEnv() |
| @@ -246,6 +263,32 @@ export class ThreadsService { | ||
| 246 | 263 | throw new Error('Connect Threads before publishing.') |
| 247 | 264 | } |
| 248 | 265 | |
| 266 | /** | |
| 267 | * Refresh a long-lived token that's within its pre-expiry window, extending | |
| 268 | * it another ~60 days. Best-effort: a refresh failure leaves the current | |
| 269 | * token in place, so a genuinely dead token still surfaces at publish time | |
| 270 | * (marking the identity expired and prompting a reconnect) rather than here. | |
| 271 | */ | |
| 272 | private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> { | |
| 273 | if (!identity.access_token || !isExpiringSoon(identity.token_expires_at)) return identity | |
| 274 | ||
| 275 | try { | |
| 276 | const refreshed = await this.driver.refreshLongLivedToken(identity.access_token) | |
| 277 | const nextExpiry = expiresAt(refreshed.expiresIn) | |
| 278 | await database.updateTable('social_identities').set({ | |
| 279 | access_token: refreshed.accessToken, | |
| 280 | token_expires_at: nextExpiry, | |
| 281 | auth_status: 'connected', | |
| 282 | updated_at: now(), | |
| 283 | }).where('id', '=', identity.id).execute() | |
| 284 | ||
| 285 | return { ...identity, access_token: refreshed.accessToken, token_expires_at: nextExpiry } | |
| 286 | } | |
| 287 | catch { | |
| 288 | return identity | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 249 | 292 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { |
| 250 | 293 | return await database |
| 251 | 294 | .selectFrom('social_identities') |
| @@ -255,7 +298,7 @@ export class ThreadsService { | ||
| 255 | 298 | .executeTakeFirst() |
| 256 | 299 | } |
| 257 | 300 | |
| 258 | private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string }): Promise<SocialIdentityRow> { | |
| 301 | private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> { | |
| 259 | 302 | const accountId = await ensureAccount() |
| 260 | 303 | const driver = await this.ensureDriver() |
| 261 | 304 | const existing = await this.findIdentity() |
| @@ -270,6 +313,7 @@ export class ThreadsService { | ||
| 270 | 313 | auth_status: 'connected', |
| 271 | 314 | access_token: input.accessToken, |
| 272 | 315 | refresh_token: null, |
| 316 | token_expires_at: expiresAt(input.expiresIn), | |
| 273 | 317 | account_id: accountId, |
| 274 | 318 | social_driver_id: driver.id, |
| 275 | 319 | updated_at: savedAt, |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' |
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver' | |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 4 | import { TwitterApiError, TwitterDriver } from './Drivers/TwitterDriver' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -10,30 +10,29 @@ type SocialIdentityRow = { | ||
| 10 | 10 | id: number |
| 11 | 11 | handle: string |
| 12 | 12 | display_name?: string | null |
| 13 | provider: 'linkedin' | |
| 13 | provider: 'twitter' | |
| 14 | 14 | external_id?: string | null |
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| 21 | 22 | |
| 22 | 23 | type SocialDriverRow = { |
| 23 | 24 | id: number |
| 24 | provider: 'linkedin' | |
| 25 | provider: 'twitter' | |
| 25 | 26 | display_name: string |
| 26 | 27 | status: 'active' | 'planned' | 'disabled' |
| 27 | 28 | character_limit: number |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | interface LinkedInConfig { | |
| 31 | interface TwitterConfig { | |
| 31 | 32 | clientId: string |
| 32 | 33 | clientSecret: string |
| 33 | 34 | redirectUrl: string |
| 34 | apiVersion: string | |
| 35 | 35 | accessToken: string |
| 36 | authorUrn: string | |
| 37 | 36 | scopes: string[] |
| 38 | 37 | } |
| 39 | 38 | |
| @@ -43,25 +42,20 @@ function randomState(): string { | ||
| 43 | 42 | return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') |
| 44 | 43 | } |
| 45 | 44 | |
| 46 | export class LinkedInService { | |
| 47 | private driver: LinkedInDriver | |
| 48 | // OAuth CSRF token stashed between the auth redirect and the callback. | |
| 49 | // Postline runs single-user, so module memory is an adequate store. | |
| 45 | export class TwitterService { | |
| 46 | private driver = new TwitterDriver() | |
| 47 | // OAuth CSRF token + PKCE verifier stashed between the auth redirect and the | |
| 48 | // callback. Postline runs single-user, so module memory is an adequate store. | |
| 50 | 49 | private pendingState: string | null = null |
| 50 | private pendingVerifier: string | null = null | |
| 51 | 51 | |
| 52 | constructor() { | |
| 53 | this.driver = new LinkedInDriver({ apiVersion: this.config().apiVersion }) | |
| 54 | } | |
| 55 | ||
| 56 | private config(): LinkedInConfig { | |
| 52 | private config(): TwitterConfig { | |
| 57 | 53 | return { |
| 58 | clientId: String(env.LINKEDIN_CLIENT_ID || '').trim(), | |
| 59 | clientSecret: String(env.LINKEDIN_CLIENT_SECRET || '').trim(), | |
| 60 | redirectUrl: String(env.LINKEDIN_REDIRECT_URL || '').trim(), | |
| 61 | apiVersion: String(env.LINKEDIN_API_VERSION || '202405').trim(), | |
| 62 | accessToken: String(env.LINKEDIN_ACCESS_TOKEN || '').trim(), | |
| 63 | authorUrn: String(env.LINKEDIN_AUTHOR_URN || '').trim(), | |
| 64 | scopes: ['openid', 'profile', 'w_member_social'], | |
| 54 | clientId: String(env.TWITTER_CLIENT_ID || '').trim(), | |
| 55 | clientSecret: String(env.TWITTER_CLIENT_SECRET || '').trim(), | |
| 56 | redirectUrl: String(env.TWITTER_REDIRECT_URL || '').trim(), | |
| 57 | accessToken: String(env.TWITTER_ACCESS_TOKEN || '').trim(), | |
| 58 | scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], | |
| 65 | 59 | } |
| 66 | 60 | } |
| 67 | 61 | |
| @@ -72,7 +66,7 @@ export class LinkedInService { | ||
| 72 | 66 | |
| 73 | 67 | return { |
| 74 | 68 | connected, |
| 75 | provider: 'linkedin', | |
| 69 | provider: 'twitter', | |
| 76 | 70 | handle: identity?.handle || null, |
| 77 | 71 | displayName: identity?.display_name || null, |
| 78 | 72 | did: identity?.external_id || null, |
| @@ -80,53 +74,55 @@ export class LinkedInService { | ||
| 80 | 74 | characterLimit: this.driver.characterLimit, |
| 81 | 75 | canPublish: connected, |
| 82 | 76 | configuredFromEnv: Boolean(cfg.accessToken), |
| 83 | oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl), | |
| 77 | oauthConfigured: Boolean(cfg.clientId && cfg.redirectUrl), | |
| 84 | 78 | } |
| 85 | 79 | } |
| 86 | 80 | |
| 87 | /** Build the LinkedIn consent URL and remember the CSRF state. */ | |
| 88 | getAuthUrl(): string { | |
| 81 | /** Build the X consent URL (with PKCE) and remember the CSRF state + verifier. */ | |
| 82 | async getAuthUrl(): Promise<string> { | |
| 89 | 83 | const cfg = this.config() |
| 90 | if (!cfg.clientId || !cfg.clientSecret) { | |
| 91 | throw new Error('Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET to connect with LinkedIn.') | |
| 92 | } | |
| 93 | if (!cfg.redirectUrl) { | |
| 94 | throw new Error('Set LINKEDIN_REDIRECT_URL to connect with LinkedIn.') | |
| 95 | } | |
| 84 | if (!cfg.clientId) throw new Error('Set TWITTER_CLIENT_ID to connect X/Twitter.') | |
| 85 | if (!cfg.redirectUrl) throw new Error('Set TWITTER_REDIRECT_URL to connect X/Twitter.') | |
| 96 | 86 | |
| 97 | 87 | this.pendingState = randomState() |
| 98 | return this.driver.getAuthUrl({ | |
| 88 | const { url, codeVerifier } = await this.driver.createAuthorization({ | |
| 99 | 89 | clientId: cfg.clientId, |
| 100 | 90 | redirectUrl: cfg.redirectUrl, |
| 101 | 91 | scopes: cfg.scopes, |
| 102 | 92 | state: this.pendingState, |
| 103 | 93 | }) |
| 94 | this.pendingVerifier = codeVerifier | |
| 95 | return url | |
| 104 | 96 | } |
| 105 | 97 | |
| 106 | /** Handle the OAuth redirect: exchange the code and store the token. */ | |
| 98 | /** Handle the OAuth redirect: exchange the code and store the tokens. */ | |
| 107 | 99 | async handleCallback(code: string, state: string) { |
| 108 | 100 | const cfg = this.config() |
| 109 | if (!code) throw new Error('LinkedIn did not return an authorization code.') | |
| 101 | if (!code) throw new Error('X/Twitter did not return an authorization code.') | |
| 110 | 102 | if (this.pendingState && state !== this.pendingState) { |
| 111 | throw new Error('LinkedIn OAuth state mismatch. Please start the connection again.') | |
| 103 | throw new Error('X/Twitter OAuth state mismatch. Please start the connection again.') | |
| 112 | 104 | } |
| 105 | const codeVerifier = this.pendingVerifier | |
| 106 | if (!codeVerifier) throw new Error('Missing PKCE verifier — please start the X/Twitter connection again.') | |
| 113 | 107 | this.pendingState = null |
| 108 | this.pendingVerifier = null | |
| 114 | 109 | |
| 115 | 110 | const token = await this.driver.exchangeCode({ |
| 116 | 111 | clientId: cfg.clientId, |
| 117 | clientSecret: cfg.clientSecret, | |
| 112 | clientSecret: cfg.clientSecret || undefined, | |
| 118 | 113 | redirectUrl: cfg.redirectUrl, |
| 119 | 114 | code, |
| 115 | codeVerifier, | |
| 120 | 116 | }) |
| 121 | 117 | |
| 122 | const profile = await this.driver.getProfile(token.accessToken).catch(() => undefined) | |
| 123 | const authorUrn = profile?.sub ? `urn:li:person:${profile.sub}` : cfg.authorUrn | |
| 124 | if (!authorUrn) throw new Error('Could not resolve your LinkedIn member URN.') | |
| 125 | ||
| 118 | const profile = await this.driver.getProfile(token.accessToken) | |
| 126 | 119 | const identity = await this.saveSession({ |
| 127 | 120 | accessToken: token.accessToken, |
| 128 | authorUrn, | |
| 129 | name: profile?.name, | |
| 121 | refreshToken: token.refreshToken, | |
| 122 | expiresIn: token.expiresIn, | |
| 123 | userId: profile.id, | |
| 124 | username: profile.username, | |
| 125 | name: profile.name, | |
| 130 | 126 | }) |
| 131 | 127 | return this.publicIdentity(identity) |
| 132 | 128 | } |
| @@ -135,25 +131,23 @@ export class LinkedInService { | ||
| 135 | 131 | async connectFromEnv() { |
| 136 | 132 | const cfg = this.config() |
| 137 | 133 | if (!cfg.accessToken) { |
| 138 | throw new Error('Set LINKEDIN_ACCESS_TOKEN (or connect via OAuth) before using LinkedIn.') | |
| 134 | throw new Error('Set TWITTER_ACCESS_TOKEN (or connect via OAuth) before using X/Twitter.') | |
| 139 | 135 | } |
| 140 | 136 | |
| 141 | let authorUrn = cfg.authorUrn | |
| 142 | let name: string | undefined | |
| 143 | if (!authorUrn) { | |
| 144 | const profile = await this.driver.getProfile(cfg.accessToken) | |
| 145 | authorUrn = `urn:li:person:${profile.sub}` | |
| 146 | name = profile.name | |
| 147 | } | |
| 148 | ||
| 149 | const identity = await this.saveSession({ accessToken: cfg.accessToken, authorUrn, name }) | |
| 137 | const profile = await this.driver.getProfile(cfg.accessToken) | |
| 138 | const identity = await this.saveSession({ | |
| 139 | accessToken: cfg.accessToken, | |
| 140 | userId: profile.id, | |
| 141 | username: profile.username, | |
| 142 | name: profile.name, | |
| 143 | }) | |
| 150 | 144 | return this.publicIdentity(identity) |
| 151 | 145 | } |
| 152 | 146 | |
| 153 | 147 | /** |
| 154 | * Publish an already-created post row to LinkedIn as a new target. Never | |
| 155 | * throws — failures are recorded on the target and returned so a crosspost | |
| 156 | * to other providers can still succeed. | |
| 148 | * Publish an already-created post row to X/Twitter. Never throws — failures | |
| 149 | * are recorded on the target and returned so a crosspost to other providers | |
| 150 | * can still succeed. | |
| 157 | 151 | */ |
| 158 | 152 | async publishToPost( |
| 159 | 153 | post: { id: number, body: string }, |
| @@ -166,14 +160,14 @@ export class LinkedInService { | ||
| 166 | 160 | identity = await this.requireIdentity() |
| 167 | 161 | } |
| 168 | 162 | catch (error) { |
| 169 | return { provider: 'linkedin', ok: false, error: messageOf(error) } | |
| 163 | return { provider: 'twitter', ok: false, error: messageOf(error) } | |
| 170 | 164 | } |
| 171 | 165 | |
| 172 | 166 | if (post.body.length > this.driver.characterLimit) { |
| 173 | 167 | return { |
| 174 | provider: 'linkedin', | |
| 168 | provider: 'twitter', | |
| 175 | 169 | ok: false, |
| 176 | error: `LinkedIn posts must be ${this.driver.characterLimit} characters or fewer.`, | |
| 170 | error: `Twitter posts must be ${this.driver.characterLimit} characters or fewer.`, | |
| 177 | 171 | } |
| 178 | 172 | } |
| 179 | 173 | |
| @@ -181,7 +175,7 @@ export class LinkedInService { | ||
| 181 | 175 | const createdAt = now() |
| 182 | 176 | await database.insertInto('post_targets').values({ |
| 183 | 177 | uuid: targetUuid, |
| 184 | provider: 'linkedin', | |
| 178 | provider: 'twitter', | |
| 185 | 179 | status: 'publishing', |
| 186 | 180 | post_id: post.id, |
| 187 | 181 | social_driver_id: driver.id, |
| @@ -203,27 +197,30 @@ export class LinkedInService { | ||
| 203 | 197 | accessToken: identity.access_token || undefined, |
| 204 | 198 | }, { |
| 205 | 199 | text: post.body, |
| 206 | ...(content?.external ? { external: content.external } : {}), | |
| 200 | ...(content?.media?.length ? { media: content.media } : {}), | |
| 201 | ...(content?.reply ? { reply: content.reply } : {}), | |
| 207 | 202 | }) |
| 208 | 203 | |
| 209 | 204 | await database.updateTable('post_targets').set({ |
| 210 | 205 | status: 'published', |
| 211 | 206 | remote_uri: published.uri || null, |
| 207 | remote_cid: published.cid || null, | |
| 212 | 208 | failure_reason: null, |
| 213 | 209 | updated_at: now(), |
| 214 | 210 | }).where('id', '=', target.id).execute() |
| 215 | 211 | |
| 216 | 212 | return { |
| 217 | provider: 'linkedin', | |
| 213 | provider: 'twitter', | |
| 218 | 214 | ok: true, |
| 219 | 215 | url: published.url, |
| 220 | 216 | uri: published.uri, |
| 217 | cid: published.cid, | |
| 221 | 218 | targetId: Number(target.id), |
| 222 | 219 | } |
| 223 | 220 | } |
| 224 | 221 | catch (error) { |
| 225 | 222 | const message = messageOf(error) |
| 226 | if (error instanceof LinkedInApiError && error.isAuthError) { | |
| 223 | if (error instanceof TwitterApiError && error.isAuthError) { | |
| 227 | 224 | await this.markExpired(identity.id) |
| 228 | 225 | } |
| 229 | 226 | await database.updateTable('post_targets').set({ |
| @@ -232,48 +229,84 @@ export class LinkedInService { | ||
| 232 | 229 | updated_at: now(), |
| 233 | 230 | }).where('id', '=', target.id).execute() |
| 234 | 231 | |
| 235 | return { provider: 'linkedin', ok: false, error: message, targetId: Number(target.id) } | |
| 232 | return { provider: 'twitter', ok: false, error: message, targetId: Number(target.id) } | |
| 236 | 233 | } |
| 237 | 234 | } |
| 238 | 235 | |
| 239 | 236 | private async requireIdentity(): Promise<SocialIdentityRow> { |
| 240 | 237 | const existing = await this.findIdentity() |
| 241 | if (existing?.access_token && existing.auth_status === 'connected') return existing | |
| 238 | if (existing?.access_token && existing.auth_status === 'connected') { | |
| 239 | return await this.refreshIfExpiring(existing) | |
| 240 | } | |
| 242 | 241 | |
| 243 | // Fall back to an env-configured token if one is available. | |
| 244 | 242 | if (this.config().accessToken) { |
| 245 | 243 | await this.connectFromEnv() |
| 246 | 244 | const connected = await this.findIdentity() |
| 247 | 245 | if (connected?.access_token) return connected |
| 248 | 246 | } |
| 249 | 247 | |
| 250 | throw new Error('Connect LinkedIn before publishing.') | |
| 248 | throw new Error('Connect X/Twitter before publishing.') | |
| 249 | } | |
| 250 | ||
| 251 | /** | |
| 252 | * Refresh the access token when it's within its pre-expiry window and a | |
| 253 | * refresh token is on file (present when connected with the `offline.access` | |
| 254 | * scope). Best-effort: any failure leaves the current token in place so a real | |
| 255 | * auth failure still surfaces at publish time. | |
| 256 | */ | |
| 257 | private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> { | |
| 258 | const cfg = this.config() | |
| 259 | if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity | |
| 260 | if (!cfg.clientId) return identity | |
| 261 | ||
| 262 | try { | |
| 263 | const refreshed = await this.driver.refreshAccessToken({ | |
| 264 | clientId: cfg.clientId, | |
| 265 | clientSecret: cfg.clientSecret || undefined, | |
| 266 | refreshToken: identity.refresh_token, | |
| 267 | }) | |
| 268 | const nextExpiry = expiresAt(refreshed.expiresIn) | |
| 269 | // X rotates the refresh token on every refresh — always keep the new one. | |
| 270 | const nextRefresh = refreshed.refreshToken ?? identity.refresh_token | |
| 271 | await database.updateTable('social_identities').set({ | |
| 272 | access_token: refreshed.accessToken, | |
| 273 | refresh_token: nextRefresh, | |
| 274 | token_expires_at: nextExpiry, | |
| 275 | auth_status: 'connected', | |
| 276 | updated_at: now(), | |
| 277 | }).where('id', '=', identity.id).execute() | |
| 278 | ||
| 279 | return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry } | |
| 280 | } | |
| 281 | catch { | |
| 282 | return identity | |
| 283 | } | |
| 251 | 284 | } |
| 252 | 285 | |
| 253 | 286 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { |
| 254 | 287 | return await database |
| 255 | 288 | .selectFrom('social_identities') |
| 256 | 289 | .selectAll() |
| 257 | .where('provider', '=', 'linkedin') | |
| 290 | .where('provider', '=', 'twitter') | |
| 258 | 291 | .orderBy('updated_at', 'desc') |
| 259 | 292 | .executeTakeFirst() |
| 260 | 293 | } |
| 261 | 294 | |
| 262 | private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> { | |
| 295 | private async saveSession(input: { accessToken: string, refreshToken?: string, expiresIn?: number, userId: string, username: string, name?: string }): Promise<SocialIdentityRow> { | |
| 263 | 296 | const accountId = await ensureAccount() |
| 264 | 297 | const driver = await this.ensureDriver() |
| 265 | 298 | const existing = await this.findIdentity() |
| 266 | 299 | const savedAt = now() |
| 267 | const handle = (input.name || input.authorUrn.replace('urn:li:person:', '')).trim() | |
| 268 | 300 | |
| 269 | 301 | const values = { |
| 270 | handle, | |
| 271 | display_name: input.name || null, | |
| 272 | provider: 'linkedin', | |
| 273 | external_id: input.authorUrn, | |
| 302 | handle: input.username, | |
| 303 | display_name: input.name || input.username, | |
| 304 | provider: 'twitter', | |
| 305 | external_id: input.userId, | |
| 274 | 306 | auth_status: 'connected', |
| 275 | 307 | access_token: input.accessToken, |
| 276 | refresh_token: null, | |
| 308 | refresh_token: input.refreshToken ?? null, | |
| 309 | token_expires_at: expiresAt(input.expiresIn), | |
| 277 | 310 | account_id: accountId, |
| 278 | 311 | social_driver_id: driver.id, |
| 279 | 312 | updated_at: savedAt, |
| @@ -304,7 +337,7 @@ export class LinkedInService { | ||
| 304 | 337 | const existing = await database |
| 305 | 338 | .selectFrom('social_drivers') |
| 306 | 339 | .selectAll() |
| 307 | .where('provider', '=', 'linkedin') | |
| 340 | .where('provider', '=', 'twitter') | |
| 308 | 341 | .executeTakeFirst() |
| 309 | 342 | |
| 310 | 343 | if (existing) { |
| @@ -326,8 +359,8 @@ export class LinkedInService { | ||
| 326 | 359 | const driverUuid = uuid() |
| 327 | 360 | await database.insertInto('social_drivers').values({ |
| 328 | 361 | uuid: driverUuid, |
| 329 | provider: 'linkedin', | |
| 330 | display_name: 'LinkedIn', | |
| 362 | provider: 'twitter', | |
| 363 | display_name: 'X (Twitter)', | |
| 331 | 364 | status: 'active', |
| 332 | 365 | character_limit: this.driver.characterLimit, |
| 333 | 366 | capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }), |
| @@ -346,7 +379,7 @@ export class LinkedInService { | ||
| 346 | 379 | if (!row) { |
| 347 | 380 | return { |
| 348 | 381 | connected: false, |
| 349 | provider: 'linkedin', | |
| 382 | provider: 'twitter', | |
| 350 | 383 | handle: null, |
| 351 | 384 | displayName: null, |
| 352 | 385 | did: null, |
| @@ -357,7 +390,7 @@ export class LinkedInService { | ||
| 357 | 390 | const connected = row.auth_status === 'connected' && Boolean(row.access_token) |
| 358 | 391 | return { |
| 359 | 392 | connected, |
| 360 | provider: 'linkedin', | |
| 393 | provider: 'twitter', | |
| 361 | 394 | handle: row.handle, |
| 362 | 395 | displayName: row.display_name || null, |
| 363 | 396 | did: row.external_id || null, |
| @@ -370,4 +403,4 @@ function messageOf(error: unknown): string { | ||
| 370 | 403 | return error instanceof Error ? error.message : String(error) |
| 371 | 404 | } |
| 372 | 405 | |
| 373 | export const linkedin = new LinkedInService() | |
| 406 | export const twitter = new TwitterService() | |
| @@ -7,6 +7,29 @@ export function now(): string { | ||
| 7 | 7 | return new Date().toISOString().slice(0, 19).replace('T', ' ') |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | /** | |
| 11 | * A SQLite `YYYY-MM-DD HH:MM:SS` (UTC) timestamp `secondsFromNow` in the | |
| 12 | * future — used to record when an OAuth access token will expire. Returns null | |
| 13 | * when the provider didn't report a lifetime, so callers can store NULL. | |
| 14 | */ | |
| 15 | export function expiresAt(secondsFromNow: number | null | undefined): string | null { | |
| 16 | if (!secondsFromNow || secondsFromNow <= 0) return null | |
| 17 | return new Date(Date.now() + secondsFromNow * 1000).toISOString().slice(0, 19).replace('T', ' ') | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * Whether a stored `token_expires_at` (SQLite `YYYY-MM-DD HH:MM:SS`, UTC) is | |
| 22 | * already past or falls within `withinSeconds` of now — the trigger for a | |
| 23 | * proactive token refresh before publishing. Unknown/blank/unparseable expiry | |
| 24 | * returns false: there's nothing to refresh against, so leave the token alone. | |
| 25 | */ | |
| 26 | export function isExpiringSoon(value: string | null | undefined, withinSeconds = 7 * 24 * 60 * 60): boolean { | |
| 27 | if (!value) return false | |
| 28 | const expiry = Date.parse(`${String(value).replace(' ', 'T')}Z`) | |
| 29 | if (Number.isNaN(expiry)) return false | |
| 30 | return expiry - Date.now() <= withinSeconds * 1000 | |
| 31 | } | |
| 32 | ||
| 10 | 33 | export function uuid(): string { |
| 11 | 34 | return crypto.randomUUID() |
| 12 | 35 | } |
| @@ -1,3 +1,11 @@ | ||
| 1 | import { mkdir, unlink } from 'node:fs/promises' | |
| 2 | import { join } from 'node:path' | |
| 3 | import process from 'node:process' | |
| 4 | import { env } from '@stacksjs/env' | |
| 5 | ||
| 6 | /** Uploaded images for queued posts live here until publish. */ | |
| 7 | export const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media') | |
| 8 | ||
| 1 | 9 | /** |
| 2 | 10 | * Read an uploaded image regardless of shape: the router's multipart |
| 3 | 11 | * parser stores raw Web `File` objects on `request.files`, while the |
| @@ -19,3 +27,88 @@ export async function readUploadedImage(uploaded: unknown): Promise<{ bytes: Uin | ||
| 19 | 27 | |
| 20 | 28 | return null |
| 21 | 29 | } |
| 30 | ||
| 31 | /** | |
| 32 | * A stored media filename is safe to serve only when it's a bare basename with | |
| 33 | * an extension and no path separators or `..` — i.e. exactly the shape | |
| 34 | * QueueService writes (`<uuid>.<ext>` / `<uuid>-<hex>.<ext>`). This is the | |
| 35 | * traversal guard for the public media route; anything else is rejected. | |
| 36 | */ | |
| 37 | export function isSafeMediaFilename(name: string): boolean { | |
| 38 | return typeof name === 'string' && /^[\w-]+\.[a-z0-9]+$/i.test(name) | |
| 39 | } | |
| 40 | ||
| 41 | /** Map a media filename extension to a content type for inline serving. */ | |
| 42 | export function mediaContentType(name: string): string { | |
| 43 | const ext = name.split('.').pop()?.toLowerCase() || '' | |
| 44 | switch (ext) { | |
| 45 | case 'png': return 'image/png' | |
| 46 | case 'gif': return 'image/gif' | |
| 47 | case 'webp': return 'image/webp' | |
| 48 | case 'jpg': | |
| 49 | case 'jpeg': return 'image/jpeg' | |
| 50 | default: return 'application/octet-stream' | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Pure URL builder: given a public base and a stored filename, produce the | |
| 56 | * media URL a remote platform fetches — or null when the base is empty or the | |
| 57 | * filename is unsafe. Split out from `publicMediaUrl` so the URL shape is | |
| 58 | * unit-testable without touching env. | |
| 59 | */ | |
| 60 | export function buildMediaUrl(base: string, filename: string): string | null { | |
| 61 | if (!isSafeMediaFilename(filename)) return null | |
| 62 | const raw = String(base || '').trim() | |
| 63 | if (!raw) return null | |
| 64 | ||
| 65 | const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}` | |
| 66 | const normalized = withScheme.replace(/\/+$/, '') | |
| 67 | return `${normalized}/postline/media?file=${encodeURIComponent(filename)}` | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * The public URL Instagram/Threads fetch (via the Meta Graph API) for a stored | |
| 72 | * media file. Meta pulls the image server-side, so it must be reachable over | |
| 73 | * the public internet — returns null when no public base is configured | |
| 74 | * (`STORAGE_PUBLIC_URL` or `APP_URL`), in which case callers fall back to | |
| 75 | * byte-upload providers only. | |
| 76 | */ | |
| 77 | export function publicMediaUrl(filename: string): string | null { | |
| 78 | return buildMediaUrl(String(env.STORAGE_PUBLIC_URL || env.APP_URL || ''), filename) | |
| 79 | } | |
| 80 | ||
| 81 | /** Whether a public base is configured — i.e. whether URL-only providers can be | |
| 82 | * handed a fetchable image URL at all. */ | |
| 83 | export function hasPublicMediaBase(): boolean { | |
| 84 | return Boolean(String(env.STORAGE_PUBLIC_URL || env.APP_URL || '').trim()) | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Persist uploaded bytes under a fresh name so a URL-only provider (Instagram/ | |
| 89 | * Threads) can fetch them during an immediate publish, returning the file's | |
| 90 | * public URL. Returns null when no public base is configured (nothing is | |
| 91 | * written — there'd be no fetchable URL). The caller MUST `removeTempMedia` the | |
| 92 | * returned filename once publishing finishes. | |
| 93 | */ | |
| 94 | export async function persistTempMedia(bytes: Uint8Array, mimeType?: string): Promise<{ filename: string, url: string } | null> { | |
| 95 | if (!hasPublicMediaBase()) return null | |
| 96 | ||
| 97 | const ext = (mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg' | |
| 98 | const filename = `tmp-${crypto.randomUUID()}.${ext}` | |
| 99 | await mkdir(MEDIA_DIR, { recursive: true }) | |
| 100 | await Bun.write(join(MEDIA_DIR, filename), bytes) | |
| 101 | ||
| 102 | const url = publicMediaUrl(filename) | |
| 103 | if (!url) { | |
| 104 | await removeTempMedia(filename) | |
| 105 | return null | |
| 106 | } | |
| 107 | return { filename, url } | |
| 108 | } | |
| 109 | ||
| 110 | /** Delete a file previously written by `persistTempMedia`. Never throws. */ | |
| 111 | export async function removeTempMedia(filename: string): Promise<void> { | |
| 112 | if (!isSafeMediaFilename(filename)) return | |
| 113 | await unlink(join(MEDIA_DIR, filename)).catch(() => {}) | |
| 114 | } | |
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | <div> |
| 13 | 13 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p> |
| 14 | 14 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing drivers</h1> |
| 15 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p> | |
| 15 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, X, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p> | |
| 16 | 16 | </div> |
| 17 | 17 | </header> |
| 18 | 18 | |
| @@ -97,13 +97,17 @@ | ||
| 97 | 97 | <p class="sm:col-span-3 m-0 text-xs text-zinc-500">Create a token in your instance's Preferences → Development → New application (needs the <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:statuses</code> + <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:media</code> scopes).</p> |
| 98 | 98 | </form> |
| 99 | 99 | </article> |
| 100 | <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80"> | |
| 100 | <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="twitter"> | |
| 101 | 101 | <div> |
| 102 | <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2> | |
| 103 | <p class="my-1.5 text-[13px] text-zinc-600">Planned</p> | |
| 104 | <span class="text-xs text-zinc-500">Driver adapter coming after the current set is stable.</span> | |
| 102 | <h2 class="my-0 font-bold text-[15px]">X (Twitter)</h2> | |
| 103 | <p class="my-1.5 text-[13px] text-slate-700" data-twitter-channel-handle>Not connected</p> | |
| 104 | <span class="text-xs text-zinc-500" data-twitter-channel-detail>Connect X to crosspost tweets. Posting needs a paid X API tier.</span> | |
| 105 | </div> | |
| 106 | <div class="flex flex-col gap-2 items-end"> | |
| 107 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-twitter-channel-status>Setup</span> | |
| 108 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="button" data-twitter-connect>Connect with X</button> | |
| 109 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-twitter-connect-env>Use token from .env</button> | |
| 105 | 110 | </div> |
| 106 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span> | |
| 107 | 111 | </article> |
| 108 | 112 | </div> |
| 109 | 113 | </section> |
| @@ -289,6 +293,105 @@ | ||
| 289 | 293 | startLinkedIn() |
| 290 | 294 | } |
| 291 | 295 | |
| 296 | // --- X (Twitter) --- | |
| 297 | const twitterHandle = root.querySelector('[data-twitter-channel-handle]') | |
| 298 | const twitterDetail = root.querySelector('[data-twitter-channel-detail]') | |
| 299 | const twitterStatusPill = root.querySelector('[data-twitter-channel-status]') | |
| 300 | const twitterConnect = root.querySelector('[data-twitter-connect]') | |
| 301 | const twitterConnectEnv = root.querySelector('[data-twitter-connect-env]') | |
| 302 | ||
| 303 | const renderTwitter = (data) => { | |
| 304 | const connected = Boolean(data?.canPublish) | |
| 305 | const label = data?.handle ? `@${data.handle}` : 'Not connected' | |
| 306 | if (twitterHandle) { | |
| 307 | twitterHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected') | |
| 308 | } | |
| 309 | if (twitterDetail) { | |
| 310 | twitterDetail.textContent = connected | |
| 311 | ? 'Ready to crosspost tweets from Postline.' | |
| 312 | : data?.oauthConfigured | |
| 313 | ? 'Connect X to crosspost tweets. Posting needs a paid X API tier.' | |
| 314 | : data?.configuredFromEnv | |
| 315 | ? 'Token found in .env — connect to start crossposting.' | |
| 316 | : 'Add X (Twitter) API keys to .env, then connect.' | |
| 317 | } | |
| 318 | if (twitterStatusPill) { | |
| 319 | twitterStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup') | |
| 320 | twitterStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${ | |
| 321 | connected | |
| 322 | ? 'border-emerald-200 bg-emerald-50 text-emerald-700' | |
| 323 | : data?.handle | |
| 324 | ? 'border-amber-200 bg-amber-50 text-amber-800' | |
| 325 | : 'border-zinc-200 text-zinc-500' | |
| 326 | }` | |
| 327 | } | |
| 328 | if (twitterConnect) { | |
| 329 | twitterConnect.textContent = connected ? 'Reconnect X' : 'Connect with X' | |
| 330 | if (data && !data.oauthConfigured) twitterConnect.setAttribute('disabled', 'disabled') | |
| 331 | else twitterConnect.removeAttribute('disabled') | |
| 332 | } | |
| 333 | if (twitterConnectEnv) twitterConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv) | |
| 334 | } | |
| 335 | ||
| 336 | const refreshTwitter = async () => { | |
| 337 | try { | |
| 338 | const response = await fetch('/api/postline/twitter/status') | |
| 339 | const payload = await response.json() | |
| 340 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load X/Twitter status.') | |
| 341 | renderTwitter(payload.data) | |
| 342 | } | |
| 343 | catch (error) { | |
| 344 | renderTwitter(null) | |
| 345 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'X/Twitter' }) | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | twitterConnect?.addEventListener('click', () => { | |
| 350 | notify('Redirecting to X…', 'info', { title: 'X/Twitter' }) | |
| 351 | window.location.href = '/api/postline/twitter/auth' | |
| 352 | }) | |
| 353 | ||
| 354 | twitterConnectEnv?.addEventListener('click', async () => { | |
| 355 | twitterConnectEnv.setAttribute('disabled', 'disabled') | |
| 356 | notify('Connecting X from .env…', 'info', { title: 'X/Twitter' }) | |
| 357 | try { | |
| 358 | const response = await fetch('/api/postline/twitter/connect', { method: 'POST' }) | |
| 359 | const payload = await response.json() | |
| 360 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'X connect failed.') | |
| 361 | await refreshTwitter() | |
| 362 | notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' }) | |
| 363 | } | |
| 364 | catch (error) { | |
| 365 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' }) | |
| 366 | } | |
| 367 | finally { | |
| 368 | twitterConnectEnv.removeAttribute('disabled') | |
| 369 | } | |
| 370 | }) | |
| 371 | ||
| 372 | const twitterParams = new URLSearchParams(window.location.search) | |
| 373 | const twitterResult = twitterParams.get('twitter') | |
| 374 | if (twitterResult === 'connected') { | |
| 375 | notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' }) | |
| 376 | } | |
| 377 | else if (twitterResult === 'error') { | |
| 378 | notify(twitterParams.get('message') || 'X connection failed.', 'error', { title: 'X/Twitter' }) | |
| 379 | } | |
| 380 | if (twitterResult) { | |
| 381 | twitterParams.delete('twitter') | |
| 382 | twitterParams.delete('message') | |
| 383 | const query = twitterParams.toString() | |
| 384 | window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`) | |
| 385 | } | |
| 386 | ||
| 387 | const startTwitter = () => void refreshTwitter() | |
| 388 | if (document.readyState === 'loading') { | |
| 389 | document.addEventListener('DOMContentLoaded', startTwitter, { once: true }) | |
| 390 | } | |
| 391 | else { | |
| 392 | startTwitter() | |
| 393 | } | |
| 394 | ||
| 292 | 395 | // --- Instagram --- |
| 293 | 396 | const instagramHandle = root.querySelector('[data-instagram-channel-handle]') |
| 294 | 397 | const instagramDetail = root.querySelector('[data-instagram-channel-detail]') |
| @@ -23,6 +23,11 @@ route.post('/register', 'Actions/Postline/RegisterFirstUserAction').skipCsrf().r | ||
| 23 | 23 | route.get('/', () => response.text('hello world')) |
| 24 | 24 | route.get('/coming-soon', 'Controllers/ComingSoonController@index') |
| 25 | 25 | |
| 26 | // Public: Instagram/Threads fetch a queued post's image server-side, so this | |
| 27 | // route is intentionally unauthenticated. The filename is an unguessable | |
| 28 | // per-upload UUID and strictly validated against traversal (MediaServeAction). | |
| 29 | route.get('/postline/media', 'Actions/Postline/MediaServeAction').skipCsrf() | |
| 30 | ||
| 26 | 31 | route.group({ prefix: '/postline/bluesky' }, () => { |
| 27 | 32 | route.get('/status', 'Actions/Postline/BlueskyStatusAction').middleware('auth').skipCsrf() |
| 28 | 33 | route.post('/connect', 'Actions/Postline/BlueskyConnectAction').middleware('auth').skipCsrf() |
| @@ -52,6 +57,13 @@ route.group({ prefix: '/postline/threads' }, () => { | ||
| 52 | 57 | route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf() |
| 53 | 58 | }) |
| 54 | 59 | |
| 60 | route.group({ prefix: '/postline/twitter' }, () => { | |
| 61 | route.get('/status', 'Actions/Postline/TwitterStatusAction').middleware('auth').skipCsrf() | |
| 62 | route.post('/connect', 'Actions/Postline/TwitterConnectAction').middleware('auth').skipCsrf() | |
| 63 | route.get('/auth', 'Actions/Postline/TwitterAuthAction').skipCsrf() | |
| 64 | route.get('/callback', 'Actions/Postline/TwitterCallbackAction').skipCsrf() | |
| 65 | }) | |
| 66 | ||
| 55 | 67 | route.group({ prefix: '/postline/mastodon' }, () => { |
| 56 | 68 | route.get('/status', 'Actions/Postline/MastodonStatusAction').middleware('auth').skipCsrf() |
| 57 | 69 | route.post('/connect', 'Actions/Postline/MastodonConnectAction').middleware('auth').skipCsrf() |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { InstagramApiError, InstagramDriver } from '../../app/Services/Social/Drivers/InstagramDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | interface Captured { url: string, method: string } | |
| 8 | ||
| 9 | function mockGraphApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void { | |
| 10 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 11 | const url = String(input) | |
| 12 | const over = overrides(url) | |
| 13 | if (over) { captured.push({ url, method: init.method || 'GET' }); return over } | |
| 14 | ||
| 15 | if (url.includes('fb_exchange_token')) { | |
| 16 | captured.push({ url, method: init.method || 'GET' }) | |
| 17 | return new Response(JSON.stringify({ access_token: 'LONG_LIVED_USER', token_type: 'bearer', expires_in: 5184000 }), { status: 200 }) | |
| 18 | } | |
| 19 | captured.push({ url, method: init.method || 'GET' }) | |
| 20 | return new Response('{}', { status: 404 }) | |
| 21 | }) as typeof fetch | |
| 22 | } | |
| 23 | ||
| 24 | describe('InstagramDriver.exchangeLongLivedUserToken', () => { | |
| 25 | test('exchanges a short-lived user token for a long-lived one', async () => { | |
| 26 | const cap: Captured[] = []; mockGraphApi(cap) | |
| 27 | const driver = new InstagramDriver() | |
| 28 | const result = await driver.exchangeLongLivedUserToken('client-1', 'secret-2', 'short-user-token') | |
| 29 | ||
| 30 | expect(result).toEqual({ accessToken: 'LONG_LIVED_USER', expiresIn: 5184000 }) | |
| 31 | const call = cap.find(c => c.url.includes('fb_exchange_token')) | |
| 32 | expect(call?.url).toContain('graph.facebook.com') | |
| 33 | expect(call?.url).toContain('/oauth/access_token') | |
| 34 | expect(call?.url).toContain('client_id=client-1') | |
| 35 | expect(call?.url).toContain('fb_exchange_token=short-user-token') | |
| 36 | }) | |
| 37 | ||
| 38 | test('surfaces a Graph error as InstagramApiError', async () => { | |
| 39 | const cap: Captured[] = [] | |
| 40 | mockGraphApi(cap, () => new Response(JSON.stringify({ error: { message: 'invalid token' } }), { status: 400 })) | |
| 41 | const driver = new InstagramDriver() | |
| 42 | let caught: unknown | |
| 43 | await driver.exchangeLongLivedUserToken('c', 's', 'bad').catch((e) => { caught = e }) | |
| 44 | expect(caught).toBeInstanceOf(InstagramApiError) | |
| 45 | expect((caught as InstagramApiError).status).toBe(400) | |
| 46 | }) | |
| 47 | }) | |
| @@ -0,0 +1,215 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { LinkedInApiError, LinkedInDriver } from '../../app/Services/Social/Drivers/LinkedInDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | const identity = { handle: 'glenn', did: 'urn:li:person:ABC', accessToken: 'tok' } | |
| 8 | ||
| 9 | interface Captured { url: string, method: string, body: any, headers: any } | |
| 10 | ||
| 11 | /** | |
| 12 | * Mock LinkedIn's versioned REST API. Recognizes the three calls an image post | |
| 13 | * makes — initializeUpload, the binary PUT, and the /rest/posts create — plus a | |
| 14 | * bytes fallback for any other GET (used when the driver fetches a media URL). | |
| 15 | */ | |
| 16 | function mockLinkedInApi(captured: Captured[], overrides: (url: string, method: string) => Response | null = () => null): void { | |
| 17 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 18 | const url = String(input) | |
| 19 | const method = init.method || 'GET' | |
| 20 | ||
| 21 | const over = overrides(url, method) | |
| 22 | if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over } | |
| 23 | ||
| 24 | if (url.includes('action=initializeUpload')) { | |
| 25 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 26 | return new Response( | |
| 27 | JSON.stringify({ value: { uploadUrl: 'https://upload.linkedin.example/img', image: 'urn:li:image:IMG123' } }), | |
| 28 | { status: 200 }, | |
| 29 | ) | |
| 30 | } | |
| 31 | if (method === 'PUT') { | |
| 32 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 33 | return new Response(null, { status: 201 }) | |
| 34 | } | |
| 35 | if (url.endsWith('/rest/posts')) { | |
| 36 | const body = init.body ? JSON.parse(String(init.body)) : undefined | |
| 37 | captured.push({ url, method, body, headers: init.headers }) | |
| 38 | return new Response('', { status: 201, headers: { 'x-restli-id': 'urn:li:share:987' } }) | |
| 39 | } | |
| 40 | // Fallback: fetching a remote media URL — hand back a few bytes. | |
| 41 | captured.push({ url, method, body: undefined, headers: init.headers }) | |
| 42 | return new Response(new Uint8Array([9, 9, 9]) as any, { status: 200, headers: { 'content-type': 'image/png' } }) | |
| 43 | }) as typeof fetch | |
| 44 | } | |
| 45 | ||
| 46 | function postRequest(captured: Captured[]): Captured | undefined { | |
| 47 | return captured.find(c => c.url.endsWith('/rest/posts')) | |
| 48 | } | |
| 49 | ||
| 50 | describe('LinkedInDriver.publish', () => { | |
| 51 | test('publishes a plain text share with no content block', async () => { | |
| 52 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 53 | const driver = new LinkedInDriver() | |
| 54 | const result = await driver.publish(identity, { text: 'hello linkedin' }) | |
| 55 | ||
| 56 | const post = postRequest(cap) | |
| 57 | expect(post?.body.commentary).toBe('hello linkedin') | |
| 58 | expect(post?.body.content).toBeUndefined() | |
| 59 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined() | |
| 60 | expect(result.uri).toBe('urn:li:share:987') | |
| 61 | expect(result.url).toContain('linkedin.com/feed/update/urn:li:share:987') | |
| 62 | }) | |
| 63 | ||
| 64 | test('attaches an article link card when external is provided and no image', async () => { | |
| 65 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 66 | const driver = new LinkedInDriver() | |
| 67 | await driver.publish(identity, { | |
| 68 | text: 'read this', | |
| 69 | external: { uri: 'https://example.com', title: 'Example', description: 'Desc' }, | |
| 70 | }) | |
| 71 | ||
| 72 | const post = postRequest(cap) | |
| 73 | expect(post?.body.content.article.source).toBe('https://example.com') | |
| 74 | expect(post?.body.content.media).toBeUndefined() | |
| 75 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined() | |
| 76 | }) | |
| 77 | ||
| 78 | test('uploads image bytes and references the URN under content.media', async () => { | |
| 79 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 80 | const driver = new LinkedInDriver() | |
| 81 | await driver.publish(identity, { | |
| 82 | text: 'photo', | |
| 83 | media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png', altText: 'a chart' }], | |
| 84 | }) | |
| 85 | ||
| 86 | const init = cap.find(c => c.url.includes('initializeUpload')) | |
| 87 | expect(init).toBeDefined() | |
| 88 | expect(JSON.parse(String(init!.body)).initializeUploadRequest.owner).toBe('urn:li:person:ABC') | |
| 89 | ||
| 90 | const put = cap.find(c => c.method === 'PUT') | |
| 91 | expect(put?.url).toBe('https://upload.linkedin.example/img') | |
| 92 | ||
| 93 | const post = postRequest(cap) | |
| 94 | expect(post?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 95 | expect(post?.body.content.media.altText).toBe('a chart') | |
| 96 | expect(post?.body.content.article).toBeUndefined() | |
| 97 | }) | |
| 98 | ||
| 99 | test('fetches a media URL to bytes when only a url is given', async () => { | |
| 100 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 101 | const driver = new LinkedInDriver() | |
| 102 | await driver.publish(identity, { text: 'via url', media: [{ url: 'https://cdn.example/pic.png' }] }) | |
| 103 | ||
| 104 | expect(cap.find(c => c.url === 'https://cdn.example/pic.png')).toBeDefined() | |
| 105 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeDefined() | |
| 106 | expect(postRequest(cap)?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 107 | }) | |
| 108 | ||
| 109 | test('an attached image supersedes an article link card', async () => { | |
| 110 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 111 | const driver = new LinkedInDriver() | |
| 112 | await driver.publish(identity, { | |
| 113 | text: 'both', | |
| 114 | external: { uri: 'https://example.com', title: 'E' }, | |
| 115 | media: [{ bytes: new Uint8Array([1]), mimeType: 'image/jpeg' }], | |
| 116 | }) | |
| 117 | ||
| 118 | const post = postRequest(cap) | |
| 119 | expect(post?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 120 | expect(post?.body.content.article).toBeUndefined() | |
| 121 | }) | |
| 122 | ||
| 123 | test('rejects text over the character limit before calling the API', async () => { | |
| 124 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 125 | const driver = new LinkedInDriver() | |
| 126 | await expect(driver.publish(identity, { text: 'x'.repeat(3001) })).rejects.toThrow(/3000 characters/) | |
| 127 | expect(cap).toHaveLength(0) | |
| 128 | }) | |
| 129 | ||
| 130 | test('rejects when the identity has no member URN', async () => { | |
| 131 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 132 | const driver = new LinkedInDriver() | |
| 133 | await expect(driver.publish({ handle: 'glenn', accessToken: 'tok' }, { text: 'hi' })).rejects.toThrow(/member URN/) | |
| 134 | expect(cap).toHaveLength(0) | |
| 135 | }) | |
| 136 | ||
| 137 | test('rejects when the identity has no access token', async () => { | |
| 138 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 139 | const driver = new LinkedInDriver() | |
| 140 | await expect(driver.publish({ handle: 'glenn', did: 'urn:li:person:ABC' }, { text: 'hi' })).rejects.toThrow(/access token/) | |
| 141 | expect(cap).toHaveLength(0) | |
| 142 | }) | |
| 143 | ||
| 144 | test('surfaces a posts API error as LinkedInApiError with status', async () => { | |
| 145 | const cap: Captured[] = [] | |
| 146 | mockLinkedInApi(cap, url => url.endsWith('/rest/posts') ? new Response('{"message":"nope"}', { status: 401 }) : null) | |
| 147 | const driver = new LinkedInDriver() | |
| 148 | let caught: unknown | |
| 149 | await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e }) | |
| 150 | expect(caught).toBeInstanceOf(LinkedInApiError) | |
| 151 | expect((caught as LinkedInApiError).status).toBe(401) | |
| 152 | expect((caught as LinkedInApiError).isAuthError).toBe(true) | |
| 153 | }) | |
| 154 | ||
| 155 | test('surfaces an image upload failure as LinkedInApiError', async () => { | |
| 156 | const cap: Captured[] = [] | |
| 157 | mockLinkedInApi(cap, (_url, method) => method === 'PUT' ? new Response('fail', { status: 500 }) : null) | |
| 158 | const driver = new LinkedInDriver() | |
| 159 | let caught: unknown | |
| 160 | await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] }) | |
| 161 | .catch((e) => { caught = e }) | |
| 162 | expect(caught).toBeInstanceOf(LinkedInApiError) | |
| 163 | expect((caught as LinkedInApiError).status).toBe(500) | |
| 164 | // The post itself must never be created when its image failed to upload. | |
| 165 | expect(postRequest(cap)).toBeUndefined() | |
| 166 | }) | |
| 167 | }) | |
| 168 | ||
| 169 | interface TokenCall { url: string, body: string } | |
| 170 | ||
| 171 | function mockTokenEndpoint(captured: TokenCall[], payload: Record<string, unknown>, status = 200): void { | |
| 172 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 173 | captured.push({ url: String(input), body: String(init.body || '') }) | |
| 174 | return new Response(JSON.stringify(payload), { status }) | |
| 175 | }) as typeof fetch | |
| 176 | } | |
| 177 | ||
| 178 | describe('LinkedInDriver token exchange + refresh', () => { | |
| 179 | test('exchangeCode surfaces the refresh token the base driver drops', async () => { | |
| 180 | const cap: TokenCall[] = [] | |
| 181 | mockTokenEndpoint(cap, { access_token: 'AT1', expires_in: 5184000, refresh_token: 'RT1', refresh_token_expires_in: 31536000 }) | |
| 182 | const driver = new LinkedInDriver() | |
| 183 | ||
| 184 | const token = await driver.exchangeCode({ clientId: 'c', clientSecret: 's', redirectUrl: 'https://app/cb', code: 'abc' }) | |
| 185 | ||
| 186 | expect(cap[0].url).toContain('/oauth/v2/accessToken') | |
| 187 | expect(cap[0].body).toContain('grant_type=authorization_code') | |
| 188 | expect(token.accessToken).toBe('AT1') | |
| 189 | expect(token.refreshToken).toBe('RT1') | |
| 190 | expect(token.expiresIn).toBe(5184000) | |
| 191 | }) | |
| 192 | ||
| 193 | test('refreshAccessToken mints a new token from a refresh token', async () => { | |
| 194 | const cap: TokenCall[] = [] | |
| 195 | mockTokenEndpoint(cap, { access_token: 'AT2', expires_in: 5184000, refresh_token: 'RT2' }) | |
| 196 | const driver = new LinkedInDriver() | |
| 197 | ||
| 198 | const token = await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'RT1' }) | |
| 199 | ||
| 200 | expect(cap[0].body).toContain('grant_type=refresh_token') | |
| 201 | expect(cap[0].body).toContain('refresh_token=RT1') | |
| 202 | expect(token.accessToken).toBe('AT2') | |
| 203 | expect(token.refreshToken).toBe('RT2') | |
| 204 | }) | |
| 205 | ||
| 206 | test('refreshAccessToken throws when LinkedIn returns no token', async () => { | |
| 207 | const cap: TokenCall[] = [] | |
| 208 | mockTokenEndpoint(cap, { error: 'invalid_grant' }, 400) | |
| 209 | const driver = new LinkedInDriver() | |
| 210 | let caught: unknown | |
| 211 | await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'stale' }).catch((e) => { caught = e }) | |
| 212 | expect(caught).toBeInstanceOf(LinkedInApiError) | |
| 213 | expect((caught as LinkedInApiError).status).toBe(400) | |
| 214 | }) | |
| 215 | }) | |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { buildMediaUrl, isSafeMediaFilename, mediaContentType } from '../../app/Support/Social/uploads' | |
| 3 | ||
| 4 | describe('isSafeMediaFilename', () => { | |
| 5 | test('accepts the UUID basenames QueueService writes', () => { | |
| 6 | expect(isSafeMediaFilename('9f8c2e1a-1234-4abc-9def-0123456789ab.jpg')).toBe(true) | |
| 7 | expect(isSafeMediaFilename('9f8c2e1a-1234-4abc-9def-0123456789ab-1a2b3c4d.png')).toBe(true) | |
| 8 | expect(isSafeMediaFilename('photo123.webp')).toBe(true) | |
| 9 | }) | |
| 10 | ||
| 11 | test('rejects path traversal and separators', () => { | |
| 12 | expect(isSafeMediaFilename('../secret.jpg')).toBe(false) | |
| 13 | expect(isSafeMediaFilename('..%2Fsecret.jpg')).toBe(false) | |
| 14 | expect(isSafeMediaFilename('sub/dir/photo.jpg')).toBe(false) | |
| 15 | expect(isSafeMediaFilename('/etc/passwd')).toBe(false) | |
| 16 | expect(isSafeMediaFilename('photo.jpg/../../x')).toBe(false) | |
| 17 | }) | |
| 18 | ||
| 19 | test('rejects missing extension, empty, and non-strings', () => { | |
| 20 | expect(isSafeMediaFilename('noextension')).toBe(false) | |
| 21 | expect(isSafeMediaFilename('')).toBe(false) | |
| 22 | expect(isSafeMediaFilename('.hidden')).toBe(false) | |
| 23 | expect(isSafeMediaFilename(undefined as unknown as string)).toBe(false) | |
| 24 | }) | |
| 25 | }) | |
| 26 | ||
| 27 | describe('mediaContentType', () => { | |
| 28 | test('maps known image extensions', () => { | |
| 29 | expect(mediaContentType('a.jpg')).toBe('image/jpeg') | |
| 30 | expect(mediaContentType('a.jpeg')).toBe('image/jpeg') | |
| 31 | expect(mediaContentType('a.PNG')).toBe('image/png') | |
| 32 | expect(mediaContentType('a.gif')).toBe('image/gif') | |
| 33 | expect(mediaContentType('a.webp')).toBe('image/webp') | |
| 34 | }) | |
| 35 | ||
| 36 | test('falls back to octet-stream for unknown extensions', () => { | |
| 37 | expect(mediaContentType('a.bin')).toBe('application/octet-stream') | |
| 38 | }) | |
| 39 | }) | |
| 40 | ||
| 41 | describe('buildMediaUrl', () => { | |
| 42 | test('builds a media URL from an https base', () => { | |
| 43 | expect(buildMediaUrl('https://posts.example.com', 'a.jpg')) | |
| 44 | .toBe('https://posts.example.com/postline/media?file=a.jpg') | |
| 45 | }) | |
| 46 | ||
| 47 | test('adds https:// when the base has no scheme, and strips trailing slashes', () => { | |
| 48 | expect(buildMediaUrl('posts.example.com', 'a.jpg')) | |
| 49 | .toBe('https://posts.example.com/postline/media?file=a.jpg') | |
| 50 | expect(buildMediaUrl('https://posts.example.com//', 'a.jpg')) | |
| 51 | .toBe('https://posts.example.com/postline/media?file=a.jpg') | |
| 52 | }) | |
| 53 | ||
| 54 | test('returns null when the base is empty', () => { | |
| 55 | expect(buildMediaUrl('', 'a.jpg')).toBeNull() | |
| 56 | expect(buildMediaUrl(' ', 'a.jpg')).toBeNull() | |
| 57 | }) | |
| 58 | ||
| 59 | test('returns null (never a traversing URL) for an unsafe filename', () => { | |
| 60 | expect(buildMediaUrl('https://posts.example.com', '../../etc/passwd')).toBeNull() | |
| 61 | expect(buildMediaUrl('https://posts.example.com', 'sub/dir.jpg')).toBeNull() | |
| 62 | }) | |
| 63 | }) | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { ThreadsApiError, ThreadsDriver } from '../../app/Services/Social/Drivers/ThreadsDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | interface Captured { url: string, method: string } | |
| 8 | ||
| 9 | function mockThreadsTokenApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void { | |
| 10 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 11 | const url = String(input) | |
| 12 | const over = overrides(url) | |
| 13 | if (over) { captured.push({ url, method: init.method || 'GET' }); return over } | |
| 14 | ||
| 15 | if (url.includes('th_exchange_token')) { | |
| 16 | captured.push({ url, method: init.method || 'GET' }) | |
| 17 | return new Response(JSON.stringify({ access_token: 'LONG_LIVED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 }) | |
| 18 | } | |
| 19 | if (url.includes('th_refresh_token')) { | |
| 20 | captured.push({ url, method: init.method || 'GET' }) | |
| 21 | return new Response(JSON.stringify({ access_token: 'REFRESHED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 }) | |
| 22 | } | |
| 23 | captured.push({ url, method: init.method || 'GET' }) | |
| 24 | return new Response('{}', { status: 404 }) | |
| 25 | }) as typeof fetch | |
| 26 | } | |
| 27 | ||
| 28 | describe('ThreadsDriver token lifecycle', () => { | |
| 29 | test('exchangeLongLivedToken swaps a short-lived token for a ~60-day one', async () => { | |
| 30 | const cap: Captured[] = []; mockThreadsTokenApi(cap) | |
| 31 | const driver = new ThreadsDriver() | |
| 32 | const result = await driver.exchangeLongLivedToken('secret-xyz', 'short-token') | |
| 33 | ||
| 34 | expect(result).toEqual({ accessToken: 'LONG_LIVED', expiresIn: 5184000 }) | |
| 35 | const call = cap.find(c => c.url.includes('th_exchange_token')) | |
| 36 | expect(call?.url).toContain('graph.threads.net/access_token') | |
| 37 | expect(call?.url).toContain('client_secret=secret-xyz') | |
| 38 | expect(call?.url).toContain('access_token=short-token') | |
| 39 | }) | |
| 40 | ||
| 41 | test('refreshLongLivedToken extends an existing long-lived token', async () => { | |
| 42 | const cap: Captured[] = []; mockThreadsTokenApi(cap) | |
| 43 | const driver = new ThreadsDriver() | |
| 44 | const result = await driver.refreshLongLivedToken('long-token') | |
| 45 | ||
| 46 | expect(result).toEqual({ accessToken: 'REFRESHED', expiresIn: 5184000 }) | |
| 47 | const call = cap.find(c => c.url.includes('th_refresh_token')) | |
| 48 | expect(call?.url).toContain('graph.threads.net/refresh_access_token') | |
| 49 | expect(call?.url).toContain('access_token=long-token') | |
| 50 | }) | |
| 51 | ||
| 52 | test('surfaces a token error as ThreadsApiError with status', async () => { | |
| 53 | const cap: Captured[] = [] | |
| 54 | mockThreadsTokenApi(cap, () => new Response(JSON.stringify({ error: { message: 'bad token' } }), { status: 400 })) | |
| 55 | const driver = new ThreadsDriver() | |
| 56 | let caught: unknown | |
| 57 | await driver.refreshLongLivedToken('nope').catch((e) => { caught = e }) | |
| 58 | expect(caught).toBeInstanceOf(ThreadsApiError) | |
| 59 | expect((caught as ThreadsApiError).status).toBe(400) | |
| 60 | }) | |
| 61 | }) | |
| @@ -0,0 +1,160 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { TwitterApiError, TwitterDriver } from '../../app/Services/Social/Drivers/TwitterDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | const identity = { handle: 'glenn', did: '77', accessToken: 'AT' } | |
| 8 | ||
| 9 | interface Captured { url: string, method: string, body: any, headers: any } | |
| 10 | ||
| 11 | function mockTwitterApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void { | |
| 12 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 13 | const url = String(input) | |
| 14 | const method = init.method || 'GET' | |
| 15 | const over = overrides(url) | |
| 16 | if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over } | |
| 17 | ||
| 18 | if (url.includes('/2/oauth2/token')) { | |
| 19 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 20 | return new Response(JSON.stringify({ token_type: 'bearer', expires_in: 7200, access_token: 'NEW_AT', refresh_token: 'NEW_RT', scope: 'tweet.write' }), { status: 200 }) | |
| 21 | } | |
| 22 | if (url.includes('/2/users/me')) { | |
| 23 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 24 | return new Response(JSON.stringify({ data: { id: '77', username: 'glenn', name: 'Glenn' } }), { status: 200 }) | |
| 25 | } | |
| 26 | if (url.includes('/2/media/upload')) { | |
| 27 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 28 | return new Response(JSON.stringify({ data: { id: 'media123' } }), { status: 200 }) | |
| 29 | } | |
| 30 | // /2/tweets | |
| 31 | const body = init.body ? JSON.parse(String(init.body)) : undefined | |
| 32 | captured.push({ url, method, body, headers: init.headers }) | |
| 33 | return new Response(JSON.stringify({ data: { id: 'tweet999' } }), { status: 200 }) | |
| 34 | }) as typeof fetch | |
| 35 | } | |
| 36 | ||
| 37 | function tweetRequest(captured: Captured[]): Captured | undefined { | |
| 38 | return captured.find(c => c.url.endsWith('/2/tweets')) | |
| 39 | } | |
| 40 | ||
| 41 | describe('TwitterDriver.createAuthorization (PKCE)', () => { | |
| 42 | test('builds a consent URL with an S256 challenge and returns the verifier', async () => { | |
| 43 | const driver = new TwitterDriver() | |
| 44 | const { url, codeVerifier } = await driver.createAuthorization({ | |
| 45 | clientId: 'cid', | |
| 46 | redirectUrl: 'https://app.example/cb', | |
| 47 | scopes: ['tweet.read', 'tweet.write', 'offline.access'], | |
| 48 | state: 'st8', | |
| 49 | }) | |
| 50 | ||
| 51 | expect(url).toContain('twitter.com/i/oauth2/authorize') | |
| 52 | expect(url).toContain('client_id=cid') | |
| 53 | expect(url).toContain('state=st8') | |
| 54 | expect(url).toContain('code_challenge=') | |
| 55 | expect(url).toContain('code_challenge_method=S256') | |
| 56 | expect(url).toContain('scope=tweet.read+tweet.write+offline.access') | |
| 57 | expect(codeVerifier.length).toBeGreaterThan(20) | |
| 58 | }) | |
| 59 | }) | |
| 60 | ||
| 61 | describe('TwitterDriver tokens', () => { | |
| 62 | test('exchangeCode posts the code + PKCE verifier and returns tokens', async () => { | |
| 63 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 64 | const driver = new TwitterDriver() | |
| 65 | const token = await driver.exchangeCode({ clientId: 'cid', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'ver123' }) | |
| 66 | ||
| 67 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 68 | expect(String(call?.body)).toContain('grant_type=authorization_code') | |
| 69 | expect(String(call?.body)).toContain('code_verifier=ver123') | |
| 70 | expect(token.accessToken).toBe('NEW_AT') | |
| 71 | expect(token.refreshToken).toBe('NEW_RT') | |
| 72 | expect(token.expiresIn).toBe(7200) | |
| 73 | }) | |
| 74 | ||
| 75 | test('exchangeCode adds HTTP Basic auth for a confidential client', async () => { | |
| 76 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 77 | const driver = new TwitterDriver() | |
| 78 | await driver.exchangeCode({ clientId: 'cid', clientSecret: 'secret', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'v' }) | |
| 79 | ||
| 80 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 81 | expect(call?.headers.authorization).toBe(`Basic ${btoa('cid:secret')}`) | |
| 82 | }) | |
| 83 | ||
| 84 | test('refreshAccessToken uses the refresh_token grant', async () => { | |
| 85 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 86 | const driver = new TwitterDriver() | |
| 87 | const token = await driver.refreshAccessToken({ clientId: 'cid', refreshToken: 'RT1' }) | |
| 88 | ||
| 89 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 90 | expect(String(call?.body)).toContain('grant_type=refresh_token') | |
| 91 | expect(String(call?.body)).toContain('refresh_token=RT1') | |
| 92 | expect(token.accessToken).toBe('NEW_AT') | |
| 93 | }) | |
| 94 | ||
| 95 | test('getProfile resolves the authenticated account', async () => { | |
| 96 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 97 | const driver = new TwitterDriver() | |
| 98 | const profile = await driver.getProfile('AT') | |
| 99 | expect(cap[0].url).toContain('/2/users/me') | |
| 100 | expect(profile).toEqual({ id: '77', username: 'glenn', name: 'Glenn' }) | |
| 101 | }) | |
| 102 | }) | |
| 103 | ||
| 104 | describe('TwitterDriver.publish', () => { | |
| 105 | test('posts a plain tweet and returns the id as both uri and cid', async () => { | |
| 106 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 107 | const driver = new TwitterDriver() | |
| 108 | const result = await driver.publish(identity, { text: 'hello world' }) | |
| 109 | ||
| 110 | expect(tweetRequest(cap)?.body.text).toBe('hello world') | |
| 111 | expect(result.uri).toBe('tweet999') | |
| 112 | expect(result.cid).toBe('tweet999') | |
| 113 | expect(result.url).toContain('x.com/glenn/status/tweet999') | |
| 114 | expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeUndefined() | |
| 115 | }) | |
| 116 | ||
| 117 | test('chains a reply via in_reply_to_tweet_id', async () => { | |
| 118 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 119 | const driver = new TwitterDriver() | |
| 120 | await driver.publish(identity, { | |
| 121 | text: 'second', | |
| 122 | reply: { root: { uri: 't1', cid: 't1' }, parent: { uri: 't1', cid: 't1' } }, | |
| 123 | }) | |
| 124 | expect(tweetRequest(cap)?.body.reply.in_reply_to_tweet_id).toBe('t1') | |
| 125 | }) | |
| 126 | ||
| 127 | test('uploads image bytes and attaches media_ids', async () => { | |
| 128 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 129 | const driver = new TwitterDriver() | |
| 130 | await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] }) | |
| 131 | ||
| 132 | expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeDefined() | |
| 133 | expect(tweetRequest(cap)?.body.media.media_ids).toEqual(['media123']) | |
| 134 | }) | |
| 135 | ||
| 136 | test('rejects text over the character limit before calling the API', async () => { | |
| 137 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 138 | const driver = new TwitterDriver() | |
| 139 | await expect(driver.publish(identity, { text: 'x'.repeat(281) })).rejects.toThrow(/280 characters/) | |
| 140 | expect(cap).toHaveLength(0) | |
| 141 | }) | |
| 142 | ||
| 143 | test('rejects when the identity has no access token', async () => { | |
| 144 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 145 | const driver = new TwitterDriver() | |
| 146 | await expect(driver.publish({ handle: 'glenn' }, { text: 'hi' })).rejects.toThrow(/access token/) | |
| 147 | expect(cap).toHaveLength(0) | |
| 148 | }) | |
| 149 | ||
| 150 | test('surfaces an API error as TwitterApiError with status', async () => { | |
| 151 | const cap: Captured[] = [] | |
| 152 | mockTwitterApi(cap, url => url.endsWith('/2/tweets') ? new Response('{"title":"Unauthorized"}', { status: 401 }) : null) | |
| 153 | const driver = new TwitterDriver() | |
| 154 | let caught: unknown | |
| 155 | await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e }) | |
| 156 | expect(caught).toBeInstanceOf(TwitterApiError) | |
| 157 | expect((caught as TwitterApiError).status).toBe(401) | |
| 158 | expect((caught as TwitterApiError).isAuthError).toBe(true) | |
| 159 | }) | |
| 160 | }) | |