add: post image uploads (supporter)
This commit is contained in:
parent
ba1f8ef063
commit
70965298b5
18 changed files with 455 additions and 50 deletions
|
@ -4,6 +4,7 @@ use axum::{
|
||||||
http::{StatusCode, header::CONTENT_TYPE},
|
http::{StatusCode, header::CONTENT_TYPE},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Multipart;
|
use axum_extra::extract::Multipart;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
use std::{fs::File, io::BufWriter};
|
use std::{fs::File, io::BufWriter};
|
||||||
|
|
||||||
/// An image extractor accepting:
|
/// An image extractor accepting:
|
||||||
|
@ -80,3 +81,85 @@ pub fn save_buffer(path: &str, bytes: Vec<u8>, format: image::ImageFormat) -> st
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file extractor accepting:
|
||||||
|
/// * `multipart/form-data`
|
||||||
|
///
|
||||||
|
/// Will also attempt to parse out the **last** field in the multipart upload
|
||||||
|
/// as the given struct from JSON. Every other field is put into a vector of bytes,
|
||||||
|
/// as they are seen as raw binary data.
|
||||||
|
pub struct JsonMultipart<T: DeserializeOwned>(pub Vec<Bytes>, pub T);
|
||||||
|
|
||||||
|
impl<S, T> FromRequest<S> for JsonMultipart<T>
|
||||||
|
where
|
||||||
|
Bytes: FromRequest<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, String);
|
||||||
|
|
||||||
|
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let Some(content_type) = req.headers().get(CONTENT_TYPE) else {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"no content type header".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = content_type.to_str().unwrap();
|
||||||
|
|
||||||
|
if !content_type.starts_with("multipart/form-data") {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"expected multipart/form-data".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut multipart = Multipart::from_request(req, state).await.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"could not read multipart".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut body: Vec<Bytes> = {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
out.push(field.bytes().await.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"could not read field as bytes".to_string(),
|
||||||
|
)
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
let last = match body.pop() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"could not read json data".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json: T = match serde_json::from_str(&match String::from_utf8(last.to_vec()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())),
|
||||||
|
}) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"could not parse json data as json".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self(body, json))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -317,6 +317,60 @@ img.emoji {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media_gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-columns: 1fr 1fr;
|
||||||
|
grid-auto-flow: column dense;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
.media_gallery {
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media_gallery img {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media_gallery img:hover {
|
||||||
|
filter: brightness(80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9999;
|
||||||
|
background: hsla(0, 0%, 0%, 50%);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox_exit {
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox img {
|
||||||
|
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||||
|
var(--color-shadow);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
/* avatar/banner */
|
/* avatar/banner */
|
||||||
.avatar {
|
.avatar {
|
||||||
--size: 50px;
|
--size: 50px;
|
||||||
|
@ -802,7 +856,7 @@ nav .button:not(.title):not(.active):hover {
|
||||||
top: unset;
|
top: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
#page {
|
||||||
padding-bottom: 72px;
|
padding-bottom: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,9 +61,13 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="files_list" class="flex gap-2 flex-wrap"></div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{{ components::emoji_picker(element_id="content",
|
{{ components::emoji_picker(element_id="content",
|
||||||
render_dialog=true) }}
|
render_dialog=true) }} {% if is_supporter %} {{
|
||||||
|
components::file_picker(files_list_id="files_list") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="small square quaternary"
|
class="small square quaternary"
|
||||||
|
@ -85,17 +89,32 @@
|
||||||
async function create_post_from_form_town_square(e) {
|
async function create_post_from_form_town_square(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await trigger("atto::debounce", ["posts::create"]);
|
await trigger("atto::debounce", ["posts::create"]);
|
||||||
fetch("/api/v1/posts", {
|
|
||||||
method: "POST",
|
e.target
|
||||||
headers: {
|
.querySelector("button.primary")
|
||||||
"Content-Type": "application/json",
|
.classList.add("hidden");
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
// create body
|
||||||
|
const body = new FormData();
|
||||||
|
|
||||||
|
for (const file of e.target.file_picker.files) {
|
||||||
|
body.append(file.name, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append(
|
||||||
|
"body",
|
||||||
|
JSON.stringify({
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
community: document.getElementById(
|
community: document.getElementById(
|
||||||
"community_to_post_to",
|
"community_to_post_to",
|
||||||
).selectedOptions[0].value,
|
).selectedOptions[0].value,
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
fetch("/api/v1/posts", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
@ -130,6 +149,10 @@
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/post/${res.payload}`;
|
window.location.href = `/post/${res.payload}`;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
} else {
|
||||||
|
e.target
|
||||||
|
.querySelector("button.primary")
|
||||||
|
.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,15 +78,21 @@
|
||||||
async function create_post_from_form(e) {
|
async function create_post_from_form(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await trigger("atto::debounce", ["posts::create"]);
|
await trigger("atto::debounce", ["posts::create"]);
|
||||||
fetch("/api/v1/posts", {
|
|
||||||
method: "POST",
|
// create body
|
||||||
headers: {
|
const body = new FormData();
|
||||||
"Content-Type": "application/json",
|
body.append(
|
||||||
},
|
"body",
|
||||||
body: JSON.stringify({
|
JSON.stringify({
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
community: "{{ community.id }}",
|
community: "{{ community.id }}",
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
fetch("/api/v1/posts", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
|
@ -238,7 +238,8 @@ and show_community and community.id != config.town_square or question %}
|
||||||
>
|
>
|
||||||
{{ post.content|markdown|safe }}
|
{{ post.content|markdown|safe }}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
|
||||||
|
{{ self::post_media(upload_ids=post.uploads) }} {% else %}
|
||||||
<details>
|
<details>
|
||||||
<summary
|
<summary
|
||||||
class="card flex gap-2 items-center tertiary red w-full"
|
class="card flex gap-2 items-center tertiary red w-full"
|
||||||
|
@ -247,13 +248,17 @@ and show_community and community.id != config.town_square or question %}
|
||||||
<b>{{ post.context.content_warning }}</b>
|
<b>{{ post.context.content_warning }}</b>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<span
|
<div class="flex flex-col gap-2">
|
||||||
id="post-content:{{ post.id }}"
|
<span
|
||||||
class="no_p_margin"
|
id="post-content:{{ post.id }}"
|
||||||
hook="long"
|
class="no_p_margin"
|
||||||
>
|
hook="long"
|
||||||
{{ post.content|markdown|safe }}
|
>
|
||||||
</span>
|
{{ post.content|markdown|safe }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{{ self::post_media(upload_ids=post.uploads) }}
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -386,6 +391,17 @@ and show_community and community.id != config.town_square or question %}
|
||||||
{% if community and show_community and community.id != config.town_square or
|
{% if community and show_community and community.id != config.town_square or
|
||||||
question %}
|
question %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if
|
||||||
|
upload_ids|length > 0%}
|
||||||
|
<div class="media_gallery gap-2">
|
||||||
|
{% for upload in upload_ids %}
|
||||||
|
<img
|
||||||
|
src="/api/v1/uploads/{{ upload }}"
|
||||||
|
alt="Image upload"
|
||||||
|
onclick="trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"
|
||||||
|
/>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %} {%- endmacro %} {% macro notification(notification) -%}
|
{% endif %} {%- endmacro %} {% macro notification(notification) -%}
|
||||||
<div class="w-full card-nest">
|
<div class="w-full card-nest">
|
||||||
<div class="card small notif_title flex items-center">
|
<div class="card small notif_title flex items-center">
|
||||||
|
@ -1262,8 +1278,77 @@ show_kick=false, secondary=false) -%}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
{% endif %} {%- endmacro %} {% macro supporter_ad(body="") -%} {% if
|
{% endif %} {%- endmacro %} {% macro file_picker(files_list_id) -%}
|
||||||
config.stripe and not is_supporter %}
|
<button
|
||||||
|
class="button small square quaternary"
|
||||||
|
onclick="pick_file()"
|
||||||
|
title="Images"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ icon "image-up" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/png,image/jpeg,image/avif,image/webp"
|
||||||
|
style="display: none"
|
||||||
|
name="file_picker"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style="display: none" id="file_template">
|
||||||
|
{{ icon "image" }}
|
||||||
|
<b class="name shorter" style="overflow-wrap: normal">.file_name</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector("input[name=file_picker]");
|
||||||
|
const element = document.getElementById("{{ files_list_id }}");
|
||||||
|
const template = document.getElementById("file_template");
|
||||||
|
|
||||||
|
globalThis.pick_file = () => {
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.render_file_picker_files = () => {
|
||||||
|
element.innerHTML = "";
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
for (const file of input.files) {
|
||||||
|
element.innerHTML += `<div class="card small secondary flex items-center gap-2" onclick="remove_file(${idx})" style="cursor: pointer">${template.innerHTML.replace(
|
||||||
|
".file_name",
|
||||||
|
file.name,
|
||||||
|
)}</div>`;
|
||||||
|
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.remove_file = (idx) => {
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
files.splice(idx - 1, 1);
|
||||||
|
|
||||||
|
// update files
|
||||||
|
const list = new DataTransfer();
|
||||||
|
|
||||||
|
for (item of files) {
|
||||||
|
list.items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.files = list.files;
|
||||||
|
|
||||||
|
// render
|
||||||
|
render_file_picker_files();
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
render_file_picker_files();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{%- endmacro %} {% macro supporter_ad(body="") -%} {% if config.stripe and not
|
||||||
|
is_supporter %}
|
||||||
<div
|
<div
|
||||||
class="card w-full supporter_ad"
|
class="card w-full supporter_ad"
|
||||||
ui_ident="supporter_ad"
|
ui_ident="supporter_ad"
|
||||||
|
|
|
@ -290,16 +290,23 @@
|
||||||
async function create_reply_from_form(e) {
|
async function create_reply_from_form(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await trigger("atto::debounce", ["posts::create"]);
|
await trigger("atto::debounce", ["posts::create"]);
|
||||||
fetch("/api/v1/posts", {
|
|
||||||
method: "POST",
|
// create body
|
||||||
headers: {
|
const body = new FormData();
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
body.append(
|
||||||
body: JSON.stringify({
|
"body",
|
||||||
|
JSON.stringify({
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
community: "{{ community.id }}",
|
community: "{{ community.id }}",
|
||||||
replying_to: "{{ post.id }}",
|
replying_to: "{{ post.id }}",
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
fetch("/api/v1/posts", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
|
@ -67,7 +67,7 @@ macros -%}
|
||||||
<body>
|
<body>
|
||||||
<div id="toast_zone"></div>
|
<div id="toast_zone"></div>
|
||||||
|
|
||||||
<div id="page" style="display: contents">
|
<div id="page">
|
||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
{% if user and user.id == 0 %}
|
{% if user and user.id == 0 %}
|
||||||
<article>
|
<article>
|
||||||
|
@ -281,6 +281,17 @@ macros -%}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<div class="lightbox hidden" id="lightbox">
|
||||||
|
<button
|
||||||
|
class="lightbox_exit small square quaternary red"
|
||||||
|
onclick="trigger('ui::lightbox_close')"
|
||||||
|
>
|
||||||
|
{{ icon "x" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img src="" alt="Image" id="lightbox_img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<dialog id="tokens_dialog">
|
<dialog id="tokens_dialog">
|
||||||
<div class="inner flex flex-col gap-2">
|
<div class="inner flex flex-col gap-2">
|
||||||
|
|
|
@ -1028,6 +1028,16 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
||||||
return get_permissions_html;
|
return get_permissions_html;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// lightbox
|
||||||
|
self.define("lightbox_open", (_, src) => {
|
||||||
|
document.getElementById("lightbox_img").src = src;
|
||||||
|
document.getElementById("lightbox").classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
self.define("lightbox_close", () => {
|
||||||
|
document.getElementById("lightbox").classList.add("hidden");
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
|
|
@ -309,7 +309,7 @@ pub async fn delete_membership(
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match data.delete_membership(membership.id, user).await {
|
match data.delete_membership(membership.id, &user).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Membership deleted".to_string(),
|
message: "Membership deleted".to_string(),
|
||||||
|
|
|
@ -1,17 +1,30 @@
|
||||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
use std::fs::exists;
|
||||||
|
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use tetratto_core::model::{ApiReturn, Error, communities::Post};
|
use image::ImageFormat;
|
||||||
|
use pathbufd::PathBufD;
|
||||||
|
use tetratto_core::model::{
|
||||||
|
communities::Post,
|
||||||
|
permissions::FinePermission,
|
||||||
|
uploads::{MediaType, MediaUpload},
|
||||||
|
ApiReturn, Error,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
get_user_from_token,
|
get_user_from_token,
|
||||||
routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext},
|
image::{save_buffer, JsonMultipart},
|
||||||
|
routes::api::v1::{
|
||||||
|
auth::images::read_image, CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext,
|
||||||
|
},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// maximum file dimensions: 2048x2048px (4 MiB)
|
||||||
|
pub const MAXIUMUM_FILE_SIZE: usize = 4194304;
|
||||||
|
|
||||||
pub async fn create_request(
|
pub async fn create_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Json(req): Json<CreatePost>,
|
JsonMultipart(images, req): JsonMultipart<CreatePost>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let data = &(data.read().await).0;
|
let data = &(data.read().await).0;
|
||||||
let user = match get_user_from_token!(jar, data) {
|
let user = match get_user_from_token!(jar, data) {
|
||||||
|
@ -19,6 +32,15 @@ pub async fn create_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
None => return Json(Error::NotAllowed.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !user.permissions.check(FinePermission::SUPPORTER) {
|
||||||
|
if images.len() > 0 {
|
||||||
|
// this is currently supporter only until it's been tested better...
|
||||||
|
// after it's fully release, file limit will be raised to 8 MiB for supporters,
|
||||||
|
// and left at 4 for non-supporters
|
||||||
|
return Json(Error::RequiresSupporter.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut props = Post::new(
|
let mut props = Post::new(
|
||||||
req.content,
|
req.content,
|
||||||
match req.community.parse::<usize>() {
|
match req.community.parse::<usize>() {
|
||||||
|
@ -44,12 +66,63 @@ pub async fn create_request(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
match data.create_post(props).await {
|
// check sizes
|
||||||
Ok(id) => Json(ApiReturn {
|
for img in &images {
|
||||||
ok: true,
|
if img.len() > MAXIUMUM_FILE_SIZE {
|
||||||
message: "Post created".to_string(),
|
return Json(Error::DataTooLong("image".to_string()).into());
|
||||||
payload: Some(id.to_string()),
|
}
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
// create uploads
|
||||||
|
for _ in 0..images.len() {
|
||||||
|
props.uploads.push(
|
||||||
|
match data
|
||||||
|
.create_upload(MediaUpload::new(MediaType::Webp, props.owner))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(u) => u.id,
|
||||||
|
Err(e) => return Json(e.into()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
match data.create_post(props.clone()).await {
|
||||||
|
Ok(id) => {
|
||||||
|
// write to uploads
|
||||||
|
for (i, upload_id) in props.uploads.iter().enumerate() {
|
||||||
|
let image = match images.get(i) {
|
||||||
|
Some(img) => img,
|
||||||
|
None => {
|
||||||
|
if let Err(e) = data.delete_upload(*upload_id).await {
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let upload = match data.get_upload_by_id(*upload_id).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => return Json(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = save_buffer(
|
||||||
|
&upload.path(&data.0).to_string(),
|
||||||
|
image.to_vec(),
|
||||||
|
ImageFormat::WebP,
|
||||||
|
) {
|
||||||
|
return Json(Error::MiscError(e.to_string()).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return
|
||||||
|
Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "Post created".to_string(),
|
||||||
|
payload: Some(id.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,6 +160,32 @@ pub async fn create_repost_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_upload_request(
|
||||||
|
Path(id): Path<usize>,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let data = &(data.read().await).0;
|
||||||
|
|
||||||
|
let upload = data.get_upload_by_id(id).await.unwrap();
|
||||||
|
let path = upload.path(&data.0);
|
||||||
|
|
||||||
|
if !exists(&path).unwrap() {
|
||||||
|
return Err((
|
||||||
|
[("Content-Type", "image/svg+xml")],
|
||||||
|
Body::from(read_image(PathBufD::current().extend(&[
|
||||||
|
data.0.dirs.media.as_str(),
|
||||||
|
"images",
|
||||||
|
"default-banner.svg",
|
||||||
|
]))),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[("Content-Type", upload.what.mime())],
|
||||||
|
Body::from(read_image(path)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_request(
|
pub async fn delete_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
|
|
|
@ -348,6 +348,8 @@ pub fn routes() -> Router {
|
||||||
.route("/stacks/{id}/users", post(stacks::add_user_request))
|
.route("/stacks/{id}/users", post(stacks::add_user_request))
|
||||||
.route("/stacks/{id}/users", delete(stacks::remove_user_request))
|
.route("/stacks/{id}/users", delete(stacks::remove_user_request))
|
||||||
.route("/stacks/{id}", delete(stacks::delete_request))
|
.route("/stacks/{id}", delete(stacks::delete_request))
|
||||||
|
// uploads
|
||||||
|
.route("/uploads/{id}", get(communities::posts::get_upload_request))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
@ -167,9 +167,20 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
|
||||||
|
|
||||||
let mut communities: Vec<Community> = Vec::new();
|
let mut communities: Vec<Community> = Vec::new();
|
||||||
for membership in &list {
|
for membership in &list {
|
||||||
match data.0.get_community_by_id(membership.community).await {
|
match data
|
||||||
|
.0
|
||||||
|
.get_community_by_id_no_void(membership.community)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(c) => communities.push(c),
|
Ok(c) => communities.push(c),
|
||||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
|
Err(_) => {
|
||||||
|
// delete membership; community doesn't exist
|
||||||
|
if let Err(e) = data.0.delete_membership(membership.id, &user).await {
|
||||||
|
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,5 +10,7 @@ CREATE TABLE IF NOT EXISTS posts (
|
||||||
likes INT NOT NULL,
|
likes INT NOT NULL,
|
||||||
dislikes INT NOT NULL,
|
dislikes INT NOT NULL,
|
||||||
-- other counts
|
-- other counts
|
||||||
comment_count INT NOT NULL
|
comment_count INT NOT NULL,
|
||||||
|
-- ...
|
||||||
|
uploads TEXT NOT NULL
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
|
|
|
@ -250,7 +250,7 @@ impl DataManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a membership given its `id`
|
/// Delete a membership given its `id`
|
||||||
pub async fn delete_membership(&self, id: usize, user: User) -> Result<()> {
|
pub async fn delete_membership(&self, id: usize, user: &User) -> Result<()> {
|
||||||
let y = self.get_membership_by_id(id).await?;
|
let y = self.get_membership_by_id(id).await?;
|
||||||
|
|
||||||
if user.id != y.owner {
|
if user.id != y.owner {
|
||||||
|
|
|
@ -41,6 +41,8 @@ impl DataManager {
|
||||||
dislikes: get!(x->8(i32)) as isize,
|
dislikes: get!(x->8(i32)) as isize,
|
||||||
// other counts
|
// other counts
|
||||||
comment_count: get!(x->9(i32)) as usize,
|
comment_count: get!(x->9(i32)) as usize,
|
||||||
|
// ...
|
||||||
|
uploads: serde_json::from_str(&get!(x->10(String))).unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1038,7 +1040,7 @@ impl DataManager {
|
||||||
|
|
||||||
let res = execute!(
|
let res = execute!(
|
||||||
&conn,
|
&conn,
|
||||||
"INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
"INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||||
params![
|
params![
|
||||||
&(data.id as i64),
|
&(data.id as i64),
|
||||||
&(data.created as i64),
|
&(data.created as i64),
|
||||||
|
@ -1053,7 +1055,8 @@ impl DataManager {
|
||||||
},
|
},
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&0_i32
|
&0_i32,
|
||||||
|
&serde_json::to_string(&data.uploads).unwrap()
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1148,6 +1151,11 @@ impl DataManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete uploads
|
||||||
|
for upload in y.uploads {
|
||||||
|
self.delete_upload(upload).await?;
|
||||||
|
}
|
||||||
|
|
||||||
// return
|
// return
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -236,6 +236,8 @@ pub struct Post {
|
||||||
pub likes: isize,
|
pub likes: isize,
|
||||||
pub dislikes: isize,
|
pub dislikes: isize,
|
||||||
pub comment_count: usize,
|
pub comment_count: usize,
|
||||||
|
/// IDs of all uploads linked to this post.
|
||||||
|
pub uploads: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Post {
|
impl Post {
|
||||||
|
@ -257,6 +259,7 @@ impl Post {
|
||||||
likes: 0,
|
likes: 0,
|
||||||
dislikes: 0,
|
dislikes: 0,
|
||||||
comment_count: 0,
|
comment_count: 0,
|
||||||
|
uploads: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
sql_changes/posts_uploads.sql
Normal file
2
sql_changes/posts_uploads.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN uploads TEXT NOT NULL DEFAULT '[]';
|
Loading…
Add table
Add a link
Reference in a new issue