a permission calculation fix, bugfixes, a refactor

This commit is contained in:
tezlm 2023-08-13 10:30:11 -07:00
parent 0a07631638
commit 0c41dedbf8
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
22 changed files with 281 additions and 167 deletions

View file

@ -141,6 +141,17 @@ pub async fn prepare_special(
return Err(Error::Validation("mismatched event types"));
}
// FIXME: allow editing name, but make chunks immutable?
// relations.values()
// .filter_map(|(event, _)| match &event.content {
// EventContent::File(file) => Some(file),
// _ => None,
// })
// .any(|file| !content.0.get("chunks").map(|i| i.as_array()).is_some_and(|c| file.chunks != c));
if relations.values().any(|(event, _)| event.get_type() == "x.file") {
return Err(Error::Validation("cannot edit x.files"));
}
// validate it would parse
use serde_json::json;
let EventContentWrapper(_) = serde_json::from_value(json!({

View file

@ -29,6 +29,12 @@ pub async fn can_view_event(db: &Sqlite, event: &Event, user: &ActorId) -> bool
return true;
}
// FIXME (security): don't allow user enumeration! and find a better way to do this
if event.get_type() == "x.user" {
trace!("TEMP: matches because this is an x.user event");
return true;
}
// or if event relates to any visible event
for (event, _) in relations.values() {
if can_view_event(db, event, user).await {
@ -146,10 +152,10 @@ async fn validate_relations(db: &Sqlite, event: &Event, ctx: &Context<'_>) -> Re
#[async_recursion::async_recursion]
async fn is_relation_valid(
db: &Sqlite,
relation: &Event,
event: &Event,
ctx: &Context<'_>,
) -> Result<bool, Error> {
if let Some(acl) = get_acl(db, &relation.id).await {
if let Some(acl) = get_acl(db, &event.id).await {
// a relation is valid if an acl set on it allows it
let valid = acl.can_send(ctx.user, ctx.event.content.get_type(), ctx.relations);
if valid {
@ -158,15 +164,17 @@ async fn is_relation_valid(
trace!("relation is invalid because acl does not match (acl={:?})", acl);
}
Ok(valid)
} else {
} else if event.relations.is_empty() {
// or if the user has sent that event (no acl = pretend the user is the sole admin)
if &relation.sender == ctx.user {
if &event.sender == ctx.user {
trace!("relation is valid because sender == user");
return Ok(true);
Ok(true)
} else {
Ok(false)
}
} else {
// or if all of it's relations are also valid
validate_relations(db, relation, ctx).await
validate_relations(db, event, ctx).await
}
}

View file

@ -48,7 +48,8 @@ pub async fn route(
let is_visible = if let Some(user) = &auth.user {
can_view_event(&state.db, &event, user).await
} else {
event.content.get_type() == "x.file" && !event.is_redacted()
// note that this doesn't define whether any event is *enumeratable*
(event.get_type() == "x.file" || event.get_type() == "x.user") && !event.is_redacted()
};
if !is_visible {
// TODO (security): should this be "not found" instead of forbidden?

View file

@ -12,6 +12,7 @@ type DocBlock = DocInline
| { type: "table", headers?: Array<DocInline>, rows: Array<Array<DocInline>> }
| { type: "ol", items: Array<Document> }
| { type: "ul", items: Array<Document> }
| { type: "details", summary: DocInline, content: Document } // maybe merge with "callout"?
| { type: "callout", call: CalloutType, content: Document }
| { type: "header", content: DocInline, level: number }
| { type: "embed", ref: string };
@ -43,17 +44,54 @@ handling malformed text
- extra args are ignored, missing/optional args are null, null is rendered to an empty string ("") if not handled
*/
// defined tags:
// defined tags
// (underline is specifically not included)
// there's a lot of tags already, but i don't want a lot of tags. it
// complicates implementations for dimishing returns. right now, i'm
// trying to find a good weight/use ratio.
type Tags = {
bold: (text: string) => string,
italic: (text: string) => string,
strike: (text: string) => string,
sub: (text: string) => string,
sup: (text: string) => string,
sub: (text: string) => string, // may remove: use `math` tag instead
sup: (text: string) => string, // may remove: use `math` tag instead
del: (text: string) => string, // may remove: little to no use outside of version control and may conflict document diff semantics
ins: (text: string) => string, // may remove: same as del
color: (text: string, color: string) => string,
bgcolor: (text: string, color: string) => string,
code: (text: string, lang?: string) => string,
"inline-note": (text: string, note: string) => string,
"block-note": (text: string, note: string) => string,
spoiler: (text: string, reason?: string) => string,
kbd: (keycodes: string) => string,
abbr: (short: string, description: string) => string, // may remove: probably better to have a more general "title text" tag
time: (text: string, time: Date) => string,
// these ones are questionable and may be removed
"inline-note": (text: string, note: string) => string, // may remove: only needed for one niche use case currently
"block-note": (text: string, note: string) => string, // has potential
}
/*
## potential tags in the future:
- <dl>, <dt>, and <dd>: like ol/ul but defines several terms. maybe
useful and has nice semantics, but a table or other list already is
already pretty good and is more flexible.
- some kind of scripting: yes, <script> in its current form is awful
and too easily abusable. but i've seen enough sites that make *really
good* use of scripting that i'm not sure. general purpose scripting
almost definitely won't exist[1]
- interactive canvases: on the other hand, canvases might! this wouldn't
be its own tag, but could be its own thing, able to be embedded
in documents.
- <form>/<input>: ask people for structured input to create
an event. or forms that don't create any events, but are for calculating
things. like "interactive canvases", these probably would be their
own thing (but would be embeddable)
[1]: most pages are either completely static or webapps,
but i really think interactive documents could be really
good, see https://ciechanow.ski/mechanical-watch/ and
https://samwho.dev/memory-allocation/. these mostly fall into the
category of "static page with interactive canvas" though.
*/

View file

@ -97,10 +97,11 @@
</script>
<div id="wrapper" style:--pixel-ratio={devicePixelRatio}>
<header id="header">
header goes here - maybe search bar later?
{#await selected then selected}
{#if selected?.getContent?.().description}
- {selected.getContent().description}
{selected.getContent().description}
{:else}
<i>[insert header here]</i>
{/if}
{/await}
</header>

View file

@ -12,6 +12,7 @@
import ChatIc from "carbon-icons-svelte/lib/Chat.svelte";
import UnknownIc from "carbon-icons-svelte/lib/Unknown.svelte";
import { Event } from "./lib/api";
import { filterRels } from "./lib/util";
export let items: Array<Event>;
export let selected: Promise<Event | null> = Promise.resolve(null);
@ -60,19 +61,21 @@
const openContext = (event: Event) => (e) => {
const items = [
{ type: "submenu", text: "edit", clicked: state.curry("popup/open", { type: "edit", event, panel: "general" }), items: [
{ type: "item", text: "general", clicked: state.curry("popup/open", { type: "edit", event, panel: "general" }) },
{ type: "item", text: "access", clicked: state.curry("popup/open", { type: "edit", event, panel: "access" }) },
{ type: "item", text: "members", clicked: state.curry("popup/open", { type: "edit", event, panel: "members" }) },
{ type: "item", text: "debug", clicked: state.curry("popup/open", { type: "edit", event, panel: "debug" }) },
{ type: "item", text: "general", clicked: state.curry("popup/open", { type: "edit", event, panel: "general" }) },
{ type: "item", text: "access", clicked: state.curry("popup/open", { type: "edit", event, panel: "access" }) },
{ type: "item", text: "members", clicked: state.curry("popup/open", { type: "edit", event, panel: "members" }) },
{ type: "item", text: "debug", clicked: state.curry("popup/open", { type: "edit", event, panel: "debug" }) },
] },
{ type: "item", text: "pin", clicked: state.curry("popup/open", { type: "text", text: "not implemented yet!" }) },
{ type: "item", text: "copy id", clicked: navigator.clipboard.writeText(event.id) },
];
state.do("menu/open", { x: e.clientX, y: e.clientY, items });
};
$: selectedResParentId = selectedRes ? filterRels(selectedRes, "in")[0] : null;
</script>
<nav id="nav">
{#if selectedRes && items.findIndex(ev => ev.id === selectedRes.id) === -1}
{#if selectedRes && !selectedResParentId && items.findIndex(ev => ev.id === selectedRes.id) === -1}
{@const name = getName(selectedRes) || "unnamed"}
<a
href="#/{selectedRes.id}"
@ -102,6 +105,19 @@
{/if}
</div>
</a>
{#if item.id === selectedResParentId}
{@const name = getName(selectedRes) || "unnamed"}
<a
href="#/{item.id}"
class="wrapper selected child"
on:contextmenu|preventDefault|stopPropagation={openContext(selectedRes)}
>
<div class="highlight">
<span class="name" title={name}>{name}</span>
<button on:click|preventDefault={state.curry("popup/open", { type: "edit", event: selectedRes, panel: "general" })}>+</button>
</div>
</a>
{/if}
{/each}
</nav>
<style lang="scss">
@ -128,6 +144,27 @@
border-bottom-left-radius: 0;
margin-left: -4px;
}
&.child {
position: relative;
padding-left: 32px;
&::before {
content: "";
display: block;
position: absolute;
--size: 12px;
left: calc(4px + var(--size));
top: calc(50% - var(--size));
height: var(--size);
width: var(--size);
--color: #44535f;
border-bottom: solid var(--color) 1px;
border-left: solid var(--color) 1px;
}
}
& > .highlight {
display: flex;

View file

@ -157,26 +157,28 @@ export class Events extends Map<string, Event> {
}
}
export class Actors extends Map<string, Event> {
// TODO: invalidate cache on user updates
export class Actors extends Map<string, Event | null> {
private requests = new Map();
constructor(public client: Client) {
super();
}
// TODO: expose bulk fetch
// also: maybe have a ~~syntax~~ api sugar endpoint for getting a user instead of query?
// the whole api is designed to be flexible enough for this, but qol endpoints might be nice
// also will save a request (maybe *another* create-then-immediately-fetch query endpoint would also be good)
// i'll wait for a while and see if there's a strong use case for it
async fetch(actor: string, force = false): Promise<Event> {
if (!force && this.has(actor)) return this.get(actor)!;
if (!force && this.has(actor)) {
if (this.get(actor) === null) throw new Error("no such actor");
return this.get(actor)!;
}
if (this.requests.has(actor)) return this.requests.get(actor)!;
const [promise, done, fail] = createAsync();
this.requests.set(actor, promise);
try {
const events = await this._fetch([actor]);
if (!events.length) throw new Error("no such actor");
if (!events.length) {
this.set(actor, null);
throw new Error("no such actor");
};
const [event] = events;
this.set(actor, event);
done(event);
@ -193,13 +195,13 @@ export class Actors extends Map<string, Event> {
const got = new Map();
const missing = [];
for (const actor of actors) {
if (this.has(actor)) {
if (this.has(actor) && !force) {
got.set(actor, this.get(actor));
} else {
missing.push(actor);
}
}
log.dbg("fetchBulk missing", missing);
log.dbg("api:actors fetch missing", missing);
if (!missing.length) return got;
for (const event of await this._fetch(missing)) {
got.set(event.sender, event);

View file

@ -1,5 +1,6 @@
import { onDestroy } from "svelte";
import { Readable, readable } from "svelte/store";
import { Event, Query, QueryOptions } from "./api";
import { api, Event, Query, QueryOptions } from "./api";
import log from "./log";
const timeUnits = [
@ -38,7 +39,6 @@ export function formatSize(size: number): string {
return "very big";
}
export function formatTime(time?: number): string {
if (time === undefined || isNaN(time)) return "-:--";
const seconds = Math.floor(time) % 60;
@ -53,6 +53,16 @@ export function formatTime(time?: number): string {
}
}
export function query(options: QueryOptions, filter?: (event: Event) => boolean): Readable<Array<Event>> {
const opts = {
...options,
ephemeral: [...options.ephemeral ?? [], ["x.redact", "redact"], ["x.update", "update"]] as Array<[string, string] | [string, string, string]>,
};
const { events, stop } = watch(api.query(opts), filter);
onDestroy(() => stop());
return events;
}
export function watch(queryPromise: Promise<Query>, filter?: (event: Event) => boolean): {
events: Readable<Array<Event>>,
stop: () => void,
@ -71,11 +81,11 @@ export function watch(queryPromise: Promise<Query>, filter?: (event: Event) => b
for (const event of events) {
if (event.type === "x.redact") {
const rels = event.relations ?? {};
log.dbg("remove", rels);
log.dbg("watcher:remove", rels);
items = items.filter(i => !(i.id in rels));
} else if (event.type === "x.update") {
const rels = event.relations ?? {};
log.dbg("update", rels);
log.dbg("watcher:update", rels);
for (const target of items.filter(i => i.id in rels)) {
if (target.derived) {
target.derived.update = event.content;

View file

@ -66,6 +66,7 @@
...acl!.getContent(),
roles,
});
roles.do("select", null);
}
async function saveRole(roleId: string) {
@ -83,6 +84,7 @@
[roleId]: newRole,
}
});
roles.do("select", null);
}
type UserId = string;

View file

@ -1,33 +1,33 @@
<script lang="ts">
import { api, Event } from "../../lib/api";
import { Event } from "../../lib/api";
import { state } from "../../state";
import Access from "./Access.svelte";
import Members from "./Members.svelte";
import type { EditPanels } from "../../types";
import { query } from "../../lib/util";
import type { Readable } from "svelte/store";
import log from "../../lib/log";
export let event: Event;
export let panel: EditPanels;
let content = event.getContent() ?? {};
let name = content.name;
let description = content.description;
$: acl = fetchAcl(event);
$: members = acl.then(acl => fetchMembers(acl?.getContent() ?? null));
let events: Readable<Array<Event>>;
let oldEvent: Event | null = null;
$: if (event.id !== oldEvent?.id) {
events = fetchAcl(event);
oldEvent = event;
};
$: acl = $events.at(-1) ?? null;
async function fetchAcl(event: Event): Promise<Event | null> {
const query = await api.query({ refs: [event.id], relations: [["x.acl", "acl"]] });
const chunk = await query.next();
if (!chunk) return null;
return [...chunk.relations.values()].find(event => event.type === "x.acl") ?? null;
function fetchAcl(event: Event) {
log.dbg("web:edit fetch acl");
return query({
refs: [event.id],
relations: [["x.acl", "acl"]],
}, ev => ev.type === "x.acl");
}
async function fetchMembers(acl: Acl | null): Promise<Map<UserId, Event>> {
if (acl) {
return api.actors.fetchBulk([...acl.admins, ...Object.keys(acl.users)]);
} else {
const actor = await api.actors.fetch(event.sender);
return new Map([[actor.sender, actor]]);
}
}
function select(panel: EditPanels) {
state.do("popup/replace", { type: "edit", event, panel });
}
@ -43,8 +43,9 @@
}
async function update() {
if (name === event.getContent().name && description === event.getContent().description) return state.do("close", "popup");
event.update({ name, description });
const cont = event.getContent();
if (name === cont.name && description === cont.description) return state.do("popup/close");
await event.update({ name, description });
state.do("popup/close");
}
@ -86,35 +87,23 @@
</form>
<button on:click={deleteNexus}>delete</button>
{:else if panel === "access"}
{#await acl}
loading...
{:then acl}
<Access {acl} {event} />
{/await}
<Access {acl} {event} />
{:else if panel === "members"}
{#await acl}
loading...
{:then acl}
<Members {acl} {event} />
{/await}
<Members {acl} {event} />
{:else if panel === "debug"}
{#await acl}
one second...
{:then acl}
{@const orphans = Object.values(acl?.getContent().users ?? {}).filter(roles => !roles.length).length}
acl:
<pre style="font-size: 12px">{JSON.stringify(acl?.toRaw() ?? null, null, 4)}</pre>
<ul>
{#if acl === null}
<li><b>note:</b> because acl is null, this event behaves as if the sender is the sole admin</li>
{:else}
{#if orphans > 0}
<li><b>note:</b> some users have no roles, denying them access to anything (empty roles != readonly)</li>
{/if}
{@const orphans = Object.values(acl?.getContent().users ?? {}).filter(roles => !roles.length).length}
acl:
<pre style="font-size: 12px">{JSON.stringify(acl?.toRaw() ?? null, null, 4)}</pre>
<ul>
{#if acl === null}
<li><b>note:</b> because acl is null, this event behaves as if the sender is the sole admin</li>
{:else}
{#if orphans > 0}
<li><b>note:</b> some users have no roles, denying them access to anything (empty roles != readonly)</li>
{/if}
<li><b>note:</b> a warning - the sender isn't necessarily the admin. make sure to include yourself in <code>"admins": [...]</code>!</li>
</ul>
{/await}
{/if}
<li><b>note:</b> a warning - the sender isn't necessarily the admin. make sure to include yourself in <code>"admins": [...]</code>!</li>
</ul>
{/if}
</main>
</div>

View file

@ -15,7 +15,7 @@
function getMemberIds(acl: Acl | null): Array<UserId> {
if (acl) {
return [...acl.admins, ...Object.keys(acl.users)];
return [...new Set([...acl.admins, ...Object.keys(acl.users)])];
} else {
return [event.sender];
}
@ -52,13 +52,14 @@
async function addRole(select: HTMLSelectElement, userId: string) {
const cont = acl!.getContent() as Acl;
await acl!.update({
...cont,
users: {
...cont.users,
[userId]: [...cont.users[userId], select.value],
},
});
const role = select.value;
const roles = cont.users[userId] ?? [];
if (!roles.includes(role)) {
await acl!.update({
...cont,
users: { ...cont.users, [userId]: [...roles, role] },
});
};
select.value = "";
}
@ -68,7 +69,7 @@
...cont,
users: {
...cont.users,
[userId]: cont.users[userId].filter(i => i !== roleId),
[userId]: cont.users[userId]?.filter(i => i !== roleId) ?? [],
},
});
}
@ -90,7 +91,7 @@
{#await members}
one second...
{:then members}
{@const cont = acl?.getContent()}
{@const cont = acl?.getContent() ?? null}
<ul>
{#each getMemberIds(cont) as memberId}
{@const member = members.get(memberId)}
@ -105,7 +106,7 @@
{/each}
<select on:input={(e) => e.target.value && addRole(e.target, memberId)}>
<option value="">+</option>
{#each Object.entries(cont?.roles) ?? [] as [roleId, role]}
{#each cont ? Object.entries(cont.roles) : [] as [roleId, role]}
<option value={roleId}>{role.name}</option>
{/each}
</select>

View file

@ -16,9 +16,26 @@ export const perms: Record<string, Record<string, Array<Permission>>> = {
["l.forum.flag", "flag", "l.forum.comment"],
],
},
"l.forum.post": {
"comment": [["l.forum.comment", "comment", "l.forum.comment"]],
},
"l.files": {
"files": [["x.file", "in", "l.files"]],
}
},
"l.docs": {
"create": [["l.doc", "in", "l.docs"]],
"manage": [
["x.update", "update", "l.doc"],
["x.redact", "redact", "l.doc"],
],
},
"l.notes": {
"create": [["l.note", "in", "l.notes"]],
"manage": [
["x.update", "update", "l.note"],
["x.redact", "redact", "l.note"],
],
},
}
export function getPerms(eventType: string, permissions: Array<Permission>): Array<string> {

View file

@ -28,6 +28,7 @@
{:else if block.type === "quote"} <blockquote>{@html renderNested(block.content, false)}</blockquote>
{:else if block.type === "ol"} <ol>{@html block.items.map(li => `<li>${renderNested(li, !hideNotes)}</li>`).join("")}</ol>
{:else if block.type === "ul"} <ul>{@html block.items.map(li => `<li>${renderNested(li, !hideNotes)}</li>`).join("")}</ul>
{:else if block.type === "details"} <details><summary>${renderInline(block.summary)}</summary>${renderNested(block.content, false)}</details>
{:else if block.type === "header"} <svelte:element this={"h" + block.level}>{@html renderInline(block.content)}</svelte:element>
{:else if block.type === "callout"} <div class="callout {block.call}">{@html renderNested(block.content, !hideNotes)}</div>
{:else if block.type === "embed"}

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { api, Event } from "../../lib/api";
import { timeAgo, watch } from "../../lib/util";
import { query, timeAgo, watch } from "../../lib/util";
import { events } from "../../events";
export let options = new URLSearchParams();
export let bucket: Event;
let { events: docs, stop } = loadDocs(options);
$: docs = loadDocs(options);
async function removeDoc(id: string) {
await api.createEvent("x.redact", {}, { [id]: { type: "redact" }});
@ -13,12 +12,11 @@
function loadDocs(options: URLSearchParams) {
const tags = options.getAll("tag");
const query = api.query({
return query({
refs: [bucket.id],
tags: tags.length ? tags : undefined,
relations: [["x.redact", "redact"], ["l.doc", "in"]],
});
return watch(query, ev => ev.type === "l.doc");
}, ev => ev.type === "l.doc");
}
interface UploadStatus {
@ -50,8 +48,6 @@
uploadStatus = null;
}
}
onDestroy(() => stop());
</script>
<ul>
<button>

View file

@ -24,12 +24,17 @@ const tags: Record<string, Array<string>> = {
"inline-note": ["note"],
"aside-note": ["note"],
"spoiler": ["reason"],
abbr: ["abbr"],
time: ["time"],
kbd: [],
del: [],
ins: [],
}
export function tokenize(text: DocInline): Array<Token> {
const tokens: Array<Token> = [];
while (text.length) {
const match = text.match(/[{}]|(?<!~)~([a-z0-9-]+)/i);
const match = text.match(/[{}]|~[~{}]|~([a-z0-9-]+)/i);
if (!match) {
tokens.push({ text, type: "text" });
break;
@ -37,7 +42,14 @@ export function tokenize(text: DocInline): Array<Token> {
const chunk = text.slice(0, match.index!);
if (chunk) tokens.push({ text: chunk, type: "text" });
switch (match[0][0]) {
case "~": tokens.push({ text: match[1], type: "tag" }); break;
case "~": {
if (/[~{}]/.test(match[0][1])) {
tokens.push({ text: match[0].slice(1), type: "text" });
} else {
tokens.push({ text: match[1], type: "tag" });
}
break;
}
case "{": tokens.push({ text: "", type: "start" }); break;
case "}": tokens.push({ text: "", type: "end" }); break;
}
@ -84,6 +96,8 @@ export function parse(tokens: Array<Token>): Array<Node> {
// const ctx = tags[name].reduce((ctx, name, idx) => ({ ...ctx, [name]: idx < args.length + 1 ? stringify(args[idx + 1]) : null }), {});
return { children: args.length >= 1 ? [args[0]] : [], tag: name, ctx };
} else {
// this converts `~foo{bar` -> `~foo{bar}`, which is annoying
// but invalid syntax isn't supported/undefined so whatever
return { children: ["~" + name, ...args.flatMap(node => ["{", node, "}"])] };
}
}

View file

@ -35,6 +35,18 @@ export function renderInline(doc: DocInline, allowNote = true): string {
case "strike": return `<s>${text}</s>`;
case "sub": return `<sub>${text}</sub>`;
case "sup": return `<sup>${text}</sup>`;
case "ins": return `<ins>${text}</ins>`;
case "del": return `<del>${text}</del>`;
case "kbd": return `<kbd>${text}</kbd>`;
case "abbr": return `<abbr>${text}</abbr>`;
case "time": {
if (!ctx?.time) return text;
return `<time datetime="${new Date(ctx.time).toISOString()}">${text}</time>`;
}
case "abbr": {
if (!ctx?.abbr) return text;
return `<abbr title="${ctx.abbr}">${text}</abbr>`;
}
case "link": {
if (!ctx?.href) return `<a class="broken">${text}</a>`;
if (/^\w+-\w+$/.test(ctx.href)) return `<a href="/#/${ctx.href}">${text}</a>`;
@ -79,6 +91,7 @@ function renderBlock(doc: DocBlock, embeds: Map<string, Event>, allowNote = true
case "quote": return `<blockquote>${renderNested(doc.content, false)}</blockquote>`;
case "ol": return `<ol>${doc.items.map(li => `<li>${renderNested(li, allowNote)}</li>`).join("")}</ol>`;
case "ul": return `<ul>${doc.items.map(li => `<li>${renderNested(li, allowNote)}</li>`).join("")}</ul>`;
case "details": return `<details><summary>${renderInline(doc.summary)}</summary>${renderNested(doc.content, false)}</details>`;
case "header": return `<h${doc.level}>${renderInline(doc.content)}</h${doc.level}>`;
case "callout": return `<div class="callout ${doc.call}">${renderNested(doc.content, allowNote)}</div>`;
case "embed": {

View file

@ -1,6 +1,4 @@
/*
TODO: sync with spec
# documents
Documents have block level elements (in json) and inline text (in a
@ -14,6 +12,7 @@ export type DocBlock = DocInline
| { type: "table", headers?: Array<DocInline>, rows: Array<Array<DocInline>> }
| { type: "ol", items: Array<Document> }
| { type: "ul", items: Array<Document> }
| { type: "details", summary: DocInline, content: Document }
| { type: "callout", call: CalloutType, content: Document }
| { type: "header", content: DocInline, level: number }
| { type: "embed", ref: string };
@ -41,23 +40,27 @@ in the format `~tag{arg1}{arg2}{...args}`
handling malformed text
- append closing brackets `}` at the end of an inline string to match braces
- unexpected `{` or `}`s are interpreted literally
- `~tag` followed by anything besides `{` causes the `~tag` to be interpreted literally
- extra args are ignored, missing/optional args are null, null is rendered to an empty string ("") if not handled
*/
// defined tags:
// defined tags
// (underline is specifically not included)
type Tags = {
bold: (text: string) => string,
italic: (text: string) => string,
strike: (text: string) => string,
sub: (text: string) => string,
sup: (text: string) => string,
link: (text: string, href: string) => string,
del: (text: string) => string,
ins: (text: string) => string,
color: (text: string, color: string) => string,
bgcolor: (text: string, color: string) => string,
code: (text: string, lang?: string) => string,
"inline-note": (text: string, note: string) => string,
"aside-note": (text: string, note: string) => string,
"block-note": (text: string, note: string) => string,
spoiler: (text: string, reason?: string) => string,
kbd: (keycodes: string) => string,
abbr: (short: string, description: string) => string,
time: (text: string, time: Date) => string,
}

View file

@ -1,21 +1,13 @@
<script lang="ts">
import { api } from "../../lib/api";
import { watch } from "../../lib/util";
import { onDestroy } from "svelte";
import { derived } from "svelte/store";
import { query } from "../../lib/util";
import Gallery from "./Gallery.svelte";
import List from "./List.svelte";
import type { Event } from "../../lib/api";
export let options = new URLSearchParams();
export let bucket: Event;
let { events: items, stop } = loadImages(options);
let tooltip = { x: 0, y: 0, text: null };
$: if (options) {
stop();
({ events: items, stop } = loadImages(options));
}
$: items = loadFiles(options);
interface UploadStatus {
count: number,
@ -48,19 +40,16 @@
}
}
function loadImages(options: URLSearchParams) {
const tags = options?.getAll("tag") ?? [];
const query = api.query({
function loadFiles(options: URLSearchParams) {
const tags = options.getAll("tag");
return query({
refs: [bucket.id],
tags: tags.length ? tags : null,
relations: [["x.redact", "redact"], ["x.file", "in"]],
});
return watch(query, event => event.type === "x.file");
tags: tags.length ? tags : undefined,
relations: [["x.file", "in"]],
}, event => event.type === "x.file");
}
onDestroy(() => stop());
let fileTypes;
let fileTypes: string;
let searchRaw = "";
$: search = searchRaw.toLocaleLowerCase();
$: mapped = $items.map(event => ({ event, info: event.derived?.file ?? {} }));
@ -74,7 +63,7 @@
case "audio": if (!mime.startsWith("audio/")) return false; break;
case "text": if (!mime.startsWith("text/")) return false; break;
}
return event.content.name?.toLocaleLowerCase().includes(search);
return event.getContent().name?.toLocaleLowerCase().includes(search);
});
$: sorted = filtered.map(i => i.event).sort((a, b) => a.origin_ts - b.origin_ts);
let viewAs = "gallery";

View file

@ -26,7 +26,7 @@
{#await author}
<i>loading...</i>
{:then author}
{@const name = author.content?.name}
{@const name = author.getContent()?.name}
{#if name && isFromOp}
<b>{name}</b> (op)
{:else if name && author.origin_ts < (Date.now() + 1000 * 60 * 60 * 24 * 7)}

View file

@ -1,33 +1,26 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { api, Event } from "../../lib/api";
import { timeAgo, watch } from "../../lib/util";
import { query, timeAgo } from "../../lib/util";
export let options = new URLSearchParams();
export let bucket: Event;
let { events: posts, stop } = loadPosts();
function loadPosts() {
const query = api.query({ refs: [bucket.id], relations: [["l.forum.post", "in"]] });
return watch(query, ev => ev.type === "l.forum.post");
}
let posts = query({
refs: [bucket.id],
relations: [["l.forum.post", "in"]],
}, ev => ev.type === "l.forum.post");
let submitTitle: string;
let submitBody: string;
async function handlePost(e) {
const event = await api.createEvent("l.forum.post", {
await api.createEvent("l.forum.post", {
title: submitTitle || undefined,
body: submitBody || undefined,
}, {
[bucket.id]: { type: "in" }
});
}, { [bucket.id]: { type: "in" }});
submitTitle = submitBody = "";
}
$: sorted = $posts.sort((a, b) => a.origin_ts - b.origin_ts);
onDestroy(stop);
</script>
<div class="wrapper">
<form on:submit|preventDefault={handlePost}>

View file

@ -2,9 +2,9 @@
import { api } from "../../lib/api";
import type { Event } from "../../lib/api";
import Comments from "./Comments.svelte";
import { filterRels, watch } from "../../lib/util";
import { filterRels, query } from "../../lib/util";
import { Reduxer } from "../../lib/reduxer";
import { onDestroy, tick } from "svelte";
import { tick } from "svelte";
export let options = new URLSearchParams();
export let bucket: Event;
$: forumId = filterRels(bucket, "in")[0];
@ -22,11 +22,10 @@
},
});
const query = api.query({
const comments = query({
refs: [bucket.id],
relations: [["l.forum.comment", "comment"]],
});
const { events: comments, stop } = watch(query, event => event.type === "l.forum.comment");
}, event => event.type === "l.forum.comment");
$: topLevelComments = $comments
.filter(comment => filterRels(comment, "comment").indexOf(bucket.id) !== -1)
.sort((a, b) => a.origin_ts - b.origin_ts);
@ -42,8 +41,6 @@
commentBody = "";
state.do("reply", null);
}
onDestroy(stop);
</script>
<div class="wrapper">
<div>

View file

@ -1,25 +1,19 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { api } from "../lib/api";
import { timeAgo, watch } from "../lib/util";
import { query, timeAgo, watch } from "../lib/util";
import type { Event } from "../lib/api";
export let options = new URLSearchParams();
export let bucket: Event;
let { events, stop } = loadNotes(options);
$: events = loadNotes(options);
let searchBodyEl;
let searchBodyEl: HTMLTextAreaElement;
let searchBody = "";
$: search = searchBody?.toLocaleLowerCase() ?? "";
$: filtered = $events
.filter(ev => ev.content.body?.toLocaleLowerCase().includes(search))
.filter(ev => ev.getContent().body?.toLocaleLowerCase().includes(search))
.sort((a, b) => a.origin_ts - b.origin_ts);
$: if (options) {
stop();
({ events, stop } = loadNotes(options));
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Enter" || e.shiftKey) return;
e.preventDefault();
@ -29,7 +23,7 @@
}
function handleInput() {
searchBodyEl.style.height = 0;
searchBodyEl.style.height = "0";
searchBodyEl.style.height = (searchBodyEl.scrollHeight + 2) + "px";
}
@ -39,15 +33,12 @@
function loadNotes(options: URLSearchParams) {
const tags = options.getAll("tag");
const query = api.query({
return query({
refs: [bucket.id],
tags: tags.length ? tags : undefined,
relations: [["x.redact", "redact"], ["l.note", "in"]],
});
return watch(query, ev => ev.type === "l.note");
relations: [["l.note", "in"]],
}, ev => ev.type === "l.note");
}
onDestroy(() => stop());
</script>
<ul>
<textarea