ReviewOS

also looking at this

stacks/postline

feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter

#30
Merged glennmichael123 wants to merge feat/crosspost-media-and-token-refresh into main
28 files +1810 -109

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.

app/Actions/Postline/CrosspostPublishAction.tsmodified+14-3
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -3,7 +3,7 @@ import type { RequestInstance } from '@stacksjs/types'
33import { Action } from '@stacksjs/actions'
44import { response } from '@stacksjs/router'
55import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService'
6import { readUploadedImage } from '../../Support/Social/uploads'
6import { persistTempMedia, readUploadedImage, removeTempMedia } from '../../Support/Social/uploads'
77
88export default new Action({
99 name: 'Postline Crosspost Publish',
@@ -35,9 +35,17 @@ export default new Action({
3535 const imageAlt = String(request.get('image_alt') || '').trim()
3636
3737 const media: NonNullable<Parameters<typeof crosspost.publish>[2]>['media'] = []
38 let tempMediaFile: string | null = null
3839 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 }
4149 if (imageUrl)
4250 media.push({ url: imageUrl, altText: imageAlt || undefined })
4351
@@ -66,5 +74,8 @@ export default new Action({
6674 error: error instanceof Error ? error.message : String(error),
6775 }, { status: 422 })
6876 }
77 finally {
78 if (tempMediaFile) await removeTempMedia(tempMediaFile)
79 }
6980 },
7081})
app/Actions/Postline/MediaServeAction.tsadded+45-0
Changes to app/Actions/Postline/MediaServeAction.ts
@@ -0,0 +1,45 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { join } from 'node:path'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { 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 */
20export 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})
app/Services/Social/QueueService.tsmodified+6-4
Changes to app/Services/Social/QueueService.ts
@@ -1,17 +1,14 @@
11import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
22import { mkdir, unlink } from 'node:fs/promises'
33import { join } from 'node:path'
4import process from 'node:process'
54import { db } from '@stacksjs/database'
65import { env } from '@stacksjs/env'
6import { MEDIA_DIR, publicMediaUrl } from '../../Support/Social/uploads'
77import { crosspost, crosspostProviders } from './CrosspostService'
88import { ensureAccount, now, uuid } from './support'
99
1010const database = db as any
1111
12/** Uploaded images for queued posts live here until publish. */
13const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
14
1512/** Serialized into posts.content — everything beyond the text itself. */
1613interface StoredContent {
1714 /** Only set when the composer provided an explicit title. */
@@ -379,6 +376,11 @@ export class QueueService {
379376 if (await file.exists()) {
380377 media.push({
381378 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,
382384 mimeType: item.mimeType,
383385 altText: item.altText,
384386 })
app/Support/Social/uploads.tsmodified+93-0
Changes to app/Support/Social/uploads.ts
@@ -1,3 +1,11 @@
1import { mkdir, unlink } from 'node:fs/promises'
2import { join } from 'node:path'
3import process from 'node:process'
4import { env } from '@stacksjs/env'
5
6/** Uploaded images for queued posts live here until publish. */
7export const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
8
19/**
210 * Read an uploaded image regardless of shape: the router's multipart
311 * parser stores raw Web `File` objects on `request.files`, while the
@@ -19,3 +27,88 @@ export async function readUploadedImage(uploaded: unknown): Promise<{ bytes: Uin
1927
2028 return null
2129}
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 */
37export 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. */
42export 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 */
60export 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 */
77export 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. */
83export 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 */
94export 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. */
111export async function removeTempMedia(filename: string): Promise<void> {
112 if (!isSafeMediaFilename(filename)) return
113 await unlink(join(MEDIA_DIR, filename)).catch(() => {})
114}
routes/api.tsmodified+5-0
Changes to routes/api.ts
@@ -23,6 +23,11 @@ route.post('/register', 'Actions/Postline/RegisterFirstUserAction').skipCsrf().r
2323route.get('/', () => response.text('hello world'))
2424route.get('/coming-soon', 'Controllers/ComingSoonController@index')
2525
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).
29route.get('/postline/media', 'Actions/Postline/MediaServeAction').skipCsrf()
30
2631route.group({ prefix: '/postline/bluesky' }, () => {
2732 route.get('/status', 'Actions/Postline/BlueskyStatusAction').middleware('auth').skipCsrf()
2833 route.post('/connect', 'Actions/Postline/BlueskyConnectAction').middleware('auth').skipCsrf()
tests/unit/media-serve.test.tsadded+63-0
Changes to tests/unit/media-serve.test.ts
@@ -0,0 +1,63 @@
1import { describe, expect, test } from 'bun:test'
2import { buildMediaUrl, isSafeMediaFilename, mediaContentType } from '../../app/Support/Social/uploads'
3
4describe('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
27describe('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
41describe('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})