add: drawings in questions

This commit is contained in:
trisua 2025-06-20 17:40:55 -04:00
parent 6be729de50
commit 16843a6ab8
28 changed files with 1181 additions and 32 deletions

View file

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

View file

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

View file

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

View file

@ -234,6 +234,9 @@ pub struct UserSettings {
/// If timelines are paged instead of infinitely scrolled.
#[serde(default)]
pub paged_timelines: bool,
/// If drawings are enabled for questions sent to the user.
#[serde(default)]
pub enable_drawings: bool,
}
fn mime_avif() -> String {

View file

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

View file

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

View file

@ -1,6 +1,7 @@
pub mod addr;
pub mod apps;
pub mod auth;
pub mod carp;
pub mod channels;
pub mod communities;
pub mod communities_permissions;
@ -46,6 +47,7 @@ pub enum Error {
TitleInUse,
QuestionsDisabled,
RequiresSupporter,
DrawingsDisabled,
Unknown,
}
@ -68,6 +70,7 @@ impl Display for Error {
Self::TitleInUse => "Title in use".to_string(),
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
Self::RequiresSupporter => "Only site supporters can do this".to_string(),
Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
_ => format!("An unknown error as occurred: ({:?})", self),
})
}

View file

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