ReviewOS

also looking at this

stacks/postline

feat: crosspost to LinkedIn and Instagram

#1
Merged chris wants to merge feat/crosspost-linkedin-instagram into main
31 files +2335 -87

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.env.examplemodified+27-0
Changes to .env.example
@@ -103,3 +103,30 @@ FRONTEND_STRIPE_PUBLIC_KEY=
103103# Create an app password at https://bsky.app/settings/app-passwords
104104BLUESKY_IDENTIFIER=
105105BLUESKY_APP_PASSWORD=
106
107# LinkedIn (optional — connect via Accounts, or set a pre-obtained token below)
108# 1. Create an app at https://www.linkedin.com/developers/apps
109# 2. Add the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products
110# 3. Add the redirect URL below to the app's "Authorized redirect URLs"
111LINKEDIN_CLIENT_ID=
112LINKEDIN_CLIENT_SECRET=
113LINKEDIN_REDIRECT_URL=https://postline.localhost/api/postline/linkedin/callback
114# Bump if LinkedIn deprecates this monthly REST API version (YYYYMM)
115LINKEDIN_API_VERSION=202405
116# Env-only shortcut: paste a member access token (w_member_social) to skip the
117# OAuth flow. Optionally set the author URN to avoid an extra profile lookup.
118LINKEDIN_ACCESS_TOKEN=
119LINKEDIN_AUTHOR_URN=
120
121# Instagram (optional — posts REQUIRE an image URL; text-only is rejected)
122# Uses the Facebook Graph API: create a Meta app, add Instagram + Facebook Login,
123# link an Instagram Business/Creator account to a Facebook Page.
124INSTAGRAM_CLIENT_ID=
125INSTAGRAM_CLIENT_SECRET=
126INSTAGRAM_REDIRECT_URL=https://postline.localhost/api/postline/instagram/callback
127# Bump if Meta deprecates this Graph API version
128INSTAGRAM_GRAPH_VERSION=v21.0
129# Env-only shortcut: a Page access token + the IG Business account id skip OAuth.
130INSTAGRAM_ACCESS_TOKEN=
131INSTAGRAM_USER_ID=
132INSTAGRAM_USERNAME=
app/Actions/Postline/CrosspostPublishAction.tsadded+52-0
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -0,0 +1,52 @@
1import type { SocialProvider } from '../../Support/Social/types'
2import type { RequestInstance } from '@stacksjs/types'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService'
6
7export default new Action({
8 name: 'Postline Crosspost Publish',
9 description: 'Publish a post to one or more connected providers at once.',
10 method: 'POST',
11
12 async handle(request: RequestInstance) {
13 const text = String(request.get('text') || request.get('body') || '').trim()
14
15 const available = new Set<string>(crosspostProviders())
16 const providers = String(request.get('providers') || request.get('provider') || '')
17 .split(',')
18 .map(value => value.trim().toLowerCase())
19 .filter(value => available.has(value)) as SocialProvider[]
20
21 const externalUri = String(request.get('external_uri') || '').trim()
22 const externalTitle = String(request.get('external_title') || '').trim()
23 const externalDescription = String(request.get('external_description') || '').trim()
24 const external = externalUri && externalTitle
25 ? {
26 uri: externalUri,
27 title: externalTitle,
28 description: externalDescription || undefined,
29 }
30 : undefined
31
32 const imageUrl = String(request.get('image_url') || '').trim()
33 const imageAlt = String(request.get('image_alt') || '').trim()
34 const media = imageUrl
35 ? [{ url: imageUrl, altText: imageAlt || undefined }]
36 : undefined
37
38 try {
39 const data = await crosspost.publish(text, providers, { external, media })
40 // Surface a partial-failure (some targets failed) as a 207-style payload
41 // while still returning 200 so the client can report per-provider state.
42 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
43 return response.json({ ok: !allFailed, data }, { status: allFailed ? 422 : 200 })
44 }
45 catch (error) {
46 return response.json({
47 ok: false,
48 error: error instanceof Error ? error.message : String(error),
49 }, { status: 422 })
50 }
51 },
52})
app/Actions/Postline/InstagramAuthAction.tsadded+19-0
Changes to app/Actions/Postline/InstagramAuthAction.ts
@@ -0,0 +1,19 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Auth',
7 description: 'Redirect to Facebook for Instagram publishing consent.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.redirect(instagram.getAuthUrl())
13 }
14 catch (error) {
15 const message = error instanceof Error ? error.message : String(error)
16 return response.redirect(`/accounts?instagram=error&message=${encodeURIComponent(message)}`)
17 }
18 },
19})
app/Actions/Postline/InstagramCallbackAction.tsadded+30-0
Changes to app/Actions/Postline/InstagramCallbackAction.ts
@@ -0,0 +1,30 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { instagram } from '../../Services/Social/InstagramService'
5
6export default new Action({
7 name: 'Postline Instagram Callback',
8 description: 'Handle the Facebook OAuth redirect and store the Instagram 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?instagram=error&message=${encodeURIComponent(description)}`)
19 }
20
21 try {
22 await instagram.handleCallback(code, state)
23 return response.redirect('/accounts?instagram=connected')
24 }
25 catch (error) {
26 const message = error instanceof Error ? error.message : String(error)
27 return response.redirect(`/accounts?instagram=error&message=${encodeURIComponent(message)}`)
28 }
29 },
30})
app/Actions/Postline/InstagramConnectAction.tsadded+22-0
Changes to app/Actions/Postline/InstagramConnectAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Connect',
7 description: 'Connect Instagram from a pre-obtained access token in the environment.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await instagram.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})
app/Actions/Postline/InstagramStatusAction.tsadded+21-0
Changes to app/Actions/Postline/InstagramStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Status',
7 description: 'Return the current Instagram connection state.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.json({ ok: true, data: await instagram.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})
app/Actions/Postline/LinkedInAuthAction.tsadded+19-0
Changes to app/Actions/Postline/LinkedInAuthAction.ts
@@ -0,0 +1,19 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Auth',
7 description: 'Redirect the member to LinkedIn for OAuth consent.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.redirect(linkedin.getAuthUrl())
13 }
14 catch (error) {
15 const message = error instanceof Error ? error.message : String(error)
16 return response.redirect(`/accounts?linkedin=error&message=${encodeURIComponent(message)}`)
17 }
18 },
19})
app/Actions/Postline/LinkedInCallbackAction.tsadded+30-0
Changes to app/Actions/Postline/LinkedInCallbackAction.ts
@@ -0,0 +1,30 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { linkedin } from '../../Services/Social/LinkedInService'
5
6export default new Action({
7 name: 'Postline LinkedIn Callback',
8 description: 'Handle the LinkedIn 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?linkedin=error&message=${encodeURIComponent(description)}`)
19 }
20
21 try {
22 await linkedin.handleCallback(code, state)
23 return response.redirect('/accounts?linkedin=connected')
24 }
25 catch (error) {
26 const message = error instanceof Error ? error.message : String(error)
27 return response.redirect(`/accounts?linkedin=error&message=${encodeURIComponent(message)}`)
28 }
29 },
30})
app/Actions/Postline/LinkedInConnectAction.tsadded+22-0
Changes to app/Actions/Postline/LinkedInConnectAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Connect',
7 description: 'Connect LinkedIn from a pre-obtained access token in the environment.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await linkedin.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})
app/Actions/Postline/LinkedInStatusAction.tsadded+21-0
Changes to app/Actions/Postline/LinkedInStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Status',
7 description: 'Return the current LinkedIn connection state.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.json({ ok: true, data: await linkedin.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})
app/Actions/Postline/ProvidersStatusAction.tsadded+29-0
Changes to app/Actions/Postline/ProvidersStatusAction.ts
@@ -0,0 +1,29 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { bluesky } from '../../Services/Social/BlueskyService'
4import { instagram } from '../../Services/Social/InstagramService'
5import { linkedin } from '../../Services/Social/LinkedInService'
6
7export default new Action({
8 name: 'Postline Providers Status',
9 description: 'Return the connection state for every crosspost provider.',
10 method: 'GET',
11
12 async handle() {
13 try {
14 const providers = await Promise.all([
15 bluesky.status(),
16 linkedin.status(),
17 instagram.status(),
18 ])
19
20 return response.json({ ok: true, data: { providers } })
21 }
22 catch (error) {
23 return response.json({
24 ok: false,
25 error: error instanceof Error ? error.message : String(error),
26 }, { status: 500 })
27 }
28 },
29})
app/Services/Social/BlueskyService.tsmodified+66-23
Changes to app/Services/Social/BlueskyService.ts
@@ -1,4 +1,4 @@
1import type { BlueskySession, PublishedPost, TimelineResult } from '../../Support/Social/types'
1import type { BlueskySession, CrosspostTargetResult, PublishContent, PublishedPost, TimelineResult } from '../../Support/Social/types'
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { BlueskyApiError, BlueskyDriver } from './Drivers/BlueskyDriver'
@@ -108,11 +108,8 @@ export class BlueskyService {
108108 throw new Error(`Bluesky posts must be ${this.driver.characterLimit} characters or fewer.`)
109109 }
110110
111 const identity = await this.requireIdentity()
112 const driver = await this.ensureDriver()
113111 const accountId = await this.ensureAccount()
114112 const postUuid = uuid()
115 const targetUuid = uuid()
116113 const createdAt = now()
117114
118115 await database.insertInto('posts').values({
@@ -132,10 +129,65 @@ export class BlueskyService {
132129 .selectAll()
133130 .where('uuid', '=', postUuid)
134131 .executeTakeFirstOrThrow()
135 const publishInput = { text: body } as Parameters<BlueskyDriver['publish']>[1] & {
132
133 const result = await this.publishToPost({ id: Number(post.id), body }, external ? { external } : undefined)
134 const finishedAt = now()
135
136 if (!result.ok) {
137 await database.updateTable('posts').set({
138 status: 'failed',
139 updated_at: finishedAt,
140 }).where('id', '=', post.id).execute()
141
142 throw new Error(result.error || 'Bluesky publish failed.')
143 }
144
145 await database.updateTable('posts').set({
146 status: 'published',
147 published_at: finishedAt,
148 updated_at: finishedAt,
149 }).where('id', '=', post.id).execute()
150
151 return {
152 post: { provider: 'bluesky', uri: result.uri || '', url: result.url },
153 postId: Number(post.id),
154 targetId: Number(result.targetId),
155 }
156 }
157
158 /**
159 * Publish an already-created post row to Bluesky as a new target. Never
160 * throws — failures are recorded on the target and returned so a crosspost
161 * to other providers can still proceed.
162 */
163 async publishToPost(
164 post: { id: number, body: string },
165 content?: PublishContent,
166 ): Promise<CrosspostTargetResult> {
167 const driver = await this.ensureDriver()
168
169 let identity: SocialIdentityRow
170 try {
171 identity = await this.requireIdentity()
172 }
173 catch (error) {
174 return { provider: 'bluesky', ok: false, error: error instanceof Error ? error.message : String(error) }
175 }
176
177 if (post.body.length > this.driver.characterLimit) {
178 return {
179 provider: 'bluesky',
180 ok: false,
181 error: `Bluesky posts must be ${this.driver.characterLimit} characters or fewer.`,
182 }
183 }
184
185 const targetUuid = uuid()
186 const createdAt = now()
187 const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & {
136188 external?: { uri: string, title: string, description?: string }
137189 }
138 if (external) publishInput.external = external
190 if (content?.external) publishInput.external = content.external
139191
140192 await database.insertInto('post_targets').values({
141193 uuid: targetUuid,
@@ -158,19 +210,13 @@ export class BlueskyService {
158210 const published = await this.withFreshSession(identity, freshIdentity =>
159211 this.driver.publish({
160212 handle: freshIdentity.handle,
161 did: freshIdentity.external_id || undefined,
162 accessToken: freshIdentity.access_token || undefined,
163 refreshToken: freshIdentity.refresh_token || undefined,
213 did: freshIdentity.external_id || undefined,
214 accessToken: freshIdentity.access_token || undefined,
215 refreshToken: freshIdentity.refresh_token || undefined,
164216 }, publishInput),
165217 )
166218
167219 const publishedAt = now()
168 await database.updateTable('posts').set({
169 status: 'published',
170 published_at: publishedAt,
171 updated_at: publishedAt,
172 }).where('id', '=', post.id).execute()
173
174220 await database.updateTable('post_targets').set({
175221 status: 'published',
176222 remote_uri: published.uri,
@@ -180,26 +226,23 @@ export class BlueskyService {
180226 }).where('id', '=', target.id).execute()
181227
182228 return {
183 post: published,
184 postId: Number(post.id),
229 provider: 'bluesky',
230 ok: true,
231 url: published.url,
232 uri: published.uri,
185233 targetId: Number(target.id),
186234 }
187235 }
188236 catch (error) {
189237 const message = error instanceof Error ? error.message : String(error)
190238 const failedAt = now()
191 await database.updateTable('posts').set({
192 status: 'failed',
193 updated_at: failedAt,
194 }).where('id', '=', post.id).execute()
195
196239 await database.updateTable('post_targets').set({
197240 status: 'failed',
198241 failure_reason: message,
199242 updated_at: failedAt,
200243 }).where('id', '=', target.id).execute()
201244
202 throw error
245 return { provider: 'bluesky', ok: false, error: message, targetId: Number(target.id) }
203246 }
204247 }
205248
app/Services/Social/CrosspostService.tsadded+86-0
Changes to app/Services/Social/CrosspostService.ts
@@ -0,0 +1,86 @@
1import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { bluesky } from './BlueskyService'
5import { instagram } from './InstagramService'
6import { linkedin } from './LinkedInService'
7import { ensureAccount, now, uuid } from './support'
8
9const database = db as any
10
11interface ProviderPublisher {
12 publishToPost: (post: { id: number, body: string }, content?: PublishContent) => Promise<CrosspostTargetResult>
13}
14
15// Each provider owns its own connection/token handling behind `publishToPost`.
16const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = {
17 bluesky,
18 linkedin,
19 instagram,
20}
21
22export function crosspostProviders(): SocialProvider[] {
23 return Object.keys(publishers) as SocialProvider[]
24}
25
26export class CrosspostService {
27 /**
28 * Publish one piece of content to several providers at once. A single
29 * `posts` row is created and each provider gets its own `post_targets` row.
30 * Per-provider failures are isolated — one platform erroring never aborts
31 * the others.
32 */
33 async publish(
34 text: string,
35 providers: SocialProvider[],
36 content?: PublishContent,
37 ): Promise<{ postId: number, results: CrosspostTargetResult[] }> {
38 const body = text.trim()
39 if (!body) throw new Error('Post text is required.')
40
41 const selected = providers.filter(provider => publishers[provider])
42 if (selected.length === 0) {
43 throw new Error('Select at least one connected provider to publish.')
44 }
45
46 const accountId = await ensureAccount()
47 const postUuid = uuid()
48 const createdAt = now()
49
50 await database.insertInto('posts').values({
51 uuid: postUuid,
52 title: body.slice(0, 80),
53 body,
54 status: 'publishing',
55 timezone: env.TZ || 'America/Los_Angeles',
56 source: 'composer',
57 account_id: accountId,
58 created_at: createdAt,
59 updated_at: createdAt,
60 }).execute()
61
62 const post = await database
63 .selectFrom('posts')
64 .selectAll()
65 .where('uuid', '=', postUuid)
66 .executeTakeFirstOrThrow()
67
68 const results: CrosspostTargetResult[] = []
69 for (const provider of selected) {
70 const publisher = publishers[provider]!
71 results.push(await publisher.publishToPost({ id: Number(post.id), body }, content))
72 }
73
74 const anyOk = results.some(result => result.ok)
75 const finishedAt = now()
76 await database.updateTable('posts').set({
77 status: anyOk ? 'published' : 'failed',
78 ...(anyOk ? { published_at: finishedAt } : {}),
79 updated_at: finishedAt,
80 }).where('id', '=', post.id).execute()
81
82 return { postId: Number(post.id), results }
83 }
84}
85
86export const crosspost = new CrosspostService()
app/Services/Social/DriverRegistry.tsmodified+5-0
Changes to app/Services/Social/DriverRegistry.ts
@@ -1,8 +1,13 @@
11import type { SocialDriver, SocialProvider } from '../../Support/Social/types'
2import { env } from '@stacksjs/env'
23import { BlueskyDriver } from './Drivers/BlueskyDriver'
4import { InstagramDriver } from './Drivers/InstagramDriver'
5import { LinkedInDriver } from './Drivers/LinkedInDriver'
36
47const drivers: Partial<Record<SocialProvider, SocialDriver>> = {
58 bluesky: new BlueskyDriver(),
9 linkedin: new LinkedInDriver({ apiVersion: String(env.LINKEDIN_API_VERSION || '202405') }),
10 instagram: new InstagramDriver({ graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0') }),
611}
712
813export function getSocialDriver(provider: SocialProvider): SocialDriver {
app/Services/Social/Drivers/InstagramDriver.tsadded+1-0
Changes to app/Services/Social/Drivers/InstagramDriver.ts
@@ -0,0 +1,1 @@
1export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials'
app/Services/Social/Drivers/LinkedInDriver.tsadded+1-0
Changes to app/Services/Social/Drivers/LinkedInDriver.ts
@@ -0,0 +1,1 @@
1export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials'
app/Services/Social/InstagramService.tsadded+374-0
Changes to app/Services/Social/InstagramService.ts
@@ -0,0 +1,374 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: 'instagram'
14 external_id?: string | null
15 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
16 access_token?: string | null
17 refresh_token?: string | null
18 account_id?: number | null
19 social_driver_id?: number | null
20}
21
22type SocialDriverRow = {
23 id: number
24 provider: 'instagram'
25 display_name: string
26 status: 'active' | 'planned' | 'disabled'
27 character_limit: number
28}
29
30interface InstagramConfig {
31 clientId: string
32 clientSecret: string
33 redirectUrl: string
34 graphVersion: string
35 accessToken: string
36 userId: string
37 username: string
38 scopes: string[]
39}
40
41function randomState(): string {
42 const bytes = new Uint8Array(24)
43 crypto.getRandomValues(bytes)
44 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
45}
46
47export class InstagramService {
48 private driver: InstagramDriver
49 private pendingState: string | null = null
50
51 constructor() {
52 this.driver = new InstagramDriver({ graphVersion: this.config().graphVersion })
53 }
54
55 private config(): InstagramConfig {
56 return {
57 clientId: String(env.INSTAGRAM_CLIENT_ID || '').trim(),
58 clientSecret: String(env.INSTAGRAM_CLIENT_SECRET || '').trim(),
59 redirectUrl: String(env.INSTAGRAM_REDIRECT_URL || '').trim(),
60 graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0').trim(),
61 accessToken: String(env.INSTAGRAM_ACCESS_TOKEN || '').trim(),
62 userId: String(env.INSTAGRAM_USER_ID || '').trim(),
63 username: String(env.INSTAGRAM_USERNAME || '').trim(),
64 scopes: ['instagram_basic', 'instagram_content_publish', 'pages_show_list', 'pages_read_engagement'],
65 }
66 }
67
68 async status() {
69 const identity = await this.findIdentity()
70 const cfg = this.config()
71 const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token)
72
73 return {
74 connected,
75 provider: 'instagram',
76 handle: identity?.handle || null,
77 displayName: identity?.display_name || null,
78 did: identity?.external_id || null,
79 authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'),
80 characterLimit: this.driver.characterLimit,
81 canPublish: connected,
82 requiresMedia: true,
83 configuredFromEnv: Boolean(cfg.accessToken && cfg.userId),
84 oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl),
85 }
86 }
87
88 getAuthUrl(): string {
89 const cfg = this.config()
90 if (!cfg.clientId || !cfg.clientSecret) {
91 throw new Error('Set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET to connect Instagram.')
92 }
93 if (!cfg.redirectUrl) {
94 throw new Error('Set INSTAGRAM_REDIRECT_URL to connect Instagram.')
95 }
96
97 this.pendingState = randomState()
98 return this.driver.getAuthUrl({
99 clientId: cfg.clientId,
100 redirectUrl: cfg.redirectUrl,
101 scopes: cfg.scopes,
102 state: this.pendingState,
103 })
104 }
105
106 async handleCallback(code: string, state: string) {
107 const cfg = this.config()
108 if (!code) throw new Error('Facebook did not return an authorization code.')
109 if (this.pendingState && state !== this.pendingState) {
110 throw new Error('Instagram OAuth state mismatch. Please start the connection again.')
111 }
112 this.pendingState = null
113
114 const token = await this.driver.exchangeCode({
115 clientId: cfg.clientId,
116 clientSecret: cfg.clientSecret,
117 redirectUrl: cfg.redirectUrl,
118 code,
119 })
120
121 const account = await this.driver.resolveAccount(token.accessToken)
122 const identity = await this.saveSession({
123 accessToken: account.pageAccessToken,
124 igUserId: account.igUserId,
125 username: account.username,
126 })
127 return this.publicIdentity(identity)
128 }
129
130 async connectFromEnv() {
131 const cfg = this.config()
132 if (!cfg.accessToken) {
133 throw new Error('Set INSTAGRAM_ACCESS_TOKEN (or connect via OAuth) before using Instagram.')
134 }
135
136 let igUserId = cfg.userId
137 let username = cfg.username || undefined
138 let pageAccessToken = cfg.accessToken
139 if (!igUserId) {
140 const account = await this.driver.resolveAccount(cfg.accessToken)
141 igUserId = account.igUserId
142 username = account.username
143 pageAccessToken = account.pageAccessToken
144 }
145
146 const identity = await this.saveSession({ accessToken: pageAccessToken, igUserId, username })
147 return this.publicIdentity(identity)
148 }
149
150 /**
151 * Publish an already-created post row to Instagram. Never throws — failures
152 * (including the "image required" case) are recorded on the target and
153 * returned so other crosspost providers still succeed.
154 */
155 async publishToPost(
156 post: { id: number, body: string },
157 content?: PublishContent,
158 ): Promise<CrosspostTargetResult> {
159 const media = content?.media?.[0]
160 if (!media?.url) {
161 return { provider: 'instagram', ok: false, error: 'Instagram requires an image — add an image URL.' }
162 }
163
164 const driver = await this.ensureDriver()
165
166 let identity: SocialIdentityRow
167 try {
168 identity = await this.requireIdentity()
169 }
170 catch (error) {
171 return { provider: 'instagram', ok: false, error: messageOf(error) }
172 }
173
174 if (post.body.length > this.driver.characterLimit) {
175 return {
176 provider: 'instagram',
177 ok: false,
178 error: `Instagram captions must be ${this.driver.characterLimit} characters or fewer.`,
179 }
180 }
181
182 const targetUuid = uuid()
183 const createdAt = now()
184 await database.insertInto('post_targets').values({
185 uuid: targetUuid,
186 provider: 'instagram',
187 status: 'publishing',
188 post_id: post.id,
189 social_driver_id: driver.id,
190 social_identity_id: identity.id,
191 created_at: createdAt,
192 updated_at: createdAt,
193 }).execute()
194
195 const target = await database
196 .selectFrom('post_targets')
197 .selectAll()
198 .where('uuid', '=', targetUuid)
199 .executeTakeFirstOrThrow()
200
201 try {
202 const published = await this.driver.publish({
203 handle: identity.handle,
204 did: identity.external_id || undefined,
205 accessToken: identity.access_token || undefined,
206 }, {
207 text: post.body,
208 media: content?.media,
209 })
210
211 await database.updateTable('post_targets').set({
212 status: 'published',
213 remote_uri: published.uri || null,
214 failure_reason: null,
215 updated_at: now(),
216 }).where('id', '=', target.id).execute()
217
218 return {
219 provider: 'instagram',
220 ok: true,
221 url: published.url,
222 uri: published.uri,
223 targetId: Number(target.id),
224 }
225 }
226 catch (error) {
227 const message = messageOf(error)
228 if (error instanceof InstagramApiError && error.isAuthError) {
229 await this.markExpired(identity.id)
230 }
231 await database.updateTable('post_targets').set({
232 status: 'failed',
233 failure_reason: message,
234 updated_at: now(),
235 }).where('id', '=', target.id).execute()
236
237 return { provider: 'instagram', ok: false, error: message, targetId: Number(target.id) }
238 }
239 }
240
241 private async requireIdentity(): Promise<SocialIdentityRow> {
242 const existing = await this.findIdentity()
243 if (existing?.access_token && existing.auth_status === 'connected') return existing
244
245 if (this.config().accessToken) {
246 await this.connectFromEnv()
247 const connected = await this.findIdentity()
248 if (connected?.access_token) return connected
249 }
250
251 throw new Error('Connect Instagram before publishing.')
252 }
253
254 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
255 return await database
256 .selectFrom('social_identities')
257 .selectAll()
258 .where('provider', '=', 'instagram')
259 .orderBy('updated_at', 'desc')
260 .executeTakeFirst()
261 }
262
263 private async saveSession(input: { accessToken: string, igUserId: string, username?: string }): Promise<SocialIdentityRow> {
264 const accountId = await ensureAccount()
265 const driver = await this.ensureDriver()
266 const existing = await this.findIdentity()
267 const savedAt = now()
268 const handle = (input.username || input.igUserId).trim()
269
270 const values = {
271 handle,
272 display_name: input.username || null,
273 provider: 'instagram',
274 external_id: input.igUserId,
275 auth_status: 'connected',
276 access_token: input.accessToken,
277 refresh_token: null,
278 account_id: accountId,
279 social_driver_id: driver.id,
280 updated_at: savedAt,
281 }
282
283 if (existing) {
284 await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute()
285 }
286 else {
287 await database.insertInto('social_identities').values({
288 uuid: uuid(),
289 ...values,
290 created_at: savedAt,
291 }).execute()
292 }
293
294 return await this.findIdentity() as SocialIdentityRow
295 }
296
297 private async markExpired(id: number): Promise<void> {
298 await database.updateTable('social_identities').set({
299 auth_status: 'expired',
300 updated_at: now(),
301 }).where('id', '=', id).execute()
302 }
303
304 private async ensureDriver(): Promise<SocialDriverRow> {
305 const existing = await database
306 .selectFrom('social_drivers')
307 .selectAll()
308 .where('provider', '=', 'instagram')
309 .executeTakeFirst()
310
311 if (existing) {
312 if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) {
313 await database.updateTable('social_drivers').set({
314 status: 'active',
315 character_limit: this.driver.characterLimit,
316 updated_at: now(),
317 }).where('id', '=', existing.id).execute()
318 }
319
320 return await database
321 .selectFrom('social_drivers')
322 .selectAll()
323 .where('id', '=', existing.id)
324 .executeTakeFirstOrThrow()
325 }
326
327 const driverUuid = uuid()
328 await database.insertInto('social_drivers').values({
329 uuid: driverUuid,
330 provider: 'instagram',
331 display_name: 'Instagram',
332 status: 'active',
333 character_limit: this.driver.characterLimit,
334 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true, requiresMedia: true }),
335 created_at: now(),
336 updated_at: now(),
337 }).execute()
338
339 return await database
340 .selectFrom('social_drivers')
341 .selectAll()
342 .where('uuid', '=', driverUuid)
343 .executeTakeFirstOrThrow()
344 }
345
346 private publicIdentity(row: SocialIdentityRow | undefined) {
347 if (!row) {
348 return {
349 connected: false,
350 provider: 'instagram',
351 handle: null,
352 displayName: null,
353 did: null,
354 authStatus: 'missing',
355 }
356 }
357
358 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
359 return {
360 connected,
361 provider: 'instagram',
362 handle: row.handle,
363 displayName: row.display_name || null,
364 did: row.external_id || null,
365 authStatus: connected ? 'connected' : 'missing',
366 }
367 }
368}
369
370function messageOf(error: unknown): string {
371 return error instanceof Error ? error.message : String(error)
372}
373
374export const instagram = new InstagramService()
app/Services/Social/LinkedInService.tsadded+373-0
Changes to app/Services/Social/LinkedInService.ts
@@ -0,0 +1,373 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: 'linkedin'
14 external_id?: string | null
15 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
16 access_token?: string | null
17 refresh_token?: string | null
18 account_id?: number | null
19 social_driver_id?: number | null
20}
21
22type SocialDriverRow = {
23 id: number
24 provider: 'linkedin'
25 display_name: string
26 status: 'active' | 'planned' | 'disabled'
27 character_limit: number
28}
29
30interface LinkedInConfig {
31 clientId: string
32 clientSecret: string
33 redirectUrl: string
34 apiVersion: string
35 accessToken: string
36 authorUrn: string
37 scopes: string[]
38}
39
40function randomState(): string {
41 const bytes = new Uint8Array(24)
42 crypto.getRandomValues(bytes)
43 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
44}
45
46export 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.
50 private pendingState: string | null = null
51
52 constructor() {
53 this.driver = new LinkedInDriver({ apiVersion: this.config().apiVersion })
54 }
55
56 private config(): LinkedInConfig {
57 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'],
65 }
66 }
67
68 async status() {
69 const identity = await this.findIdentity()
70 const cfg = this.config()
71 const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token)
72
73 return {
74 connected,
75 provider: 'linkedin',
76 handle: identity?.handle || null,
77 displayName: identity?.display_name || null,
78 did: identity?.external_id || null,
79 authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'),
80 characterLimit: this.driver.characterLimit,
81 canPublish: connected,
82 configuredFromEnv: Boolean(cfg.accessToken),
83 oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl),
84 }
85 }
86
87 /** Build the LinkedIn consent URL and remember the CSRF state. */
88 getAuthUrl(): string {
89 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 }
96
97 this.pendingState = randomState()
98 return this.driver.getAuthUrl({
99 clientId: cfg.clientId,
100 redirectUrl: cfg.redirectUrl,
101 scopes: cfg.scopes,
102 state: this.pendingState,
103 })
104 }
105
106 /** Handle the OAuth redirect: exchange the code and store the token. */
107 async handleCallback(code: string, state: string) {
108 const cfg = this.config()
109 if (!code) throw new Error('LinkedIn did not return an authorization code.')
110 if (this.pendingState && state !== this.pendingState) {
111 throw new Error('LinkedIn OAuth state mismatch. Please start the connection again.')
112 }
113 this.pendingState = null
114
115 const token = await this.driver.exchangeCode({
116 clientId: cfg.clientId,
117 clientSecret: cfg.clientSecret,
118 redirectUrl: cfg.redirectUrl,
119 code,
120 })
121
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
126 const identity = await this.saveSession({
127 accessToken: token.accessToken,
128 authorUrn,
129 name: profile?.name,
130 })
131 return this.publicIdentity(identity)
132 }
133
134 /** Connect using a pre-obtained token from the environment. */
135 async connectFromEnv() {
136 const cfg = this.config()
137 if (!cfg.accessToken) {
138 throw new Error('Set LINKEDIN_ACCESS_TOKEN (or connect via OAuth) before using LinkedIn.')
139 }
140
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 })
150 return this.publicIdentity(identity)
151 }
152
153 /**
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.
157 */
158 async publishToPost(
159 post: { id: number, body: string },
160 content?: PublishContent,
161 ): Promise<CrosspostTargetResult> {
162 const driver = await this.ensureDriver()
163
164 let identity: SocialIdentityRow
165 try {
166 identity = await this.requireIdentity()
167 }
168 catch (error) {
169 return { provider: 'linkedin', ok: false, error: messageOf(error) }
170 }
171
172 if (post.body.length > this.driver.characterLimit) {
173 return {
174 provider: 'linkedin',
175 ok: false,
176 error: `LinkedIn posts must be ${this.driver.characterLimit} characters or fewer.`,
177 }
178 }
179
180 const targetUuid = uuid()
181 const createdAt = now()
182 await database.insertInto('post_targets').values({
183 uuid: targetUuid,
184 provider: 'linkedin',
185 status: 'publishing',
186 post_id: post.id,
187 social_driver_id: driver.id,
188 social_identity_id: identity.id,
189 created_at: createdAt,
190 updated_at: createdAt,
191 }).execute()
192
193 const target = await database
194 .selectFrom('post_targets')
195 .selectAll()
196 .where('uuid', '=', targetUuid)
197 .executeTakeFirstOrThrow()
198
199 try {
200 const published = await this.driver.publish({
201 handle: identity.handle,
202 did: identity.external_id || undefined,
203 accessToken: identity.access_token || undefined,
204 }, {
205 text: post.body,
206 ...(content?.external ? { external: content.external } : {}),
207 })
208
209 await database.updateTable('post_targets').set({
210 status: 'published',
211 remote_uri: published.uri || null,
212 failure_reason: null,
213 updated_at: now(),
214 }).where('id', '=', target.id).execute()
215
216 return {
217 provider: 'linkedin',
218 ok: true,
219 url: published.url,
220 uri: published.uri,
221 targetId: Number(target.id),
222 }
223 }
224 catch (error) {
225 const message = messageOf(error)
226 if (error instanceof LinkedInApiError && error.isAuthError) {
227 await this.markExpired(identity.id)
228 }
229 await database.updateTable('post_targets').set({
230 status: 'failed',
231 failure_reason: message,
232 updated_at: now(),
233 }).where('id', '=', target.id).execute()
234
235 return { provider: 'linkedin', ok: false, error: message, targetId: Number(target.id) }
236 }
237 }
238
239 private async requireIdentity(): Promise<SocialIdentityRow> {
240 const existing = await this.findIdentity()
241 if (existing?.access_token && existing.auth_status === 'connected') return existing
242
243 // Fall back to an env-configured token if one is available.
244 if (this.config().accessToken) {
245 await this.connectFromEnv()
246 const connected = await this.findIdentity()
247 if (connected?.access_token) return connected
248 }
249
250 throw new Error('Connect LinkedIn before publishing.')
251 }
252
253 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
254 return await database
255 .selectFrom('social_identities')
256 .selectAll()
257 .where('provider', '=', 'linkedin')
258 .orderBy('updated_at', 'desc')
259 .executeTakeFirst()
260 }
261
262 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> {
263 const accountId = await ensureAccount()
264 const driver = await this.ensureDriver()
265 const existing = await this.findIdentity()
266 const savedAt = now()
267 const handle = (input.name || input.authorUrn.replace('urn:li:person:', '')).trim()
268
269 const values = {
270 handle,
271 display_name: input.name || null,
272 provider: 'linkedin',
273 external_id: input.authorUrn,
274 auth_status: 'connected',
275 access_token: input.accessToken,
276 refresh_token: null,
277 account_id: accountId,
278 social_driver_id: driver.id,
279 updated_at: savedAt,
280 }
281
282 if (existing) {
283 await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute()
284 }
285 else {
286 await database.insertInto('social_identities').values({
287 uuid: uuid(),
288 ...values,
289 created_at: savedAt,
290 }).execute()
291 }
292
293 return await this.findIdentity() as SocialIdentityRow
294 }
295
296 private async markExpired(id: number): Promise<void> {
297 await database.updateTable('social_identities').set({
298 auth_status: 'expired',
299 updated_at: now(),
300 }).where('id', '=', id).execute()
301 }
302
303 private async ensureDriver(): Promise<SocialDriverRow> {
304 const existing = await database
305 .selectFrom('social_drivers')
306 .selectAll()
307 .where('provider', '=', 'linkedin')
308 .executeTakeFirst()
309
310 if (existing) {
311 if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) {
312 await database.updateTable('social_drivers').set({
313 status: 'active',
314 character_limit: this.driver.characterLimit,
315 updated_at: now(),
316 }).where('id', '=', existing.id).execute()
317 }
318
319 return await database
320 .selectFrom('social_drivers')
321 .selectAll()
322 .where('id', '=', existing.id)
323 .executeTakeFirstOrThrow()
324 }
325
326 const driverUuid = uuid()
327 await database.insertInto('social_drivers').values({
328 uuid: driverUuid,
329 provider: 'linkedin',
330 display_name: 'LinkedIn',
331 status: 'active',
332 character_limit: this.driver.characterLimit,
333 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }),
334 created_at: now(),
335 updated_at: now(),
336 }).execute()
337
338 return await database
339 .selectFrom('social_drivers')
340 .selectAll()
341 .where('uuid', '=', driverUuid)
342 .executeTakeFirstOrThrow()
343 }
344
345 private publicIdentity(row: SocialIdentityRow | undefined) {
346 if (!row) {
347 return {
348 connected: false,
349 provider: 'linkedin',
350 handle: null,
351 displayName: null,
352 did: null,
353 authStatus: 'missing',
354 }
355 }
356
357 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
358 return {
359 connected,
360 provider: 'linkedin',
361 handle: row.handle,
362 displayName: row.display_name || null,
363 did: row.external_id || null,
364 authStatus: connected ? 'connected' : 'missing',
365 }
366 }
367}
368
369function messageOf(error: unknown): string {
370 return error instanceof Error ? error.message : String(error)
371}
372
373export const linkedin = new LinkedInService()
app/Services/Social/support.tsadded+46-0
Changes to app/Services/Social/support.ts
@@ -0,0 +1,46 @@
1import { db } from '@stacksjs/database'
2
3const database = db as any
4
5/** SQLite-friendly `YYYY-MM-DD HH:MM:SS` timestamp. */
6export function now(): string {
7 return new Date().toISOString().slice(0, 19).replace('T', ' ')
8}
9
10export function uuid(): string {
11 return crypto.randomUUID()
12}
13
14/**
15 * Postline is currently single-account. Return the existing account id, or
16 * create the default workspace on first use. Shared by every social service
17 * so a crosspost groups all of its targets under one account/post.
18 */
19export async function ensureAccount(): Promise<number> {
20 const existing = await database
21 .selectFrom('accounts')
22 .select(['id'])
23 .orderBy('id', 'asc')
24 .executeTakeFirst()
25
26 if (existing?.id) return Number(existing.id)
27
28 const accountUuid = uuid()
29 await database.insertInto('accounts').values({
30 uuid: accountUuid,
31 name: 'Chris Breuer',
32 workspace_name: 'Postline',
33 timezone: 'America/Los_Angeles',
34 default_audience: 'public',
35 created_at: now(),
36 updated_at: now(),
37 }).execute()
38
39 const account = await database
40 .selectFrom('accounts')
41 .select(['id'])
42 .where('uuid', '=', accountUuid)
43 .executeTakeFirstOrThrow()
44
45 return Number(account.id)
46}
app/Support/Social/types.tsmodified+23-0
Changes to app/Support/Social/types.ts
@@ -48,6 +48,29 @@ export interface PublishedPost {
4848 url?: string
4949}
5050
51/** Optional content attached to a post: a link card and/or media. */
52export interface PublishContent {
53 external?: {
54 uri: string
55 title: string
56 description?: string
57 }
58 media?: Array<{
59 url: string
60 altText?: string
61 }>
62}
63
64/** Outcome of publishing one post to a single provider during a crosspost. */
65export interface CrosspostTargetResult {
66 provider: SocialProvider
67 ok: boolean
68 url?: string
69 uri?: string
70 targetId?: number
71 error?: string
72}
73
5174export interface TimelineQuery {
5275 cursor?: string
5376 limit?: number
routes/api.tsmodified+19-0
Changes to routes/api.ts
@@ -23,3 +23,22 @@ route.group({ prefix: '/postline/bluesky' }, () => {
2323 route.get('/link-preview', 'Actions/Postline/BlueskyLinkPreviewAction').skipCsrf()
2424 route.get('/timeline', 'Actions/Postline/BlueskyTimelineAction').skipCsrf()
2525})
26
27route.group({ prefix: '/postline/linkedin' }, () => {
28 route.get('/status', 'Actions/Postline/LinkedInStatusAction').skipCsrf()
29 route.post('/connect', 'Actions/Postline/LinkedInConnectAction').skipCsrf()
30 route.get('/auth', 'Actions/Postline/LinkedInAuthAction').skipCsrf()
31 route.get('/callback', 'Actions/Postline/LinkedInCallbackAction').skipCsrf()
32})
33
34route.group({ prefix: '/postline/instagram' }, () => {
35 route.get('/status', 'Actions/Postline/InstagramStatusAction').skipCsrf()
36 route.post('/connect', 'Actions/Postline/InstagramConnectAction').skipCsrf()
37 route.get('/auth', 'Actions/Postline/InstagramAuthAction').skipCsrf()
38 route.get('/callback', 'Actions/Postline/InstagramCallbackAction').skipCsrf()
39})
40
41route.group({ prefix: '/postline' }, () => {
42 route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf()
43 route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf()
44})