Initial commit

This commit is contained in:
Aleksandr 2025-09-19 18:31:17 +03:00
commit bd9b07052b
81 changed files with 5516 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake .

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.direnv
Cargo.lock

32
Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[workspace]
members = [
"core/",
"http/"
]
resolver = "3"
[workspace.dependencies]
eva = { git = "ssh://forgejo@git.viende.su:61488/VienDesu/eva.git" }
futures = "0.3.31"
tokio = "1.44.2"
eyre = "0.6.12"
serde_json = "1.0.137"
serde_with = { version = "3.12.0", default-features = false, features = ["alloc", "macros"] }
serde = "1"
schemars = { git = "ssh://forgejo@git.viende.su:61488/VienDesu/schemars.git", features = [
"compact_str08",
"smallvec1",
"bytes1",
"url2",
"hashbrown015"
] }
viendesu-core = { path = "core/" }
# TODO: upstream.
[patch.crates-io]
schemars = { git = "ssh://forgejo@git.viende.su:61488/VienDesu/schemars.git", version = "1.0.0-alpha.17" }

21
core/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "viendesu-core"
version = "0.1.0"
edition = "2024"
[features]
default = []
test-util = ["dep:tokio"]
[dependencies]
eva.workspace = true
serde.workspace = true
schemars.workspace = true
eyre.workspace = true
serde_with.workspace = true
futures.workspace = true
lazy_static = "1.5.0"
tokio = { workspace = true, features = ["rt"], optional = true }

141
core/src/errors.rs Normal file
View file

@ -0,0 +1,141 @@
use eva::{data, str::CompactString};
use std::fmt;
pub mod ext;
pub mod boards;
pub mod messages;
pub mod threads;
pub mod auth;
pub mod authors;
pub mod games;
pub mod uploads;
pub mod users;
pub trait FromReport {
fn from_report(report: eyre::Report) -> Self;
}
#[macro_export]
macro_rules! bail {
($($tt:tt)*) => {
return ::core::result::Result::Err($crate::report!($($tt)*))
};
}
#[macro_export]
macro_rules! report {
($($tt:tt)*) => {
$crate::errors::FromReport::from_report($crate::_priv::eyre::eyre!($($tt)*))
};
}
#[macro_export]
macro_rules! mk_error {
($ident:ident) => {
#[derive(Debug)]
#[repr(transparent)]
pub struct $ident($crate::_priv::eyre::Report);
const _: () = {
use std::error::Error as StdError;
use $crate::{_priv::eyre, errors::FromReport};
impl $ident {
pub fn map_report(self, f: impl FnOnce(eyre::Report) -> eyre::Report) -> Self {
Self(f(self.0))
}
pub const fn from_report(report: eyre::Report) -> Self {
Self(report)
}
}
impl FromReport for $ident {
fn from_report(report: eyre::Report) -> Self {
Self(report)
}
}
impl From<$ident> for eyre::Report {
fn from(value: $ident) -> Self {
value.0
}
}
impl<E: StdError + Send + Sync + 'static> From<E> for $ident {
#[track_caller]
fn from(value: E) -> Self {
Self(value.into())
}
}
};
};
}
pub type Result<O, E> = ::core::result::Result<O, Generic<E>>;
pub type AuxResult<O> = ::core::result::Result<O, Aux>;
#[data(copy, ord, display("impossible"), error)]
#[derive(Hash)]
pub enum Impossible {}
/// Error from auxiliary system.
#[data(error)]
pub enum Aux {
/// Database failure.
#[display("{_0}")]
Db(CompactString),
/// Captcha failure.
#[display("{_0}")]
Captcha(CompactString),
/// Mail failure.
#[display("{_0}")]
Mail(CompactString),
/// Authentication failure.
#[display("unauthenticated")]
Unauthenticated,
#[display("{_0}")]
InvalidSession(#[from] auth::InvalidSession),
/// Authorization failure.
#[display("{_0}")]
InvalidRole(#[from] auth::InvalidRole),
/// Internal failure.
#[display("{_0}")]
InternalError(CompactString),
/// object store failure.
#[display("{_0}")]
ObjectStore(CompactString),
#[display("{_0}")]
Deserialization(String),
}
/// Generic error.
#[data]
#[serde(untagged)]
pub enum Generic<S> {
/// Auxiliary system failure.
Aux(#[from] Aux),
/// Specific for this handler error.
Spec(S),
}
impl<S> From<Impossible> for Generic<S> {
fn from(value: Impossible) -> Self {
match value {}
}
}
// TODO: add ability to specify bounds for error.
impl<S: fmt::Display + std::error::Error> std::error::Error for Generic<S> {}
// TODO: add ability to specify bounds in display attr.
impl<S: fmt::Display> fmt::Display for Generic<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Aux(aux) => aux.fmt(f),
Self::Spec(spec) => spec.fmt(f),
}
}
}

18
core/src/errors/auth.rs Normal file
View file

@ -0,0 +1,18 @@
use eva::data;
use crate::types::{session, user::Role};
#[data(
error,
display(
"invalid role, it's required to be at least {required_at_least} to perform this action"
)
)]
pub struct InvalidRole {
pub required_at_least: Role,
}
#[data(error, display("session {token} does not exists or is expired"))]
pub struct InvalidSession {
pub token: session::Token,
}

View file

@ -0,0 +1,16 @@
use eva::data;
use crate::types::author::{Selector, Slug};
#[data(error, display("this action will orphan games belonged to the author"))]
pub struct WillOrphanGames;
#[data(error, copy, display("the {author} was not found"))]
pub struct NotFound {
pub author: Selector,
}
#[data(error, display("@{slug} already exists"))]
pub struct AlreadyExists {
pub slug: Slug,
}

14
core/src/errors/boards.rs Normal file
View file

@ -0,0 +1,14 @@
use crate::types::board;
use eva::data;
#[data(error)]
pub enum AlreadyExists {
#[display("the slug {_0} already exists")]
Slug(#[from] board::Slug),
}
#[data(error, display("the {board} was not found"))]
pub struct NotFound {
pub board: board::Selector,
}

65
core/src/errors/ext.rs Normal file
View file

@ -0,0 +1,65 @@
use crate::{
errors::{Aux, Generic, auth::InvalidRole},
types::user::Role,
};
pub trait AuthzRoleExt {
fn require_at_least(self, role: Role) -> Result<(), InvalidRole>;
}
impl AuthzRoleExt for Role {
fn require_at_least(self, role: Role) -> Result<(), InvalidRole> {
if self >= role {
Ok(())
} else {
Err(InvalidRole {
required_at_least: role,
})
}
}
}
pub trait ResultExt: Sized {
type Ok;
type Err;
fn aux_err(self) -> Result<Self::Ok, Aux>
where
Self::Err: Into<Aux>;
}
impl<O, E> ResultExt for Result<O, E> {
type Ok = O;
type Err = E;
#[track_caller]
fn aux_err(self) -> Result<O, Aux>
where
E: Into<Aux>,
{
match self {
Self::Ok(o) => Ok(o),
Self::Err(e) => Err(e.into()),
}
}
}
pub trait ErrExt: Sized {
#[track_caller]
fn aux(self) -> Aux
where
Self: Into<Aux>,
{
self.into()
}
#[track_caller]
fn spec<S>(self) -> Generic<S>
where
S: From<Self>,
{
Generic::Spec(self.into())
}
}
impl<T> ErrExt for T {}

19
core/src/errors/games.rs Normal file
View file

@ -0,0 +1,19 @@
use eva::data;
use crate::types::{author, game};
#[data(error, display("{author} is not an owner of the {game}"))]
pub struct NotAnOwner {
pub author: author::Id,
pub game: game::Id,
}
#[data(error, display("the @{slug} is already taken for that author"))]
pub struct AlreadyTaken {
pub slug: game::Slug,
}
#[data(error, copy, display("the {game} was not found"))]
pub struct NotFound {
pub game: game::Selector,
}

View file

@ -0,0 +1,8 @@
use eva::data;
use crate::types::message;
#[data(error, display("message {what} was not found"))]
pub struct NotFound {
pub what: message::Selector,
}

View file

@ -0,0 +1,13 @@
use eva::data;
use crate::types::thread;
#[data(error, display("thread {what} was not found"))]
pub struct NotFound {
pub what: thread::Selector,
}
#[data(error, copy, display("you don't own the {thread} thread"))]
pub struct NotAnOwner {
pub thread: thread::Id,
}

View file

@ -0,0 +1,51 @@
use eva::{bytesize::ByteSize, data};
use crate::types::{file, upload};
#[data(error, copy, display("someone is finishing file already"))]
#[derive(Default)]
pub enum ConcurrentUploadInProgress {
#[serde(rename = "concurrent_upload_in_progress")]
#[default]
Value,
}
#[data(error, display("hash mismatch, expected: {expected}, got: {got}"))]
pub struct HashMismatch {
pub expected: file::Hash,
pub got: file::Hash,
}
#[data(
error,
copy,
display("uploading more than requested ({expected} bytes)")
)]
pub struct Overuploading {
pub expected: u64,
}
#[data(error, copy, display("simultaneous upload quota exceeded: {}/{}", ByteSize::b(*in_progress), ByteSize::b(*quota)))]
pub struct SimUpQuotaExceeded {
pub in_progress: u64,
pub quota: u64,
}
#[data(
error,
copy,
display(
"file uploading quota exceeded({}/{})",
ByteSize::b(*uploaded),
ByteSize::b(*quota)
)
)]
pub struct QuotaExceeded {
pub uploaded: u64,
pub quota: u64,
}
#[data(error, copy, display("{id} was not found"))]
pub struct NotFound {
pub id: upload::Id,
}

37
core/src/errors/users.rs Normal file
View file

@ -0,0 +1,37 @@
use eva::data;
use crate::types::user;
#[data(copy, display(name))]
pub enum WhatTaken {
Nickname,
Email,
}
#[data(error, display("passed invalid sign up token"))]
pub struct InvalidSignUpToken;
#[data(error, display("the {what} is already taken"))]
pub struct AlreadyTaken {
pub what: WhatTaken,
}
#[data(
error,
display("sign up for specified user was already completed or the user was not found")
)]
pub struct NotFoundOrCompleted;
#[data(error, display("user {user} was not found"))]
pub struct NotFound {
pub user: user::Selector,
}
#[data(error, display("invalid password specified"))]
pub struct InvalidPassword;
#[data(error, display("the session is required for this request"))]
pub struct NoSession;
#[data(error, display("you must complete sign up first"))]
pub struct MustCompleteSignUp;

16
core/src/lib.rs Normal file
View file

@ -0,0 +1,16 @@
pub mod requests;
pub mod types;
pub mod uploads;
pub mod service;
pub mod errors;
pub mod world;
pub mod rt;
#[doc(hidden)]
pub mod _priv {
pub use eva::paste;
pub use eyre;
}

View file

@ -0,0 +1,93 @@
use eva::data;
use crate::{
errors::{
authors::{AlreadyExists, NotFound},
users as users_errors,
},
types::{Patch, True, author, file, user},
};
pub mod update {
///! # Update author.
use super::*;
#[data]
pub struct Args {
/// Who to update.
pub author: author::Selector,
/// Specific update to apply.
pub update: Update,
}
#[serde_with::apply(Patch => #[serde(default)])]
#[data]
pub struct Update {
pub title: Patch<author::Title>,
pub description: Patch<Option<author::Description>>,
pub pfp: Patch<Option<file::Id>>,
pub slug: Patch<author::Slug>,
pub verified: Patch<bool>,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] NotFound),
}
}
pub mod get {
///! # Get author by selector.
use super::*;
#[data]
pub struct Args {
pub author: author::Selector,
}
#[data]
pub struct Ok {
pub author: author::Author,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] NotFound),
}
}
pub mod create {
///! # Create author.
use super::*;
#[data]
pub struct Args {
pub title: author::Title,
pub slug: author::Slug,
pub description: Option<author::Description>,
/// Owner of the author, `None` for current user. Creating
/// authors for different user than currently authenticated
/// requires at least `admin` role.
pub owner: Option<user::Id>,
}
#[data]
pub struct Ok {
pub id: author::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] NotFound),
#[display("{_0}")]
AlreadyExists(#[from] AlreadyExists),
#[display("{_0}")]
NoSuchUser(#[from] users_errors::NotFound),
}
}

View file

@ -0,0 +1,86 @@
use eva::data;
use crate::{
errors,
types::{Patch, True, board, message},
};
pub mod edit {
use super::*;
#[data]
pub struct Args {
pub board: board::Selector,
pub text: Patch<message::Text>,
pub slug: Patch<Option<board::Slug>>,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::boards::NotFound),
}
}
pub mod delete {
use super::*;
#[data]
pub struct Args {
pub board: board::Selector,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::boards::NotFound),
}
}
pub mod create {
use super::*;
#[data]
pub struct Args {
pub slug: board::Slug,
pub initial_message: message::Text,
pub by: Option<message::ById>,
}
#[data]
pub struct Ok {
pub id: board::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
AlreadyExists(#[from] errors::boards::AlreadyExists),
}
}
pub mod get {
use super::*;
#[data]
pub struct Args {
pub board: board::Selector,
}
#[data]
pub struct Ok {
pub board: board::Board,
}
#[data]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::boards::NotFound),
}
}

View file

@ -0,0 +1 @@

187
core/src/requests/games.rs Normal file
View file

@ -0,0 +1,187 @@
use crate::{
errors,
types::{Patch, True, author, file, game},
};
use eva::{array, data, int, perfect_derive, str, time};
pub mod update {
use super::*;
#[data]
pub struct Args {
pub id: game::Id,
pub update: Update,
}
#[data]
#[serde_with::apply(Patch => #[serde(default)])]
pub struct Update {
pub title: Patch<game::Title>,
pub description: Patch<Option<game::Description>>,
pub slug: Patch<game::Slug>,
pub thumbnail: Patch<Option<file::Id>>,
pub genres: Patch<game::Genres>,
pub badges: Patch<game::Badges>,
pub tags: Patch<game::Tags>,
pub screenshots: Patch<game::Screenshots>,
pub published: Patch<bool>,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::games::NotFound),
}
}
pub mod search {
use super::*;
#[data]
#[perfect_derive(Default)]
pub struct Logic<T, const LIMIT: usize> {
pub all: array::ImmutableHeap<T, LIMIT>,
pub any: array::ImmutableHeap<T, LIMIT>,
}
#[data]
#[derive(Default)]
pub struct Marks {
pub tags: Logic<game::Tag, 16>,
pub genres: Logic<game::Genre, 16>,
pub badges: Logic<game::Badge, 16>,
}
#[data(copy, display(name))]
pub enum SortBy {
/// By game release date.
ReleaseDate {
after: Option<(time::Date, game::Id)>,
},
/// Sort by game creation date.
CreatedAt { after: Option<game::Id> },
/// By publish on site date.
PublishedAt {
after: Option<(time::Date, game::Id)>,
},
/// By game rating.
Rating {
after: Option<(game::RatingValue, game::Id)>,
},
}
impl Default for SortBy {
fn default() -> Self {
Self::CreatedAt { after: None }
}
}
#[data(copy, display(name))]
#[derive(Default)]
pub enum Order {
/// From lowest to highest.
Asc,
/// From highest to lowest.
#[default]
Desc,
}
#[int(u8, 1..=32)]
pub enum Limit {}
impl Default for Limit {
fn default() -> Self {
Self::POS16
}
}
#[data]
#[derive(Default)]
pub struct Condition {
pub marks: Marks,
}
#[data]
pub struct Args {
pub query: Option<game::SearchQuery>,
pub author: Option<author::Selector>,
#[serde(default)]
pub include: Condition,
#[serde(default)]
pub exclude: Condition,
#[serde(default)]
pub order: Order,
#[serde(default)]
pub sort_by: SortBy,
pub limit: Option<Limit>,
}
#[data]
pub struct Ok {
pub found: Vec<game::Game>,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NoSuchAuthor(#[from] errors::authors::NotFound),
}
}
pub mod create {
use super::*;
#[data]
pub struct Args {
pub title: game::Title,
pub description: Option<game::Description>,
pub thumbnail: Option<file::Id>,
pub author: author::Id,
pub slug: Option<game::Slug>,
pub vndb: Option<game::VndbId>,
pub release_date: Option<game::ReleaseDate>,
}
#[data]
pub struct Ok {
pub id: game::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NoSuchAuthor(#[from] errors::authors::NotFound),
#[display("{_0}")]
AlreadyTaken(#[from] errors::games::AlreadyTaken),
}
}
pub mod get {
use super::*;
#[data]
pub struct Args {
pub game: game::Selector,
}
#[data]
pub struct Ok {
// TODO: include if requested
// - translation maps.
// - comments
// - downloads
pub game: game::Game,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::games::NotFound),
#[display("{_0}")]
NoSuchAuthor(#[from] errors::authors::NotFound),
}
}

View file

@ -0,0 +1,84 @@
use eva::data;
use crate::{
errors,
types::{True, message, thread},
};
pub mod edit {
use super::*;
#[data]
pub struct Args {
pub message: message::Id,
pub text: message::Text,
}
#[data]
pub struct Ok;
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::messages::NotFound),
}
}
pub mod delete {
use super::*;
#[data]
pub struct Args {
pub message: message::Id,
}
#[data]
pub struct Ok(pub True);
#[data]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::messages::NotFound),
}
}
pub mod post {
use super::*;
#[data]
pub struct Args {
pub thread: thread::Selector,
pub text: message::Text,
}
#[data]
pub struct Ok {
pub id: message::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NoSuchThread(#[from] errors::threads::NotFound),
}
}
pub mod get {
use super::*;
#[data]
pub struct Args {
pub message: message::Selector,
}
#[data]
pub struct Ok {
pub message: message::Message,
}
#[data]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::messages::NotFound),
}
}

13
core/src/requests/mod.rs Normal file
View file

@ -0,0 +1,13 @@
use crate::errors::Generic;
pub type Response<O, E> = Result<O, Generic<E>>;
pub mod boards;
pub mod messages;
pub mod threads;
pub mod authors;
pub mod files;
pub mod games;
pub mod uploads;
pub mod users;

View file

@ -0,0 +1,114 @@
use eva::{data, int, str};
use crate::{
errors,
types::{Patch, True, board, message, thread},
};
pub mod search {
use super::*;
#[int(u8, 1..=64)]
pub enum Limit {}
impl Default for Limit {
fn default() -> Self {
Self::POS24
}
}
#[data]
pub struct Args {
#[serde(default)]
pub limit: Limit,
// TODO(MKS-6): implement better pagination.
pub after: Option<thread::Id>,
}
#[data]
pub struct Ok {
pub results: Vec<thread::Thread>,
}
#[data(error, display("_"))]
pub enum Err {}
}
pub mod delete {
use super::*;
#[data]
pub struct Args {
pub thread: thread::Id,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::threads::NotFound),
}
}
pub mod edit {
use super::*;
#[data]
pub struct Args {
pub thread: thread::Id,
pub text: Patch<message::Text>,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::threads::NotFound),
#[display("{_0}")]
NotAnOwner(#[from] errors::threads::NotAnOwner),
}
}
pub mod get {
use super::*;
#[data]
pub struct Args {
pub thread: thread::Selector,
}
#[data]
pub struct Ok {
pub thread: thread::Thread,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::threads::NotFound),
}
}
pub mod create {
use super::*;
#[data]
pub struct Args {
pub board: board::Selector,
pub initial_message: message::Text,
}
#[data]
pub struct Ok {
pub id: thread::Id,
}
#[data]
pub enum Err {
NoSuchBoard(#[from] errors::boards::NotFound),
}
}

View file

@ -0,0 +1,107 @@
use eva::{data, str};
use crate::{
errors,
types::{True, file, upload},
uploads::UploadStream,
};
pub mod start {
//! # Upload file to the server.
//!
//! The following quotas apply:
//! - Rate limiting - by bytes, temporal and permanent
//! - Concurrent uploads limit - also by bytes
//!
//! Quotas are applied per-class.
use super::*;
#[data]
pub struct Args {
/// Name of the file. Mainly serves as a hint to user to not
/// download files with "scary" names. Don't set if that doesn't
/// matter.
pub name: Option<file::BaseName>,
/// Hash of the file.
pub hash: file::Hash,
/// Class of the file.
pub class: file::Class,
/// Size of the file to upload. Must be known
/// prior to upload, streaming is not supported.
pub size: u64,
}
#[data]
pub struct Ok {
pub upload: upload::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
QuotaExceeded(#[from] errors::uploads::QuotaExceeded),
#[display("{_0}")]
SimUpQuotaExceeded(#[from] errors::uploads::SimUpQuotaExceeded),
}
}
pub mod finish {
use super::*;
#[data]
pub struct Args<S: UploadStream> {
pub id: upload::Id,
pub stream: S,
}
#[data]
pub struct Ok {
pub file: file::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::uploads::NotFound),
#[display("{_0}")]
ConcurrentUploadInProgress(#[from] errors::uploads::ConcurrentUploadInProgress),
#[display("{_0}")]
HashMismatch(#[from] errors::uploads::HashMismatch),
#[display("{_0}")]
Overuploading(#[from] errors::uploads::Overuploading),
}
}
pub mod cancel {
use super::*;
#[data]
pub struct Args {
pub upload: file::Id,
}
pub type Ok = True;
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::uploads::NotFound),
}
}
pub mod list_pending {
use super::*;
#[data]
pub struct Args {}
#[data]
pub struct Ok {
pub uploads: Vec<upload::Upload>,
}
pub type Err = errors::Impossible;
}

149
core/src/requests/users.rs Normal file
View file

@ -0,0 +1,149 @@
///! # Users functionality
///!
///! Subject for actions.
use eva::{data, time::Timestamp};
use crate::{
errors::users as errors,
types::{True, file, patch::Patch, session, user},
};
pub mod check_auth {
use super::*;
#[data]
pub struct Args {}
#[data(copy)]
pub struct Ok {
pub user: user::Id,
pub role: user::Role,
}
#[data(error, display(""))]
pub enum Err {}
}
pub mod update {
use super::*;
#[data]
pub struct Args {
pub user: Option<user::Selector>,
pub update: Update,
}
#[serde_with::apply(
Patch => #[serde(default)]
)]
#[data]
#[derive(Default)]
pub struct Update {
pub nickname: Patch<user::Nickname>,
pub display_name: Patch<Option<user::DisplayName>>,
pub bio: Patch<Option<user::Bio>>,
pub password: Patch<user::Password>,
pub role: Patch<user::Role>,
pub pfp: Patch<Option<file::Id>>,
pub email: Patch<user::Email>,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::NotFound),
}
}
pub mod get {
use super::*;
#[data]
pub struct Args {
pub user: Option<user::Selector>,
}
#[data]
pub struct Ok {
pub user: user::User,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::NotFound),
}
}
pub mod confirm_sign_up {
use super::*;
#[data]
pub struct Args {
pub user: user::Id,
pub token: user::SignUpCompletionToken,
}
#[data]
pub struct Ok(pub True);
#[data(error)]
pub enum Err {
#[display("{_0}")]
InvalidSignUpToken(#[from] errors::InvalidSignUpToken),
#[display("{_0}")]
NotFoundOrCompleted(#[from] errors::NotFoundOrCompleted),
}
}
pub mod sign_in {
use super::*;
#[data]
pub struct Args {
pub nickname: user::Nickname,
pub password: user::Password,
}
#[data]
pub struct Ok {
pub token: session::Token,
pub expires_at: Timestamp,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
NotFound(#[from] errors::NotFound),
#[display("{_0}")]
InvalidPassword(#[from] errors::InvalidPassword),
#[display("{_0}")]
MustCompleteSignUp(#[from] errors::MustCompleteSignUp),
}
}
pub mod sign_up {
use super::*;
#[data]
pub struct Args {
pub nickname: user::Nickname,
pub email: user::Email,
pub display_name: Option<user::DisplayName>,
pub password: user::Password,
}
#[data]
pub struct Ok {
pub id: user::Id,
}
#[data(error)]
pub enum Err {
#[display("{_0}")]
AlreadyTaken(#[from] errors::AlreadyTaken),
}
}

22
core/src/rt.rs Normal file
View file

@ -0,0 +1,22 @@
use eva::{auto_impl, fut::Fut};
#[auto_impl(&, &mut)]
pub trait RtRef: Send + Sync {}
pub trait BlockingFn: FnOnce() -> Self::Ret + Send + 'static {
type Ret: Send + 'static;
}
impl<F, R> BlockingFn for F
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
type Ret = R;
}
#[auto_impl(&mut)]
pub trait RtMut: RtRef {
fn spawn_blocking<F: BlockingFn>(&mut self, task: F) -> impl Fut<Output = F::Ret>;
fn spawn<F: Fut<Output: Send> + 'static>(&mut self, task: F);
}

View file

@ -0,0 +1,17 @@
use crate::{
requests::authors::{create, get, update},
service::CallStep,
};
use eva::auto_impl;
#[auto_impl(&mut, Box)]
pub trait AuthorsRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
}
#[auto_impl(&mut, Box)]
pub trait AuthorsMut: AuthorsRef {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err>;
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err>;
}

View file

@ -0,0 +1,9 @@
use eva::auto_impl;
use crate::{service::AuxFut, types::session};
#[auto_impl(&mut)]
pub trait AuthenticationMut: Send + Sync {
fn authenticate(&mut self, session: session::Token) -> impl AuxFut<()>;
fn clear(&mut self);
}

View file

@ -0,0 +1,18 @@
use eva::auto_impl;
use crate::{
requests::boards::{create, delete, edit, get},
service::CallStep,
};
#[auto_impl(&mut, Box)]
pub trait BoardsRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
}
#[auto_impl(&mut, Box)]
pub trait BoardsMut: BoardsRef {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err>;
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err>;
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err>;
}

18
core/src/service/games.rs Normal file
View file

@ -0,0 +1,18 @@
use eva::auto_impl;
use crate::{
requests::games::{create, get, search, update},
service::CallStep,
};
#[auto_impl(&mut, Box)]
pub trait GamesRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err>;
}
#[auto_impl(&mut, Box)]
pub trait GamesMut: GamesRef {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err>;
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err>;
}

View file

@ -0,0 +1,18 @@
use eva::auto_impl;
use crate::{
requests::messages::{delete, edit, get, post},
service::CallStep,
};
#[auto_impl(&mut, Box)]
pub trait MessagesRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
}
#[auto_impl(&mut, Box)]
pub trait MessagesMut: Send + Sync {
fn post(&mut self) -> impl CallStep<post::Args, Ok = post::Ok, Err = post::Err>;
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err>;
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err>;
}

75
core/src/service/mod.rs Normal file
View file

@ -0,0 +1,75 @@
use eva::{auto_impl, handling, trait_set};
use crate::{errors::Aux, requests::Response};
pub mod boards;
pub mod messages;
pub mod threads;
pub mod authors;
pub mod games;
pub mod users;
pub mod authz;
pub type SessionOf<T> = <T as SessionMaker>::Session;
trait_set! {
pub trait RespFut<O, E> = Future<Output = Response<O, E>> + Send;
pub trait AuxFut<O> = Future<Output = Result<O, Aux>> + Send;
}
pub trait CallStep<I>: Send + Sync {
type Ok;
type Err;
fn call(&mut self, args: I) -> impl RespFut<Self::Ok, Self::Err>;
}
pub trait IsEndpoint<I, S>:
handling::Endpoint<I, S, Output = Response<Self::Ok, Self::Err>>
{
type Ok;
type Err;
}
trait_set! {
pub trait IsState = Send + Sync;
}
impl<I, S, O, E, Ep> IsEndpoint<I, S> for Ep
where
Ep: handling::Endpoint<I, S, Output = Response<O, E>>,
{
type Ok = O;
type Err = E;
}
#[auto_impl(&, &mut, Arc)]
pub trait SessionMaker: Send + Sync {
type Session: Session;
fn make_session(&self) -> impl AuxFut<Self::Session>;
}
trait_set! {
pub trait Service = SessionMaker;
}
#[auto_impl(&mut)]
pub trait Session: Send + Sync {
fn users(&mut self) -> impl users::UsersRef;
fn users_mut(&mut self) -> impl users::UsersMut;
fn authors(&mut self) -> impl authors::AuthorsRef;
fn authors_mut(&mut self) -> impl authors::AuthorsMut;
fn games(&mut self) -> impl games::GamesRef;
fn games_mut(&mut self) -> impl games::GamesMut;
fn authentication_mut(&mut self) -> impl authz::AuthenticationMut;
fn boards(&mut self) -> impl boards::BoardsMut;
fn threads(&mut self) -> impl threads::ThreadsMut;
fn messages(&mut self) -> impl messages::MessagesMut;
}

View file

@ -0,0 +1,19 @@
use eva::auto_impl;
use crate::{
requests::threads::{create, delete, edit, get, search},
service::CallStep,
};
#[auto_impl(&mut, Box)]
pub trait ThreadsRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err>;
}
#[auto_impl(&mut, Box)]
pub trait ThreadsMut: ThreadsRef {
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err>;
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err>;
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err>;
}

26
core/src/service/users.rs Normal file
View file

@ -0,0 +1,26 @@
use eva::auto_impl;
use crate::{
requests::users::{check_auth, confirm_sign_up, get, sign_in, sign_up, update},
service::CallStep,
};
#[auto_impl(&mut)]
pub trait UsersRef: Send + Sync {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err>;
fn check_auth(
&mut self,
) -> impl CallStep<check_auth::Args, Ok = check_auth::Ok, Err = check_auth::Err>;
}
#[auto_impl(&mut)]
pub trait UsersMut: UsersRef {
fn sign_in(&mut self) -> impl CallStep<sign_in::Args, Ok = sign_in::Ok, Err = sign_in::Err>;
fn sign_up(&mut self) -> impl CallStep<sign_up::Args, Ok = sign_up::Ok, Err = sign_up::Err>;
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err>;
fn confirm_sign_up(
&mut self,
) -> impl CallStep<confirm_sign_up::Args, Ok = confirm_sign_up::Ok, Err = confirm_sign_up::Err>;
}

114
core/src/types/author.rs Normal file
View file

@ -0,0 +1,114 @@
use std::borrow::Cow;
use eva::{data, str, str::CompactString, time::Date, zst_error};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de};
use crate::types::{entity, file, slug, user};
/// Author selector.
#[data(copy, ord)]
#[serde(untagged)]
pub enum Selector {
#[display("{_0}")]
Id(#[from] Id),
#[display("@{_0}")]
#[serde(with = "SlugStr")]
Slug(#[from] Slug),
}
struct SlugStr;
impl JsonSchema for SlugStr {
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(concat!(module_path!(), "::SlugStr"))
}
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("SlugStr")
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut pat = String::from("^@");
<Slug as str::HasPattern>::pat_into(&mut pat);
pat.push('$');
schemars::json_schema!({
"type": "string",
"pattern": pat,
})
}
}
impl SlugStr {
fn serialize<S: serde::Serializer>(slug: &Slug, serializer: S) -> Result<S::Ok, S::Error> {
str::format_compact!("@{slug}").serialize(serializer)
}
fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Slug, D::Error> {
let s = <&'de str as Deserialize<'de>>::deserialize(deserializer)?;
if let Some(slug) = s.strip_prefix('@') {
slug.parse().map_err(de::Error::custom)
} else {
Err(de::Error::custom(zst_error!("slug must start with a @")))
}
}
}
/// Author miniature.
#[data]
pub struct Mini {
pub id: Id,
pub title: Title,
pub slug: Slug,
pub owner: user::Id,
#[serde(skip_serializing_if = "Option::is_none")]
pub pfp: Option<file::Id>,
}
/// Author.
#[data]
pub struct Author {
pub id: Id,
pub slug: Slug,
pub title: Title,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Description>,
pub owner: user::Mini,
#[serde(skip_serializing_if = "Option::is_none")]
pub pfp: Option<file::Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option<Verification>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<ContactEmail>,
pub created_at: Date,
}
/// Information about author verification.
#[data]
pub struct Verification {
pub by: user::Mini,
pub at: Date,
}
/// Email for contacting the author.
#[data]
pub struct ContactEmail(pub CompactString);
entity::define_eid! {
/// ID of the author.
pub struct Id(Author);
}
/// Author's slug.
#[str(newtype, copy)]
pub struct Slug(pub slug::Slug<23>);
/// Author's title.
#[str(newtype)]
pub struct Title(pub CompactString);
/// Author's description.
#[str(newtype)]
pub struct Description(pub CompactString);

106
core/src/types/board.rs Normal file
View file

@ -0,0 +1,106 @@
use crate::types::{entity, game, slug, user};
use std::borrow::Cow;
use eva::{
data, str,
str::{HasPattern, format_compact},
time::Timestamp,
zst_error,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de};
#[data(copy)]
#[serde(untagged)]
pub enum Selector {
#[display("@{_0}")]
#[serde(with = "Slugged")]
Slug(#[from] Slug),
#[display("{_0}")]
Id(#[from] Id),
}
struct Slugged;
impl JsonSchema for Slugged {
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(concat!(module_path!(), "::Slugged"))
}
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("Slugged")
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut pat = String::from("^@");
Slug::pat_into(&mut pat);
pat.push('$');
schemars::json_schema!({
"type": "string",
"pattern": pat,
"description": "slug of the board"
})
}
}
impl Slugged {
fn serialize<S>(slug: &Slug, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
format_compact!("@{slug}").serialize(serializer)
}
fn deserialize<'de, D>(deserializer: D) -> Result<Slug, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <&'de str as Deserialize<'de>>::deserialize(deserializer)?;
if let Some(rest) = s.strip_prefix('@') {
let slug: Slug = rest.parse().map_err(de::Error::custom)?;
Ok(slug)
} else {
Err(de::Error::custom(zst_error!("slug must start with a @")))
}
}
}
entity::define_eid! {
pub struct Id(Board);
}
entity::define_eid! {
pub struct Op(User | Game);
}
impl Op {
pub const fn user(user: user::Id) -> Self {
Self(user.raw_id())
}
pub const fn game(game: game::Id) -> Self {
Self(game.raw_id())
}
}
#[str(newtype, copy)]
pub struct Slug(slug::LowerSlug<7>);
#[str(newtype)]
pub struct Title(str::CompactString);
#[data]
pub struct Brief {
pub id: Id,
pub slug: Option<Slug>,
}
#[data]
pub struct Board {
pub id: Id,
pub title: Option<Title>,
pub slug: Option<Slug>,
pub op: Op,
pub created_at: Timestamp,
}

View file

@ -0,0 +1,5 @@
use eva::str;
/// String that proves successful captcha solution.
#[str(newtype)]
pub struct Token(pub str::CompactString);

843
core/src/types/entity.rs Normal file
View file

@ -0,0 +1,843 @@
//! # Generic entity identifier
//!
//! Used to identify entities in the system. Should be treated as 128bits of randomness. Unique and stable, thus
//! it should be preferred over slugs (such as nicknames) for referring to entities.
//!
//! # Schema
//!
//! Schema is not guaranteed, can be changed any time. From highest to lowest bits.
//! ```plaintext
//! Bits
//! 48 72 8
//! +---+---+---+
//! | T | R | M |
//! +---+---+---+
//! ```
//!
//! 1. T - timestamp in milliseconds
//! 2. R - randomness, non-zero
//! 3. M - metadata
//!
//! Thus, identifier has some kind of monotonicity and never zero. Strictly speaking, two generated IDs are
//! not guaranteed to be in order "first created > created after", it's actually "first >= after".
//!
//! The format takes inspiration from the ULID, slightly modifying it for our purposes. The exact modification is
//! metadata at the lowest bits, instead of additional 16bits of randomness, we take that bits for storing metadata, 72bits
//! is still enormous amount of possible values in a millisecond. Especially since identifiers comparison includes
//! comparing metadata, thus identifier is equal only if all of 128bits matches.
//!
//! Metadata
//! ```plaintext
//! Bits
//! 3 5
//! +---+---+
//! | D | K |
//! +---+---+
//! ```
//! 1. D - data specific to each kind, can be randomness.
//! 2. K - Kind. Entity kind to which ID refers.
//!
//! # Encoding
//!
//! TODO.
use eva::{
data, int,
rand::Rng as _,
str,
str::{FixedUtf8, HasPattern, Seq},
time::{Clock as _, Timestamp},
};
use std::{
borrow::Cow,
fmt, mem,
num::NonZeroU128,
slice,
str::{FromStr, from_utf8_unchecked},
};
use crate::world::{World, WorldMut};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de};
#[data(
copy,
display("provided number of steps would overflow capacity of the identifier")
)]
pub struct IdCapacityOverflow;
pub trait IsEntityId:
for<'de> Deserialize<'de> + Serialize + fmt::Display + FromStr<Err = ParseError> + Copy + Eq + Ord
{
fn from_generic(generic: Id) -> Option<Self>;
fn to_str(&self) -> StrId;
fn into_inner(&self) -> NonZeroU128;
}
/// Entity kind.
#[data(ord, copy, display(name))]
#[derive(Hash)]
#[repr(u8)]
pub enum Kind {
Session = 0,
File,
Upload,
User,
Author,
Game,
Tag,
Badge,
Message,
Thread,
Board,
}
const _: () = {
if Kind::MAX as u8 >= (1 << 5_u8) {
panic!("Number of kinds exceed limits of 5bits");
}
};
impl Kind {
pub const MIN: Self = Self::Session;
pub const MAX: Self = Self::Board;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Metadata(u8);
impl Metadata {
const fn validate(v: u8) -> bool {
let kind = v & 0b1111;
kind <= Kind::MAX as u8
}
pub const fn new(kind: Kind, data: u8) -> Self {
let kind = kind as u8;
let data = data & !0b1111_u8;
Self((data << 4) | kind)
}
pub const fn repr(self) -> u8 {
self.0
}
pub const fn kind(self) -> Kind {
unsafe { mem::transmute::<u8, Kind>(self.0 & 0b1111) }
}
pub const fn data(self) -> u8 {
self.0 >> 4
}
}
#[doc(hidden)]
pub struct DisplayArray<'a, T>(pub &'a [T]);
impl<T: fmt::Display> fmt::Display for DisplayArray<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[")?;
for value in self.0 {
fmt::Display::fmt(value, f)?;
}
f.write_str("]")
}
}
#[data(copy, error)]
pub enum ParseError {
#[display("unexpected entity kinds {got}, expected {}", DisplayArray(&[expected]))]
UnexpectedKind { expected: Kind, got: Kind },
#[display("provided identifier is malformed")]
Malformed,
#[display("invalid string length")]
Length,
#[display("invalid char")]
Char,
}
impl From<str::ParseError> for ParseError {
fn from(value: str::ParseError) -> Self {
match value {
str::ParseError::Length => Self::Length,
str::ParseError::Char => Self::Char,
}
}
}
#[int(u8, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')]
pub enum Char {}
eva::single_ascii_char!(Char);
impl Char {
pub const NULL: Self = Self::VARIANTS[0];
}
unsafe impl FixedUtf8 for Char {}
#[str(fixed(error = eva::str::ParseError))]
struct StrIdBuf(Char, Seq<22, Char>);
impl StrIdBuf {
const fn to_array(self) -> [Char; 23] {
unsafe { mem::transmute::<Self, [Char; 23]>(self) }
}
const fn as_mut_array<'this>(&'this mut self) -> &'this mut [Char; 23] {
unsafe { mem::transmute::<&'this mut Self, &'this mut [Char; 23]>(self) }
}
}
/// String representation of the [`Id`].
#[str(custom, copy)]
pub struct StrId {
buf: StrIdBuf,
len: u8,
}
impl HasPattern for StrId {
#[inline]
fn pat_into(buf: &mut String) {
Char::pat_into(buf);
buf.push_str("{1,23}");
}
}
impl StrId {
pub const fn len(&self) -> u8 {
self.len
}
pub const fn is_empty(&self) -> bool {
self.len == 0
}
pub const fn as_str(&self) -> &str {
unsafe {
from_utf8_unchecked(slice::from_raw_parts(
(&raw const self.buf).cast(),
self.len as usize,
))
}
}
}
impl FromStr for StrId {
type Err = ParseError;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
use ParseError as E;
let bytes = s.as_bytes();
if bytes.is_empty() || bytes.len() > 23 {
return Err(E::Length);
}
let mut buf = StrIdBuf(Char::NULL, Seq([Char::NULL; 22]));
let array = buf.as_mut_array();
let mut idx = 0_usize;
let mut len = 0_u8;
for &byte in bytes {
array[idx] = Char::new(byte).ok_or(E::Char)?;
idx += 1;
len += 1;
}
Ok(Self { buf, len })
}
}
#[doc(inline)]
pub use crate::_define_eid as define_eid;
#[doc(inline)]
pub use crate::_match_kind as match_;
#[macro_export]
#[doc(hidden)]
macro_rules! _if_tail_exists {
([$($Inp:ident)+] => {$($True:tt)*}; _ => {$($False:tt)*};) => {$($True)*};
([] => {$($True:tt)*}; _ => {$($False:tt)*};) => {$($False)*};
}
#[doc(hidden)]
#[macro_export]
macro_rules! _use_match_arms_ty {
(@ [$($Acc:ident)*] $Last:ident ;; as $As:ident) => {$crate::_priv::paste! { use $($Acc ::)* [<$Last _Match>] as $As; }};
(@ [$($Acc:ident)*] $Hd:ident $($Tail:ident)+ ;; as $As:ident) => { $crate::_use_match_arms_ty!(@ [$($Acc)* $Hd] $($Tail)+ ;; as $As) };
($($List:ident)+ ;; as $As:ident) => { $crate::_use_match_arms_ty!(@ [] $($List)+ ;; as $As) };
}
#[macro_export]
#[doc(hidden)]
macro_rules! _match_kind {
($id:expr, $($IdT:ident)::+ => {$(
$Kind:ident => $Code:expr
),* $(,)?}) => {$crate::_priv::paste! {
{
$crate::_use_match_arms_ty!($($IdT)* ;; as __Match);
{$(
use $crate::types::entity::Kind::$Kind as _;
)*}
$id.match_(__Match {$(
[<$Kind:snake>]: move || $Code
),*})
}
}};
}
#[macro_export]
#[doc(hidden)]
macro_rules! _define_eid {
($(
$(#[$outer_meta:meta])*
$vis:vis struct $Name:ident($KindHd:ident $(| $KindTs:ident)*);
)*) => {$crate::_priv::paste! {$(
$(#[$outer_meta])*
#[::eva::data(copy, ord, not(Deserialize), display("{_0}"))]
#[derive(Hash)]
#[repr(transparent)]
$vis struct $Name($crate::types::entity::Id);
#[allow(non_camel_case_types, dead_code)]
$vis struct [<$Name _Match>] <$KindHd $(, $KindTs)*> {
$vis [<$KindHd:snake>]: $KindHd,
$(
$vis [<$KindTs:snake>]: $KindTs
),*
}
const _: () = {
use $crate::{
world::{World, WorldMut},
types::entity,
_if_tail_exists
};
use ::core::{
result::Result,
cmp::PartialEq,
num::NonZeroU128,
ops::Deref,
str::FromStr,
};
use ::eva::{
zst_error,
_priv::serde::{
Deserialize,
Deserializer,
de,
},
};
impl entity::IsEntityId for $Name {
fn to_str(&self) -> entity::StrId {
self.0.to_str()
}
fn into_inner(&self) -> NonZeroU128 {
self.0.into_inner()
}
fn from_generic(v: entity::Id) -> Option<Self> {
Self::from_generic(v)
}
}
impl Deref for $Name {
type Target = entity::Id;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromStr for $Name {
type Err = entity::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let id: entity::Id = s.parse()?;
Self::from_generic(id).ok_or(entity::ParseError::UnexpectedKind {
// TODO(MKS-5): enrich.
expected: entity::Kind::$KindHd,
got: id.metadata().kind(),
})
}
}
impl PartialEq<NonZeroU128> for $Name {
fn eq(&self, other: &NonZeroU128) -> bool {
self.0.into_inner() == *other
}
}
impl PartialEq<u128> for $Name {
fn eq(&self, other: &u128) -> bool {
self.0.into_inner().get() == *other
}
}
impl PartialEq<entity::Id> for $Name {
fn eq(&self, other: &entity::Id) -> bool {
self.0 == *other
}
}
impl<'de> Deserialize<'de> for $Name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let id = entity::Id::deserialize(deserializer)?;
Self::from_generic(id).ok_or_else(|| de::Error::custom(zst_error!(
"unexpected entity kind, expected {}",
entity::DisplayArray(&[entity::Kind::$KindHd $(, entity::Kind::$KindTs)*]),
)))
}
}
impl $Name {
pub const fn raw_id(self) -> entity::Id {
self.0
}
pub const fn is_valid_kind(kind: entity::Kind) -> bool {
matches!(kind, entity::Kind::$KindHd $(| entity::Kind::$KindTs)*)
}
/// Create identifier from the generic entity id.
pub const fn from_generic(entity: entity::Id) -> Option<Self> {
if Self::is_valid_kind(entity.metadata().kind()) {
Some(Self(entity))
} else {
None
}
}
#[track_caller]
pub const fn next_gt(self, steps: u128) -> Self {
match self.try_next_gt(steps) {
Ok(r) => r,
Err(..) => panic!("id capacity overflow")
}
}
pub const fn try_next_gt(self, steps: u128) -> Result<Self, entity::IdCapacityOverflow> {
let raw = self.raw_id();
match raw.try_next_gt(steps) {
Ok(r) => Ok(Self(r)),
Err(e) => Err(e),
}
}
pub fn match_<__FnOut, $KindHd $(, $KindTs)*>(self, arms: [<$Name _Match>] <$KindHd $(, $KindTs)*>) -> __FnOut
where
$KindHd: FnOnce() -> __FnOut,
$($KindTs: FnOnce() -> __FnOut),*
{
use entity::Kind as K;
match self.raw_id().metadata().kind() {
K::$KindHd => (arms.[<$KindHd:snake>])()
$(, K::$KindTs => (arms.[<$KindTs:snake>])())*
,
_ => unsafe { core::hint::unreachable_unchecked() }
}
}
/// Convert identifier to the str.
pub const fn to_str(self) -> entity::StrId {
self.0.to_str()
}
_if_tail_exists! {
[$($KindTs)*] => {
/// Generate new identifier.
///
/// # Errors
///
/// panics if passed wrong kind.
#[track_caller]
pub fn generate<W: WorldMut>(w: World<W>, kind: entity::Kind) -> Self {
if Self::is_valid_kind(kind) {
let id = entity::Id::generate(w, entity::Metadata::new(kind, 0));
Self(id)
} else {
panic!(
"passed wrong kind for `{}`, available: [{}]",
stringify!($Name),
entity::DisplayArray(&[entity::Kind::$KindHd $(, entity::Kind::$KindTs)*])
)
}
}
};
_ => {
/// Minimal ID in sense of ordering. No ID is lesser than this one.
pub const MIN: Self = Self(entity::Id::from_parts(0, 0, entity::Metadata::new(entity::Kind::$KindHd, 0)));
/// Maximal ID in sense of ordering. No ID could be greater than this one.
pub const MAX: Self = Self(entity::Id::from_parts(u64::MAX, u128::MAX, entity::Metadata::new(entity::Kind::$KindHd, u8::MAX)));
/// Generate new identifier.
pub fn generate<W: WorldMut>(w: World<W>) -> Self {
let id = entity::Id::generate(w, entity::Metadata::new(entity::Kind::$KindHd, 0));
Self(id)
}
};
}
}
};
)*}};
}
/// Generic entity identifier.
#[data(copy, ord, not(serde, schemars, Debug), display("{}:{}", self.metadata().kind(), self.to_str()))]
#[derive(Hash)]
pub struct Id(NonZeroU128);
impl IsEntityId for Id {
fn from_generic(generic: Id) -> Option<Self> {
Some(generic)
}
fn to_str(&self) -> StrId {
Id::to_str(*self)
}
fn into_inner(&self) -> NonZeroU128 {
self.0
}
}
impl Id {
pub const TIMESTAMP_OFFSET: u64 = 1735678800000;
}
impl fmt::Debug for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Id").field(&self.to_str().as_str()).finish()
}
}
impl Serialize for Id {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if serializer.is_human_readable() {
self.to_str().serialize(serializer)
} else {
self.0.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for Id {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let res = if deserializer.is_human_readable() {
let s = <&'de str as Deserialize<'de>>::deserialize(deserializer)?;
Self::parse(StrId::from_str(s).map_err(de::Error::custom)?)
.map_err(de::Error::custom)?
} else {
let repr = u128::deserialize(deserializer)?;
Self::from_repr(repr).ok_or_else(|| serde::de::Error::custom(ParseError::Malformed))?
};
Ok(res)
}
}
impl JsonSchema for Id {
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(concat!(module_path!(), "::Id"))
}
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("Id")
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"anyOf": [
{
"type": "integer",
"minimum": 0,
"maximum": u128::MAX,
"description": "integer representation of the identifier, only in binary formats"
},
{
"type": "string",
"pattern": StrId::regex_pat_fullmatch(),
"minLength": 1,
"maxLength": 23,
"description": "unique entity identifier, only in human-readable formats"
}
]
})
}
}
impl FromStr for Id {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s: StrId = s.parse()?;
Ok(Self::parse(s)?)
}
}
impl Id {
pub const fn to_str(self) -> StrId {
let mut buf = StrIdBuf(Char::NULL, Seq([Char::NULL; 22]));
let array = buf.as_mut_array();
let mut len = 0_u8;
let mut value = self.0.get();
let base = Char::VARIANTS.len() as u128;
let idx = value.ilog(base) as usize;
loop {
array[idx - len as usize] = Char::VARIANTS[(value % base) as usize];
value /= base;
len += 1;
if value == 0 {
break;
}
}
StrId { buf, len }
}
pub const fn from_repr(x: u128) -> Option<Self> {
let Some(x) = NonZeroU128::new(x) else {
return None;
};
if !Metadata::validate((x.get() & 0xFF) as u8) {
return None;
}
Some(Self(x))
}
pub fn parse(s: StrId) -> Result<Self, ParseError> {
let mut res = 0_u128;
let mut idx = 0_usize;
let array = s.buf.to_array();
while idx != s.len() as usize {
res = res
.checked_mul(Char::VARIANTS.len() as u128)
.ok_or(ParseError::Malformed)?;
res = res
.checked_add(array[idx].nth() as u128)
.ok_or(ParseError::Malformed)?;
idx += 1;
}
Self::from_repr(res).ok_or(ParseError::Malformed)
}
pub const fn timestamp(self) -> Timestamp {
Timestamp::from_millis(self.timestamp_ms())
}
pub const fn timestamp_ms_rel(self) -> u64 {
self.timestamp_ms() - Self::TIMESTAMP_OFFSET
}
pub const fn timestamp_ms(self) -> u64 {
(self.0.get() >> 80) as u64 + Self::TIMESTAMP_OFFSET
}
pub const fn metadata(self) -> Metadata {
Metadata((self.0.get() & 0xFF) as u8)
}
pub const fn with_metadata(self, new: Metadata) -> Self {
Self(unsafe { NonZeroU128::new_unchecked((self.0.get() & !0xFF) | new.repr() as u128) })
}
/// Same as [`Id::try_next_gt`], but panics on overflow.
#[track_caller]
pub const fn next_gt(self, steps: u128) -> Self {
if let Ok(this) = self.try_next_gt(steps) {
this
} else {
panic!("steps would overflow")
}
}
/// Get entity id which'd be greater than `steps` of next identifiers.
pub const fn try_next_gt(self, mut steps: u128) -> Result<Self, IdCapacityOverflow> {
const RAND_QUOTA: u128 = (1 << 72) - 1;
let mut ts = self.timestamp_ms_rel();
let rand = self.random().get();
let meta = self.metadata();
let rand_quota_left = RAND_QUOTA - rand;
if rand_quota_left >= steps {
return Ok(Self::from_parts(ts, rand + steps, meta));
}
// rand_quota_left < steps
ts += 1;
steps -= rand_quota_left;
if RAND_QUOTA >= steps {
return Ok(Self::from_parts(ts, steps, meta));
}
// RAND_QUOTA < steps
// So, at minimum we're `2^72 + 1` steps ahead, one more
// jump and we got the maximum value.
steps -= RAND_QUOTA;
ts += 1;
if RAND_QUOTA >= steps {
Ok(Self::from_parts(ts, steps, meta))
} else {
// We're already `2^144 + 1` steps ahead, there's no more
// values.
Err(IdCapacityOverflow)
}
}
/// Get random bits of the identifier.
pub const fn random(self) -> NonZeroU128 {
unsafe { NonZeroU128::new_unchecked((self.0.get() & (((1 << 72) - 1) << 8)) >> 8) }
}
/// Construct ID from parts.
pub const fn from_parts(mut millis: u64, mut random: u128, metadata: Metadata) -> Self {
millis &= (1 << 48) - 1;
random &= (1 << 72) - 1;
if random == 0 {
random = 1;
}
let mut result = metadata.repr() as u128;
result |= random << 8;
result |= (millis as u128) << 80;
Self(unsafe { NonZeroU128::new_unchecked(result) })
}
/// Generate new identifier.
pub fn generate<W: WorldMut>(mut world: World<W>, metadata: Metadata) -> Self {
let millis = world
.clock()
.get()
.as_millis()
.saturating_sub(Self::TIMESTAMP_OFFSET);
let random: u128 = world.rng().random();
Self::from_parts(millis, random, metadata)
}
/// Convert ID into its raw representation.
pub const fn into_inner(self) -> NonZeroU128 {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
const KIND: Kind = Kind::MIN;
const META: Metadata = Metadata::new(KIND, 0);
#[test]
fn steps_simple_ordering() {
let fst = Id::from_parts(0, 1, META);
assert_eq!(fst.random().get(), 1);
// 0 steps = same id.
assert_eq!(fst.next_gt(0), fst);
// Only random should increment.
{
let next = fst.next_gt(1);
assert_eq!(next.timestamp_ms_rel(), 0);
assert_eq!(next.random().get(), 2);
assert_eq!(next.metadata(), META);
}
}
#[test]
fn steps_borderline_ordering() {
const QUOTA: u128 = (1 << 72) - 1;
let fst = Id::from_parts(0, QUOTA - 1, META);
// random() = QUOTA is ok, should be preserved.
{
let next = fst.next_gt(1);
assert_eq!(next.timestamp_ms_rel(), 0);
assert_eq!(next.random().get(), QUOTA);
assert_eq!(next.metadata(), META);
}
// Timestamp increment.
{
let next = fst.next_gt(2);
assert_eq!(next.timestamp_ms_rel(), 1);
assert_eq!(next.random().get(), 1);
assert_eq!(next.metadata(), META);
}
// Random should be 2, when getting next greater id, 1 = 0
// shouldn't be visible.
{
let next = fst.next_gt(3);
assert_eq!(next.timestamp_ms_rel(), 1);
// May be faulty due to `1 = 0` in random, MUST be worked-around
// by implementation by removing `0` from the set of possible values
// here.
assert_eq!(next.random().get(), 2);
assert_eq!(next.metadata(), META);
}
// Make third jump, should be correct as well.
{
let next = fst.next_gt(2 + QUOTA);
assert_eq!(next.timestamp_ms_rel(), 2);
assert_eq!(next.random().get(), 1);
assert_eq!(next.metadata(), META);
}
// Same for third jump, 1 = 0 check.
{
let next = fst.next_gt(2 + QUOTA + 1);
assert_eq!(next.timestamp_ms_rel(), 2);
assert_eq!(next.random().get(), 2);
assert_eq!(next.metadata(), META);
}
}
#[test]
fn simple_ordering() {
assert!(Id::from_parts(0, 0, META) < Id::from_parts(1, 0, META));
}
#[test]
fn converting_back() {
let id: Id = "nigger".parse().unwrap();
let back = id.to_str().to_string();
assert_eq!(back, "nigger");
}
}

129
core/src/types/file.rs Normal file
View file

@ -0,0 +1,129 @@
//! # File identifier
//!
//! Basically same as [`entity::Id`], but additionally stores server where file is located in
//! two lowest bits of random.
use std::{mem, num::NonZeroU128, str::FromStr};
use crate::types::entity;
use eva::{data, hash::blake3, int, str, str::ToCompactString};
pub type Hash = blake3::Hash;
#[data(copy, ord, display(name))]
pub enum ImageFormat {
Png,
Jpeg,
Bmp,
Gif,
Webp,
}
#[data(copy, ord, display(name))]
pub enum Class {
Image,
GameFile,
}
lazy_static::lazy_static! {
static ref BASE_NAME_CHAR_PAT: String = {
let mut out = String::from("^");
eva::push_ascii_pat!(BaseNameBoundChar, &mut out);
eva::push_ascii_pat!(BaseNameChar, &mut out);
out.push_str("{0,");
out.push_str(&str::format_compact!("{}", const { BaseName::MAX_LEN - 2 }));
out.push('}');
eva::push_ascii_pat!(BaseNameBoundChar, &mut out);
out.push('$');
out
};
}
#[int(u8, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_')]
enum BaseNameBoundChar {}
#[int(u8, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_')]
enum BaseNameChar {}
/// Base file name.
#[str(custom)]
#[derive(schemars::JsonSchema)]
pub struct BaseName(#[schemars(regex(pattern = BASE_NAME_CHAR_PAT))] str::CompactString);
impl BaseName {
pub const MAX_LEN: usize = 256;
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl FromStr for BaseName {
type Err = str::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() >= Self::MAX_LEN {
return Err(str::ParseError::Length);
}
let bytes = s.as_bytes();
if let Some((&first, &last)) = bytes.first().zip(bytes.last()) {
_ = BaseNameBoundChar::new(first).ok_or(str::ParseError::Char)?;
_ = BaseNameBoundChar::new(last).ok_or(str::ParseError::Char)?;
}
for b in s.as_bytes().iter().copied() {
_ = BaseNameChar::new(b).ok_or(str::ParseError::Char)?;
}
Ok(Self(s.to_compact_string()))
}
}
/// ID of the server.
#[int(u8, 0..8)]
#[derive(Hash)]
pub enum Server {}
impl Server {
pub const fn try_from_ascii_repr(val: u8) -> Option<Self> {
if val < b'a' {
return None;
}
Self::new(val - b'a')
}
pub const fn ascii_repr(self) -> u8 {
b'a' + (self as u8)
}
}
entity::define_eid! {
pub struct Id(File);
}
impl Id {
pub const fn from_parts(millis: u64, random: u128, server: Server) -> Self {
let id = entity::Id::from_parts(
millis,
random,
entity::Metadata::new(entity::Kind::File, server as u8),
);
Self(id)
}
pub const fn server(self) -> Server {
// It's still possible that ID is invalid because of deserialization.
unsafe { mem::transmute::<u8, Server>(self.0.metadata().data() & 0b111) }
}
pub const fn into_inner(self) -> NonZeroU128 {
self.0.into_inner()
}
}

161
core/src/types/game.rs Normal file
View file

@ -0,0 +1,161 @@
use eva::{array, data, int, str, str::CompactString, time::Date};
use crate::types::{author, entity::define_eid, file, slug, user};
#[data]
#[derive(Default)]
pub struct Tags(pub array::ImmutableHeap<Tag, 64>);
#[data]
#[derive(Default)]
pub struct Genres(pub array::ImmutableHeap<Genre, 64>);
#[data]
#[derive(Default)]
pub struct Badges(pub array::ImmutableHeap<Badge, 64>);
#[data]
#[derive(Default)]
pub struct Screenshots(pub array::ImmutableHeap<file::Id, 8>);
/// Query for searching.
#[str(newtype)]
pub struct SearchQuery(pub CompactString);
/// Fully qualified game path.
#[data(copy, display("{author}/@{slug}"))]
pub struct FullyQualified {
pub author: author::Selector,
pub slug: Slug,
}
/// How game can be selected.
#[data(copy)]
pub enum Selector {
#[display("{_0}")]
FullyQualified(#[from] FullyQualified),
#[display("{_0}")]
Id(#[from] Id),
}
/// Date precision.
#[data(copy, ord, display("name"))]
pub enum DatePrecision {
/// Precise to day, month and year.
Day,
/// Precise to month and year.
Month,
/// Precise to year.
Year,
}
/// Date when game is released.
#[data]
pub struct ReleaseDate {
/// Date.
pub date: Date,
/// Precision of the date.
pub precision: DatePrecision,
}
/// ID of the vndb entry.
#[data(copy, ord, display("v{_0}"))]
pub struct VndbId(pub u64);
#[data]
pub struct PubModerated {
pub by: user::Id,
pub at: Date,
}
#[data]
pub struct PubVerified {
pub by: user::Id,
pub at: Date,
}
#[data]
pub enum Publication {
/// Game was published after a manual moderation.
Moderated(PubModerated),
/// Game was published immediately after publication
/// request, since author is verified.
Verified(PubVerified),
}
#[data]
pub struct Game {
pub id: Id,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<Slug>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<file::Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vndb: Option<VndbId>,
pub title: Title,
pub description: Option<Description>,
pub mean_rating: MeanRating,
pub author: author::Mini,
pub release_date: Option<ReleaseDate>,
pub publication: Option<Publication>,
pub screenshots: Screenshots,
pub tags: Tags,
pub genres: Genres,
pub badges: Badges,
}
#[data(copy)]
pub struct MeanRating {
/// Mean value of all ratings.
pub mean: RatingValue,
pub votes: Votes,
}
impl Default for MeanRating {
fn default() -> Self {
Self {
mean: RatingValue::POS0,
votes: Votes(0),
}
}
}
/// Number of votes from users.
#[data(copy, ord, display("{_0}"))]
#[derive(Hash)]
pub struct Votes(pub u32);
define_eid! {
/// ID of the game.
pub struct Id(Game);
/// ID of the tag.
pub struct Tag(Tag);
/// Game badge.
pub struct Badge(Badge);
}
#[str(newtype, copy)]
pub struct Genre(pub slug::LowerSlug<15>);
/// Estimated time to read.
#[int(u8, 0..=100)]
pub enum TimeToReadHours {}
/// Numerical rating of the game.
#[int(u8, 0..=100)]
pub enum RatingValue {}
/// Short, url-safe human-readable identifier of the game.
#[str(newtype, copy)]
pub struct Slug(pub slug::Slug<31>);
/// Game title.
#[str(newtype)]
pub struct Title(CompactString);
/// Game description.
#[str(newtype)]
pub struct Description(CompactString);

51
core/src/types/message.rs Normal file
View file

@ -0,0 +1,51 @@
use eva::{data, int, str, time::Timestamp};
use crate::types::{author, entity, file, thread, user};
#[int(u8, 1..=128)]
pub enum PaginationLimit {}
#[data(copy)]
#[serde(untagged)]
pub enum Selector {
#[display("{_0}")]
Id(#[from] Id),
}
entity::define_eid! {
pub struct Id(Message);
}
entity::define_eid! {
pub struct ById(User | Author);
}
impl ById {
pub const fn user(user: user::Id) -> Self {
Self(user.raw_id())
}
pub const fn author(author: author::Id) -> Self {
Self(author.raw_id())
}
}
/// Text of the message.
#[str(newtype)]
pub struct Text(str::CompactString);
#[data]
pub struct By {
pub id: ById,
pub display_name: str::CompactString,
pub pfp: file::Id,
}
#[data]
pub struct Message {
pub id: Id,
pub by: By,
pub thread: thread::Id,
pub text: Text,
pub created_at: Timestamp,
}

22
core/src/types/mod.rs Normal file
View file

@ -0,0 +1,22 @@
pub use self::{patch::Patch, r#true::True};
pub mod r#true;
pub mod board;
pub mod message;
pub mod thread;
pub mod author;
pub mod user;
pub mod game;
pub mod entity;
pub mod file;
pub mod upload;
pub mod patch;
pub mod slug;
pub mod captcha;
pub mod session;

33
core/src/types/patch.rs Normal file
View file

@ -0,0 +1,33 @@
use eva::data;
#[data(copy)]
#[derive(Default)]
pub enum Patch<T> {
#[default]
Keep,
Change(T),
}
impl<T> Patch<T> {
pub fn option(self) -> Option<T> {
self.into()
}
}
impl<T> From<Option<T>> for Patch<T> {
fn from(value: Option<T>) -> Self {
match value {
Some(x) => Self::Change(x),
None => Self::Keep,
}
}
}
impl<T> From<Patch<T>> for Option<T> {
fn from(value: Patch<T>) -> Self {
match value {
Patch::Keep => None,
Patch::Change(v) => Some(v),
}
}
}

View file

@ -0,0 +1,5 @@
use crate::types::entity;
entity::define_eid! {
pub struct Token(Session);
}

181
core/src/types/slug.rs Normal file
View file

@ -0,0 +1,181 @@
use std::{borrow::Cow, num::NonZeroU8, slice, str::FromStr};
use eva::{data, int, str, str::HasPattern};
use schemars::JsonSchema;
pub type LowerSlug<const MAX: usize> = Slug<MAX, LowerSlugStart, LowerSlugRest>;
pub unsafe trait SlugPart: Default + Copy + Eq + Ord {
const TYPE_NAME: &str;
fn from_u8(u: u8) -> Option<Self>;
fn push_pat(into: &mut String);
}
#[data(copy, error, display(doc))]
pub enum ParseError {
/// Invalid char at start.
CharAtStart,
/// Invalid char at rest.
CharAtRest { pos: u8 },
/// Invalid length.
Length,
}
macro_rules! slug_part {
($ty:ident: $default:literal) => {
impl Default for $ty {
fn default() -> Self {
Self::new($default).expect("shit happens")
}
}
unsafe impl SlugPart for $ty {
const TYPE_NAME: &str = stringify!($ty);
fn push_pat(into: &mut String) {
eva::push_ascii_pat!($ty, into);
}
fn from_u8(u: u8) -> Option<Self> {
Self::new(u)
}
}
};
}
#[int(u8, b'a'..=b'z')]
pub enum LowerSlugStart {}
slug_part!(LowerSlugStart: b'z');
#[int(u8, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')]
pub enum LowerSlugRest {}
slug_part!(LowerSlugRest: b'z');
#[int(u8, b'A'..=b'Z' | b'a'..=b'z')]
pub enum SlugStart {}
slug_part!(SlugStart: b'Z');
#[int(u8, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')]
pub enum SlugRest {}
slug_part!(SlugRest: b'Z');
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(C, align(1))]
struct SlugBuf<const MAX: usize, Start, Rest>(Start, [Rest; MAX]);
#[str(custom, copy)]
pub struct Slug<const MAX: usize, Start: SlugPart = SlugStart, Rest: SlugPart = SlugRest> {
buf: SlugBuf<MAX, Start, Rest>,
len: NonZeroU8,
}
impl<const MAX: usize> Slug<MAX> {
pub const MAX: usize = MAX;
pub const fn lower(mut self) -> LowerSlug<MAX> {
unsafe {
self.buf.0 = SlugStart::new_unchecked((self.buf.0 as u8).to_ascii_lowercase());
let mut idx = 0;
while idx != MAX {
let rest = self.buf.1[idx] as u8;
self.buf.1[idx] = SlugRest::new_unchecked(rest.to_ascii_lowercase());
idx += 1;
}
}
unsafe { std::mem::transmute_copy::<Slug<MAX>, LowerSlug<MAX>>(&self) }
}
}
impl<const MAX: usize, Start: SlugPart, Rest: SlugPart> FromStr for Slug<MAX, Start, Rest> {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use ParseError as E;
#[expect(non_snake_case)]
let Z = Rest::default();
let Some((head, tail_str)) = s.split_at_checked(1) else {
return Err(E::Length);
};
let head = Start::from_u8(head.as_bytes()[0]).ok_or(E::CharAtStart)?;
let mut tail = [Z; MAX];
let mut len = 1_u8;
for (idx, b) in tail_str.as_bytes().iter().copied().enumerate() {
tail[idx] = Rest::from_u8(b).ok_or(E::CharAtRest {
pos: (idx + 1) as u8,
})?;
len = len.checked_add(1).ok_or(E::Length)?;
}
Ok(Self {
buf: SlugBuf(head, tail),
len: unsafe { NonZeroU8::new_unchecked(len) },
})
}
}
impl<const MAX: usize, Start: SlugPart, Rest: SlugPart> JsonSchema for Slug<MAX, Start, Rest> {
fn schema_id() -> Cow<'static, str> {
format!(
"{}::Slug<{}, {}, {}>",
module_path!(),
MAX + 1,
Start::TYPE_NAME,
Rest::TYPE_NAME
)
.into()
}
fn schema_name() -> Cow<'static, str> {
Cow::Owned(format!(
"Slug_{}_{}_{}",
MAX + 1,
Start::TYPE_NAME,
Rest::TYPE_NAME
))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut pat = String::from("^");
Self::pat_into(&mut pat);
pat.push('$');
schemars::json_schema!({
"type": "string",
"description": "human and machine readable identifier",
"minLength": 1,
"maxLength": MAX + 1,
"pattern": pat,
})
}
}
impl<const MAX: usize, Start: SlugPart, Rest: SlugPart> Slug<MAX, Start, Rest> {
pub const fn as_str(&self) -> &str {
unsafe {
std::str::from_utf8_unchecked(slice::from_raw_parts(
(&raw const self.buf).cast(),
self.len.get() as usize,
))
}
}
}
impl<const MAX: usize, Start: SlugPart, Rest: SlugPart> HasPattern for Slug<MAX, Start, Rest> {
fn pat_into(buf: &mut String) {
Start::push_pat(buf);
Rest::push_pat(buf);
buf.push_str("{0,");
buf.push_str(&MAX.to_string());
buf.push_str("}");
}
}

22
core/src/types/thread.rs Normal file
View file

@ -0,0 +1,22 @@
use eva::data;
use crate::types::{board, entity, message};
entity::define_eid! {
pub struct Id(Thread);
}
#[data(copy)]
pub enum Selector {
#[display("{_0}")]
Id(#[from] Id),
}
#[data]
pub struct Thread {
pub id: Id,
pub by: message::By,
pub board: board::Brief,
pub text: message::Text,
}

60
core/src/types/true.rs Normal file
View file

@ -0,0 +1,60 @@
use std::borrow::Cow;
use eva::data;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de};
#[data(copy, ord, error, display("must be `true`"))]
pub struct MustBeTrue;
#[data(copy, ord, not(serde, schemars), display(doc))]
#[derive(Default)]
pub enum True {
/// true.
#[default]
Value,
}
impl JsonSchema for True {
fn schema_id() -> Cow<'static, str> {
bool::schema_id()
}
fn schema_name() -> Cow<'static, str> {
bool::schema_name()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({"const": true})
}
}
impl True {
pub const fn new() -> Self {
Self::Value
}
}
impl<'de> Deserialize<'de> for True {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let res = bool::deserialize(deserializer)?;
if res {
Ok(True::Value)
} else {
Err(de::Error::custom(MustBeTrue))
}
}
}
impl Serialize for True {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
true.serialize(serializer)
}
}

17
core/src/types/upload.rs Normal file
View file

@ -0,0 +1,17 @@
use eva::data;
use crate::types::{entity, file};
entity::define_eid! {
pub struct Id(Upload);
}
#[data]
pub struct Upload {
pub id: Id,
pub file_name: Option<file::BaseName>,
pub uploaded: u64,
pub size: u64,
pub class: file::Class,
pub hash: file::Hash,
}

157
core/src/types/user.rs Normal file
View file

@ -0,0 +1,157 @@
use std::borrow::Cow;
use serde::{Serialize, de};
use eva::{
data, rand, str,
str::{CompactString, HasPattern, ParseError, Seq, ascii},
zst_error,
};
use crate::types::{entity, file, slug::Slug};
/// Miniature for the user.
#[data]
pub struct Mini {
pub id: Id,
pub nickname: Nickname,
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
pub pfp: Option<file::Id>,
}
#[data]
pub struct User {
pub id: Id,
pub nickname: Nickname,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<Email>,
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<Bio>,
pub pfp: Option<file::Id>,
}
#[data(copy)]
#[serde(untagged)]
pub enum Selector {
#[serde(with = "NicknameStr")]
#[display("@{_0}")]
Nickname(#[from] Nickname),
#[display("{_0}")]
Id(#[from] Id),
}
struct NicknameStr;
impl schemars::JsonSchema for NicknameStr {
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(concat!(module_path!(), "::NicknameStr"))
}
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("NicknameStr")
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut pat = String::from("^@");
Nickname::pat_into(&mut pat);
pat.push('$');
schemars::json_schema!({
"type": "string",
"pattern": pat,
})
}
}
impl NicknameStr {
pub fn deserialize<'de, D: serde::Deserializer<'de>>(des: D) -> Result<Nickname, D::Error> {
let s = <&'de str as serde::Deserialize<'de>>::deserialize(des)?;
if let Some(nickname) = s.strip_prefix('@') {
nickname.parse().map_err(de::Error::custom)
} else {
Err(de::Error::custom(zst_error!(
"nickname must start with a '@'"
)))
}
}
pub fn serialize<S: serde::Serializer>(nick: &Nickname, ser: S) -> Result<S::Ok, S::Error> {
eva::str::format_compact!("@{nick}").serialize(ser)
}
}
#[str(fixed(error = ParseError))]
pub struct SignUpCompletionToken(Seq<16, ascii::Printable>);
// TODO: impl this from macro.
impl rand::distr::Distribution<SignUpCompletionToken> for rand::distr::StandardUniform {
fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> SignUpCompletionToken {
let seq = std::array::from_fn(|_| rng.random());
SignUpCompletionToken(Seq(seq))
}
}
/// Email of the user.
#[str(newtype)]
pub struct Email(pub CompactString);
/// Hashed user password.
#[str(newtype)]
pub struct PasswordHash(pub CompactString);
/// Cleartext user password.
#[str(newtype)]
pub struct Password(pub CompactString);
entity::define_eid! {
/// ID of the user.
pub struct Id(User);
}
/// Machine and user-readable identifier.
#[str(newtype, copy)]
pub struct Nickname(pub Slug<23>);
/// Preferred name for display.
#[str(newtype)]
pub struct DisplayName(pub CompactString);
/// User's bio.
#[str(newtype)]
pub struct Bio(pub CompactString);
#[data(copy, ord, display(name))]
#[derive(Default)]
pub enum Role {
/// The least powerful.
Slave,
/// Plain user.
#[default]
User,
/// Moderator.
Mod,
/// Admin of the site.
Admin,
/// The most powerful user.
Master,
}
#[cfg(test)]
mod tests {
use super::*;
// Simple role ordering test.
#[test]
fn role_ordering() {
let ordering = [Role::Admin, Role::Mod, Role::User, Role::Slave];
let mut prev_role = Role::Master;
for role in ordering {
assert!(
prev_role > role,
"{prev_role} <= {role}, must be {prev_role} > {role}",
);
prev_role = role;
}
}
}

13
core/src/uploads.rs Normal file
View file

@ -0,0 +1,13 @@
use eva::{bytes::Bytes, data, trait_set};
use futures::stream::Stream;
#[data]
pub enum Action {
Push(Bytes),
Abort,
}
trait_set! {
pub trait UploadStream = Stream<Item = Action> + Unpin + Send;
}

70
core/src/world.rs Normal file
View file

@ -0,0 +1,70 @@
use eva::{auto_impl, rand, time::Clock};
use crate::rt;
#[cfg(feature = "test-util")]
pub mod testing;
eva::trait_set! {
pub trait Traits = Send + Sync;
}
pub trait Demiurge: Traits {
type World: WorldMut + 'static;
fn make_world(&self) -> World<Self::World>;
}
#[auto_impl(&, &mut)]
pub trait WorldRef: Traits + Clock {
fn rt(&self) -> impl rt::RtRef;
}
#[auto_impl(&mut)]
pub trait WorldMut: WorldRef {
fn rng(&mut self) -> impl rand::Rng;
fn rt_mut(&mut self) -> impl rt::RtMut;
}
pub struct World<W>(pub W);
macro_rules! narrow {
($($method:ident : $Trait:ty),* $(,)?) => {eva::paste! {$(
impl<W: WorldMut> World<W> {
pub const fn [<$method _mut>](&mut self) -> &mut impl $Trait {
&mut self.0
}
}
impl<W: WorldRef> World<W> {
pub const fn $method(&self) -> &impl $Trait {
&self.0
}
}
)*}};
}
narrow! {
clock: Clock,
}
impl<W: WorldRef> World<W> {
pub fn rt(&self) -> impl rt::RtRef {
self.0.rt()
}
}
impl<W: WorldMut> World<W> {
pub fn rng(&mut self) -> impl rand::Rng {
self.0.rng()
}
pub fn rt_mut(&mut self) -> impl rt::RtMut {
self.0.rt_mut()
}
pub fn mut_(&mut self) -> World<&mut W> {
World(&mut self.0)
}
}

87
core/src/world/testing.rs Normal file
View file

@ -0,0 +1,87 @@
use std::sync::{Arc, Mutex};
use eva::{
fut::Fut,
rand::{self, xoshiro},
time,
};
use crate::rt;
pub struct World {
rng: xoshiro::Xoshiro256StarStar,
clock: time::Mock,
}
impl time::Clock for World {
fn get(&self) -> time::Timestamp {
self.clock.get()
}
}
impl super::WorldRef for World {
fn rt(&self) -> impl rt::RtRef {
self
}
}
impl super::WorldMut for World {
fn rt_mut(&mut self) -> impl rt::RtMut {
self
}
fn rng(&mut self) -> impl rand::Rng {
&mut self.rng
}
}
impl rt::RtRef for World {}
impl rt::RtMut for World {
fn spawn<F: Fut<Output: Send> + 'static>(&mut self, task: F) {
tokio::spawn(task);
}
async fn spawn_blocking<F: rt::BlockingFn>(&mut self, task: F) -> F::Ret {
tokio::task::spawn_blocking(task)
.await
.expect("underlying task panicked")
}
}
pub struct Demiurge {
rng: Arc<Mutex<xoshiro::Xoshiro256StarStar>>,
pub clock: time::Mock,
}
impl Demiurge {
pub fn from_os_rng() -> Self {
Self {
rng: Arc::new(Mutex::new(rand::SeedableRng::from_os_rng())),
clock: time::Mock::default(),
}
}
pub fn new(seed: u64) -> Self {
Self {
rng: Arc::new(Mutex::new(rand::SeedableRng::seed_from_u64(seed))),
clock: time::Mock::default(),
}
}
}
impl super::Demiurge for Demiurge {
type World = World;
fn make_world(&self) -> super::World<Self::World> {
super::World(World {
rng: {
let mut global = self.rng.lock().unwrap();
let rng = (*global).clone();
// Jump to avoid overlapping.
global.jump();
rng
},
clock: self.clock.clone(),
})
}
}

99
flake.lock generated Normal file
View file

@ -0,0 +1,99 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1758264155,
"narHash": "sha256-sgg1sd/pYO9C7ccY9tAvR392CDscx8sqXrHkxspjIH0=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a7d9df0179fcc48259a68b358768024f8e5a6372",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1758198701,
"narHash": "sha256-7To75JlpekfUmdkUZewnT6MoBANS0XVypW6kjUOXQwc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0147c2f1d54b30b5dd6d4a8c8542e8d7edf93b5d",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1758224093,
"narHash": "sha256-buZMH6NgzSLowTda+aArct5ISsMR/S888EdFaqUvbog=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "958a8d06e3e5ba7dca7cc23b0639335071d65f2a",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

41
flake.nix Normal file
View file

@ -0,0 +1,41 @@
{
inputs = {
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "nixpkgs/nixos-unstable";
};
outputs = { flake-utils, fenix, nixpkgs, ... }:
flake-utils.lib.eachDefaultSystem(system:
let
overlays = [ fenix.overlays.default ];
pkgs = import nixpkgs { inherit system overlays; };
rust = with pkgs.fenix; combine [
((fromToolchainName { name = "1.87"; sha256 = "sha256-KUm16pHj+cRedf8vxs/Hd2YWxpOrWZ7UOrwhILdSJBU="; }).withComponents [
"cargo"
"rustc"
"rust-src"
"rust-analyzer"
"clippy"
"llvm-tools-preview"
])
default.rustfmt
];
llvm = pkgs.llvmPackages_20;
in
{
devShells.default = pkgs.mkShell {
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
packages = [ rust ] ++ (with pkgs; [
libclang.lib
]);
buildInputs = with pkgs; [
stdenv.cc.cc.lib
];
};
}
);
}

33
http/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "viendesu-http"
version = "0.1.0"
edition = "2024"
[features]
default = []
client = ["dep:reqwest"]
server = [
"dep:axum",
"dep:http-body-util",
"dep:tower-http",
"dep:serde_urlencoded",
]
[dependencies]
viendesu-core.workspace = true
http = "1.3.1"
serde.workspace = true
serde_with.workspace = true
serde_json.workspace = true
futures.workspace = true
eva.workspace = true
eyre.workspace = true
axum = { optional = true, version = "0.8.4", default-features = false, features = ["http2", "ws", "macros"] }
http-body-util = { version = "0.1.3", optional = true }
tower-http = { optional = true, version = "0.6.6", default-features = false, features = ["limit"] }
serde_urlencoded = { version = "0.7.1", optional = true }
reqwest = { optional = true, version = "0.12.23", default-features = false }

15
http/src/client/auth.rs Normal file
View file

@ -0,0 +1,15 @@
use viendesu_core::errors::AuxResult;
use super::*;
impl AuthenticationMut for Hidden<'_> {
async fn authenticate(&mut self, session: session::Token) -> AuxResult<()> {
self.0.session = Some(session);
Ok(())
}
fn clear(&mut self) {
self.0.session = None;
}
}

View file

@ -0,0 +1,23 @@
use super::*;
use viendesu_core::requests::authors::{create, get, update};
use crate::requests::authors as requests;
impl AuthorsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, |get::Args { author }| {
(format_compact!("/authors/{author}"), requests::Get {})
})
}
}
impl AuthorsMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Update>())
}
}

25
http/src/client/boards.rs Normal file
View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::boards::{create, delete, edit, get};
use crate::requests::boards as requests;
impl BoardsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
}
impl BoardsMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
}

25
http/src/client/games.rs Normal file
View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::games::{create, get, search, update};
use crate::requests::games as requests;
impl GamesRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err> {
self.do_call(Method::POST, todo::<_, requests::Search>())
}
}
impl GamesMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Update>())
}
}

View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::messages::{delete, edit, get, post};
use crate::requests::messages as requests;
impl MessagesRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
}
impl MessagesMut for Hidden<'_> {
fn post(&mut self) -> impl CallStep<post::Args, Ok = post::Ok, Err = post::Err> {
self.do_call(Method::POST, todo::<_, requests::Post>())
}
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
}

165
http/src/client/mod.rs Normal file
View file

@ -0,0 +1,165 @@
use std::sync::Arc;
use eva::{
error::ShitHappens,
str::{CompactString, format_compact},
};
use http::Method;
use viendesu_core::{
errors,
requests::Response,
service::{
CallStep, Session, SessionMaker,
authors::{AuthorsMut, AuthorsRef},
authz::AuthenticationMut,
boards::{BoardsMut, BoardsRef},
games::{GamesMut, GamesRef},
messages::{MessagesMut, MessagesRef},
threads::{ThreadsMut, ThreadsRef},
users::{UsersMut, UsersRef},
},
types::session,
};
use crate::{format::Format, requests::Request};
mod boards;
mod messages;
mod threads;
mod auth;
mod authors;
mod games;
mod users;
struct DoRequest<'c, P> {
client: &'c mut HttpClient,
method: Method,
map_payload: P,
}
impl<'c, P, C, H> CallStep<C> for DoRequest<'c, P>
where
C: Send + Sync,
P: Send + Sync + FnMut(C) -> (CompactString, H),
H: Request,
{
type Ok = H::Response;
type Err = H::Error;
async fn call(&mut self, req: C) -> Response<Self::Ok, Self::Err> {
let (path, request) = (self.map_payload)(req);
self.client
.do_request(self.method.clone(), &path, request)
.await
}
}
struct Hidden<'a>(&'a mut HttpClient);
fn todo<C, O>() -> impl Send + Sync + FnMut(C) -> (CompactString, O) {
|_| todo!()
}
impl<'t> Hidden<'t> {
fn do_call<'this, P>(&'this mut self, method: Method, map_payload: P) -> DoRequest<'this, P> {
DoRequest {
client: self.0,
method,
map_payload,
}
}
}
macro_rules! fwd {
($($method:ident -> $Ret:ident;)*) => {$(
fn $method(&mut self) -> impl $Ret {
Hidden(self)
}
)*};
}
pub struct HttpClient {
options: Arc<ClientOptions>,
client: reqwest::Client,
session: Option<session::Token>,
}
impl HttpClient {
async fn do_request<R>(
&self,
method: Method,
path: &str,
request: R,
) -> Response<R::Response, R::Error>
where
R: Request,
{
todo!()
}
}
impl Session for HttpClient {
fwd! {
users -> UsersRef;
users_mut -> UsersMut;
authors -> AuthorsRef;
authors_mut -> AuthorsMut;
games -> GamesRef;
games_mut -> GamesMut;
boards -> BoardsMut;
threads -> ThreadsMut;
messages -> MessagesMut;
authentication_mut -> AuthenticationMut;
}
}
pub struct ClientOptions {
pub format: Format,
pub endpoint: String,
}
#[derive(Clone)]
pub struct HttpService {
options: Arc<ClientOptions>,
inner: reqwest::Client,
}
impl SessionMaker for HttpService {
type Session = HttpClient;
async fn make_session(&self) -> errors::AuxResult<Self::Session> {
Ok(HttpClient {
options: Arc::clone(&self.options),
client: self.inner.clone(),
session: None,
})
}
}
impl HttpService {
pub fn new(options: ClientOptions) -> Self {
let inner = reqwest::Client::builder()
.user_agent("Makise/HTTPP")
.default_headers({
use reqwest::header;
let mut headers = header::HeaderMap::new();
headers.insert(
"Accept",
header::HeaderValue::from_static(options.format.mime_type()),
);
headers
})
.build()
.shit_happens();
Self {
inner,
options: Arc::new(options),
}
}
}

View file

@ -0,0 +1,29 @@
use super::*;
use viendesu_core::requests::threads::{create, delete, edit, get, search};
use crate::requests::threads as requests;
impl ThreadsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err> {
self.do_call(Method::POST, todo::<_, requests::Search>())
}
}
impl ThreadsMut for Hidden<'_> {
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
}

74
http/src/client/users.rs Normal file
View file

@ -0,0 +1,74 @@
use super::*;
use eva::str::format_compact;
use viendesu_core::requests::users::{check_auth, confirm_sign_up, get, sign_in, sign_up, update};
use crate::requests::users as requests;
impl UsersRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, |get::Args { user }| match user {
Some(u) => (format_compact!("/users/{u}"), requests::Get {}),
None => ("/users/me".into(), requests::Get {}),
})
}
fn check_auth(
&mut self,
) -> impl CallStep<check_auth::Args, Ok = check_auth::Ok, Err = check_auth::Err> {
self.do_call(Method::GET, |check_auth::Args {}| {
("/users/check_auth".into(), requests::CheckAuth {})
})
}
}
impl UsersMut for Hidden<'_> {
fn confirm_sign_up(
&mut self,
) -> impl CallStep<confirm_sign_up::Args, Ok = confirm_sign_up::Ok, Err = confirm_sign_up::Err>
{
self.do_call(Method::POST, todo::<_, requests::ConfirmSignUp>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, |update::Args { user, update }| match user {
Some(u) => (
format_compact!("/users/{u}"),
requests::Update::from(update),
),
None => ("/users/me".into(), requests::Update::from(update)),
})
}
fn sign_up(&mut self) -> impl CallStep<sign_up::Args, Ok = sign_up::Ok, Err = sign_up::Err> {
self.do_call(
Method::POST,
|sign_up::Args {
nickname,
email,
display_name,
password,
}| {
(
"/users/sign_up".into(),
requests::SignUp {
nickname,
email,
password,
display_name,
},
)
},
)
}
fn sign_in(&mut self) -> impl CallStep<sign_in::Args, Ok = sign_in::Ok, Err = sign_in::Err> {
self.do_call(Method::POST, |sign_in::Args { nickname, password }| {
(
"/users/sign_in".into(),
requests::SignIn { nickname, password },
)
})
}
}

63
http/src/format.rs Normal file
View file

@ -0,0 +1,63 @@
use eva::{data, error::ShitHappens};
use eyre::Context;
#[data(copy, display("got unsupported mime type"), error)]
pub struct UnknownMimeType;
#[data(copy, display("{}", self.mime_type()))]
#[derive(Default)]
pub enum Format {
#[default]
Json,
}
impl Format {
pub fn from_mime_type(mime: &str) -> Result<Self, UnknownMimeType> {
use Format::*;
use UnknownMimeType as E;
match mime {
"application/json" => Ok(Json),
_ => Err(E),
}
}
pub const fn mime_type(self) -> &'static str {
use Format::*;
match self {
Json => "application/json",
}
}
}
#[data]
#[derive(Default)]
pub struct DumpParams {
pub pretty: bool,
}
impl Format {
pub fn dump<T: ?Sized>(self, params: DumpParams, what: &T, dst: &mut Vec<u8>)
where
T: serde::Serialize,
{
match self {
Self::Json => if params.pretty {
serde_json::to_writer_pretty(dst, what)
} else {
serde_json::to_writer(dst, what)
}
.shit_happens(),
}
}
pub fn load<'de, T>(&self, buf: &'de [u8]) -> eyre::Result<T>
where
T: serde::Deserialize<'de>,
{
match self {
Self::Json => serde_json::from_slice(buf).wrap_err("failed to deserialize JSON"),
}
}
}

7
http/src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "server")]
pub mod server;
pub mod format;
pub mod requests;

View file

@ -0,0 +1,53 @@
use eva::data;
use viendesu_core::{
errors,
requests::authors as reqs,
types::{Patch, author, file, user},
};
use crate::requests::status_code;
#[serde_with::apply(Patch => #[serde(default)])]
#[data]
pub struct Update {
pub title: Patch<author::Title>,
pub description: Patch<Option<author::Description>>,
pub pfp: Patch<Option<file::Id>>,
pub slug: Patch<author::Slug>,
pub verified: Patch<bool>,
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
#[data]
pub struct Create {
pub title: author::Title,
pub slug: author::Slug,
pub description: Option<author::Description>,
pub owner: Option<user::Id>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => OK);
status_code::map!(reqs::create::Err => [NotFound, AlreadyExists, NoSuchUser]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
const _: () = {
use errors::authors::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(AlreadyExists => BAD_REQUEST);
};

View file

@ -0,0 +1,56 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::boards as reqs,
types::{Patch, board, message},
};
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
#[data]
pub struct Edit {
pub text: Patch<message::Text>,
pub slug: Patch<Option<board::Slug>>,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Create {
pub slug: board::Slug,
pub initial_message: message::Text,
pub by: Option<message::ById>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => OK);
status_code::map!(reqs::create::Err => [AlreadyExists]);
const _: () = {
use errors::boards::*;
use status_code::direct;
direct!(AlreadyExists => BAD_REQUEST);
direct!(NotFound => NOT_FOUND);
};

View file

@ -0,0 +1,81 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::games as reqs,
types::{Patch, author, file, game},
};
#[data]
#[serde_with::apply(Patch => #[serde(default)])]
pub struct Update {
pub title: Patch<game::Title>,
pub description: Patch<Option<game::Description>>,
pub slug: Patch<game::Slug>,
pub thumbnail: Patch<Option<file::Id>>,
pub genres: Patch<game::Genres>,
pub badges: Patch<game::Badges>,
pub tags: Patch<game::Tags>,
pub screenshots: Patch<game::Screenshots>,
pub published: Patch<bool>,
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
#[data]
pub struct Search {
pub query: Option<game::SearchQuery>,
pub author: Option<author::Selector>,
#[serde(default)]
pub include: reqs::search::Condition,
#[serde(default)]
pub exclude: reqs::search::Condition,
#[serde(default)]
pub order: reqs::search::Order,
#[serde(default)]
pub sort_by: reqs::search::SortBy,
pub limit: Option<reqs::search::Limit>,
}
impl_req!(Search => [reqs::search::Ok; reqs::search::Err]);
status_code::direct!(reqs::search::Ok => OK);
status_code::map!(reqs::search::Err => [NoSuchAuthor]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound, NoSuchAuthor]);
#[data]
pub struct Create {
pub title: game::Title,
pub description: Option<game::Description>,
pub thumbnail: Option<file::Id>,
pub author: author::Id,
pub slug: Option<game::Slug>,
pub vndb: Option<game::VndbId>,
pub release_date: Option<game::ReleaseDate>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => CREATED);
status_code::map!(reqs::create::Err => [AlreadyTaken, NoSuchAuthor]);
const _: () = {
use errors::games::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(NotAnOwner => FORBIDDEN);
direct!(AlreadyTaken => BAD_REQUEST);
};

View file

@ -0,0 +1,53 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::messages as reqs,
types::{message, thread},
};
#[data]
pub struct Edit {
pub text: message::Text,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Post {
pub thread: thread::Selector,
pub text: message::Text,
}
impl_req!(Post => [reqs::post::Ok; reqs::post::Err]);
status_code::direct!(reqs::post::Ok => OK);
status_code::map!(reqs::post::Err => [NoSuchThread]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
const _: () = {
use errors::messages::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
};

32
http/src/requests/mod.rs Normal file
View file

@ -0,0 +1,32 @@
pub mod status_code;
pub trait Request: Send + Sync + 'static + for<'de> serde::Deserialize<'de> {
type Response: IsResponse;
type Error: IsResponse;
}
eva::trait_set! {
pub trait IsResponse = status_code::HasStatusCode + serde::Serialize + Send + Sync + 'static;
}
macro_rules! impl_req {
($Input:ty => [$Ok:ty; $Err:ty]) => {
const _: () = {
use $crate::requests::Request;
impl Request for $Input {
type Response = $Ok;
type Error = $Err;
}
};
};
}
pub mod users;
pub mod authors;
pub mod games;
pub mod boards;
pub mod messages;
pub mod threads;

View file

@ -0,0 +1,81 @@
use http::status::StatusCode;
use viendesu_core::errors;
pub trait HasStatusCode {
fn status_code(&self) -> StatusCode;
}
impl<O: HasStatusCode, E: HasStatusCode> HasStatusCode for Result<O, E> {
fn status_code(&self) -> StatusCode {
match self {
Ok(o) => o.status_code(),
Err(e) => e.status_code(),
}
}
}
impl<S: HasStatusCode> HasStatusCode for errors::Generic<S> {
fn status_code(&self) -> StatusCode {
match self {
Self::Aux(aux) => aux.status_code(),
Self::Spec(spec) => spec.status_code(),
}
}
}
impl HasStatusCode for errors::Aux {
fn status_code(&self) -> StatusCode {
use StatusCode as C;
use errors::Aux::*;
match self {
Unauthenticated => C::UNAUTHORIZED,
InvalidRole(e) => e.status_code(),
InvalidSession(e) => e.status_code(),
Captcha(..) | Deserialization(..) => C::BAD_REQUEST,
Db(..) | InternalError(..) | ObjectStore(..) | Mail(..) => C::INTERNAL_SERVER_ERROR,
}
}
}
macro_rules! map {
($Ty:ty => [$($Item:ident),* $(,)?]) => {
const _: () = {
use $crate::requests::status_code::HasStatusCode;
use ::http::status::StatusCode;
impl HasStatusCode for $Ty {
fn status_code(&self) -> StatusCode {
match *self {$(
Self::$Item(ref e) => e.status_code(),
)*}
}
}
};
};
}
pub(crate) use map;
macro_rules! direct {
($($ty:ty => $code:ident),* $(,)?) => {$(
const _: () = {
use $crate::requests::status_code::HasStatusCode;
use ::http::status::StatusCode;
impl HasStatusCode for $ty {
fn status_code(&self) -> StatusCode {
StatusCode::$code
}
}
};
)*};
}
pub(crate) use direct;
direct! {
errors::auth::InvalidRole => FORBIDDEN,
errors::auth::InvalidSession => BAD_REQUEST,
}

View file

@ -0,0 +1,66 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::threads as reqs,
types::{Patch, board, message, thread},
};
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Edit {
pub text: Patch<message::Text>,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound, NotAnOwner]);
#[data]
pub struct Search {
#[serde(default)]
pub limit: reqs::search::Limit,
pub after: Option<thread::Id>,
}
impl_req!(Search => [reqs::search::Ok; reqs::search::Err]);
status_code::direct!(reqs::search::Ok => OK);
status_code::map!(reqs::search::Err => []);
#[data]
pub struct Create {
pub board: board::Selector,
pub initial_message: message::Text,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => CREATED);
status_code::map!(reqs::create::Err => [NoSuchBoard]);
const _: () = {
use errors::threads::*;
use status_code::direct;
direct!(NotAnOwner => FORBIDDEN);
direct!(NotFound => NOT_FOUND);
};

102
http/src/requests/users.rs Normal file
View file

@ -0,0 +1,102 @@
use eva::data;
use viendesu_core::{
errors,
requests::users as reqs,
types::{Patch, file, user as core},
};
use super::status_code;
#[data]
pub struct CheckAuth {}
impl_req!(CheckAuth => [reqs::check_auth::Ok; reqs::check_auth::Err]);
status_code::direct!(reqs::check_auth::Ok => OK);
status_code::map!(reqs::check_auth::Err => []);
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::map!(reqs::get::Err => [NotFound]);
status_code::direct!(reqs::get::Ok => OK);
#[data]
pub struct SignIn {
pub nickname: core::Nickname,
pub password: core::Password,
}
impl_req!(SignIn => [reqs::sign_in::Ok; reqs::sign_in::Err]);
status_code::map!(reqs::sign_in::Err => [NotFound, InvalidPassword, MustCompleteSignUp]);
status_code::direct!(reqs::sign_in::Ok => OK);
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct ConfirmSignUp {}
impl_req!(ConfirmSignUp => [reqs::confirm_sign_up::Ok; reqs::confirm_sign_up::Err]);
status_code::direct!(reqs::confirm_sign_up::Ok => OK);
status_code::map!(reqs::confirm_sign_up::Err => [NotFoundOrCompleted, InvalidSignUpToken]);
#[data]
pub struct SignUp {
pub nickname: core::Nickname,
pub email: core::Email,
pub password: core::Password,
pub display_name: Option<core::DisplayName>,
}
impl_req!(SignUp => [reqs::sign_up::Ok; reqs::sign_up::Err]);
status_code::direct!(reqs::sign_up::Ok => CREATED);
status_code::map!(reqs::sign_up::Err => [AlreadyTaken]);
#[serde_with::apply(
Patch => #[serde(default)]
)]
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct Update {
pub nickname: Patch<core::Nickname>,
pub display_name: Patch<Option<core::DisplayName>>,
pub bio: Patch<Option<core::Bio>>,
pub password: Patch<core::Password>,
pub role: Patch<core::Role>,
pub pfp: Patch<Option<file::Id>>,
pub email: Patch<core::Email>,
}
impl From<reqs::update::Update> for Update {
fn from(u: reqs::update::Update) -> Self {
Self {
nickname: u.nickname,
display_name: u.display_name,
bio: u.bio,
password: u.password,
role: u.role,
pfp: u.pfp,
email: u.email,
}
}
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
const _: () = {
use errors::users::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(InvalidPassword => FORBIDDEN);
direct!(MustCompleteSignUp => FORBIDDEN);
direct!(AlreadyTaken => BAD_REQUEST);
direct!(NotFoundOrCompleted => BAD_REQUEST);
direct!(InvalidSignUpToken => BAD_REQUEST);
};

21
http/src/server/config.rs Normal file
View file

@ -0,0 +1,21 @@
use std::net::SocketAddr;
use eva::data;
#[data]
pub struct Http {
pub enable: bool,
pub listen: SocketAddr,
}
#[data]
pub struct Ssl {
pub enable: bool,
pub listen: SocketAddr,
}
#[data]
pub struct Config {
pub unencrypted: Option<Http>,
pub ssl: Option<Ssl>,
}

View file

@ -0,0 +1,85 @@
use crate::{
format::Format,
server::{
request::{ServerRequest, extract},
response,
},
};
use axum::{
RequestExt,
extract::Request as AxumRequest,
http::{Method, request::Parts},
response::Response as AxumResponse,
};
use futures::StreamExt;
use serde::Deserialize;
use viendesu_core::errors::{Aux, AuxResult};
#[non_exhaustive]
pub struct Context<R: ServerRequest> {
pub request: R,
pub parts: Parts,
pub response_format: Format,
}
impl<R: ServerRequest> Context<R> {
pub async fn path<P>(&mut self) -> AuxResult<P>
where
P: for<'de> Deserialize<'de> + Send + 'static,
{
extract::path(&mut self.parts).await
}
pub fn query<'this, T: serde::Deserialize<'this>>(&'this self) -> AuxResult<T> {
extract::query(&self.parts)
}
}
pub async fn load_args<R: ServerRequest>(req: AxumRequest) -> Result<Context<R>, AxumResponse> {
let (parts, body) = req.with_limited_body().into_parts();
let response_format =
extract::response_format(&parts).map_err(|e| response::err(Format::default(), e))?;
let request_format =
extract::request_format(&parts).map_err(|e| response::err(Format::default(), e))?;
let request: R = if parts.method == Method::GET {
let query = parts.uri.query().unwrap_or("");
serde_urlencoded::from_str(query)
.map_err(|e| Aux::Deserialization(format!("failed to decode query: {e}")))
.map_err(|e| response::err(response_format, e))?
} else {
let content_length =
extract::content_length(&parts).map_err(|e| response::err(response_format, e))?;
let mut data_stream = body.into_data_stream();
let mut buffer = Vec::with_capacity(content_length);
while let Some(frame) = data_stream.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => {
return Err(response::err(
response_format,
Aux::Deserialization(format!("failed to read body frame: {e}")),
));
}
};
buffer.extend_from_slice(&frame);
}
request_format
.load(&buffer)
.map_err(|e| Aux::Deserialization(format!("failed to deserialize body: {e}")))
.map_err(|e| response::err(response_format, e))?
};
Ok(Context {
request,
parts,
response_format,
})
}

250
http/src/server/handler.rs Normal file
View file

@ -0,0 +1,250 @@
use eva::fut::Fut;
use std::{marker::PhantomData, sync::Arc};
use axum::{
Router as AxumRouter,
extract::Request as AxumRequest,
response::Response as AxumResponse,
routing::{MethodFilter, method_routing},
};
use viendesu_core::{errors as core_errors, service::SessionOf};
use crate::{
format::Format,
server::{
State, Types,
context::{Context, load_args},
request::{ServerRequest, extract},
response,
},
};
struct Inner<R: ServerRequest, Cx> {
make_context: Cx,
method: MethodFilter,
_phantom: PhantomData<R>,
}
pub struct RouterScope<T: Types> {
router: AxumRouter,
state: State<T>,
}
impl<T: Types> RouterScope<T> {
pub fn root(state: State<T>) -> Self {
Self {
router: AxumRouter::new(),
state,
}
}
pub fn include(self, f: impl FnOnce(RouterScope<T>) -> RouterScope<T>) -> RouterScope<T> {
f(self)
}
pub fn route<R: ServerRequest, M, Cx>(
self,
path: &str,
handler: FinishedHandler<R, M, T, Cx>,
) -> RouterScope<T>
where
M: MakeRequest<T, R>,
Cx: MakeContext<R>,
{
let Self { mut router, state } = self;
router = router.route(
path,
method_routing::on(
handler.inner.method,
handler.into_axum_handler(state.clone()),
),
);
Self { router, state }
}
pub fn nest(
self,
path: &str,
f: impl FnOnce(RouterScope<T>) -> RouterScope<T>,
) -> RouterScope<T> {
let nested = f(RouterScope {
router: AxumRouter::new(),
state: self.state.clone(),
});
let Self { mut router, state } = self;
router = router.nest(path, nested.router);
Self { router, state }
}
pub fn into_axum(self) -> AxumRouter {
self.router
}
}
pub struct FinishedHandler<R: ServerRequest, M, T: Types, Cx> {
inner: Inner<R, Cx>,
make_request: M,
_phantom: PhantomData<T>,
}
impl<R: ServerRequest, M, T: Types, Cx: MakeContext<R>> FinishedHandler<R, M, T, Cx> {
fn into_axum_handler(self, state: State<T>) -> impl AxumHandler
where
M: MakeRequest<T, R>,
{
let Self {
inner,
make_request,
_phantom: _,
} = self;
let captures = Arc::new((make_request, inner, state));
move |req: AxumRequest| {
let captures = captures.clone();
async move {
let (make_request, inner, state) = Arc::as_ref(&captures);
let Inner {
make_context,
method: _,
_phantom: _,
} = inner;
let (parts, body) = req.into_parts();
let context = make_context(AxumRequest::from_parts(parts, body)).await?;
let resp_format = context.response_format;
let session = state
.make_session()
.await
.map_err(|e| response::err(resp_format, e))?;
match make_request(session, context).await {
Ok(r) => Ok(response::ok(resp_format, r)),
Err(e) => Err(response::err(resp_format, e)),
}
}
}
}
}
pub fn get<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::get(load_args::<R>).exec(make_request)
}
pub fn post<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::post(load_args::<R>).exec(make_request)
}
pub fn patch<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::patch(load_args::<R>).exec(make_request)
}
pub struct Handler<R: ServerRequest, Cx>(Inner<R, Cx>);
impl<R: ServerRequest, Cx: MakeContext<R>> Handler<R, Cx> {
pub fn get(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::GET,
_phantom: PhantomData,
})
}
pub fn post(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::POST,
_phantom: PhantomData,
})
}
pub fn patch(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::PATCH,
_phantom: PhantomData,
})
}
pub fn exec<T, M>(self, make_request: M) -> FinishedHandler<R, M, T, Cx>
where
T: Types,
M: MakeRequest<T, R>,
{
let Self(inner) = self;
FinishedHandler {
inner,
make_request,
_phantom: PhantomData,
}
}
}
// == Complex trait magic ==
pub trait AxumHandler: Send + Sync + 'static + Clone + Fn(AxumRequest) -> Self::AFut {
#[doc(hidden)]
type AFut: Fut<Output = Result<AxumResponse, AxumResponse>>;
}
impl<H, F> AxumHandler for H
where
H: Clone + Send + Sync + 'static + Fn(AxumRequest) -> F,
F: Fut<Output = Result<AxumResponse, AxumResponse>>,
{
type AFut = F;
}
pub trait Captures<T: ?Sized> {}
impl<T: ?Sized, U: ?Sized> Captures<T> for U {}
pub trait MakeContext<R: ServerRequest>:
Send + Sync + 'static + Fn(AxumRequest) -> Self::MkFut
{
type MkFut: Fut<Output = Result<Context<R>, AxumResponse>>;
}
impl<R: ServerRequest, H, F> MakeContext<R> for H
where
H: Send + Sync + 'static + Fn(AxumRequest) -> F,
F: Fut<Output = Result<Context<R>, AxumResponse>>,
{
type MkFut = F;
}
pub trait MakeRequest<T: Types, R: ServerRequest>:
Send + Sync + 'static + Fn(SessionOf<T::Service>, Context<R>) -> Self::HFut
{
type HFut: Fut<Output = core_errors::Result<R::Response, R::Error>>
+ Captures<SessionOf<T::Service>>;
}
impl<T, R, F, H> MakeRequest<T, R> for H
where
H: Send + Sync + 'static + Fn(SessionOf<T::Service>, Context<R>) -> F,
F: Fut<Output = core_errors::Result<R::Response, R::Error>> + Captures<SessionOf<T::Service>>,
T: Types,
R: ServerRequest,
{
type HFut = F;
}

35
http/src/server/mod.rs Normal file
View file

@ -0,0 +1,35 @@
use eva::perfect_derive;
use viendesu_core::{
errors::AuxResult,
service::{Service, SessionMaker, SessionOf},
};
pub mod config;
mod context;
mod handler;
mod request;
mod response;
mod routes;
pub trait Types: Send + Sync + 'static {
type Service: Service + Clone;
}
pub fn make_router<T: Types>(service: T::Service) -> axum::Router {
let scope = handler::RouterScope::root(State::<T> { service });
routes::make(scope).into_axum()
}
#[perfect_derive(Clone)]
struct State<T: Types> {
service: T::Service,
}
impl<T: Types> State<T> {
async fn make_session(&self) -> AuxResult<SessionOf<T::Service>> {
self.service.make_session().await
}
}

View file

@ -0,0 +1,26 @@
use eva::{data, trait_set};
use axum::http::request::Parts;
use viendesu_core::errors::AuxResult;
use crate::{format::Format, requests::Request};
pub mod extract;
#[data]
pub struct MetaInfo {
pub request_format: Format,
}
impl MetaInfo {
pub fn gather(parts: &Parts) -> AuxResult<Self> {
let request_format = extract::request_format(parts)?;
Ok(Self { request_format })
}
}
trait_set! {
pub trait ServerRequest = Request;
}

View file

@ -0,0 +1,132 @@
use axum::{
extract::{FromRequest, FromRequestParts, Path, Request},
http::{HeaderValue, request::Parts},
};
use eva::bytes::Bytes;
use viendesu_core::{
errors::{Aux, AuxResult},
types::{captcha, session},
};
use crate::format::Format;
pub async fn read_body<R: serde::de::DeserializeOwned>(
format: Format,
request: Request,
) -> AuxResult<R> {
let raw_body = read_raw_body(request).await?;
format
.load(&raw_body)
.map_err(|e| Aux::Deserialization(format!("failed to deserialize body: {e:#}")))
}
pub fn raw_query(parts: &Parts) -> &str {
parts.uri.query().unwrap_or("")
}
pub fn query<'de, T: serde::de::Deserialize<'de>>(parts: &'de Parts) -> AuxResult<T> {
let raw = raw_query(parts);
serde_urlencoded::from_str(raw)
.map_err(|e| Aux::Deserialization(format!("failed to decode query string: {e}")))
}
pub async fn read_raw_body(request: Request) -> AuxResult<Bytes> {
match Bytes::from_request(request, &()).await {
Ok(r) => Ok(r),
Err(e) => Err(Aux::Deserialization(format!("failed to read body: {e}"))),
}
}
pub fn session_token(parts: &Parts) -> AuxResult<Option<session::Token>> {
let Some(val) = str_header(parts, "authorization")? else {
return Ok(None);
};
let Some((scheme, rest)) = val.split_once(' ') else {
return Err(Aux::Deserialization(format!(
"invalid Authorization header format, expected `<scheme> <rest>`"
)));
};
match scheme {
"Bearer" => rest
.parse()
.map(Some)
.map_err(|e| Aux::Deserialization(format!("invalid session token: {e}"))),
_ => Err(Aux::Deserialization(format!(
"scheme {scheme:?} is not supported"
))),
}
}
pub fn captcha(parts: &Parts) -> AuxResult<Option<captcha::Token>> {
let Some(raw_token) = str_header(parts, "x-challenge")? else {
return Ok(None);
};
match raw_token.parse() {
Ok(r) => Ok(Some(r)),
Err(e) => Err(Aux::Deserialization(format!(
"invalid X-Challenge header: {e}"
))),
}
}
pub fn str_header<'p>(parts: &'p Parts, header: &str) -> AuxResult<Option<&'p str>> {
let Some(value) = raw_header(parts, header) else {
return Ok(None);
};
let value = value.to_str().map_err(|e| {
Aux::Deserialization(format!(
"failed to decode UTF-8 content of header {header:?}: {e}"
))
})?;
Ok(Some(value))
}
pub fn raw_header<'p>(parts: &'p Parts, header: &str) -> Option<&'p HeaderValue> {
parts.headers.get(header)
}
pub fn request_format(parts: &Parts) -> AuxResult<Format> {
let Some(raw) = str_header(parts, "content-type")? else {
return Err(Aux::Deserialization(
"`Content-Type` header is required".into(),
));
};
Format::from_mime_type(raw)
.map_err(|e| Aux::Deserialization(format!("failed to parse `Content-Type` header: {e}")))
}
pub fn response_format(parts: &Parts) -> AuxResult<Format> {
let Some(raw) = str_header(parts, "accept")? else {
return Ok(Format::Json);
};
Format::from_mime_type(raw)
.map_err(|e| Aux::Deserialization(format!("failed to parse `Accept` header: {e}")))
}
pub fn content_length(parts: &Parts) -> AuxResult<usize> {
let Some(raw) = str_header(parts, "content-length")? else {
return Ok(0);
};
let content_length: usize = raw
.parse()
.map_err(|e| Aux::Deserialization(format!("failed to decode content length: {e}")))?;
Ok(content_length)
}
pub async fn path<T>(parts: &mut Parts) -> AuxResult<T>
where
T: serde::de::DeserializeOwned + Send + 'static,
{
let response = Path::<T>::from_request_parts(parts, &()).await;
match response {
Ok(Path(r)) => Ok(r),
Err(rej) => Err(Aux::Deserialization(format!("failed to parse path: {rej}"))),
}
}

View file

@ -0,0 +1,64 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response as AxumResponse},
};
use serde::Serialize;
use crate::format::{DumpParams, Format};
use crate::requests::{IsResponse, status_code::HasStatusCode};
macro_rules! header {
($name:expr => $value:expr) => {
(
axum::http::HeaderName::from_static($name),
axum::http::HeaderValue::from_static($value),
)
};
}
#[track_caller]
pub fn err<E: IsResponse>(format: Format, error: E) -> AxumResponse {
#[derive(Serialize)]
struct Failure<E> {
pub error: E,
}
impl<T: HasStatusCode> HasStatusCode for Failure<T> {
fn status_code(&self) -> StatusCode {
self.error.status_code()
}
}
respond(format, Failure { error })
}
#[track_caller]
pub fn ok<O: IsResponse>(format: Format, ok: O) -> AxumResponse {
#[derive(Serialize)]
struct Success<T> {
ok: T,
}
impl<T: HasStatusCode> HasStatusCode for Success<T> {
fn status_code(&self) -> StatusCode {
self.ok.status_code()
}
}
respond(format, Success { ok })
}
#[track_caller]
pub fn respond<O: IsResponse>(format: Format, res: O) -> AxumResponse {
let status_code = res.status_code();
let headers = [
header!("Server" => "Kurisu-desu"),
header!("Content-Type" => format.mime_type()),
];
let mut body = Vec::with_capacity(128);
let dump_params = DumpParams { pretty: false };
format.dump(dump_params, &res, &mut body);
(status_code, headers, body).into_response()
}

167
http/src/server/routes.rs Normal file
View file

@ -0,0 +1,167 @@
use crate::server::{
Types,
context::Context as Ctx,
handler::{RouterScope, get, patch, post},
};
use viendesu_core::{
service::{
CallStep, Session, SessionOf as SessionOfService,
users::{UsersMut, UsersRef},
},
types::user,
};
type SessionOf<T> = SessionOfService<<T as Types>::Service>;
pub fn make<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
.nest("/users", users)
.nest("/authors", authors)
.nest("/games", games)
.nest("/boards", boards)
.nest("/threads", threads)
.nest("/messages", messages)
}
// == Routes ==
fn users<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
use crate::requests::users::{CheckAuth, ConfirmSignUp, Get, SignIn, SignUp, Update};
use viendesu_core::requests::users::{
check_auth, confirm_sign_up, get, sign_in, sign_up, update,
};
fn convert_update(u: Update) -> update::Update {
update::Update {
nickname: u.nickname,
display_name: u.display_name,
bio: u.bio,
password: u.password,
role: u.role,
pfp: u.pfp,
email: u.email,
}
}
router
.route(
"/{sel}",
get(async |mut session: SessionOf<T>, mut ctx: Ctx<Get>| {
let selector: user::Selector = ctx.path().await?;
session
.users()
.get()
.call(get::Args {
user: Some(selector),
})
.await
}),
)
.route(
"/check_auth",
get(async |mut session: SessionOf<T>, _ctx: Ctx<CheckAuth>| {
session.users().check_auth().call(check_auth::Args {}).await
}),
)
.route(
"/me",
get(async |mut session: SessionOf<T>, _ctx: Ctx<Get>| {
session.users().get().call(get::Args { user: None }).await
}),
)
.route(
"/sign_in",
post(async |mut session: SessionOf<T>, ctx: Ctx<SignIn>| {
session
.users_mut()
.sign_in()
.call(sign_in::Args {
nickname: ctx.request.nickname,
password: ctx.request.password,
})
.await
}),
)
.route(
"/sign_up",
post(async |mut session: SessionOf<T>, ctx: Ctx<SignUp>| {
let SignUp {
nickname,
email,
password,
display_name,
} = ctx.request;
session
.users_mut()
.sign_up()
.call(sign_up::Args {
nickname,
email,
display_name,
password,
})
.await
}),
)
.route(
"/{sel}",
patch(async |mut session: SessionOf<T>, mut ctx: Ctx<Update>| {
let user: user::Selector = ctx.path().await?;
session
.users_mut()
.update()
.call(update::Args {
user: Some(user),
update: convert_update(ctx.request),
})
.await
}),
)
.route(
"/me",
patch(async |mut session: SessionOf<T>, ctx: Ctx<Update>| {
session
.users_mut()
.update()
.call(update::Args {
user: None,
update: convert_update(ctx.request),
})
.await
}),
)
.route(
"/{user}/confirm/{token}",
post(
async |mut session: SessionOf<T>, mut ctx: Ctx<ConfirmSignUp>| {
let (user, token) = ctx.path().await?;
session
.users_mut()
.confirm_sign_up()
.call(confirm_sign_up::Args { user, token })
.await
},
),
)
}
fn authors<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn games<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn boards<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn threads<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn messages<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}

1
http/tests/it/main.rs Normal file
View file

@ -0,0 +1 @@