Compare commits

..

9 commits

46 changed files with 1558 additions and 83 deletions

View file

@ -39,6 +39,7 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const ME_JS: &str = include_str!("./public/js/me.js");
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
// html
pub const BODY: &str = include_str!("./public/html/body.lisp");

View file

@ -135,6 +135,8 @@ version = "1.0.0"
"communities:label.file" = "File"
"communities:label.drafts" = "Drafts"
"communities:label.load" = "Load"
"communities:action.draw" = "Draw"
"communities:action.remove_drawing" = "Remove drawing"
"notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread"

View file

@ -389,3 +389,11 @@ blockquote {
transform: rotateZ(360deg);
}
}
canvas {
border-radius: var(--radius);
border: solid 5px var(--color-primary);
background: white;
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
}

View file

@ -1250,3 +1250,32 @@ details.accordion .inner {
.CodeMirror-focused .CodeMirror-placeholder {
opacity: 50%;
}
.CodeMirror-gutters {
border-color: var(--color-super-lowered) !important;
background-color: var(--color-lowered) !important;
}
.CodeMirror-hints {
background: var(--color-raised) !important;
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
border-radius: var(--radius) !important;
padding: var(--pad-1) !important;
border-color: var(--color-super-lowered) !important;
}
.CodeMirror-hints li {
color: var(--color-text-raised) !important;
border-radius: var(--radius) !important;
transition:
background 0.15s,
color 0.15s;
font-size: 10px;
padding: calc(var(--pad-1) / 2) var(--pad-2);
}
.CodeMirror-hints li.CodeMirror-hint-active {
background-color: var(--color-primary) !important;
color: var(--color-text-primary) !important;
}

View file

@ -18,6 +18,42 @@
(div ("class" "skel") ("style" "width: 25%; height: 25px;"))
(div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
(template
("id" "carp_canvas")
(div
("class" "flex flex-col gap-2")
(div ("ui_ident" "canvas_loc"))
(div
("class" "flex justify-between gap-2")
(div
("class" "flex gap-2")
(input
("type" "color")
("style" "width: 5rem")
("ui_ident" "color_picker"))
(input
("type" "range")
("min" "1")
("max" "25")
("step" "1")
("value" "2")
("ui_ident" "stroke_range")))
(div
("class" "flex gap-2")
(button
("title" "Undo")
("ui_ident" "undo")
("type" "button")
(icon (text "undo")))
(button
("title" "Redo")
("ui_ident" "redo")
("type" "button")
(icon (text "redo")))))))
; random js
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {

View file

@ -405,7 +405,7 @@
(text "{%- endif %}"))))
(text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
(div
("class" "media_gallery gap-2")
(text "{% for upload in upload_ids %}")
@ -677,6 +677,8 @@
("class" "no_p_margin")
("style" "font-weight: 500")
(text "{{ question.content|markdown|safe }}"))
; question drawings
(text "{{ self::post_media(upload_ids=question.drawings) }}")
; anonymous user ip thing
; this is only shown if the post author is anonymous AND we are a helper
(text "{% if is_helper and owner.id == 0 %}")
@ -693,7 +695,7 @@
(div
("class" "flex gap-2 items-center justify-between"))))
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
(div
("class" "card-nest")
(div
@ -707,6 +709,12 @@
("onsubmit" "create_question_from_form(event)")
(div
("class" "flex flex-col gap-1")
; carp canvas
(text "{% if drawing_enabled -%}")
(div ("ui_ident" "carp_canvas_field"))
(text "{%- endif %}")
; form
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
@ -718,25 +726,83 @@
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
("class" "flex gap-2")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}")
(button
("class" "lowered")
("ui_ident" "add_drawing")
("onclick" "attach_drawing()")
("type" "button")
(text "{{ text \"communities:action.draw\" }}"))
(button
("class" "lowered red hidden")
("ui_ident" "remove_drawing")
("onclick" "remove_drawing()")
("type" "button")
(text "{{ text \"communities:action.remove_drawing\" }}"))
(script
(text "globalThis.attach_drawing = () => {
globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
globalThis.gerald.create_canvas();
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
}
globalThis.remove_drawing = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
globalThis.gerald = null;
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
}"))
(text "{%- endif %}"))))
(script
(text "async function create_question_from_form(e) {
(text "globalThis.gerald = null;
async function create_question_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"questions::create\"]);
fetch(\"/api/v1/questions\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
// create body
const body = new FormData();
if (globalThis.gerald) {
body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
type: \"application/octet-stream\"
}));
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
receiver: \"{{ receiver }}\",
community: \"{{ community }}\",
is_global: \"{{ is_global }}\" == \"true\",
}),
);
// ...
fetch(\"/api/v1/questions\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
@ -747,6 +813,10 @@
if (res.ok) {
e.target.reset();
if (globalThis.gerald) {
globalThis.gerald.clear();
}
}
});
}"))
@ -1928,7 +1998,7 @@
(text "{%- endif %}")
; note listings
(text "{% for note in notes %}")
(text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
(div
("class" "flex flex-row gap-1")
(a
@ -1958,6 +2028,6 @@
(icon (text "trash"))
(str (text "general:action.delete")))))
(text "{%- endif %}"))
(text "{% endfor %}"))
(text "{%- endif %} {% endfor %}"))
(text "{%- endif %}")
(text "{%- endmacro %}")

View file

@ -2,6 +2,27 @@
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
(style
(text "html, body {
overflow: hidden auto !important;
}
.sidebar {
position: sticky;
top: 42px;
}
@media screen and (max-width: 900px) {
.sidebar {
position: absolute;
top: unset;
}
body.sidebars_shown {
overflow: hidden !important;
}
}"))
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
; redirect to note
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
@ -9,6 +30,11 @@
; redirect to journal homepage
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
(text "{%- endif %} {%- endif %}")
(text "{% if view_mode and journal -%}")
; add journal css
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
(text "{%- endif %}")
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
(text "{% if not view_mode -%}")
(nav
@ -63,17 +89,19 @@
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
(text "{% if (view_mode and owner) or not view_mode -%}")
(a
("class" "flush")
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
(b (text "{{ journal.title }}")))
(text "{%- endif %}")
(text "{% if note -%}")
(span (text "/"))
(b (text "{{ note.title }}"))
(text "{%- endif %}"))
(text "{% if user and user.id == journal.owner -%}")
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
(div
("class" "pillmenu")
(a
@ -83,7 +111,7 @@
(icon (text "pencil")))
(a
("class" "{% if view_mode -%}active{%- endif %}")
("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
(icon (text "eye"))))
(text "{%- endif %}"))
(text "{%- endif %}")
@ -96,6 +124,7 @@
("class" "card w-full flex flex-col gap-2")
(h3 (str (text "journals:label.welcome")))
(span (str (text "journals:label.select_a_journal")))
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
(button
("onclick" "create_journal()")
(icon (text "plus"))
@ -180,10 +209,36 @@
; import codemirror
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
(text "{% if note.title == \"journal.css\" -%}")
; css editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
(style
(text ".CodeMirror {
font-family: monospace !important;
font-size: 16px;
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius);
}
.CodeMirror-line {
padding-left: 5px !important;
}"))
(text "{% else %}")
; markdown editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(text "{%- endif %}")
; tab bar
(text "{% if note.title != \"journal.css\" -%}")
(div
("class" "pillmenu")
(a
@ -198,6 +253,7 @@
("data-tab-button" "preview")
("data-turbo" "false")
(str (text "journals:label.preview_pane"))))
(text "{%- endif %}")
; tabs
(div
@ -220,11 +276,16 @@
; init codemirror
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
(script
(text "setTimeout(() => {
(text "setTimeout(async () => {
if (!document.getElementById(\"preview_tab\").shadowRoot) {
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
}
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML,
mode: \"markdown\",
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
lineWrapping: true,
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
autoCloseBrackets: true,
autofocus: true,
viewportMargin: Number.POSITIVE_INFINITY,
@ -232,7 +293,8 @@
highlightFormatting: false,
fencedCodeBlockHighlighting: false,
xml: false,
smartIndent: false,
smartIndent: true,
indentUnit: 4,
placeholder: `# {{ note.title }}`,
extraKeys: {
Home: \"goLineLeft\",
@ -243,6 +305,15 @@
},
});
editor.on(\"keydown\", (cm, e) => {
if (e.key.length > 1) {
// ignore all keys that aren't a letter
return;
}
CodeMirror.showHint(cm, CodeMirror.hint.css);
});
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
e.preventDefault();
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
@ -262,7 +333,11 @@
})
).text();
document.getElementById(\"preview_tab\").innerHTML = res;
const preview_token = window.crypto.randomUUID();
document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
@import url(\"/css/style.css\");
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
</style>`;
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
});
}, 150);"))
@ -272,7 +347,34 @@
("class" "flex flex-col gap-2 card")
(text "{{ note.content|markdown|safe }}"))
(div
("class" "flex w-full justify-between gap-2")
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(text "{% if user and user.id == owner.id -%}")
(button
("class" "small")
("onclick" "{% if journal.privacy == \"Public\" -%}
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- else -%}
prompt_make_public();
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- endif %}")
(icon (text "share"))
(str (text "general:label.share")))
(script
(text "globalThis.prompt_make_public = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Would you like to make this journal public? This is required for others to view this note.\",
]))
) {
return;
}
change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
}"))
(text "{%- endif %}"))
(text "{%- endif %}")
(text "{%- endif %}")))
(style
@ -332,7 +434,7 @@
},
body: JSON.stringify({
title,
content: `# ${title}`,
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
journal: \"{{ selected_journal }}\",
}),
})
@ -431,8 +533,8 @@
globalThis.change_journal_privacy = async (e) => {
e.preventDefault();
const selected = event.target.selectedOptions[0];
fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
const selected = e.target.selectedOptions[0];
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
(div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
(div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div
@ -44,9 +44,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div

View file

@ -564,14 +564,14 @@
(li
(text "Use custom CSS on your profile"))
(li
(text "Ability to use community emojis outside of
(text "Use community emojis outside of
their community"))
(li
(text "Ability to upload and use gif emojis"))
(text "Upload and use gif emojis"))
(li
(text "Create infinite stack timelines"))
(li
(text "Ability to upload images to posts"))
(text "Upload images to posts"))
(li
(text "Save infinite post drafts"))
(li
@ -579,7 +579,7 @@
(li
(text "Ability to create forges"))
(li
(text "Ability to create more than 1 app"))
(text "Create more than 1 app"))
(li
(text "Create up to 10 stack blocks"))
(li
@ -587,7 +587,9 @@
(li
(text "Increased proxied image size"))
(li
(text "Create infinite journals")))
(text "Create infinite journals"))
(li
(text "Create infinite notes in each journal")))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
@ -1374,6 +1376,14 @@
\"{{ profile.settings.allow_anonymous_questions }}\",
\"checkbox\",
],
[
[
\"enable_drawings\",
\"Allow users to create drawings and submit them with questions\",
],
\"{{ profile.settings.enable_drawings }}\",
\"checkbox\",
],
[
[\"motivational_header\", \"Motivational header\"],
settings.motivational_header,
@ -1401,6 +1411,11 @@
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
\"text\",
],
[
[\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
\"{{ profile.settings.paged_timelines }}\",
\"checkbox\",
],
[[], \"Fun\", \"title\"],
[
[\"disable_gpa_fun\", \"Disable GPA\"],

View file

@ -83,9 +83,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker")))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{%- endif %}"))))

View file

@ -33,9 +33,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -31,9 +31,10 @@
(div ("ui_ident" "io_data_marker")))
(text "{%- endif %}"))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -30,3 +30,7 @@
(str (text "chats:label.go_back")))
(text "{%- endif %}"))
(text "{%- endif %}")
(text "{% if paginated -%}")
(text "{{ components::pagination(page=page, items=list|length) }}")
(text "{%- endif %}")

View file

@ -39,6 +39,7 @@ media_theme_pref();
// init
use("me", () => {});
use("streams", () => {});
use("carp", () => {});
// env
self.DEBOUNCE = [];
@ -1141,7 +1142,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
},
);
self.define("io_data_load", (_, tmpl, page) => {
self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => {
self.IO_DATA_MARKER = document.querySelector(
"[ui_ident=io_data_marker]",
);
@ -1164,7 +1165,16 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
self.IO_DATA_PAGE = page;
self.IO_DATA_SEEN_IDS = [];
if (!paginated_mode) {
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
} else {
// immediately load first page
self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
self.IO_DATA_TMPL += `&paginated=true&page=`;
self.io_load_data();
}
self.IO_PAGINATED = paginated_mode;
});
self.define("io_load_data", async () => {

View file

@ -0,0 +1,624 @@
(() => {
const self = reg_ns("carp");
const END_OF_HEADER = 0x1a;
const COLOR = 0x1b;
const SIZE = 0x2b;
const LINE = 0x3b;
const POINT = 0x4b;
const EOF = 0x1f;
function enc(s, as = "guess") {
if ((as === "guess" && typeof s === "number") || as === "u32") {
// encode u32
const view = new DataView(new ArrayBuffer(16));
view.setUint32(0, s);
return new Uint8Array(view.buffer).slice(0, 4);
}
if (as === "u16") {
// encode u16
const view = new DataView(new ArrayBuffer(16));
view.setUint16(0, s);
return new Uint8Array(view.buffer).slice(0, 2);
}
// encode string
const encoder = new TextEncoder();
return encoder.encode(s);
}
function dec(as, from) {
if (as === "u32") {
// decode u32
const view = new DataView(new Uint8Array(from).buffer);
return view.getUint32(0);
}
if (as === "u16") {
// decode u16
const view = new DataView(new Uint8Array(from).buffer);
return view.getUint16(0);
}
// decode string
const decoder = new TextDecoder();
return decoder.decode(from);
}
function lpad(size, input) {
if (input.length === size) {
return input;
}
for (let i = 0; i < size - (input.length - 1); i++) {
input = [0, ...input];
}
return input;
}
self.enc = enc;
self.dec = dec;
self.lpad = lpad;
self.CARPS = {};
self.define("new", function ({ $ }, bind_to, read_only = false) {
const canvas = new CarpCanvas(bind_to, read_only);
$.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
return canvas;
});
class CarpCanvas {
#element; // HTMLElement
#ctx; // CanvasRenderingContext2D
#pos = { x: 0, y: 0 }; // Vec2
STROKE_SIZE = 2;
#stroke_size_old = 2;
COLOR = "#000000";
#color_old = "#000000";
COMMANDS = [];
HISTORY = [];
HISTORY_IDX = 0;
#cmd_store = [];
#undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
onedit;
read_only;
/// Create a new [`CarpCanvas`]
constructor(element, read_only) {
this.#element = element;
this.read_only = read_only;
}
/// Push #line_store to LINES
push_state() {
this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
this.#cmd_store = [];
this.HISTORY.push(this.COMMANDS);
this.HISTORY_IDX += 1;
if (this.#undo_clear_future) {
this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
this.#undo_clear_future = false;
}
if (this.onedit) {
this.onedit(this.as_string());
}
}
/// Read current position in history and draw it.
draw_from_history() {
this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
const bytes = this.as_carp2();
this.from_bytes(bytes); // draw
}
/// Undo changes.
undo() {
if (this.HISTORY_IDX === 0) {
// cannot undo
return;
}
this.HISTORY_IDX -= 1;
this.draw_from_history();
this.#undo_clear_future = false;
}
/// Redo changes.
redo() {
if (this.HISTORY_IDX === this.HISTORY.length - 1) {
// cannot redo
return;
}
this.HISTORY_IDX += 1;
this.draw_from_history();
}
/// Create canvas and init context
async create_canvas() {
const canvas = document.createElement("canvas");
canvas.width = "300";
canvas.height = "200";
this.#ctx = canvas.getContext("2d");
if (!this.read_only) {
// desktop
canvas.addEventListener(
"mousemove",
(e) => {
this.draw_event(e);
},
false,
);
canvas.addEventListener(
"mouseup",
(e) => {
this.push_state();
},
false,
);
canvas.addEventListener(
"mousedown",
(e) => {
this.#cmd_store.push({
type: "Line",
data: [],
});
this.move_event(e);
},
false,
);
canvas.addEventListener(
"mouseenter",
(e) => {
this.move_event(e);
},
false,
);
// mobile
canvas.addEventListener(
"touchmove",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.draw_event(e, true);
},
false,
);
canvas.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.#cmd_store.push({
type: "Line",
data: [],
});
this.move_event(e);
},
false,
);
canvas.addEventListener(
"touchend",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.push_state();
this.move_event(e);
},
false,
);
// add controls
const controls_tmpl = document
.getElementById("carp_canvas")
.content.cloneNode(true);
this.#element.appendChild(controls_tmpl);
const canvas_loc = this.#element.querySelector(
"[ui_ident=canvas_loc]",
);
canvas_loc.appendChild(canvas);
const color_picker = this.#element.querySelector(
"[ui_ident=color_picker]",
);
color_picker.addEventListener("change", (e) => {
this.set_old_color(this.COLOR);
this.COLOR = e.target.value;
});
const stroke_range = this.#element.querySelector(
"[ui_ident=stroke_range]",
);
stroke_range.addEventListener("change", (e) => {
this.set_old_stroke_size(this.STROKE_SIZE);
this.STROKE_SIZE = e.target.value;
});
const undo = this.#element.querySelector("[ui_ident=undo]");
undo.addEventListener("click", () => {
this.undo();
});
const redo = this.#element.querySelector("[ui_ident=redo]");
redo.addEventListener("click", () => {
this.redo();
});
}
}
/// Resize the canvas
resize(size) {
this.#ctx.canvas.width = size.x;
this.#ctx.canvas.height = size.y;
}
/// Clear the canvas
clear() {
const canvas = this.#ctx.canvas;
this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
}
/// Set the old color
set_old_color(value) {
this.#color_old = value;
}
/// Set the old stroke_size
set_old_stroke_size(value) {
this.#stroke_size_old = value;
}
/// Update position (from event)
move_event(e) {
const rect = this.#ctx.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.move({ x, y });
}
/// Update position
move(pos) {
this.#pos.x = pos.x;
this.#pos.y = pos.y;
}
/// Draw on the canvas (from event)
draw_event(e, mobile = false) {
if (e.buttons !== 1 && mobile === false) return;
const rect = this.#ctx.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.draw({ x, y });
}
/// Draw on the canvas
draw(pos, skip_line_store = false) {
this.#ctx.beginPath();
this.#ctx.lineWidth = this.STROKE_SIZE;
this.#ctx.strokeStyle = this.COLOR;
this.#ctx.lineCap = "round";
this.#ctx.moveTo(this.#pos.x, this.#pos.y);
this.move(pos);
this.#ctx.lineTo(this.#pos.x, this.#pos.y);
if (!skip_line_store) {
// yes flooring the values will make the image SLIGHTLY different,
// but it also saves THOUSANDS of characters
const point = [
Math.floor(this.#pos.x),
Math.floor(this.#pos.y),
];
if (this.#color_old !== this.COLOR) {
this.#cmd_store.push({
type: "Color",
data: enc(this.COLOR.replace("#", "")),
});
}
if (this.#stroke_size_old !== this.STROKE_SIZE) {
this.#cmd_store.push({
type: "Size",
data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
});
}
this.#cmd_store.push({
type: "Point",
data: [
// u32
...lpad(4, enc(point[0])),
...lpad(4, enc(point[1])),
],
});
if (this.#color_old !== this.COLOR) {
// we've already seen it once, time to update it
this.set_old_color(this.COLOR);
}
if (this.#stroke_size_old !== this.STROKE_SIZE) {
this.set_old_stroke_size(this.STROKE_SIZE);
}
}
this.#ctx.stroke();
}
/// Create blob and get URL
as_blob() {
const blob = this.#ctx.canvas.toBlob();
return URL.createObjectURL(blob);
}
/// Create Carp2 representation of the graph
as_carp2() {
// most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
const header = [
...enc("CG"),
...enc("02"),
...lpad(4, enc(this.#ctx.canvas.width)),
...lpad(4, enc(this.#ctx.canvas.height)),
END_OF_HEADER,
];
// build commands
const commands = [];
commands.push(COLOR);
commands.push(...enc("000000"));
commands.push(SIZE);
commands.push(...lpad(4, enc(2)).slice(2));
for (const command of this.COMMANDS) {
// this is `impl Into<Vec<u8>> for Command`
switch (command.type) {
case "Point":
commands.push(POINT);
break;
case "Line":
commands.push(LINE);
break;
case "Color":
commands.push(COLOR);
break;
case "Size":
commands.push(SIZE);
break;
}
commands.push(...command.data);
}
// this is so fucking stupid the fact that arraybuffers send as a fucking
// concatenated string of the NUMBERS of the bytes is so stupid this is
// actually crazy what the fuck is this shit
//
// didn't expect i'd have to do this shit myself considering it's done
// for you with File prototypes from a file input
const bin = [...header, ...commands, EOF];
let bin_str = "";
for (const byte of bin) {
bin_str += String.fromCharCode(byte);
}
// return
return bin;
}
/// Export lines as string
as_string() {
return JSON.stringify(this.COMMANDS);
}
/// From an array of bytes
from_bytes(input) {
this.clear();
let idx = -1;
function next() {
idx += 1;
return [idx, input[idx]];
}
function select_bytes(count) {
// select_bytes! macro
const data = [];
let seen_bytes = 0;
let [_, byte] = next();
while (byte !== undefined) {
seen_bytes += 1;
data.push(byte);
if (seen_bytes === count) {
break;
}
[_, byte] = next();
}
return data;
}
// everything past this is just a reverse implementation of carp2.rs in js
const commands = [];
const dimensions = { x: 0, y: 0 };
let in_header = true;
let seen_point = false;
let byte_buffer = [];
let [i, byte] = next();
while (byte !== undefined) {
switch (byte) {
case END_OF_HEADER:
in_header = false;
break;
case COLOR:
{
const data = select_bytes(6);
commands.push({
type: "Color",
data,
});
this.COLOR = `#${dec("string", new Uint8Array(data))}`;
}
break;
case SIZE:
{
const data = select_bytes(2);
commands.push({
type: "Size",
data,
});
this.STROKE_SIZE = dec("u16", data);
}
break;
case POINT:
{
const data = select_bytes(8);
commands.push({
type: "Point",
data,
});
const point = {
x: dec("u32", data.slice(0, 4)),
y: dec("u32", data.slice(4, 8)),
};
if (!seen_point) {
// this is the FIRST POINT that has been seen...
// we need to start drawing from here to avoid a line
// from 0,0 to the point
this.move(point);
seen_point = true;
}
this.draw(point, true);
}
break;
case LINE:
// each line starts at a new place (probably)
seen_point = false;
break;
case EOF:
break;
default:
if (in_header) {
if (0 <= i < 2) {
// tag
} else if (2 <= i < 4) {
//version
} else if (4 <= i < 8) {
// width
byte_buffer.push(byte);
if (i === 7) {
dimensions.x = dec("u32", byte_buffer);
byte_buffer = [];
}
} else if (8 <= i < 12) {
// height
byte_buffer.push(byte);
if (i === 7) {
dimensions.y = dec("u32", byte_buffer);
byte_buffer = [];
this.resize(dimensions); // update canvas
}
}
} else {
// misc byte
console.log(`extraneous byte at ${i}`);
}
break;
}
// ...
[i, byte] = next();
}
return commands;
}
/// Download image as `.carpgraph`
download() {
const blob = new Blob([new Uint8Array(this.as_carp2())], {
type: "image/carpgraph",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.setAttribute("download", "image.carpgraph");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/// Download image as `.carpgraph1`
download_json() {
const string = this.as_string();
const blob = new Blob([string], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.setAttribute("download", "image.carpgraph_json");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
}
})();

View file

@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
if (!res) {
return console.error(
"namespace does not exist, please use one of the following:",
`namespace "${ns}" does not exist, please use one of the following:`,
Object.keys(globalThis._app_base.ns_store),
);
}

View file

@ -213,7 +213,7 @@ pub async fn upload_avatar_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::DataTooLong("gif".to_string()).into());
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
@ -226,7 +226,7 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image
@ -314,7 +314,7 @@ pub async fn upload_banner_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::DataTooLong("gif".to_string()).into());
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
@ -327,7 +327,7 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image

View file

@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image
@ -191,7 +191,7 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image

View file

@ -133,7 +133,7 @@ pub async fn create_request(
// check sizes
for img in &images {
if img.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
}

View file

@ -15,6 +15,7 @@ use tetratto_core::model::{
};
use crate::{
get_user_from_token,
image::JsonMultipart,
routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
State,
};
@ -23,7 +24,7 @@ pub async fn create_request(
jar: CookieJar,
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<CreateQuestion>,
JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
@ -70,7 +71,10 @@ pub async fn create_request(
}
}
match data.create_question(props).await {
match data
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
.await
{
Ok(id) => Json(ApiReturn {
ok: true,
message: "Question created".to_string(),

View file

@ -9,11 +9,14 @@ use crate::{
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
State,
};
use tetratto_core::model::{
use tetratto_core::{
database::NAME_REGEX,
model::{
journals::{Journal, JournalPrivacyPermission},
oauth,
permissions::FinePermission,
ApiReturn, Error,
},
};
pub async fn get_request(
@ -46,6 +49,28 @@ pub async fn get_request(
})
}
pub async fn get_css_request(
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let note = match data.get_note_by_journal_title(id, "journal.css").await {
Ok(x) => x,
Err(e) => {
return (
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
format!("/* {e} */"),
);
}
};
(
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
note.content,
)
}
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
@ -99,7 +124,17 @@ pub async fn update_title_request(
None => return Json(Error::NotAllowed.into()),
};
props.title = props.title.replace(" ", "_");
props.title = props.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&props.title).is_some() {
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
}
// make sure this title isn't already in use
if data

View file

@ -551,6 +551,7 @@ pub fn routes() -> Router {
.route("/journals", post(journals::create_request))
.route("/journals/{id}", get(journals::get_request))
.route("/journals/{id}", delete(journals::delete_request))
.route("/journals/{id}/journal.css", get(journals::get_css_request))
.route("/journals/{id}/title", post(journals::update_title_request))
.route(
"/journals/{id}/privacy",

View file

@ -10,12 +10,15 @@ use crate::{
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
State,
};
use tetratto_core::model::{
use tetratto_core::{
database::NAME_REGEX,
model::{
journals::{JournalPrivacyPermission, Note},
oauth,
permissions::FinePermission,
uploads::CustomEmoji,
ApiReturn, Error,
},
};
pub async fn get_request(
@ -135,7 +138,17 @@ pub async fn update_title_request(
Err(e) => return Json(e.into()),
};
props.title = props.title.replace(" ", "_");
props.title = props.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&props.title).is_some() {
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
}
// make sure this title isn't already in use
if data

View file

@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
use pathbufd::PathBufD;
use crate::{get_user_from_token, State};
use super::auth::images::read_image;
use tetratto_core::model::{oauth, ApiReturn, Error};
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
pub async fn get_request(
Path(id): Path<usize>,
@ -39,10 +39,17 @@ pub async fn get_request(
));
}
Ok((
[("Content-Type", upload.what.mime())],
Body::from(read_image(path)),
))
let bytes = read_image(path);
if upload.what == MediaType::Carpgraph {
// conver to svg and return
return Ok((
[("Content-Type", "image/svg+xml".to_string())],
Body::from(CarpGraph::from_bytes(bytes).to_svg()),
));
}
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
}
pub async fn delete_request(

View file

@ -18,3 +18,4 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
serve_asset!(me_js_request: ME_JS("text/javascript"));
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_JS("text/javascript"));

View file

@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router {
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request))
.route("/js/streams.js", get(assets::streams_js_request))
.route("/js/carp.js", get(assets::carp_js_request))
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -116,7 +116,7 @@ pub async fn view_request(
}
// if we don't have a selected journal, we shouldn't be here probably
if selected_journal.is_empty() {
if selected_journal.is_empty() | (selected_note == "journal.css") {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
@ -207,3 +207,81 @@ pub async fn view_request(
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
/// `/@{owner}/{journal}`
pub async fn index_view_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((owner, selected_journal)): Path<(String, String)>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => Some(ua),
None => None,
};
// get owner
let owner = match data.0.get_user_by_username(&owner).await {
Ok(ua) => ua,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
check_user_blocked_or_private!(user, owner, data, jar);
// get journal and check privacy settings
let journal = match data
.0
.get_journal_by_owner_title(owner.id, &selected_journal)
.await
{
Ok(p) => p,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
if journal.privacy == JournalPrivacyPermission::Private {
if let Some(ref user) = user {
if user.id != journal.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
));
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// ...
let notes = match data.0.get_notes_by_journal(journal.id).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
if selected_journal.is_empty() {
context.insert("selected_journal", &0);
} else {
context.insert("selected_journal", &selected_journal);
}
context.insert("selected_note", &0);
context.insert("journal", &journal);
context.insert("owner", &owner);
context.insert("notes", &notes);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}

View file

@ -576,6 +576,8 @@ pub struct TimelineQuery {
pub user_id: usize,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub paginated: bool,
}
/// `/_swiss_army_timeline`
@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request(
context.insert("list", &list);
context.insert("page", &req.page);
context.insert("paginated", &req.paginated);
Ok(Html(
data.1
.render("timelines/swiss_army.html", &context)

View file

@ -134,7 +134,7 @@ pub fn routes() -> Router {
// journals
.route("/journals", get(journals::redirect_request))
.route("/journals/{journal}/{note}", get(journals::app_request))
.route("/@{owner}/{journal}", get(journals::view_request))
.route("/@{owner}/{journal}", get(journals::index_view_request))
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
}

View file

@ -216,7 +216,7 @@ impl DataManager {
&0_i32,
&(data.last_seen as i64),
&String::new(),
&"[]",
"[]",
&0_i32,
&0_i32,
&serde_json::to_string(&data.connections).unwrap(),

View file

@ -1,9 +1,10 @@
use oiseau::{cache::Cache, query_row};
use crate::{
database::common::NAME_REGEX,
model::{
auth::User,
permissions::FinePermission,
journals::{Journal, JournalPrivacyPermission},
permissions::FinePermission,
Error, Result,
},
};
@ -69,7 +70,7 @@ impl DataManager {
Ok(res.unwrap())
}
const MAXIMUM_FREE_JOURNALS: usize = 15;
const MAXIMUM_FREE_JOURNALS: usize = 5;
/// Create a new journal in the database.
///
@ -83,7 +84,19 @@ impl DataManager {
return Err(Error::DataTooLong("title".to_string()));
}
data.title = data.title.replace(" ", "_");
data.title = data.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&data.title).is_some() {
return Err(Error::MiscError(
"This title contains invalid characters".to_string(),
));
}
// make sure this title isn't already in use
if self

View file

@ -30,3 +30,4 @@ mod userblocks;
mod userfollows;
pub use drivers::DataManager;
pub use common::NAME_REGEX;

View file

@ -1,4 +1,5 @@
use oiseau::cache::Cache;
use crate::database::common::NAME_REGEX;
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
use crate::{auto_method, DataManager};
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
@ -64,6 +65,8 @@ impl DataManager {
Ok(res.unwrap())
}
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
/// Create a new note in the database.
///
/// # Arguments
@ -82,7 +85,33 @@ impl DataManager {
return Err(Error::DataTooLong("content".to_string()));
}
data.title = data.title.replace(" ", "_");
data.title = data.title.replace(" ", "_").to_lowercase();
// check number of notes
let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) {
let journals = self.get_notes_by_journal(data.owner).await?;
if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
return Err(Error::MiscError(
"You already have the maximum number of notes you can have in this journal"
.to_string(),
));
}
}
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&data.title).is_some() {
return Err(Error::MiscError(
"This title contains invalid characters".to_string(),
));
}
// make sure this title isn't already in use
if self

View file

@ -1952,6 +1952,15 @@ impl DataManager {
self.delete_poll(y.poll_id, &user).await?;
}
// delete question (if not global question)
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
if !question.is_global {
self.delete_question(question.id, &user).await?;
}
}
// return
Ok(())
}
@ -2031,6 +2040,15 @@ impl DataManager {
for upload in y.uploads {
self.delete_upload(upload).await?;
}
// delete question (if not global question)
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
if !question.is_global {
self.delete_question(question.id, &user).await?;
}
}
} else {
// incr parent comment count
if let Some(replying_to) = y.replying_to {

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use oiseau::cache::Cache;
use tetratto_shared::unix_epoch_timestamp;
use crate::model::communities_permissions::CommunityPermission;
use crate::model::uploads::{MediaType, MediaUpload};
use crate::model::{
Error, Result,
communities::Question,
@ -33,6 +34,7 @@ impl DataManager {
// ...
context: serde_json::from_str(&get!(x->10(String))).unwrap(),
ip: get!(x->11(String)),
drawings: serde_json::from_str(&get!(x->12(String))).unwrap(),
}
}
@ -333,13 +335,20 @@ impl DataManager {
Ok(res.unwrap())
}
const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB
/// Create a new question in the database.
///
/// # Arguments
/// * `data` - a mock [`Question`] object to insert
pub async fn create_question(&self, mut data: Question) -> Result<usize> {
pub async fn create_question(
&self,
mut data: Question,
drawings: Vec<Vec<u8>>,
) -> Result<usize> {
// check if we can post this
if data.is_global {
// global
if data.community > 0 {
// posting to community
data.receiver = 0;
@ -370,6 +379,7 @@ impl DataManager {
}
}
} else {
// single
let receiver = self.get_user_by_id(data.receiver).await?;
if !receiver.settings.enable_questions {
@ -380,6 +390,10 @@ impl DataManager {
return Err(Error::NotAllowed);
}
if !receiver.settings.enable_drawings && drawings.len() > 0 {
return Err(Error::DrawingsDisabled);
}
// check for ip block
if self
.get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
@ -390,6 +404,28 @@ impl DataManager {
}
}
// create uploads
if drawings.len() > 2 {
return Err(Error::MiscError(
"Too many uploads. Please use a maximum of 2".to_string(),
));
}
for drawing in &drawings {
// this is the initial iter to check sizes, we'll do uploads after
if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
return Err(Error::FileTooLarge);
}
}
for _ in 0..drawings.len() {
data.drawings.push(
self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner))
.await?
.id,
);
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
@ -398,7 +434,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
params![
&(data.id as i64),
&(data.created as i64),
@ -411,7 +447,8 @@ impl DataManager {
&0_i32,
&0_i32,
&serde_json::to_string(&data.context).unwrap(),
&data.ip
&data.ip,
&serde_json::to_string(&data.drawings).unwrap(),
]
);
@ -430,6 +467,23 @@ impl DataManager {
.await?;
}
// write to uploads
for (i, drawing_id) in data.drawings.iter().enumerate() {
let drawing = match drawings.get(i) {
Some(d) => d,
None => {
self.delete_upload(*drawing_id).await?;
continue;
}
};
let upload = self.get_upload_by_id(*drawing_id).await?;
if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) {
return Err(Error::MiscError(e.to_string()));
}
}
// return
Ok(data.id)
}
@ -495,6 +549,11 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// delete uploads
for upload in y.drawings {
self.delete_upload(upload).await?;
}
// return
Ok(())
}

View file

@ -231,6 +231,12 @@ pub struct UserSettings {
/// A list of strings the user has muted.
#[serde(default)]
pub muted: Vec<String>,
/// If timelines are paged instead of infinitely scrolled.
#[serde(default)]
pub paged_timelines: bool,
/// If drawings are enabled for questions sent to the user.
#[serde(default)]
pub enable_drawings: bool,
}
fn mime_avif() -> String {
@ -332,7 +338,7 @@ impl User {
// parse
for char in input.chars() {
if (char == '\\') && !escape {
if ((char == '\\') | (char == '/')) && !escape {
escape = true;
continue;
}

View file

@ -0,0 +1,285 @@
use serde::{Serialize, Deserialize};
/// Starting at the beginning of the file, the header details specific information
/// about the file.
///
/// 1. `CG` tag (2 bytes)
/// 2. version number (2 bytes)
/// 3. width of graph (4 bytes)
/// 4. height of graph (4 bytes)
/// 5. `END_OF_HEADER`
///
/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
///
/// Everything after `END_OF_HEADER` should be another command and its parameters.
pub const END_OF_HEADER: u8 = 0x1a;
/// The color command marks the beginning of a hex-encoded color **string**.
///
/// The hastag character should **not** be included.
pub const COLOR: u8 = 0x1b;
/// The size command marks the beginning of a integer brush size.
pub const SIZE: u8 = 0x2b;
/// Marks the beginning of a new line.
pub const LINE: u8 = 0x3b;
/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
/// in which a point should be drawn.
///
/// The size and color are that of the previous `COLOR` and `SIZE` commands.
///
/// Points are two `u32`s (or 8 bytes in length).
pub const POINT: u8 = 0x4b;
/// An end-of-file marker.
pub const EOF: u8 = 0x1f;
/// A type of [`Command`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CommandType {
/// [`END_OF_HEADER`]
EndOfHeader = END_OF_HEADER,
/// [`COLOR`]
Color = COLOR,
/// [`SIZE`]
Size = SIZE,
/// [`LINE`]
Line = LINE,
/// [`POINT`]
Point = POINT,
/// [`EOF`]
Eof = EOF,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Command {
/// The type of the command.
pub r#type: CommandType,
/// Raw data as bytes.
pub data: Vec<u8>,
}
impl From<Command> for Vec<u8> {
fn from(val: Command) -> Self {
let mut d = val.data;
d.insert(0, val.r#type as u8);
d
}
}
/// A graph is CarpGraph's representation of an image. It's essentially just a
/// reproducable series of commands which a renderer can traverse to reconstruct
/// an image.
#[derive(Serialize, Deserialize, Debug)]
pub struct CarpGraph {
pub header: Vec<u8>,
pub dimensions: (u32, u32),
pub commands: Vec<Command>,
}
macro_rules! select_bytes {
($count:literal, $from:ident) => {{
let mut data: Vec<u8> = Vec::new();
let mut seen_bytes = 0;
while let Some((_, byte)) = $from.next() {
seen_bytes += 1;
data.push(byte.to_owned());
if seen_bytes == $count {
// we only need <count> bytes, stop just before we eat the next byte
break;
}
}
data
}};
}
macro_rules! spread {
($into:ident, $from:expr) => {
for byte in &$from {
$into.push(byte.to_owned())
}
};
}
impl CarpGraph {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
// reconstruct header
spread!(out, self.header);
spread!(out, self.dimensions.0.to_be_bytes()); // width
spread!(out, self.dimensions.1.to_be_bytes()); // height
out.push(END_OF_HEADER);
// reconstruct commands
for command in &self.commands {
out.push(command.r#type as u8);
spread!(out, command.data);
}
// ...
out.push(EOF);
out
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
let mut header: Vec<u8> = Vec::new();
let mut dimensions: (u32, u32) = (0, 0);
let mut commands: Vec<Command> = Vec::new();
let mut in_header: bool = true;
let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
let mut bytes_iter = bytes.iter().enumerate();
while let Some((i, byte)) = bytes_iter.next() {
let byte = byte.to_owned();
match byte {
END_OF_HEADER => in_header = false,
COLOR => {
let data = select_bytes!(6, bytes_iter);
commands.push(Command {
r#type: CommandType::Color,
data,
});
}
SIZE => {
let data = select_bytes!(2, bytes_iter);
commands.push(Command {
r#type: CommandType::Size,
data,
});
}
POINT => {
let data = select_bytes!(8, bytes_iter);
commands.push(Command {
r#type: CommandType::Point,
data,
});
}
LINE => commands.push(Command {
r#type: CommandType::Line,
data: Vec::new(),
}),
EOF => break,
_ => {
if in_header {
if (0..2).contains(&i) {
// tag
header.push(byte);
} else if (2..4).contains(&i) {
// version
header.push(byte);
} else if (4..8).contains(&i) {
// width
byte_buffer.push(byte);
if i == 7 {
// end, construct from byte buffer
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
byte_buffer = Vec::new();
}
} else if (8..12).contains(&i) {
// height
byte_buffer.push(byte);
if i == 11 {
// end, construct from byte buffer
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
byte_buffer = Vec::new();
}
}
} else {
// misc byte
println!("extraneous byte at {i}");
}
}
}
}
Self {
header,
dimensions,
commands,
}
}
pub fn to_svg(&self) -> String {
let mut out: String = String::new();
out.push_str(&format!(
"<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
));
// add lines
let mut stroke_size: u16 = 2;
let mut stroke_color: String = "000000".to_string();
let mut previous_x_y: Option<(u32, u32)> = None;
let mut line_path = String::new();
for command in &self.commands {
match command.r#type {
CommandType::Size => {
let (bytes, _) = command.data.split_at(size_of::<u16>());
stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
}
CommandType::Color => {
stroke_color =
String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
}
CommandType::Line => {
if !line_path.is_empty() {
out.push_str(&format!(
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
));
}
previous_x_y = None;
line_path = String::new();
}
CommandType::Point => {
let (x, y) = command.data.split_at(size_of::<u32>());
let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
u32::from_be_bytes(y.try_into().unwrap())
});
// add to path string
line_path.push_str(&format!(
" M{} {}{}",
point.0,
point.1,
if let Some(pxy) = previous_x_y {
// line to there
format!(" L{} {}", pxy.0, pxy.1)
} else {
String::new()
}
));
previous_x_y = Some((point.0, point.1));
// add circular point
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
point.0,
point.1,
stroke_size / 2 // the size is technically the diameter of the circle
));
}
_ => unreachable!("never pushed to commands"),
}
}
if !line_path.is_empty() {
out.push_str(&format!(
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
));
}
// return
format!("{out}</svg>")
}
}

View file

@ -345,6 +345,9 @@ pub struct Question {
/// The IP of the question creator for IP blocking and identifying anonymous users.
#[serde(default)]
pub ip: String,
/// The IDs of all uploads which hold this question's drawings.
#[serde(default)]
pub drawings: Vec<usize>,
}
impl Question {
@ -369,6 +372,7 @@ impl Question {
dislikes: 0,
context: QuestionContext::default(),
ip,
drawings: Vec::new(),
}
}
}

View file

@ -1,6 +1,7 @@
pub mod addr;
pub mod apps;
pub mod auth;
pub mod carp;
pub mod channels;
pub mod communities;
pub mod communities_permissions;
@ -41,10 +42,12 @@ pub enum Error {
AlreadyAuthenticated,
DataTooLong(String),
DataTooShort(String),
FileTooLarge,
UsernameInUse,
TitleInUse,
QuestionsDisabled,
RequiresSupporter,
DrawingsDisabled,
Unknown,
}
@ -62,10 +65,12 @@ impl Display for Error {
Self::AlreadyAuthenticated => "Already authenticated".to_string(),
Self::DataTooLong(name) => format!("Given {name} is too long!"),
Self::DataTooShort(name) => format!("Given {name} is too short!"),
Self::FileTooLarge => "Given file is too large".to_string(),
Self::UsernameInUse => "Username in use".to_string(),
Self::TitleInUse => "Title in use".to_string(),
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
Self::RequiresSupporter => "Only site supporters can do this".to_string(),
Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
_ => format!("An unknown error as occurred: ({:?})", self),
})
}

View file

@ -5,7 +5,7 @@ use crate::config::Config;
use std::fs::{write, exists, remove_file};
use super::{Error, Result};
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum MediaType {
#[serde(alias = "image/webp")]
Webp,
@ -17,6 +17,8 @@ pub enum MediaType {
Jpg,
#[serde(alias = "image/gif")]
Gif,
#[serde(alias = "image/carpgraph")]
Carpgraph,
}
impl MediaType {
@ -27,6 +29,7 @@ impl MediaType {
Self::Png => "png",
Self::Jpg => "jpg",
Self::Gif => "gif",
Self::Carpgraph => "carpgraph",
}
}

View file

@ -0,0 +1,2 @@
ALTER TABLE questions
ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';