add: audit log, reports

add: theme preference setting
This commit is contained in:
trisua 2025-04-02 11:39:51 -04:00
parent b2df2739a7
commit d3d0c41334
38 changed files with 925 additions and 169 deletions

View file

@ -54,6 +54,10 @@ pub const COMMUNITIES_SETTINGS: &str = include_str!("./public/html/communities/s
pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.html");
pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.html");
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.html");
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.html");
pub const MOD_FILE_REPORT: &str = include_str!("./public/html/mod/file_report.html");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -173,6 +177,10 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config);
write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config);
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config);
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config);
write_template!(html_path->"mod/file_report.html"(crate::assets::MOD_FILE_REPORT) --config=config);
html_path
}

View file

@ -7,9 +7,18 @@ version = "1.0.0"
"general:link.communities" = "Communities"
"general:link.next" = "Next"
"general:link.previous" = "Previous"
"general:link.source_code" = "Source code"
"general:link.audit_log" = "Audit log"
"general:link.reports" = "Reports"
"general:action.save" = "Save"
"general:action.delete" = "Delete"
"general:action.back" = "Back"
"general:action.report" = "Report"
"general:action.manage" = "Manage"
"general:label.mod" = "Mod"
"general:label.file_report" = "File report"
"general:label.account_banned" = "Account banned"
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
"dialog:action.okay" = "Ok"
"dialog:action.continue" = "Continue"
@ -72,3 +81,5 @@ version = "1.0.0"
"settings:label.new_username" = "New username"
"settings:label.change_avatar" = "Change avatar"
"settings:label.change_banner" = "Change banner"
"mod_panel:label.open_reported_content" = "Open reported content"

View file

@ -63,7 +63,13 @@ macro_rules! get_user_from_token {
))
.await
{
Ok(ua) => Some(ua),
Ok(ua) => {
if ua.permissions.check_banned() {
Some(tetratto_core::model::auth::User::banned())
} else {
Some(ua)
}
}
Err(_) => None,
}
} else {

View file

@ -15,14 +15,42 @@
{{ components::community_avatar(id=community.id,
community=community, size="72px") }}
<div class="flex flex-col">
<!-- prettier-ignore -->
<h3 id="title" class="title">
{% if community.context.display_name %}
{{ community.context.display_name }}
{% else %}
{{ community.title }}
{% endif %}
</h3>
<div class="flex gap-2 items-center">
<h3 id="title" class="title">
<!-- prettier-ignore -->
{% if community.context.display_name %}
{{ community.context.display_name }}
{% else %}
{{ community.title }}
{% endif %}
</h3>
{% if user %} {% if user.id != community.owner
%}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner left">
<button
class="red"
onclick="trigger('me::report', ['{{ community.id }}', 'community'])"
>
{{ icon "flag" }}
<span
>{{ text "general:action.report"
}}</span
>
</button>
</div>
</div>
{% endif %} {% endif %}
</div>
<span class="fade">{{ community.title }}</span>
</div>

View file

@ -227,66 +227,6 @@
document.getElementById("uid").value = uid;
}
globalThis.ban_user = async (uid) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${uid}/role`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
role: 33,
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.unban_user = async (uid) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${uid}/role`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
role: 5,
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.update_user_role = async (uid, new_role) => {
if (
!(await trigger("atto::confirm", [
@ -356,7 +296,7 @@
element.innerHTML = `<div class="flex gap-2 flex-wrap" ui_ident="actions">
<a target="_blank" class="button" href="/api/v1/auth/profile/find/${e.target.uid.value}">Open user profile</a>
${res.payload.role !== 33 ? `<button class="red quaternary" onclick="ban_user('${e.target.uid.value}')">Ban</button>` : `<button class="quaternary" onclick="unban_user('${e.target.uid.value}')">Unban</button>`}
${res.payload.role !== 33 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 33)">Ban</button>` : `<button class="quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Unban</button>`}
${res.payload.role !== 65 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 65)">Send to review</button>` : `<button class="green quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Accept join request</button>`}
<button class="red quaternary" onclick="kick_user('${e.target.uid.value}')">Kick</button>
</div>`;

View file

@ -161,7 +161,7 @@ show_community=true) -%} {% if community and show_community %}
{{ icon "external-link" }}
</a>
{% if user %} {% if (user.id == post.owner) or is_helper %}
{% if user %}
<div class="dropdown">
<button
class="camo small"
@ -172,6 +172,17 @@ show_community=true) -%} {% if community and show_community %}
</button>
<div class="inner">
{% if user.id != post.owner %}
<button
class="red"
onclick="trigger('me::report', ['{{ post.id }}', 'post'])"
>
{{ icon "flag" }}
<span>{{ text "general:action.report" }}</span>
</button>
{% endif %} {% if (user.id == post.owner) or is_helper
%}
<b class="title">{{ text "general:action.manage" }}</b>
<button
class="red"
onclick="trigger('me::remove_post', ['{{ post.id }}'])"
@ -179,9 +190,10 @@ show_community=true) -%} {% if community and show_community %}
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
{% endif %}
</div>
</div>
{% endif %} {% endif %}
{% endif %}
</div>
</div>
</div>

View file

@ -75,17 +75,30 @@ show_lhs=true) -%}
<span>{{ text "auth:link.settings" }}</span>
</a>
<a href="https://github.com/trisuaso/tetratto">
{{ icon "code" }}
<span>{{ text "general:link.source_code" }}</span>
</a>
{% if is_helper %}
<b class="title">{{ text "general:label.mod" }}</b>
<a href="/mod_panel/audit_log">
{{ icon "scroll-text" }}
<span>{{ text "general:link.audit_log" }}</span>
</a>
<a href="/mod_panel/reports">
{{ icon "flag" }}
<span>{{ text "general:link.reports" }}</span>
</a>
{% endif %}
<div class="title"></div>
<button class="red" onclick="trigger('me::logout')">
{{ icon "log-out" }}
<span>{{ text "auth:action.logout" }}</span>
</button>
<div class="title"></div>
<a href="https://github.com/trisuaso/tetratto">
{{ icon "code" }}
<span>View source</span>
</a>
</div>
</div>
{% else %}

View file

@ -0,0 +1,36 @@
{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %}
<title>Audit log - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="notifications") }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "scroll" }}
<span>{{ text "general:link.audit_log" }}</span>
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
{% for item in items %}
<div class="card-nest">
<a
class="card small flex items-center gap-2 flush"
href="/api/v1/auth/profile/find/{{ item.moderator }}"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.moderator, selector_type="id") }}
<span>{{ item.moderator }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<div class="card secondary">
<span>{{ item.content|markdown|safe }}</span>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,65 @@
{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %}
<title>File report - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="notifications") }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "flag" }}
<span>{{ text "general:label.file_report" }}</span>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_report_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="title"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="16"
></textarea>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
</main>
<script>
function create_report_from_form(e) {
e.preventDefault();
fetch("/api/v1/reports", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.content.value,
asset: "{{ asset }}",
asset_type: `{{ asset_type }}`,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.close();
}, 150);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,79 @@
{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %}
<title>Reports - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="notifications") }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "flag" }}
<span>{{ text "general:link.reports" }}</span>
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
{% for item in items %}
<div class="card-nest">
<a
class="card small flex items-center gap-2 flush"
href="/api/v1/auth/profile/find/{{ item.owner }}"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.owner, selector_type="id") }}
<span>{{ item.owner }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<div class="card secondary flex flex-col gap-2">
<span>{{ item.content|markdown|safe }}</span>
<div class="card w-full flex flex-wrap gap-2">
<button
onclick="open_reported_content('{{ item.asset }}', '{{ item.asset_type }}')"
>
{{ icon "external-link" }}
<span
>{{ text "mod_panel:label.open_reported_content"
}}</span
>
</button>
<button
onclick="remove_report('{{ item.id }}')"
class="red quaternary"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
<script>
function open_reported_content(asset, asset_type) {
if (asset_type === "Post") {
window.open(`/post/${asset}`);
} else if (asset_type === "Community") {
window.open(`/community/${asset}`);
}
}
function remove_report(id) {
fetch(`/api/v1/reports/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{% endblock %}

View file

@ -209,6 +209,22 @@
}}</span
>
</button>
{% if profile.permissions != 131073 %}
<button
class="red quaternary"
onclick="update_user_role(131073)"
>
Ban
</button>
{% else %}
<button
class="quaternary"
onclick="update_user_role(1)"
>
Unban
</button>
{% endif %}
</div>
</div>
@ -286,25 +302,77 @@
});
};
ui.refresh_container(element, ["actions"]);
ui.generate_settings_ui(
element,
[
[
["is_verified", "Is verified"],
"{{ profile.is_verified }}",
"checkbox",
],
],
null,
{
is_verified: (value) => {
profile_request(false, "verified", {
is_verified: value,
});
globalThis.update_user_role = async (
new_role,
) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
`/api/v1/auth/profile/{{ profile.id }}/role`,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
},
},
);
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
ui.refresh_container(element, ["actions"]);
setTimeout(() => {
ui.refresh_container(element, ["actions"]);
ui.generate_settings_ui(
element,
[
[
["is_verified", "Is verified"],
"{{ profile.is_verified }}",
"checkbox",
],
[
["role", "Permission level"],
"{{ profile.permissions }}",
"input",
],
],
null,
{
is_verified: (value) => {
profile_request(
false,
"verified",
{
is_verified: value,
},
);
},
role: (new_role) => {
return update_user_role(
new_role,
);
},
},
);
}, 100);
}, 150);
</script>
</div>

View file

@ -142,6 +142,37 @@
<div class="w-full hidden flex flex-col gap-2" data-tab="profile">
<div class="card tertiary flex flex-col gap-2" id="profile_settings">
<div class="card-nest" ui_ident="theme_preference">
<div class="card small">
<b>Theme preference</b>
</div>
<div class="card">
<select
onchange="set_setting_field('theme_preference', event.target.selectedOptions[0].value)"
>
<option
value="Auto"
selected="{% if user.settings.theme_preference == 'Auto' %}true{% else %}false{% endif %}"
>
Auto
</option>
<option
value="Light"
selected="{% if user.settings.theme_preference == 'Light' %}true{% else %}false{% endif %}"
>
Light
</option>
<option
value="Dark"
selected="{% if user.settings.theme_preference == 'Dark' %}true{% else %}false{% endif %}"
>
Dark
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="change_avatar">
<div class="card small">
<b>{{ text "settings:label.change_avatar" }}</b>
@ -421,6 +452,7 @@
"change_username",
]);
ui.refresh_container(profile_settings, [
"theme_preference",
"change_avatar",
"change_banner",
]);

View file

@ -14,6 +14,15 @@
<link rel="stylesheet" href="/css/style.css" />
{% if user %}
<script>
window.localStorage.setItem(
"tetratto:theme",
"{{ user.settings.theme_preference }}",
);
</script>
{% endif %}
<script src="/js/loader.js"></script>
<script defer async src="/js/atto.js"></script>
@ -57,7 +66,28 @@
<div id="toast_zone"></div>
<div id="page" style="display: contents">
{% block body %}{% endblock %}
<!-- prettier-ignore -->
{% if user and user.id == 0 %}
<article>
<main>
<div class="card-nest">
<div class="card small flex items-center gap-2 red">
{{ icon "frown" }}
<span
>{{ text "general:label.account_banned" }}</span
>
</div>
<div class="card">
<span
>{{ text "general:label.account_banned_body"
}}</span
>
</div>
</div>
</main>
</article>
{% else %} {% block body %}{% endblock %} {% endif %}
</div>
<script data-turbo-permanent="true" id="init-script">

View file

@ -6,20 +6,22 @@ function media_theme_pref() {
if (
window.matchMedia("(prefers-color-scheme: dark)").matches &&
!window.localStorage.getItem("tetratto:theme")
(!window.localStorage.getItem("tetratto:theme") ||
window.localStorage.getItem("tetratto:theme") === "Auto")
) {
document.documentElement.classList.add("dark");
// window.localStorage.setItem("theme", "dark");
} else if (
window.matchMedia("(prefers-color-scheme: light)").matches &&
!window.localStorage.getItem("tetratto:theme")
(!window.localStorage.getItem("tetratto:theme") ||
window.localStorage.getItem("tetratto:theme") === "Auto")
) {
document.documentElement.classList.remove("dark");
// window.localStorage.setItem("theme", "light");
} else if (window.localStorage.getItem("tetratto:theme")) {
/* restore theme */
const current = window.localStorage.getItem("tetratto:theme");
document.documentElement.className = current;
document.documentElement.className = current.toLowerCase();
}
}

View file

@ -144,4 +144,10 @@
]);
});
});
self.define("report", (_, asset, asset_type) => {
window.open(
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`,
);
});
})();

View file

@ -37,7 +37,7 @@ pub async fn create_request(
/// Delete the given IP ban.
pub async fn delete_request(
jar: CookieJar,
Path(id): Path<usize>,
Path(ip): Path<String>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
@ -50,7 +50,7 @@ pub async fn delete_request(
return Json(Error::NotAllowed.into());
}
match data.delete_ipban(id, user).await {
match data.delete_ipban(&ip, user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "IP ban deleted".to_string(),

View file

@ -1,7 +1,9 @@
use crate::{
State, get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{DeleteUser, UpdateUserIsVerified, UpdateUserPassword, UpdateUserUsername},
routes::api::v1::{
DeleteUser, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
},
};
use axum::{
Extension, Json,
@ -171,6 +173,29 @@ pub async fn update_user_is_verified_request(
}
}
/// Update the role of the given user.
pub async fn update_user_role_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateUserRole>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_user_role(id, req.role, user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Delete the given user.
pub async fn delete_user_request(
jar: CookieJar,

View file

@ -2,6 +2,7 @@ pub mod auth;
pub mod communities;
pub mod notifications;
pub mod reactions;
pub mod reports;
pub mod util;
use axum::{
@ -15,6 +16,7 @@ use tetratto_core::model::{
PostContext,
},
communities_permissions::CommunityPermission,
permissions::FinePermission,
reactions::AssetType,
};
@ -121,6 +123,10 @@ pub fn routes() -> Router {
"/auth/profile/{id}/settings",
post(auth::profile::update_user_settings_request),
)
.route(
"/auth/profile/{id}/role",
post(auth::profile::update_user_role_request),
)
.route(
"/auth/profile/{id}",
delete(auth::profile::delete_user_request),
@ -175,6 +181,9 @@ pub fn routes() -> Router {
// ipbans
.route("/bans/{ip}", post(auth::ipbans::create_request))
.route("/bans/id/{id}", delete(auth::ipbans::delete_request))
// reports
.route("/reports", post(reports::create_request))
.route("/reports/{id}", delete(reports::delete_request))
}
#[derive(Deserialize)]
@ -238,6 +247,13 @@ pub struct CreateReaction {
pub is_like: bool,
}
#[derive(Deserialize)]
pub struct CreateReport {
pub content: String,
pub asset: String,
pub asset_type: AssetType,
}
#[derive(Deserialize)]
pub struct UpdateUserPassword {
pub from: String,
@ -264,6 +280,11 @@ pub struct UpdateMembershipRole {
pub role: CommunityPermission,
}
#[derive(Deserialize)]
pub struct UpdateUserRole {
pub role: FinePermission,
}
#[derive(Deserialize)]
pub struct DeleteUser {
pub password: String,

View file

@ -1,11 +1,9 @@
use super::UpdateNotificationRead;
use crate::{State, get_user_from_token};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{ApiReturn, Error};
use crate::{State, get_user_from_token};
use super::UpdateNotificationRead;
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,

View file

@ -1,9 +1,8 @@
use crate::{State, get_user_from_token, routes::api::v1::CreateReaction};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{ApiReturn, Error, reactions::Reaction};
use crate::{State, get_user_from_token, routes::api::v1::CreateReaction};
pub async fn get_request(
jar: CookieJar,
Extension(data): Extension<State>,

View file

@ -0,0 +1,55 @@
use super::CreateReport;
use crate::{State, get_user_from_token};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{ApiReturn, Error, moderation::Report};
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateReport>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let asset_id = match req.asset.parse::<usize>() {
Ok(n) => n,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
match data
.create_report(Report::new(user.id, req.content, asset_id, req.asset_type))
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Report created".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) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_report(id, user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Report deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,6 +1,7 @@
pub mod auth;
pub mod communities;
pub mod misc;
pub mod mod_panel;
pub mod profile;
use axum::{Router, routing::get};
@ -20,6 +21,13 @@ pub fn routes() -> Router {
.route("/popular", get(misc::popular_request))
.route("/notifs", get(misc::notifications_request))
.fallback_service(get(misc::not_found))
// mod
.route("/mod_panel/audit_log", get(mod_panel::audit_log_request))
.route("/mod_panel/reports", get(mod_panel::reports_request))
.route(
"/mod_panel/file_report",
get(mod_panel::file_report_request),
)
// auth
.route("/auth/register", get(auth::register_request))
.route("/auth/login", get(auth::login_request))

View file

@ -0,0 +1,115 @@
use super::{PaginatedQuery, render_error};
use crate::{State, assets::initial_context, get_lang, get_user_from_token};
use axum::{
Extension,
extract::Query,
response::{Html, IntoResponse},
};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{Error, permissions::FinePermission, reactions::AssetType};
/// `/mod_panel/audit_log`
pub async fn audit_log_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<PaginatedQuery>,
) -> 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,
));
}
};
if !user.permissions.check(FinePermission::VIEW_AUDIT_LOG) {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let items = match data.0.get_audit_log_entries(12, req.page).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
context.insert("items", &items);
context.insert("page", &req.page);
// return
Ok(Html(data.1.render("mod/audit_log.html", &context).unwrap()))
}
/// `/mod_panel/reports`
pub async fn reports_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<PaginatedQuery>,
) -> 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,
));
}
};
if !user.permissions.check(FinePermission::VIEW_REPORTS) {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let items = match data.0.get_reports(12, req.page).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
context.insert("items", &items);
context.insert("page", &req.page);
// return
Ok(Html(data.1.render("mod/reports.html", &context).unwrap()))
}
#[derive(Deserialize)]
pub struct FileReportQuery {
pub asset: String,
pub asset_type: AssetType,
}
/// `/mod_panel/file_report`
pub async fn file_report_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<FileReportQuery>,
) -> 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 lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
context.insert("asset", &req.asset);
context.insert("asset_type", &req.asset_type);
// return
Ok(Html(
data.1.render("mod/file_report.html", &context).unwrap(),
))
}

View file

@ -3,7 +3,7 @@ use crate::cache::Cache;
use crate::model::{
Error, Result, auth::User, moderation::AuditLogEntry, permissions::FinePermission,
};
use crate::{auto_method, execute, get, query_row};
use crate::{auto_method, execute, get, query_row, query_rows};
#[cfg(feature = "sqlite")]
use rusqlite::Row;
@ -13,7 +13,7 @@ use tokio_postgres::Row;
impl DataManager {
/// Get an [`AuditLogEntry`] from an SQL row.
pub(crate) fn get_auditlog_entry_from_row(
pub(crate) fn get_audit_log_entry_from_row(
#[cfg(feature = "sqlite")] x: &Row<'_>,
#[cfg(feature = "postgres")] x: &Row,
) -> AuditLogEntry {
@ -25,13 +25,42 @@ impl DataManager {
}
}
auto_method!(get_auditlog_entry_by_id(usize)@get_auditlog_entry_from_row -> "SELECT * FROM auditlog WHERE id = $1" --name="audit log entry" --returns=AuditLogEntry --cache-key-tmpl="atto.auditlog:{}");
auto_method!(get_audit_log_entry_by_id(usize)@get_audit_log_entry_from_row -> "SELECT * FROM audit_log WHERE id = $1" --name="audit log entry" --returns=AuditLogEntry --cache-key-tmpl="atto.audit_log:{}");
/// Get all audit log entries (paginated).
///
/// # Arguments
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_audit_log_entries(
&self,
batch: usize,
page: usize,
) -> Result<Vec<AuditLogEntry>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM audit_log ORDER BY created DESC LIMIT $1 OFFSET $2",
&[&(batch as isize), &((page * batch) as isize)],
|x| { Self::get_audit_log_entry_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("audit log entry".to_string()));
}
Ok(res.unwrap())
}
/// Create a new audit log entry in the database.
///
/// # Arguments
/// * `data` - a mock [`AuditLogEntry`] object to insert
pub async fn create_auditlog_entry(&self, data: AuditLogEntry) -> Result<()> {
pub async fn create_audit_log_entry(&self, data: AuditLogEntry) -> Result<()> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -39,7 +68,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO auditlog VALUES ($1, $2, $3, $4)",
"INSERT INTO audit_log VALUES ($1, $2, $3, $4)",
&[
&data.id.to_string().as_str(),
&data.created.to_string().as_str(),
@ -56,7 +85,7 @@ impl DataManager {
Ok(())
}
pub async fn delete_auditlog_entry(&self, id: usize, user: User) -> Result<()> {
pub async fn delete_audit_log_entry(&self, id: usize, user: User) -> Result<()> {
if !user.permissions.check(FinePermission::MANAGE_AUDITLOG) {
return Err(Error::NotAllowed);
}
@ -68,7 +97,7 @@ impl DataManager {
let res = execute!(
&conn,
"DELETE FROM auditlog WHERE id = $1",
"DELETE FROM audit_log WHERE id = $1",
&[&id.to_string()]
);
@ -76,7 +105,7 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.auditlog:{}", id)).await;
self.2.remove(format!("atto.audit_log:{}", id)).await;
// return
Ok(())

View file

@ -1,5 +1,6 @@
use super::*;
use crate::cache::Cache;
use crate::model::moderation::AuditLogEntry;
use crate::model::{
Error, Result,
auth::{Token, User, UserSettings},
@ -262,6 +263,17 @@ impl DataManager {
self.cache_clear_user(&other_user).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!(
"invoked `update_user_verified_status` with x value `{}` and y value `{}`",
other_user.id, x
),
))
.await?;
// ...
Ok(())
}
@ -326,6 +338,67 @@ impl DataManager {
Ok(())
}
pub async fn update_user_role(
&self,
id: usize,
role: FinePermission,
user: User,
) -> Result<()> {
// check permission
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
let other_user = self.get_user_by_id(id).await?;
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
return Err(Error::MiscError(
"Cannot manage the role of other managers".to_string(),
));
}
if other_user.permissions == user.permissions {
return Err(Error::MiscError(
"Cannot manage users of equal level to you".to_string(),
));
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"UPDATE users SET permissions = $1 WHERE id = $2",
&[
&(role.bits()).to_string().as_str(),
&id.to_string().as_str()
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.cache_clear_user(&other_user).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!(
"invoked `update_user_role` with x value `{}` and y value `{}`",
other_user.id,
role.bits()
),
))
.await?;
// ...
Ok(())
}
pub async fn cache_clear_user(&self, user: &User) {
self.2.remove(format!("atto.user:{}", user.id)).await;
self.2.remove(format!("atto.user:{}", user.username)).await;

View file

@ -22,7 +22,7 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_USERFOLLOWS).unwrap();
execute!(&conn, common::CREATE_TABLE_USERBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_IPBANS).unwrap();
execute!(&conn, common::CREATE_TABLE_AUDITLOG).unwrap();
execute!(&conn, common::CREATE_TABLE_AUDIT_LOG).unwrap();
execute!(&conn, common::CREATE_TABLE_REPORTS).unwrap();
Ok(())
@ -139,7 +139,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{id}`", stringify!($name)),
))
@ -169,7 +169,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{id}`", stringify!($name)),
))
@ -201,7 +201,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{id}`", stringify!($name)),
))
@ -232,7 +232,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{x}`", stringify!($name)),
))
@ -265,7 +265,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{id}`", stringify!($name), id),
))
@ -300,7 +300,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{x:?}`", stringify!($name)),
))
@ -455,7 +455,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{id}`", stringify!($name)),
))
@ -488,7 +488,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{x}`", stringify!($name)),
))
@ -546,7 +546,7 @@ macro_rules! auto_method {
if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `{}` with x value `{x:?}`", stringify!($name)),
))

View file

@ -224,7 +224,7 @@ impl DataManager {
if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `delete_community` with x value `{id}`"),
))

View file

@ -7,5 +7,5 @@ pub const CREATE_TABLE_NOTIFICATIONS: &str = include_str!("./sql/create_notifica
pub const CREATE_TABLE_USERFOLLOWS: &str = include_str!("./sql/create_userfollows.sql");
pub const CREATE_TABLE_USERBLOCKS: &str = include_str!("./sql/create_userblocks.sql");
pub const CREATE_TABLE_IPBANS: &str = include_str!("./sql/create_ipbans.sql");
pub const CREATE_TABLE_AUDITLOG: &str = include_str!("./sql/create_auditlog.sql");
pub const CREATE_TABLE_AUDIT_LOG: &str = include_str!("./sql/create_audit_log.sql");
pub const CREATE_TABLE_REPORTS: &str = include_str!("./sql/create_reports.sql");

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER NOT NULL PRIMARY KEY,
created INTEGER NOT NULL,
owner INTEGER NOT NULL,
content TEXT NOT NULL
)

View file

@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS auditlog (
ip TEXT NOT NULL,
created INTEGER NOT NULL PRIMARY KEY,
moderator TEXT NOT NULL,
content TEXT NOT NULL
)

View file

@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS reports (
ip TEXT NOT NULL,
created INTEGER NOT NULL PRIMARY KEY,
owner TEXT NOT NULL,
id INTEGER NOT NULL PRIMARY KEY,
created INTEGER NOT NULL,
owner INTEGER NOT NULL,
content TEXT NOT NULL,
asset INTEGER NOT NULL,
asset_type TEXT NOT NULL

View file

@ -1,5 +1,6 @@
use super::*;
use crate::cache::Cache;
use crate::model::moderation::AuditLogEntry;
use crate::model::{Error, Result, auth::IpBan, auth::User, permissions::FinePermission};
use crate::{auto_method, execute, get, query_row};
@ -57,11 +58,18 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!("invoked `create_ipban` with x value `{}`", data.ip),
))
.await?;
// return
Ok(())
}
pub async fn delete_ipban(&self, id: usize, user: User) -> Result<()> {
pub async fn delete_ipban(&self, ip: &str, user: User) -> Result<()> {
// ONLY moderators can manage ip bans
if !user.permissions.check(FinePermission::MANAGE_BANS) {
return Err(Error::NotAllowed);
@ -72,17 +80,20 @@ impl DataManager {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"DELETE FROM ipbans WHERE id = $1",
&[&id.to_string()]
);
let res = execute!(&conn, "DELETE FROM ipbans WHERE ip = $1", &[ip]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.ipban:{}", id)).await;
self.2.remove(format!("atto.ipban:{}", ip)).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!("invoked `delete_ipban` with x value `{ip}`"),
))
.await?;
// return
Ok(())

View file

@ -331,6 +331,31 @@ impl DataManager {
);
}
// incr comment count
if let Some(id) = data.replying_to {
self.incr_post_comments(id).await.unwrap();
// send notification
let rt = self.get_post_by_id(id).await?;
if data.owner != rt.owner {
let owner = self.get_user_by_id(rt.owner).await?;
self.create_notification(Notification::new(
"Your post has received a new comment!".to_string(),
format!(
"[@{}](/api/v1/auth/profile/find/{}) has commented on your [post](/post/{}).",
owner.username, owner.id, rt.id
),
rt.owner,
))
.await?;
if rt.context.comments_enabled == false {
return Err(Error::NotAllowed);
}
}
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
@ -364,27 +389,6 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// incr comment count
if let Some(id) = data.replying_to {
self.incr_post_comments(id).await.unwrap();
// send notification
let rt = self.get_post_by_id(id).await?;
if data.owner != rt.owner {
let owner = self.get_user_by_id(rt.owner).await?;
self.create_notification(Notification::new(
"Your post has received a new comment!".to_string(),
format!(
"[@{}](/api/v1/auth/profile/find/{}) has commented on your [post](/post/{}).",
owner.username, owner.id, rt.id
),
rt.owner,
))
.await?;
}
}
// return
Ok(data.id)
}
@ -396,7 +400,7 @@ impl DataManager {
if !user.permissions.check(FinePermission::MANAGE_POSTS) {
return Err(Error::NotAllowed);
} else {
self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new(
self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
user.id,
format!("invoked `delete_post` with x value `{id}`"),
))

View file

@ -144,6 +144,9 @@ impl DataManager {
}
}
}
AssetType::User => {
return Err(Error::NotAllowed);
}
};
// return
@ -200,6 +203,9 @@ impl DataManager {
return Err(e);
}
}
AssetType::User => {
return Err(Error::NotAllowed);
}
};
// return

View file

@ -1,7 +1,8 @@
use super::*;
use crate::cache::Cache;
use crate::model::moderation::AuditLogEntry;
use crate::model::{Error, Result, auth::User, moderation::Report, permissions::FinePermission};
use crate::{auto_method, execute, get, query_row};
use crate::{auto_method, execute, get, query_row, query_rows};
#[cfg(feature = "sqlite")]
use rusqlite::Row;
@ -27,6 +28,31 @@ impl DataManager {
auto_method!(get_report_by_id(usize)@get_report_from_row -> "SELECT * FROM reports WHERE id = $1" --name="report" --returns=Report --cache-key-tmpl="atto.reports:{}");
/// Get all reports (paginated).
///
/// # Arguments
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_reports(&self, batch: usize, page: usize) -> Result<Vec<Report>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM reports ORDER BY created DESC LIMIT $1 OFFSET $2",
&[&(batch as isize), &((page * batch) as isize)],
|x| { Self::get_report_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("report".to_string()));
}
Ok(res.unwrap())
}
/// Create a new report in the database.
///
/// # Arguments
@ -80,6 +106,13 @@ impl DataManager {
self.2.remove(format!("atto.report:{}", id)).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!("invoked `delete_report` with x value `{id}`"),
))
.await?;
// return
Ok(())
}

View file

@ -25,6 +25,19 @@ pub struct User {
pub following_count: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ThemePreference {
Auto,
Dark,
Light,
}
impl Default for ThemePreference {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserSettings {
#[serde(default)]
@ -35,6 +48,8 @@ pub struct UserSettings {
pub private_profile: bool,
#[serde(default)]
pub private_communities: bool,
#[serde(default)]
pub theme_preference: ThemePreference,
}
impl Default for UserSettings {
@ -44,6 +59,7 @@ impl Default for UserSettings {
biography: String::new(),
private_profile: false,
private_communities: false,
theme_preference: ThemePreference::default(),
}
}
}
@ -88,6 +104,15 @@ impl User {
}
}
/// Banned user profile.
pub fn banned() -> Self {
Self {
username: "<banned>".to_string(),
id: 0,
..Default::default()
}
}
/// Create a new token
///
/// # Returns

View file

@ -25,6 +25,7 @@ bitflags! {
const MANAGE_VERIFIED = 1 << 14;
const MANAGE_AUDITLOG = 1 << 15;
const MANAGE_REPORTS = 1 << 16;
const BANNED = 1 << 17;
const _ = !0;
}
@ -101,6 +102,9 @@ impl FinePermission {
if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR {
// has administrator permission, meaning everything else is automatically true
return true;
} else if self.check_banned() {
// has banned permission, meaning everything else is automatically false
return false;
}
(self & permission) == permission
@ -118,7 +122,17 @@ impl FinePermission {
/// Check if the given [`FinePermission`] qualifies as "Manager" status.
pub fn check_manager(self) -> bool {
self.check_helper() && self.check(FinePermission::ADMINISTRATOR)
self.check_helper() && self.check(FinePermission::MANAGE_USERS)
}
/// Check if the given [`FinePermission`] qualifies as "Administrator" status.
pub fn check_admin(self) -> bool {
self.check_manager() && self.check(FinePermission::ADMINISTRATOR)
}
/// Check if the given [`FinePermission`] qualifies as "Banned" status.
pub fn check_banned(self) -> bool {
(self & FinePermission::BANNED) == FinePermission::BANNED
}
}

View file

@ -4,8 +4,12 @@ use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp};
/// All of the items which support reactions.
#[derive(Serialize, Deserialize)]
pub enum AssetType {
#[serde(alias = "community")]
Community,
#[serde(alias = "post")]
Post,
#[serde(alias = "user")]
User,
}
#[derive(Serialize, Deserialize)]