support flash

This commit is contained in:
Moon.eth
2026-08-03 15:28:51 +09:00
parent 1b14d94f6a
commit 7d16969f02
16 changed files with 599 additions and 9 deletions
+34
View File
@@ -5,6 +5,7 @@ import {
fetchQuotes,
postStatus,
setEmojiReaction,
uploadMedia,
updateProfileFields,
updatePublicProfile,
votePoll,
@@ -97,6 +98,39 @@ describe('updatePublicProfile', () => {
})
})
describe('media uploads', () => {
it('preserves an SWF file and its MIME type in the multipart upload', async () => {
const swf = new File(['flash bytes'], 'animation.swf', {
type: 'application/x-shockwave-flash',
})
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const form = init?.body as FormData
expect(init?.method).toBe('POST')
expect(form.get('file')).toBe(swf)
expect((form.get('file') as File).name).toBe('animation.swf')
expect((form.get('file') as File).type).toBe('application/x-shockwave-flash')
return new Response(
JSON.stringify({
id: 'flash-1',
type: 'unknown',
url: 'https://media.example.test/animation.swf',
preview_url: null,
pleroma: { mime_type: 'application/x-shockwave-flash' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
})
vi.stubGlobal('fetch', fetchMock)
await uploadMedia(new ApiClient('example.test', 'token'), swf)
expect(fetchMock).toHaveBeenCalledWith(
'https://example.test/api/v1/media',
expect.any(Object),
)
})
})
describe('poll endpoints', () => {
it('submits selected poll option indexes as JSON', async () => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
+7 -1
View File
@@ -110,7 +110,7 @@ export interface CredentialAccount extends Account {
export interface MediaAttachment {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' | 'flash'
url: string
preview_url: string | null
remote_url?: string | null
@@ -121,6 +121,12 @@ export interface MediaAttachment {
small?: { width?: number; height?: number; aspect?: number }
[key: string]: unknown
}
/** Pleroma/Akkoma preserve the original attachment MIME type here. */
pleroma?: {
mime_type?: string | null
name?: string | null
[key: string]: unknown
}
}
export interface StatusMention {
+78
View File
@@ -0,0 +1,78 @@
export interface RufflePlayerApi {
load(options: RuffleLoadOptions | string): Promise<void>
}
export interface RuffleLoadOptions {
url: string
autoplay?: 'on' | 'off' | 'auto'
letterbox?: 'on' | 'off' | 'fullscreen'
allowScriptAccess?: boolean
allowNetworking?: 'all' | 'internal' | 'none'
openUrlMode?: 'allow' | 'confirm' | 'deny'
}
export interface RufflePlayerElement extends HTMLElement {
config: Partial<RuffleLoadOptions>
ruffle(version?: 1): RufflePlayerApi
}
export interface RuffleSource {
createPlayer(): RufflePlayerElement
}
export interface RufflePublicApi {
config?: Record<string, unknown>
newest(): RuffleSource
}
export type RuffleLoader = () => Promise<RufflePublicApi>
declare global {
interface Window {
RufflePlayer?: Partial<RufflePublicApi>
}
}
let loading: Promise<RufflePublicApi> | null = null
function installedRuffle(): RufflePublicApi | null {
return typeof window.RufflePlayer?.newest === 'function'
? (window.RufflePlayer as RufflePublicApi)
: null
}
/** Lazy-load the bundled self-hosted runtime once for every Flash attachment. */
export const loadRuffle: RuffleLoader = async () => {
const installed = installedRuffle()
if (installed) return installed
if (loading) return loading
loading = new Promise<RufflePublicApi>((resolve, reject) => {
const publicPath = new URL(`${import.meta.env.BASE_URL}ruffle/`, document.baseURI).href
window.RufflePlayer = {
...(window.RufflePlayer ?? {}),
config: {
...(window.RufflePlayer?.config ?? {}),
polyfills: false,
publicPath,
},
}
const script = document.createElement('script')
script.src = new URL('ruffle.js', publicPath).href
script.async = true
script.dataset.plspaceRuffle = 'true'
script.onload = () => {
const api = installedRuffle()
if (api) resolve(api)
else reject(new Error('Ruffle loaded without installing its player API.'))
}
script.onerror = () => reject(new Error('Could not load the bundled Ruffle runtime.'))
document.head.appendChild(script)
}).catch((cause) => {
loading = null
throw cause
})
return loading
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { MediaAttachment } from '$lib/api/types'
import { flashAspectRatio, isFlashAttachment } from './flash'
function attachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
return {
id: 'file-1',
type: 'unknown',
url: 'https://media.example.test/file.bin',
preview_url: null,
...overrides,
}
}
describe('Flash attachment detection', () => {
it('recognizes Pleroma MIME metadata and explicit Flash types', () => {
expect(
isFlashAttachment(
attachment({ pleroma: { mime_type: 'application/x-shockwave-flash' } }),
),
).toBe(true)
expect(isFlashAttachment(attachment({ type: 'flash' }))).toBe(true)
})
it('recognizes case-insensitive SWF paths despite query strings or fragments', () => {
expect(
isFlashAttachment(
attachment({ url: 'https://media.example.test/games/MOVIE.SWF?download=1#play' }),
),
).toBe(true)
expect(
isFlashAttachment(attachment({ url: 'https://media.example.test/movie.swf.png' })),
).toBe(false)
})
it('uses bounded intrinsic dimensions and a safe fallback', () => {
expect(
flashAspectRatio(
attachment({ meta: { original: { width: 1920, height: 1080 } } }),
),
).toBeCloseTo(16 / 9)
expect(
flashAspectRatio(attachment({ meta: { original: { width: 10000, height: 1 } } })),
).toBeCloseTo(4 / 3)
})
})
+25
View File
@@ -0,0 +1,25 @@
import type { MediaAttachment } from '../api/types'
/** Detect Pleroma's Flash MIME extension and Mastodon-compatible `.swf` URLs. */
export function isFlashAttachment(media: MediaAttachment): boolean {
if (media.type === 'flash') return true
if (/flash/i.test(media.pleroma?.mime_type ?? '')) return true
for (const value of [media.url, media.remote_url]) {
if (!value) continue
try {
if (/\.swf$/i.test(new URL(value, window.location.href).pathname)) return true
} catch {
if (/\.swf(?:[?#]|$)/i.test(value)) return true
}
}
return false
}
/** Use trustworthy server dimensions, while preventing pathological layouts. */
export function flashAspectRatio(media: MediaAttachment): number {
const width = media.meta?.original?.width
const height = media.meta?.original?.height
const ratio = width && height ? width / height : Number.NaN
return Number.isFinite(ratio) && ratio >= 0.25 && ratio <= 4 ? ratio : 4 / 3
}