diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f6ad996..bab0525 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -256,3 +256,6 @@ version = "1.0.0" "journals:action.create_subdir" = "Create subdirectory" "journals:action.create_root_dir" = "Create root directory" "journals:action.move" = "Move" +"journals:action.publish" = "Publish" +"journals:action.unpublish" = "Unpublish" +"journals:action.view" = "View" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 9ad6f19..bcdb77d 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2138,15 +2138,32 @@ (icon (text "pencil")) (str (text "chats:action.rename"))) (a - ("class" "button") ("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags") (icon (text "tag")) (str (text "journals:action.edit_tags"))) (button - ("class" "button") ("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()") (icon (text "brush-cleaning")) (str (text "journals:action.move"))) + (text "{% if note.is_global -%}") + (a + ("class" "button") + ("href" "/x/{{ note.title }}") + (icon (text "eye")) + (str (text "journals:action.view"))) + + (button + ("class" "purple") + ("onclick" "unpublish_note('{{ note.id }}')") + (icon (text "globe-lock")) + (str (text "journals:action.unpublish"))) + (text "{% elif note.title != 'journal.css' %}") + (button + ("class" "green") + ("onclick" "publish_note('{{ note.id }}')") + (icon (text "globe")) + (str (text "journals:action.publish"))) + (text "{%- endif %}") (button ("onclick" "delete_note('{{ note.id }}')") ("class" "red") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 39c6699..255b2ec 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -1,11 +1,19 @@ (text "{% extends \"root.html\" %} {% block head %}") -(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}") + +(text "{% if journal -%} {% if note -%}") +(title (text "{{ note.title }}")) +(text "{% else %}") +(title (text "{{ journal.title }}")) +(text "{%- endif %} {% else %}") +(title (text "Journals - {{ config.name }}")) +(text "{%- endif %}") (text "{% if note and journal and owner -%}") (meta ("name" "og:title") ("content" "{{ note.title }}")) +(text "{% if not global_mode -%}") (meta ("name" "description") ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) @@ -14,6 +22,23 @@ ("name" "og:description") ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) +(meta + ("name" "twitter:description") + ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) +(text "{% else %}") +(meta + ("name" "description") + ("content" "View this note on {{ config.name }}!")) + +(meta + ("name" "og:description") + ("content" "View this note on {{ config.name }}!")) + +(meta + ("name" "twitter:description") + ("content" "View this note on {{ config.name }}!")) +(text "{%- endif %}") + (meta ("property" "og:type") ("content" "website")) @@ -33,10 +58,6 @@ (meta ("name" "twitter:title") ("content" "{{ note.title }}")) - -(meta - ("name" "twitter:description") - ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) (text "{%- endif %}") (link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) @@ -73,7 +94,7 @@ ; 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 "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}") (text "{% if not view_mode -%}") (nav ("class" "chats_nav") @@ -117,7 +138,7 @@ (main ("class" "flex flex-col gap-2") ; the journal/note header is always shown - (text "{% if journal -%}") + (text "{% if journal and not global_mode -%}") (div ("class" "mobile_nav w-full flex items-center justify-between gap-2") (div @@ -126,8 +147,8 @@ ("class" "flex gap-2 items-center") (a ("class" "flex items-center") - ("href" "/api/v1/auth/user/find/{{ journal.owner }}") - (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}")) + ("href" "/@{{ owner.username }}") + (text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}")) (text "{% if (view_mode and owner) or not view_mode -%}") (a @@ -462,19 +483,35 @@ (div ("class" "flex w-full justify-between gap-2") (div - ("class" "flex flex-col gap-2") + ("class" "flex flex-col gap-2 fade") (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + + (text "{% if global_mode -%}") + (span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}")) + (span (text "Views: {{ redis_views }}")) + (text "{% elif note.is_global -%}") + ; globsl note, but we aren't viewing globally... + (a + ("href" "/x/{{ note.title }}") + ("class" "button lowered small green") + (icon (text "globe")) + (text "View as global")) + (text "{%- endif %}") + (text "{{ components::note_tags(note=note) }}")) (text "{% if user and user.id == owner.id -%}") (button ("class" "small") - ("onclick" "{% if journal.privacy == \"Public\" -%} + ("onclick" "{% if note.is_global -%} + trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}']) + {%- else -%} + {% 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 %}") + {%- endif -%} {%- endif %}") (icon (text "share")) (str (text "general:label.share"))) @@ -809,6 +846,62 @@ }); } + globalThis.publish_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + `Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private. + +Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`, + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}/global`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + } + + globalThis.unpublish_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This global note name will be made available.\", + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}/global`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + } + // sidebars window.SIDEBARS_OPEN = false; if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index a25f833..0867dda 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -693,6 +693,8 @@ (text "Create infinite journals")) (li (text "Create infinite notes in each journal")) + (li + (text "Publish up to 50 notes")) (text "{% if config.security.enable_invite_codes -%}") (li diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 88f4f7d..8b9954d 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -42,7 +42,7 @@ }, }; - socket.addEventListener("message", (event) => { + socket.addEventListener("message", async (event) => { if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,14 +54,14 @@ return console.info(`${stream} ${data.data}`); } - return $.sock(stream).events.message(data); + return (await $.sock(stream)).events.message(data); }); return $.STREAMS[stream]; }); - self.define("close", ({ $ }, stream) => { - const socket = $.sock(stream); + self.define("close", async ({ $ }, stream) => { + const socket = await $.sock(stream); if (!socket) { console.warn("no such stream to close"); diff --git a/crates/app/src/routes/api/v1/auth/links.rs b/crates/app/src/routes/api/v1/auth/links.rs deleted file mode 100644 index ecc921b..0000000 --- a/crates/app/src/routes/api/v1/auth/links.rs +++ /dev/null @@ -1,285 +0,0 @@ -use axum::{ - response::IntoResponse, - extract::{Json, Path}, - Extension, -}; -use axum_extra::extract::CookieJar; -use crate::{ - get_user_from_token, - image::{save_webp_buffer, JsonMultipart}, - routes::api::v1::{ - CreateLink, UpdateLinkHref, UpdateLinkLabel, UpdateLinkPosition, UploadLinkIcon, - }, - State, -}; -use tetratto_core::model::{ - links::Link, - oauth, - uploads::{MediaType, MediaUpload}, - ApiReturn, Error, -}; - -pub async fn get_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - if get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks).is_none() { - return Json(Error::NotAllowed.into()); - }; - - let link = match data.get_link_by_id(id).await { - Ok(x) => x, - Err(e) => return Json(e.into()), - }; - - Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(link), - }) -} - -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.get_links_by_owner(user.id).await { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(x), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn create_request( - jar: CookieJar, - Extension(data): Extension, - Json(props): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLinks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data - .create_link( - Link::new( - user.id, - props.label, - props.href, - match data.get_links_by_owner_count(user.id).await { - Ok(c) => (c + 1) as usize, - Err(e) => return Json(e.into()), - }, - ), - &user, - ) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Link created".to_string(), - payload: Some(x.id.to_string()), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_label_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, - Json(props): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let link = match data.get_link_by_id(id).await { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - if link.owner != user.id { - return Json(Error::NotAllowed.into()); - } - - // ... - match data.update_link_label(id, &props.label).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Link updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_href_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, - Json(props): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let link = match data.get_link_by_id(id).await { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - if link.owner != user.id { - return Json(Error::NotAllowed.into()); - } - - // ... - match data.update_link_href(id, &props.href).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Link updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_position_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, - Json(props): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let link = match data.get_link_by_id(id).await { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - if link.owner != user.id { - return Json(Error::NotAllowed.into()); - } - - if props.position < 0 { - return Json(Error::MiscError("Position must be an unsigned integer".to_string()).into()); - } - - // ... - match data.update_link_position(id, props.position).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Link updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub const MAXIMUM_FILE_SIZE: usize = 131072; // 128 KiB - -pub async fn upload_icon_request( - jar: CookieJar, - Extension(data): Extension, - JsonMultipart(images, props): JsonMultipart, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let id = match props.id.parse::() { - Ok(i) => i, - Err(_) => return Json(Error::Unknown.into()), - }; - - let link = match data.get_link_by_id(id).await { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - if link.owner != user.id { - return Json(Error::NotAllowed.into()); - } - - // create upload - let upload = match data - .create_upload(MediaUpload::new(MediaType::Webp, user.id)) - .await - { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - let image = match images.get(0) { - Some(i) => i, - None => return Json(Error::MiscError("Missing file".to_string()).into()), - }; - - if image.len() > MAXIMUM_FILE_SIZE { - return Json(Error::FileTooLarge.into()); - } - - // upload - if let Err(e) = save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) { - return Json(Error::MiscError(e.to_string()).into()); - } - - // ... - match data.update_link_upload_id(id, upload.id as i64).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Link updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn delete_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageStacks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let link = match data.get_link_by_id(id).await { - Ok(n) => n, - Err(e) => return Json(e.into()), - }; - - if link.owner != user.id { - return Json(Error::NotAllowed.into()); - } - - match data.delete_link(id).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Link deleted".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 670a44f..a332dd8 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -1,7 +1,6 @@ pub mod connections; pub mod images; pub mod ipbans; -pub mod links; pub mod profile; pub mod social; pub mod user_warnings; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index e7b9f46..9f850af 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -588,6 +588,8 @@ pub fn routes() -> Router { .route("/notes/{id}/content", post(notes::update_content_request)) .route("/notes/{id}/dir", post(notes::update_dir_request)) .route("/notes/{id}/tags", post(notes::update_tags_request)) + .route("/notes/{id}/global", post(notes::publish_request)) + .route("/notes/{id}/global", delete(notes::unpublish_request)) .route("/notes/from_journal/{id}", get(notes::list_request)) .route("/notes/preview", post(notes::render_markdown_request)) .route( @@ -597,18 +599,6 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) - // links - .route("/links", get(auth::links::list_request)) - .route("/links", post(auth::links::create_request)) - .route("/links/{id}", get(auth::links::get_request)) - .route("/links/{id}", delete(auth::links::delete_request)) - .route("/links/icon", post(auth::links::upload_icon_request)) - .route("/links/{id}/label", post(auth::links::update_label_request)) - .route("/links/{id}/href", post(auth::links::update_href_request)) - .route( - "/links/{id}/position", - post(auth::links::update_position_request), - ) } #[derive(Deserialize)] @@ -982,29 +972,3 @@ pub struct RemoveJournalDir { pub struct UpdateNoteTags { pub tags: Vec, } - -#[derive(Deserialize)] -pub struct CreateLink { - pub label: String, - pub href: String, -} - -#[derive(Deserialize)] -pub struct UpdateLinkLabel { - pub label: String, -} - -#[derive(Deserialize)] -pub struct UpdateLinkHref { - pub href: String, -} - -#[derive(Deserialize)] -pub struct UpdateLinkPosition { - pub position: i32, -} - -#[derive(Deserialize)] -pub struct UploadLinkIcon { - pub id: String, -} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 9a18559..b6bc986 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -164,11 +164,21 @@ pub async fn update_title_request( // ... match data.update_note_title(id, &user, &props.title).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Note updated".to_string(), - payload: (), - }), + Ok(_) => { + // update note global status + if note.is_global { + if let Err(e) = data.update_note_is_global(id, 0).await { + return Json(e.into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }) + } Err(e) => Json(e.into()), } } @@ -318,3 +328,92 @@ pub async fn update_tags_request( Err(e) => Json(e.into()), } } + +pub async fn publish_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if user.id != note.owner { + return Json(Error::NotAllowed.into()); + } + + // check count + if data.get_user_global_notes_count(user.id).await.unwrap_or(0) + >= if user.permissions.check(FinePermission::SUPPORTER) { + 10 + } else { + 5 + } + { + return Json( + Error::MiscError( + "You already have the maximum number of global notes you can have".to_string(), + ) + .into(), + ); + } + + // make sure note doesn't already exist globally + if data.get_global_note_by_title(¬e.title).await.is_ok() { + return Json( + Error::MiscError( + "Note name is already in use globally. Please change the name and try again" + .to_string(), + ) + .into(), + ); + } + + // ... + match data.update_note_is_global(id, 1).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unpublish_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if user.id != note.owner { + return Json(Error::NotAllowed.into()); + } + + // ... + match data.update_note_is_global(id, 0).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index 434c819..0c35e04 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -81,7 +81,7 @@ pub async fn app_request( }; let lang = get_lang!(jar, data.0); - let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; context.insert("selected_journal", &selected_journal); context.insert("selected_note", &selected_note); @@ -89,6 +89,7 @@ pub async fn app_request( context.insert("journal", &journal); context.insert("note", ¬e); + context.insert("owner", &user); context.insert("journals", &journals); context.insert("notes", ¬es); @@ -185,6 +186,10 @@ pub async fn view_request( context.insert("selected_note", &0); } else { context.insert("selected_note", &selected_note); + context.insert( + "redis_views", + &data.0.get_note_views(note.as_ref().unwrap().id).await, + ); } context.insert("journal", &journal); @@ -292,3 +297,70 @@ pub async fn index_view_request( // return Ok(Html(data.1.render("journals/app.html", &context).unwrap())) } + +/// `/x/{note}` +pub async fn global_view_request( + jar: CookieJar, + Extension(data): Extension, + Path(mut selected_note): Path, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => Some(ua), + None => None, + }; + + if selected_note == "index" { + selected_note = String::new(); + } + + // if we don't have a selected journal, we shouldn't be here probably + if selected_note == "journal.css" { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + + // ... + let note = match data.0.get_global_note_by_title(&selected_note).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let journal = match data.0.get_journal_by_id(note.journal).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // get owner + let owner = match data.0.get_user_by_id(note.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); + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + data.0.incr_note_views(note.id).await; + + context.insert("selected_journal", ¬e.journal); + context.insert("selected_note", &selected_note); + context.insert("redis_views", &data.0.get_note_views(note.id).await); + + context.insert("journal", &journal); + context.insert("note", ¬e); + + context.insert("owner", &owner); + context.insert::<[i8; 0], &str>("notes", &[]); + + context.insert("view_mode", &true); + context.insert("is_editor", &false); + context.insert("global_mode", &true); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} diff --git a/crates/app/src/routes/pages/links.rs b/crates/app/src/routes/pages/links.rs deleted file mode 100644 index 3cec4bd..0000000 --- a/crates/app/src/routes/pages/links.rs +++ /dev/null @@ -1,47 +0,0 @@ -use axum::{ - extract::Path, - response::{Html, IntoResponse}, - Extension, -}; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, Error}; -use crate::{get_user_from_token, State}; -use super::render_error; - -/// `/links/{id}` -pub async fn navigate_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = data.read().await; - let user = match get_user_from_token!(jar, data.0) { - Some(ua) => ua, - None => { - return Err(Html( - render_error(Error::NotAllowed, &jar, &data, &None).await, - )); - } - }; - - let link = match data.0.get_link_by_id(id).await { - Ok(x) => x, - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }; - - let owner = match data.0.get_user_by_id(link.owner).await { - Ok(x) => x, - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }; - - if owner.permissions.check(FinePermission::SUPPORTER) { - if let Err(e) = data.0.incr_link_clicks(link.id).await { - return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); - } - } - - Ok(Html(format!( - "Navigating...", - link.href - ))) -} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index f527f2a..a2ca470 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -4,7 +4,6 @@ pub mod communities; pub mod developer; pub mod forge; pub mod journals; -pub mod links; pub mod misc; pub mod mod_panel; pub mod profile; @@ -138,8 +137,7 @@ pub fn routes() -> Router { .route("/journals/{journal}/{note}", get(journals::app_request)) .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) - // links - .route("/links/{id}", get(links::navigate_request)) + .route("/x/{note}", get(journals::global_view_request)) } pub async fn render_error( diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index b3695b7..45111db 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,7 +40,6 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); - execute!(&conn, common::CREATE_TABLE_LINKS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 04417c2..e1cfad7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,4 +27,3 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); -pub const CREATE_TABLE_LINKS: &str = include_str!("./sql/create_links.sql"); diff --git a/crates/core/src/database/drivers/sql/create_links.sql b/crates/core/src/database/drivers/sql/create_links.sql deleted file mode 100644 index 0c1fc25..0000000 --- a/crates/core/src/database/drivers/sql/create_links.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS links ( - id BIGINT NOT NULL PRIMARY KEY, - created BIGINT NOT NULL, - owner BIGINT NOT NULL, - label TEXT NOT NULL, - href TEXT NOT NULL, - upload_id BIGINT NOT NULL, - clicks INT NOT NULL, - position INT NOT NULL -) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql index 2c85588..5018f91 100644 --- a/crates/core/src/database/drivers/sql/create_notes.sql +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS notes ( content TEXT NOT NULL, edited BIGINT NOT NULL, dir BIGINT NOT NULL, - tags TEXT NOT NULL + tags TEXT NOT NULL, + is_global INT NOT NULL ) diff --git a/crates/core/src/database/links.rs b/crates/core/src/database/links.rs deleted file mode 100644 index cb00c93..0000000 --- a/crates/core/src/database/links.rs +++ /dev/null @@ -1,146 +0,0 @@ -use oiseau::{cache::Cache, query_row, query_rows}; -use crate::model::{auth::User, links::Link, permissions::FinePermission, Error, Result}; -use crate::{auto_method, DataManager}; -use oiseau::{PostgresRow, execute, get, params}; - -impl DataManager { - /// Get a [`Link`] from an SQL row. - pub(crate) fn get_link_from_row(x: &PostgresRow) -> Link { - Link { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - label: get!(x->3(String)), - href: get!(x->4(String)), - upload_id: get!(x->5(i64)) as usize, - clicks: get!(x->6(i32)) as usize, - position: get!(x->7(i32)) as usize, - } - } - - auto_method!(get_link_by_id()@get_link_from_row -> "SELECT * FROM links WHERE id = $1" --name="link" --returns=Link --cache-key-tmpl="atto.link:{}"); - - /// Get links by `owner`. - pub async fn get_links_by_owner(&self, owner: usize) -> Result> { - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = query_rows!( - &conn, - "SELECT * FROM links WHERE owner = $1 ORDER BY position DESC", - &[&(owner as i64)], - |x| { Self::get_link_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("link".to_string())); - } - - Ok(res.unwrap()) - } - - /// Get links by `owner`. - pub async fn get_links_by_owner_count(&self, owner: usize) -> Result { - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = query_row!( - &conn, - "SELECT COUNT(*)::int FROM links WHERE owner = $1", - &[&(owner as i64)], - |x| Ok(x.get::(0)) - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("link".to_string())); - } - - Ok(res.unwrap()) - } - - const MAXIMUM_FREE_LINKS: usize = 10; - const MAXIMUM_SUPPORTER_LINKS: usize = 20; - - /// Create a new link in the database. - /// - /// # Arguments - /// * `data` - a mock [`Link`] object to insert - pub async fn create_link(&self, data: Link, user: &User) -> Result { - if !user.permissions.check(FinePermission::SUPPORTER) { - if (self.get_links_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_FREE_LINKS - { - return Err(Error::MiscError( - "You already have the maximum number of links you can create".to_string(), - )); - } - } else if !user.permissions.check(FinePermission::MANAGE_USERS) { - if (self.get_links_by_owner_count(user.id).await? as usize) - >= Self::MAXIMUM_SUPPORTER_LINKS - { - return Err(Error::MiscError( - "You already have the maximum number of links you can create".to_string(), - )); - } - } - - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!( - &conn, - "INSERT INTO links VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.owner as i64), - &data.label, - &data.href, - &(data.upload_id as i64), - &(data.clicks as i32), - &(data.position as i32), - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } - - pub async fn delete_link(&self, id: usize) -> Result<()> { - let y = self.get_link_by_id(id).await?; - - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!(&conn, "DELETE FROM links WHERE id = $1", &[&(id as i64)]); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - // delete upload - if y.upload_id != 0 { - self.delete_upload(id).await?; - } - - // ... - self.0.1.remove(format!("atto.link:{}", id)).await; - Ok(()) - } - - auto_method!(update_link_label(&str) -> "UPDATE links SET label = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); - auto_method!(update_link_href(&str) -> "UPDATE links SET href = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); - auto_method!(update_link_upload_id(i64) -> "UPDATE links SET upload_id = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); - auto_method!(update_link_position(i32) -> "UPDATE links SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); - auto_method!(incr_link_clicks() -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}" --incr); -} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 20575e0..5f81259 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -12,7 +12,6 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; -mod links; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index 364a540..2754baf 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -17,10 +17,33 @@ impl DataManager { edited: get!(x->6(i64)) as usize, dir: get!(x->7(i64)) as usize, tags: serde_json::from_str(&get!(x->8(String))).unwrap(), + is_global: get!(x->9(i32)) as i8 == 1, } } auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + + /// Get the number of global notes a user has. + pub async fn get_user_global_notes_count(&self, owner: usize) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1", + &[&(owner as i64)], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } /// Get a note by `journal` and `title`. pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result { @@ -94,6 +117,9 @@ impl DataManager { const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; + pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10; + pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50; + /// Create a new note in the database. /// /// # Arguments @@ -164,7 +190,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.created as i64), @@ -175,6 +201,7 @@ impl DataManager { &(data.edited as i64), &(data.dir as i64), &serde_json::to_string(&data.tags).unwrap(), + &if data.is_global { 1 } else { 0 } ] ); @@ -206,7 +233,7 @@ impl DataManager { } // ... - self.0.1.remove(format!("atto.note:{}", id)).await; + self.cache_clear_note(¬e).await; Ok(()) } @@ -246,9 +273,26 @@ impl DataManager { Ok(()) } - auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); - auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); - auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); - auto_method!(update_note_tags(Vec)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}"); - auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); + /// Incremenet note views. Views are only stored in the cache. + /// + /// This should only be done for global notes. + pub async fn incr_note_views(&self, id: usize) { + self.0.1.incr(format!("atto.note:{id}/views")).await; + } + + pub async fn get_note_views(&self, id: usize) -> Option { + self.0.1.get(format!("atto.note:{id}/views")).await + } + + pub async fn cache_clear_note(&self, x: &Note) { + self.0.1.remove(format!("atto.note:{}", x.id)).await; + self.0.1.remove(format!("atto.note:{}", x.title)).await; + } + + auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_tags(Vec)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 72ef2bc..347548f 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -175,7 +175,7 @@ impl DataManager { &(data.owner as i64), &(data.asset as i64), &serde_json::to_string(&data.asset_type).unwrap().as_str(), - &{ if data.is_like { 1 } else { 0 } } + &if data.is_like { 1 } else { 0 } ] ); diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs index 3d70ce0..8c2491d 100644 --- a/crates/core/src/model/journals.rs +++ b/crates/core/src/model/journals.rs @@ -60,6 +60,7 @@ pub struct Note { pub dir: usize, /// An array of tags associated with the note. pub tags: Vec, + pub is_global: bool, } impl Note { @@ -77,6 +78,7 @@ impl Note { edited: created, dir: 0, tags: Vec::new(), + is_global: false, } } } diff --git a/crates/core/src/model/links.rs b/crates/core/src/model/links.rs deleted file mode 100644 index 19b77f2..0000000 --- a/crates/core/src/model/links.rs +++ /dev/null @@ -1,41 +0,0 @@ -use serde::{Serialize, Deserialize}; -use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct Link { - pub id: usize, - pub created: usize, - /// Links should be selected by their owner, not their link list ID. - /// This is why we do not store link list ID. - pub owner: usize, - pub label: String, - pub href: String, - /// As link icons are optional, `upload_id` is allowed to be 0. - pub upload_id: usize, - /// Clicks are tracked for supporters only. - /// - /// When a user clicks on a link through the UI, they'll be redirect to - /// `/links/{id}`. If the link's owner is a supporter, the link's clicks will - /// be incremented. - /// - /// The page should just serve a simple HTML document with a meta tag to redirect. - /// We only really care about knowing they clicked it, so an automatic redirect will do. - pub clicks: usize, - pub position: usize, -} - -impl Link { - /// Create a new [`Link`]. - pub fn new(owner: usize, label: String, href: String, position: usize) -> Self { - Self { - id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp(), - owner, - label, - href, - upload_id: 0, - clicks: 0, - position, - } - } -} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 5a6933b..839310f 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,7 +6,6 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; -pub mod links; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/sql_changes/notes_is_global.sql b/sql_changes/notes_is_global.sql new file mode 100644 index 0000000..eeea878 --- /dev/null +++ b/sql_changes/notes_is_global.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes +ADD COLUMN is_global INT NOT NULL DEFAULT 0;