a permission calculation fix, bugfixes, a refactor
This commit is contained in:
parent
0a07631638
commit
0c41dedbf8
22 changed files with 281 additions and 167 deletions
|
@ -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!({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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, "}"])] };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue