add: global notes

This commit is contained in:
trisua 2025-06-26 02:56:22 -04:00
parent 59581f69c9
commit 2cd04b0db0
24 changed files with 371 additions and 608 deletions

View file

@ -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"

View file

@ -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")

View file

@ -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\") {

View file

@ -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

View file

@ -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");

View file

@ -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<usize>,
Extension(data): Extension<State>,
) -> 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<State>) -> 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<State>,
Json(props): Json<CreateLink>,
) -> 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<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkLabel>,
) -> 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<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkHref>,
) -> 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<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkPosition>,
) -> 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<State>,
JsonMultipart(images, props): JsonMultipart<UploadLinkIcon>,
) -> 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::<usize>() {
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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -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;

View file

@ -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<String>,
}
#[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,
}

View file

@ -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<usize>,
Extension(data): Extension<State>,
) -> 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(&note.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<usize>,
Extension(data): Extension<State>,
) -> 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()),
}
}

View file

@ -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", &note);
context.insert("owner", &user);
context.insert("journals", &journals);
context.insert("notes", &notes);
@ -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<State>,
Path(mut selected_note): Path<String>,
) -> 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", &note.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", &note);
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()))
}

View file

@ -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<usize>,
Extension(data): Extension<State>,
) -> 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!(
"<!doctype html /><html><head><meta http-equiv=\"refresh\" content=\"0; url={}\" /></head><body>Navigating...</body></html>",
link.href
)))
}

View file

@ -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(

View file

@ -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

View file

@ -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");

View file

@ -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
)

View file

@ -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
)

View file

@ -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<Vec<Link>> {
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<i32> {
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::<usize, i32>(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<Link> {
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);
}

View file

@ -12,7 +12,6 @@ mod invite_codes;
mod ipbans;
mod ipblocks;
mod journals;
mod links;
mod memberships;
mod message_reactions;
mod messages;

View file

@ -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<i32> {
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::<usize, i32>(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<Note> {
@ -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(&note).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<String>)@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<String> {
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<String>)@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);
}

View file

@ -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 }
]
);

View file

@ -60,6 +60,7 @@ pub struct Note {
pub dir: usize,
/// An array of tags associated with the note.
pub tags: Vec<String>,
pub is_global: bool,
}
impl Note {
@ -77,6 +78,7 @@ impl Note {
edited: created,
dir: 0,
tags: Vec::new(),
is_global: false,
}
}
}

View file

@ -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::<usize>().unwrap(),
created: unix_epoch_timestamp(),
owner,
label,
href,
upload_id: 0,
clicks: 0,
position,
}
}
}

View file

@ -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;