Add public agent profiles and community moderation
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
agent,
|
||||
size = 'default',
|
||||
meta = '',
|
||||
showId = false,
|
||||
linked = true
|
||||
}: {
|
||||
agent: { id: string; displayName: string; avatarUrl: string };
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
meta?: string;
|
||||
showId?: boolean;
|
||||
linked?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if linked}
|
||||
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
|
||||
<img src={agent.avatarUrl} alt="" />
|
||||
<span>
|
||||
<strong>{agent.displayName}</strong>
|
||||
{#if meta}<small>{meta}</small>{:else if showId}<small>AGENT {agent.id.slice(0,8).toUpperCase()}</small>{/if}
|
||||
</span>
|
||||
<i aria-hidden="true">›</i>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="agent-identity unavailable" class:compact={size === 'compact'} class:large={size === 'large'}>
|
||||
<img src={agent.avatarUrl} alt="" />
|
||||
<span><strong>{agent.displayName}</strong><small>PROFILE HIDDEN</small></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.agent-identity{min-width:0;display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;color:var(--shd-text)}
|
||||
.agent-identity img{width:38px;height:38px;border:1px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}
|
||||
.agent-identity span{min-width:0;display:grid;gap:3px}.agent-identity strong,.agent-identity small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.agent-identity strong{font-size:var(--shd-type-supporting)}.agent-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.agent-identity i{color:var(--shd-text-dim);font-size:18px;font-style:normal}.agent-identity:hover strong,.agent-identity:focus-visible strong,.agent-identity:hover i,.agent-identity:focus-visible i{color:var(--shd-orange)}
|
||||
.agent-identity.compact{grid-template-columns:30px minmax(0,1fr) auto;gap:7px}.agent-identity.compact img{width:30px;height:30px}.agent-identity.compact strong{font-size:var(--shd-type-telemetry)}
|
||||
.agent-identity.large{grid-template-columns:74px minmax(0,1fr) auto;gap:15px}.agent-identity.large img{width:74px;height:74px;box-shadow:0 0 0 5px var(--shd-bg),0 0 0 6px color-mix(in srgb,var(--shd-orange) 35%,transparent)}.agent-identity.large strong{font-size:clamp(1.5rem,3vw,2.6rem);line-height:1}.agent-identity.large small{font-size:var(--shd-type-supporting)}
|
||||
.agent-identity.unavailable{grid-template-columns:38px minmax(0,1fr)}.agent-identity.unavailable strong{color:var(--shd-text-muted)}
|
||||
.agent-identity.unavailable.compact{grid-template-columns:30px minmax(0,1fr)}.agent-identity.unavailable.large{grid-template-columns:74px minmax(0,1fr)}
|
||||
@media(max-width:600px){.agent-identity{min-height:44px}.agent-identity.large{grid-template-columns:58px minmax(0,1fr) auto}.agent-identity.large img{width:58px;height:58px}.agent-identity.large strong{font-size:1.6rem}.agent-identity.unavailable.large{grid-template-columns:58px minmax(0,1fr)}}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
html = '',
|
||||
emptyMessage = 'No field notes have been added.',
|
||||
compact = false
|
||||
}: {
|
||||
html?: string;
|
||||
emptyMessage?: string;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if html.trim()}
|
||||
<div class="markdown-content" class:compact>{@html html}</div>
|
||||
{:else}
|
||||
<p class="markdown-empty">{emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.markdown-content{max-width:76ch;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.7;overflow-wrap:anywhere}
|
||||
.markdown-content.compact{font-size:var(--shd-type-supporting);line-height:1.55}
|
||||
.markdown-content :global(h3),.markdown-content :global(h4),.markdown-content :global(h5){margin:1.5rem 0 .45rem;color:var(--shd-text);font-family:var(--shd-font-sans);line-height:1.15}
|
||||
.markdown-content :global(h3){font-size:1.55rem}.markdown-content :global(h4){font-size:1.3rem}.markdown-content :global(h5){font-size:1.1rem}
|
||||
.markdown-content :global(p),.markdown-content :global(ul),.markdown-content :global(ol),.markdown-content :global(blockquote){margin:.7em 0}
|
||||
.markdown-content :global(h3 + p),.markdown-content :global(h4 + p),.markdown-content :global(h5 + p),.markdown-content :global(h3 + ul),.markdown-content :global(h4 + ul),.markdown-content :global(h5 + ul),.markdown-content :global(h3 + ol),.markdown-content :global(h4 + ol),.markdown-content :global(h5 + ol),.markdown-content :global(h3 + blockquote),.markdown-content :global(h4 + blockquote),.markdown-content :global(h5 + blockquote){margin-top:0}
|
||||
.markdown-content :global(ul),.markdown-content :global(ol){padding-left:1.4rem}
|
||||
.markdown-content :global(li+li){margin-top:.3em}
|
||||
.markdown-content :global(a){color:var(--shd-orange);font-family:var(--shd-font-sans);font-weight:700}
|
||||
.markdown-content :global(a:hover),.markdown-content :global(a:focus-visible){color:var(--shd-orange-hover)}
|
||||
.markdown-content :global(blockquote){padding:.2em 0 .2em 1em;border-left:2px solid var(--shd-orange);color:var(--shd-text-muted)}
|
||||
.markdown-content :global(code){padding:2px 4px;border:1px solid var(--shd-border);background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-mono);font-size:.9em}
|
||||
.markdown-content :global(:first-child){margin-top:0}
|
||||
.markdown-content :global(:last-child){margin-bottom:0}
|
||||
.markdown-empty{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.55}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
|
||||
import { renderUserMarkdown } from '$lib/markdown';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
name,
|
||||
label = 'MARKDOWN',
|
||||
help = 'Use Markdown for headings, lists, emphasis, quotes, and links.',
|
||||
maxLength = 4000,
|
||||
rows = 9,
|
||||
required = false,
|
||||
disabled = false,
|
||||
id = `${name}-markdown-editor`
|
||||
}: {
|
||||
value?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
help?: string;
|
||||
maxLength?: number;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
let textarea = $state<HTMLTextAreaElement>();
|
||||
let previewing = $state(false);
|
||||
const remaining = $derived(maxLength - value.length);
|
||||
const previewHtml = $derived(renderUserMarkdown(value));
|
||||
|
||||
async function replaceSelection(
|
||||
before: string,
|
||||
after = '',
|
||||
placeholder = 'text',
|
||||
linePrefix = false
|
||||
) {
|
||||
if (!textarea || disabled) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
if (linePrefix) {
|
||||
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
|
||||
value = `${value.slice(0, lineStart)}${before}${value.slice(lineStart)}`.slice(0, maxLength);
|
||||
await tick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, end + before.length);
|
||||
return;
|
||||
}
|
||||
const selected = value.slice(start, end) || placeholder;
|
||||
const insert = `${before}${selected}${after}`;
|
||||
value = `${value.slice(0, start)}${insert}${value.slice(end)}`.slice(0, maxLength);
|
||||
await tick();
|
||||
textarea.focus();
|
||||
const selectionStart = start + before.length;
|
||||
textarea.setSelectionRange(selectionStart, Math.min(selectionStart + selected.length, value.length));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="markdown-editor">
|
||||
<div class="markdown-editor-head">
|
||||
<label for={id}>{label}</label>
|
||||
<div class="markdown-editor-meta">
|
||||
<div class="markdown-view-tabs" aria-label={`${label} view`}>
|
||||
<button type="button" class:active={!previewing} aria-pressed={!previewing} onclick={() => previewing = false}>EDIT</button>
|
||||
<button type="button" class:active={previewing} aria-pressed={previewing} onclick={() => previewing = true}>PREVIEW</button>
|
||||
</div>
|
||||
<span class:near-limit={remaining < Math.min(200, maxLength * .1)}>{remaining.toLocaleString()} REMAINING</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if previewing}
|
||||
<input type="hidden" {name} value={value}/>
|
||||
<div class="markdown-preview" aria-live="polite"><MarkdownContent html={previewHtml} emptyMessage="Nothing to preview yet."/></div>
|
||||
{:else}
|
||||
<div class="markdown-toolbar" aria-label={`${label} formatting controls`}>
|
||||
<button type="button" disabled={disabled} aria-label="Bold selected text" onclick={() => replaceSelection('**','**','bold text')}><b>B</b></button>
|
||||
<button type="button" disabled={disabled} aria-label="Italicize selected text" onclick={() => replaceSelection('*','*','italic text')}><i>I</i></button>
|
||||
<button type="button" disabled={disabled} aria-label="Add heading" onclick={() => replaceSelection('## ','','Heading',true)}>H2</button>
|
||||
<button type="button" disabled={disabled} aria-label="Add bulleted list item" onclick={() => replaceSelection('- ','','List item',true)}>LIST</button>
|
||||
<button type="button" disabled={disabled} aria-label="Add quote" onclick={() => replaceSelection('> ','','Quoted text',true)}>QUOTE</button>
|
||||
<button type="button" disabled={disabled} aria-label="Add link" onclick={() => replaceSelection('[','](https://)','link text')}>LINK</button>
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value
|
||||
{id}
|
||||
{name}
|
||||
{rows}
|
||||
{required}
|
||||
{disabled}
|
||||
maxlength={maxLength}
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
{/if}
|
||||
<div class="markdown-editor-foot">
|
||||
<small>{help}</small>
|
||||
<span>MARKDOWN SUPPORTED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-editor{display:grid;border:1px solid var(--shd-border);background:#0d0f10}
|
||||
.markdown-editor-head,.markdown-editor-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;background:var(--shd-surface-raised)}
|
||||
.markdown-editor-head{border-bottom:1px solid var(--shd-border)}.markdown-editor-foot{border-top:1px solid var(--shd-border)}
|
||||
.markdown-editor-head label,.markdown-editor-head span,.markdown-editor-foot span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.markdown-editor-head label{color:var(--shd-text)}.markdown-editor-head span,.markdown-editor-foot span{color:var(--shd-text-muted)}.markdown-editor-head span.near-limit{color:var(--shd-orange)}
|
||||
.markdown-editor-meta{display:flex;align-items:center;gap:9px}.markdown-view-tabs{display:flex;border:1px solid var(--shd-border)}.markdown-view-tabs button{min-height:28px;padding:0 8px;border:0;border-left:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.markdown-view-tabs button:first-child{border-left:0}.markdown-view-tabs button.active{background:#211a0e;color:var(--shd-orange)}
|
||||
.markdown-editor-foot small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.markdown-toolbar{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--shd-border)}
|
||||
.markdown-toolbar button{min-width:40px;height:36px;padding:0 9px;border:0;border-right:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.markdown-toolbar button:hover,.markdown-toolbar button:focus-visible{background:var(--shd-surface-active);color:var(--shd-orange)}
|
||||
.markdown-toolbar button:active{transform:translateY(1px);filter:brightness(.82)}
|
||||
.markdown-toolbar button:disabled{cursor:not-allowed;opacity:.48}
|
||||
.markdown-editor textarea{width:100%;min-height:150px;margin:0;padding:11px;border:0;border-radius:0;background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.55;resize:vertical}
|
||||
.markdown-editor textarea:focus-visible{position:relative;z-index:1;outline:1px solid var(--shd-orange);outline-offset:-1px}
|
||||
.markdown-preview{min-height:150px;padding:11px}.markdown-view-tabs button:active{transform:translateY(1px)}
|
||||
@media(max-width:600px){.markdown-toolbar button{min-width:44px;height:44px}.markdown-editor-head,.markdown-editor-foot{align-items:flex-start;flex-direction:column}.markdown-editor-meta{width:100%;justify-content:space-between}.markdown-view-tabs button{min-height:44px}.markdown-editor-foot span{align-self:flex-end}}
|
||||
@media(prefers-reduced-motion:reduce){.markdown-toolbar button{transition:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
const escapeHtml = (value: string) => value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
function safeLink(raw: string) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inlineMarkdown(value: string) {
|
||||
const code: string[] = [];
|
||||
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
|
||||
const token = `\u0000CODE${code.length}\u0000`;
|
||||
code.push(`<code>${body}</code>`);
|
||||
return token;
|
||||
});
|
||||
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
|
||||
const decodedHref = href
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('"', '"');
|
||||
const safe = safeLink(decodedHref);
|
||||
return safe
|
||||
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
|
||||
: label;
|
||||
});
|
||||
output = output
|
||||
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
|
||||
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
|
||||
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the shared Markdown subset accepted for user biographies and comments.
|
||||
* Raw HTML is always escaped. Links are restricted to HTTP(S) and receive UGC,
|
||||
* nofollow, and opener isolation attributes.
|
||||
*/
|
||||
export function renderUserMarkdown(markdown: unknown): string {
|
||||
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
||||
if (!input) return '';
|
||||
const lines = input.split('\n');
|
||||
const output: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: 'ul' | 'ol' | null = null;
|
||||
|
||||
const closeParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
const closeList = () => {
|
||||
if (!list) return;
|
||||
output.push(`</${list}>`);
|
||||
list = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length + 2;
|
||||
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
||||
if (unordered || ordered) {
|
||||
closeParagraph();
|
||||
const nextList = unordered ? 'ul' : 'ol';
|
||||
if (list !== nextList) {
|
||||
closeList();
|
||||
list = nextList;
|
||||
output.push(`<${list}>`);
|
||||
}
|
||||
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
|
||||
continue;
|
||||
}
|
||||
const quote = /^>\s?(.+)$/.exec(trimmed);
|
||||
if (quote) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
|
||||
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
||||
if (Array.from(normalized).length > maxCharacters) return null;
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
@@ -26,9 +26,15 @@ export async function softDeleteAccount(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
|
||||
)).run(deletedUsername, userId);
|
||||
await (await database.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`
|
||||
)).run(userId, 'Deleted Agent', now);
|
||||
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`
|
||||
)).run(userId, 'Deleted Agent', '', now, now);
|
||||
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeUserMarkdown, renderUserMarkdown } from '../markdown';
|
||||
|
||||
describe('user Markdown', () => {
|
||||
it('escapes raw HTML and blocks executable links', () => {
|
||||
const rendered = renderUserMarkdown(
|
||||
'<script>alert(1)</script> [unsafe](javascript:alert(1)) [safe](https://shd.build)'
|
||||
);
|
||||
expect(rendered).toContain('<script>');
|
||||
expect(rendered).not.toContain('<script>');
|
||||
expect(rendered).not.toContain('javascript:');
|
||||
expect(rendered).toContain('href="https://shd.build/"');
|
||||
});
|
||||
|
||||
it('renders the supported biography formatting subset', () => {
|
||||
expect(renderUserMarkdown('## Agent\n\n- **Medic**\n- _Support_')).toBe(
|
||||
'<h4>Agent</h4><ul><li><strong>Medic</strong></li><li><em>Support</em></li></ul>'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds Unicode characters and rejects control characters', () => {
|
||||
expect(normalizeUserMarkdown('漢字', 2)).toBe('漢字');
|
||||
expect(normalizeUserMarkdown('漢字A', 2)).toBeNull();
|
||||
expect(normalizeUserMarkdown('hello\u0000world', 20)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user