custom css works now

This commit is contained in:
Moon.eth
2026-07-29 16:03:12 +09:00
parent f51c576952
commit 81176654ba
11 changed files with 777 additions and 35 deletions
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client'
import { updateProfileFields } from './endpoints'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('updateProfileFields', () => {
it('uses the Pleroma/Mastodon indexed multipart representation', async () => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.method).toBe('PATCH')
expect(init?.headers).toBeInstanceOf(Headers)
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer token')
expect(init?.body).toBeInstanceOf(FormData)
const form = init?.body as FormData
expect([...form.entries()]).toEqual([
['fields_attributes[0][name]', 'Website'],
['fields_attributes[0][value]', 'https://example.test'],
['fields_attributes[1][name]', 'plspace:css1'],
['fields_attributes[1][value]', 'body { color: pink; }'],
])
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
vi.stubGlobal('fetch', fetchMock)
await updateProfileFields(new ApiClient('example.test', 'token'), [
{ name: 'Website', value: 'https://example.test' },
{ name: 'plspace:css1', value: 'body { color: pink; }' },
])
expect(fetchMock).toHaveBeenCalledWith(
'https://example.test/api/v1/accounts/update_credentials',
expect.any(Object),
)
})
})
+21
View File
@@ -85,6 +85,27 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
}
/**
* Replace the signed-in account's profile fields while leaving every other
* credential untouched. Mastodon and Pleroma both accept this indexed
* multipart shape; Pleroma-FE uses the same representation.
*/
export async function updateProfileFields(
api: ApiClient,
fields: Array<{ name: string; value: string }>,
): Promise<CredentialAccount> {
const form = new FormData()
fields.forEach((field, index) => {
form.append(`fields_attributes[${index}][name]`, field.name)
form.append(`fields_attributes[${index}][value]`, field.value)
})
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
method: 'PATCH',
form,
})
return data
}
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
}
+21 -2
View File
@@ -266,11 +266,30 @@ export interface InstanceInfo {
max_characters?: number
max_media_attachments?: number
}
accounts?: {
max_profile_fields?: number
/** Pleroma v2 names. */
profile_field_name_limit?: number
profile_field_value_limit?: number
/** Mastodon-compatible aliases used by some implementations. */
max_profile_field_name_length?: number
max_profile_field_value_length?: number
}
}
registrations?: boolean | { enabled?: boolean }
contact_account?: Account | null
/** Pleroma reports its "real" upstream here. */
pleroma?: unknown
/** Pleroma reports its "real" upstream and field limits here. */
pleroma?: {
metadata?: {
fields_limits?: {
max_fields?: number
name_length?: number
value_length?: number
}
[key: string]: unknown
}
[key: string]: unknown
}
}
export interface SearchResults {
+226 -10
View File
@@ -16,6 +16,8 @@
* run script.
*/
import type { InstanceInfo } from '../api/types'
const VIEWER_STYLE_ID = 'user-stylesheet'
const PROFILE_STYLE_ID = 'profile-stylesheet'
const STORAGE_KEY = 'plspace:viewer-css'
@@ -26,6 +28,228 @@ export const PROFILE_SCOPE = '.profile-page'
/** Field names checked, in order, for a profile's published stylesheet. */
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
const NAMESPACED_CSS_FIELD_PATTERN = /^plspace:css(\d+)$/i
const LEGACY_CSS_FIELD_PATTERN = /^(css|style|layout|stylesheet)(?:[\s_-]+(\d+))?$/i
const ENCODED_CSS_PREFIX = 'plspace-css-b64:'
export interface ProfileFieldLimits {
maxFields: number
nameLength: number
valueLength: number
}
/** Normalize Mastodon v2 and Pleroma extension shapes, with safe old-server defaults. */
export function profileFieldLimits(instance: InstanceInfo | null): ProfileFieldLimits {
const accounts = instance?.configuration?.accounts
const pleroma = instance?.pleroma?.metadata?.fields_limits
const isPleroma = Boolean(instance?.pleroma)
return {
maxFields: accounts?.max_profile_fields ?? pleroma?.max_fields ?? (isPleroma ? 10 : 4),
nameLength:
accounts?.profile_field_name_limit ??
accounts?.max_profile_field_name_length ??
pleroma?.name_length ??
255,
valueLength:
accounts?.profile_field_value_limit ??
accounts?.max_profile_field_value_length ??
pleroma?.value_length ??
(isPleroma ? 2048 : 255),
}
}
function fieldText(field: { value: string }): string {
const container = document.createElement('div')
container.innerHTML = field.value
for (const br of Array.from(container.querySelectorAll('br'))) {
br.replaceWith(document.createTextNode('\n'))
}
for (const block of Array.from(container.querySelectorAll('p, div, li'))) {
if (block.nextSibling) block.append(document.createTextNode('\n'))
}
return container.textContent ?? ''
}
function cssField(
field: { name: string },
): { namespace: 'plspace' | 'legacy'; base: string; part: number } | null {
const name = field.name.trim()
const namespaced = NAMESPACED_CSS_FIELD_PATTERN.exec(name)
if (namespaced) {
return { namespace: 'plspace', base: 'css', part: Number(namespaced[1]) }
}
const legacy = LEGACY_CSS_FIELD_PATTERN.exec(name)
if (!legacy) return null
return {
namespace: 'legacy',
base: legacy[1].toLowerCase(),
part: legacy[2] ? Number(legacy[2]) : 1,
}
}
/** Reassemble a single CSS field or the ordered chunks written by plspace. */
export function publishedCssFromFields(
fields: Array<{ name: string; value: string }> | undefined,
): string {
if (!fields?.length) return ''
const namespaced = fields
.map((field, index) => ({ field, index, parsed: cssField(field) }))
.filter((entry) => entry.parsed?.namespace === 'plspace')
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
if (namespaced.length > 0) {
const values = namespaced.map(({ field }) => fieldText(field))
if (values.every((value) => value.startsWith(ENCODED_CSS_PREFIX))) {
try {
return decodeCss(
values.map((value) => value.slice(ENCODED_CSS_PREFIX.length)).join(''),
).trim()
} catch {
// Keep profiles readable if a field was edited or truncated elsewhere.
return ''
}
}
return values.join('').trim()
}
for (const preferred of CSS_FIELD_NAMES) {
const chunks = fields
.map((field, index) => ({ field, index, parsed: cssField(field) }))
.filter(
(entry) =>
entry.parsed?.namespace === 'legacy' && entry.parsed.base === preferred,
)
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
if (chunks.length > 0) return chunks.map(({ field }) => fieldText(field)).join('').trim()
}
return ''
}
/**
* Browsers normalize bare LF line endings to CRLF when serializing FormData,
* and Pleroma validates the decoded multipart value. Budget two characters for
* each newline so a nominal 2,048-character chunk cannot arrive over the limit.
*
* Split on rule boundaries when possible while otherwise retaining the source.
* Chunks are concatenated without a separator, including when a very long
* quoted data URI has to be split in its middle.
*/
export function splitPublishedCss(css: string, limit: number): string[] {
const source = css.replace(/\r\n?/g, '\n').trim()
if (!source) return []
const safeLimit = Math.max(1, Math.floor(limit))
const chunks: string[] = []
let rest = source
while (multipartFieldLength(rest) > safeLimit) {
let windowEnd = 0
let encodedLength = 0
while (windowEnd < rest.length) {
const width = rest[windowEnd] === '\n' ? 2 : 1
if (encodedLength + width > safeLimit) break
encodedLength += width
windowEnd += 1
}
// A one-character limit can still make progress on a newline.
if (windowEnd === 0) windowEnd = 1
const window = rest.slice(0, windowEnd)
let cut = window.lastIndexOf('}')
if (cut >= 0) cut += 1
else {
cut = window.lastIndexOf('\n')
if (cut <= 0) cut = windowEnd
}
chunks.push(rest.slice(0, cut))
rest = rest.slice(cut)
}
if (rest) chunks.push(rest)
return chunks
}
/** Length after the newline normalization used for multipart form fields. */
export function multipartFieldLength(value: string): number {
let length = value.length
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '\n' && value[index - 1] !== '\r') length += 1
}
return length
}
function encodeCss(value: string): string {
const bytes = new TextEncoder().encode(value)
let binary = ''
const windowSize = 0x8000
for (let index = 0; index < bytes.length; index += windowSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + windowSize))
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function decodeCss(value: string): string {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
/**
* Encode published CSS before placing it in profile fields.
*
* Pleroma linkifies raw profile-field values before enforcing its configured
* value limit. CSS commonly contains URLs and hashtag-shaped tokens such as
* `#fff`, so a raw value under the advertised limit can expand into HTML over
* that limit. Base64url has no linkifiable syntax, making its server-side
* length identical to the length measured here.
*/
export function publishedCssFieldValues(css: string, limit: number): string[] {
const source = css.replace(/\r\n?/g, '\n').trim()
if (!source) return []
const capacity = Math.floor(limit) - ENCODED_CSS_PREFIX.length
if (capacity < 1) {
throw new Error('This servers profile field-value limit is too small for encoded CSS.')
}
const encoded = encodeCss(source)
const values: string[] = []
for (let index = 0; index < encoded.length; index += capacity) {
values.push(ENCODED_CSS_PREFIX + encoded.slice(index, index + capacity))
}
return values
}
/**
* Preserve normal profile fields and replace all prior CSS chunks atomically.
* Legacy CSS fields are migrated to the unambiguous `plspace:css1` namespace.
*/
export function replacePublishedCssFields(
fields: Array<{ name: string; value: string }>,
css: string,
limits: ProfileFieldLimits,
): Array<{ name: string; value: string }> {
const existingCss = fields.map((field) => ({ field, parsed: cssField(field) }))
const ordinary = existingCss
.filter((entry) => !entry.parsed)
.map(({ field }) => ({ name: field.name, value: field.value }))
const chunks = publishedCssFieldValues(css, limits.valueLength)
const cssFields = chunks.map((value, index) => ({
name: `plspace:css${index + 1}`,
value,
}))
if (ordinary.length + cssFields.length > limits.maxFields) {
const available = Math.max(0, limits.maxFields - ordinary.length)
throw new Error(
`This server has room for ${available} CSS field${available === 1 ? '' : 's'}, but this stylesheet needs ${cssFields.length}. Shorten it or remove another profile field.`,
)
}
if (cssFields.some((field) => field.name.length > limits.nameLength)) {
throw new Error('This servers profile field-name limit is too small for chunked CSS.')
}
return [...ordinary, ...cssFields]
}
/**
* Get (or create) a style element, always moving it to the end of `<head>`.
*
@@ -194,14 +418,6 @@ export const theme = new Theme()
export function profileCssFromFields(
fields: Array<{ name: string; value: string }> | undefined,
): string | null {
if (!fields?.length) return null
for (const field of fields) {
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
// Field values arrive as HTML; take the text and undo entity escaping.
const container = document.createElement('div')
container.innerHTML = field.value
const text = (container.textContent ?? '').trim()
if (text.includes('{')) return text
}
return null
const css = publishedCssFromFields(fields)
return css.includes('{') ? css : null
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import {
profileCssFromFields,
profileFieldLimits,
multipartFieldLength,
publishedCssFieldValues,
publishedCssFromFields,
replacePublishedCssFields,
splitPublishedCss,
} from './theme.svelte'
describe('published profile CSS fields', () => {
it('reads ordinary and chunked CSS fields in numeric order', () => {
const fields = [
{ name: 'Website', value: '<a href="https://example.test">example</a>' },
{ name: 'plspace:css3', value: '<p>.three { color: blue; }</p>' },
{ name: 'plspace:css1', value: '<p>.one { color: red; }</p>' },
{ name: 'plspace:css2', value: '<p>.two { color: green; }</p>' },
]
expect(publishedCssFromFields(fields)).toBe(
'.one { color: red; }.two { color: green; }.three { color: blue; }',
)
expect(profileCssFromFields(fields)).toContain('.three { color: blue; }')
})
it('splits long stylesheets on complete rule boundaries', () => {
const css = '.one { color: red; }\n.two { color: green; }\n.three { color: blue; }'
const chunks = splitPublishedCss(css, 42)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 42)).toBe(true)
expect(chunks.join('')).toBe(css)
})
it('budgets for FormData expanding line feeds to CRLF', () => {
const css = Array.from({ length: 20 }, (_, index) => `.r${index} {\n color: pink;\n}`).join(
'\n',
)
const chunks = splitPublishedCss(css, 80)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 80)).toBe(true)
expect(chunks.join('')).toBe(css)
})
it('stores CSS as non-linkifiable chunks and decodes Unicode exactly', () => {
const css =
'.profile { color: #fff; background: url("https://example.test/image.png"); }\n/* 日本語 🌸 */'
const values = publishedCssFieldValues(css, 48)
expect(values.length).toBeGreaterThan(1)
expect(values.every((value) => value.length <= 48)).toBe(true)
expect(values.every((value) => /^plspace-css-b64:[A-Za-z0-9_-]+$/.test(value))).toBe(true)
expect(
publishedCssFromFields(
values.map((value, index) => ({ name: `plspace:css${index + 1}`, value })),
),
).toBe(css)
})
it('replaces old CSS chunks while preserving unrelated profile fields', () => {
const result = replacePublishedCssFields(
[
{ name: 'Website', value: 'https://example.test' },
{ name: 'style', value: 'old' },
{ name: 'style 2', value: 'old continuation' },
{ name: 'Pronouns', value: 'they/them' },
],
'.one { color: red; }\n.two { color: green; }',
{ maxFields: 6, nameLength: 255, valueLength: 55 },
)
expect(result.slice(0, 2)).toEqual([
{ name: 'Website', value: 'https://example.test' },
{ name: 'Pronouns', value: 'they/them' },
])
expect(result.slice(2).map((field) => field.name)).toEqual([
'plspace:css1',
'plspace:css2',
])
expect(publishedCssFromFields(result.slice(2))).toBe(
'.one { color: red; }\n.two { color: green; }',
)
})
it('removes only CSS fields when publishing an empty value', () => {
expect(
replacePublishedCssFields(
[
{ name: 'css', value: 'body {}' },
{ name: 'plspace:css2', value: '.more {}' },
{ name: 'Location', value: 'Tokyo' },
],
'',
{ maxFields: 4, nameLength: 255, valueLength: 255 },
),
).toEqual([{ name: 'Location', value: 'Tokyo' }])
})
it('rejects a stylesheet that exceeds the server field count', () => {
expect(() =>
replacePublishedCssFields(
[{ name: 'Website', value: 'https://example.test' }],
'.a{color:red}.b{color:blue}.c{color:green}',
{ maxFields: 2, nameLength: 255, valueLength: 30 },
),
).toThrow(/room for 1 CSS field/)
})
it('normalizes both Mastodon and Pleroma field-limit shapes', () => {
expect(
profileFieldLimits({
title: 'Mastodon',
version: '4.5.0',
configuration: {
accounts: {
max_profile_fields: 4,
max_profile_field_name_length: 255,
max_profile_field_value_length: 500,
},
},
}),
).toEqual({ maxFields: 4, nameLength: 255, valueLength: 500 })
expect(
profileFieldLimits({
title: 'Pleroma',
version: '2.9.0',
configuration: {
accounts: {
max_profile_fields: 10,
profile_field_name_limit: 512,
profile_field_value_limit: 2048,
},
},
pleroma: {
metadata: {
fields_limits: { max_fields: 9, name_length: 500, value_length: 2000 },
},
},
}),
).toEqual({ maxFields: 10, nameLength: 512, valueLength: 2048 })
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { account } from '$test/fixtures'
import { buildProfileView } from './profile'
describe('buildProfileView', () => {
it('hides namespaced plspace storage fields from visible profile details', () => {
const profile = buildProfileView(
account({
fields: [
{ name: 'Website', value: '<a href="https://example.test">example.test</a>' },
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
{ name: ' PLSPACE:future-setting ', value: 'internal' },
],
}),
)
expect(profile.details).toHaveLength(1)
expect(profile.details[0].name).toBe('Website')
expect(profile.details[0].value).toContain('example.test')
})
})
+4
View File
@@ -132,6 +132,10 @@ export function buildProfileView(account: Account): ProfileView {
for (const field of account.fields ?? []) {
const key = field.name.trim().toLowerCase()
// `plspace:` fields are application storage, not user-facing profile
// details. Keep them on the Account for features such as published CSS,
// but never turn them into visible name/value rows.
if (key.startsWith('plspace:')) continue
if (CHROME_FIELDS.has(key)) continue
const value = renderHtml(field.value, { emojis: account.emojis })