also looking at this
feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter
#30
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.
| @@ -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 | }) | |
| @@ -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 | }) |
| @@ -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 | } | |
| @@ -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() |
| @@ -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 | }) | |