This commit is contained in:
root
2025-12-14 10:39:18 +03:00
commit 639f4e2b4e
179 changed files with 21065 additions and 0 deletions

3
dollhouse/.env Executable file
View File

@@ -0,0 +1,3 @@
POSTGRES_USER=dollhouse_user
POSTGRES_PASSWORD=hahahadollhouse
POSTGRES_DB=dollhouse_db

3770
dollhouse/Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

12
dollhouse/Cargo.toml Executable file
View File

@@ -0,0 +1,12 @@
[workspace]
members = [
"crates/dollhouse-backend",
"crates/dollhouse-db",
"crates/dollhouse-frontend",
"crates/dollhouse-api-types",
]
resolver = "2"
[workspace.dependencies]
thiserror = "2.0.17"
uuid = { version = "1.3.0", features = ["v4", "v1", "serde", "js", "rng"] }

80
dollhouse/compose.yaml Executable file
View File

@@ -0,0 +1,80 @@
name: dollhouse
services:
base:
build:
context: .
dockerfile: docker/Dockerfile.base
image: dollhouse-base:latest
backend:
restart: unless-stopped
build:
context: .
dockerfile: docker/dollhouse-backend/Dockerfile
depends_on:
db:
condition: service_healthy
base:
condition: service_completed_successfully
environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
volumes:
- dollhouse-firmwares:/app/firmware
networks:
- dollhouse-network
frontend:
build:
context: .
dockerfile: docker/dollhouse-frontend/Dockerfile
depends_on:
- base
- backend
ports:
- "3000:3000"
networks:
- dollhouse-network
db:
image: postgres:17.2
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_MULTIPLE_USERS: "yes"
command: |
postgres
-c shared_preload_libraries=pg_stat_statements
-c pg_stat_statements.track=all
volumes:
- dollhouse-postgres-data:/var/lib/postgresql/data/pgdata
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 30s
timeout: 10s
retries: 5
tty: true
networks:
- dollhouse-network
cleaner:
build:
context: docker/dollhouse-cleaner
restart: unless-stopped
depends_on:
- db
volumes:
- dollhouse-firmwares:/firmware
environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
networks:
- dollhouse-network
networks:
dollhouse-network:
volumes:
dollhouse-postgres-data:
dollhouse-firmwares:

View File

@@ -0,0 +1,9 @@
[package]
name = "dollhouse-api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
validator = { version = "0.20.0", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
uuid = { workspace = true }

View File

@@ -0,0 +1,163 @@
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ReplicantGender {
Male,
Female,
NonBinary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ReplicantStatus {
Active,
Decommissioned,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReplicantResponse {
pub id: Uuid,
pub name: String,
pub description: String,
pub status: ReplicantStatus,
pub gender: ReplicantGender,
pub is_private: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReplicantFullResponse {
pub id: Uuid,
pub name: String,
pub description: String,
pub status: ReplicantStatus,
pub gender: ReplicantGender,
pub firmware_file: Option<String>,
pub is_private: bool,
pub corp_id: Uuid,
pub health: i32,
pub strength: i32,
pub intelligence: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
CorpAdmin,
User,
}
impl TryFrom<String> for UserRole {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"corp_admin" => Ok(UserRole::CorpAdmin),
"user" => Ok(UserRole::User),
_ => Err("Invalid user role"),
}
}
}
impl TryInto<String> for UserRole {
type Error = &'static str;
fn try_into(self) -> Result<String, Self::Error> {
match self {
UserRole::CorpAdmin => Ok("corp_admin".to_string()),
UserRole::User => Ok("user".to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CreateUserRequest {
#[validate(length(min = 5, max = 50))]
pub username: String,
#[validate(length(min = 8))]
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CreateCorpRequest {
#[validate(length(min = 5, max = 50))]
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CreateReplicantRequest {
#[validate(length(min = 5, max = 50))]
pub name: String,
pub description: String,
pub gender: ReplicantGender,
pub corp_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReplicantResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub gender: ReplicantGender,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(length(min = 5, max = 50))]
pub username: String,
#[validate(length(min = 12, max = 50))]
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UserResponse {
pub id: Uuid,
pub role: UserRole,
pub username: String,
pub corp_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaffResponse {
pub id: Uuid,
pub role: UserRole,
pub username: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CorpResponse {
pub id: Uuid,
pub name: String,
pub description: String,
pub staff: Vec<StaffResponse>,
pub invite_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InviteCodeResponse {
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JoinCorpRequest {
pub invite_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChangePrivacyRequest {
pub is_private: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChangeReplicantOwnerRequest {
pub new_corp: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FirmwareOutputResponse {
pub output: String,
}

View File

@@ -0,0 +1,26 @@
[package]
name = "dollhouse-backend"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-cors = "0.7.1"
dollhouse-db = { path = "../dollhouse-db" }
dollhouse-api-types = { path = "../dollhouse-api-types" }
actix-session = { version = "0.11.0", features = ["cookie-session"] }
validator = { version = "0.20.0", features = ["derive"] }
actix-web = "4.11.0"
argon2 = "0.5.3"
env_logger = "0.11.8"
log = "0.4.28"
serde = "1.0.228"
serde_json = "1.0.145"
thiserror = "2.0.17"
tokio = "1.48.0"
mlua = { version = "0.11.4", features = ["lua53", "async", "send"] }
rand = "0.9.2"
actix-multipart = "0.7.2"
uuid = { workspace = true }
base64 = "0.22.1"
chrono = "0.4.42"
sha2 = "0.10.9"

View File

@@ -0,0 +1,159 @@
use dollhouse_api_types::ReplicantGender as ApiReplicantGender;
use dollhouse_api_types::ReplicantStatus as ApiReplicantStatus;
use dollhouse_api_types::UserRole as ApiUserRole;
use dollhouse_db::ReplicantGender as DbReplicantGender;
use dollhouse_db::ReplicantStatus as DbReplicantStatus;
use dollhouse_db::UserRole as DbUserRole;
pub trait UserRoleConvert {
fn to_db_role(&self) -> Result<DbUserRole, &'static str>;
fn to_api_role(&self) -> Result<ApiUserRole, &'static str>;
}
impl UserRoleConvert for DbUserRole {
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
Ok(self.clone())
}
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
match self {
DbUserRole::CorpAdmin => Ok(ApiUserRole::CorpAdmin),
DbUserRole::User => Ok(ApiUserRole::User),
_ => Err("Unknown user role"),
}
}
}
impl UserRoleConvert for ApiUserRole {
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
match self {
ApiUserRole::CorpAdmin => Ok(DbUserRole::CorpAdmin),
ApiUserRole::User => Ok(DbUserRole::User),
}
}
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
Ok(self.clone())
}
}
pub trait ReplicantStatusConvert {
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str>;
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str>;
}
impl ReplicantStatusConvert for DbReplicantStatus {
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
Ok(self.clone())
}
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
match self {
DbReplicantStatus::Active => Ok(ApiReplicantStatus::Active),
DbReplicantStatus::Decommissioned => Ok(ApiReplicantStatus::Decommissioned),
}
}
}
impl ReplicantStatusConvert for ApiReplicantStatus {
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
match self {
ApiReplicantStatus::Active => Ok(DbReplicantStatus::Active),
ApiReplicantStatus::Decommissioned => Ok(DbReplicantStatus::Decommissioned),
}
}
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
Ok(self.clone())
}
}
pub trait ReplicantGenderConvert {
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str>;
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str>;
}
impl ReplicantGenderConvert for DbReplicantGender {
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
Ok(self.clone())
}
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
match self {
DbReplicantGender::Male => Ok(ApiReplicantGender::Male),
DbReplicantGender::Female => Ok(ApiReplicantGender::Female),
DbReplicantGender::NonBinary => Ok(ApiReplicantGender::NonBinary),
}
}
}
impl ReplicantGenderConvert for ApiReplicantGender {
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
match self {
ApiReplicantGender::Male => Ok(DbReplicantGender::Male),
ApiReplicantGender::Female => Ok(DbReplicantGender::Female),
ApiReplicantGender::NonBinary => Ok(DbReplicantGender::NonBinary),
}
}
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
Ok(self.clone())
}
}
impl<T> ReplicantStatusConvert for Option<T>
where
T: ReplicantStatusConvert,
{
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
match self {
Some(status) => status.to_db_status(),
None => Err("Status is None"),
}
}
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
match self {
Some(status) => status.to_api_status(),
None => Err("Status is None"),
}
}
}
impl<T> ReplicantGenderConvert for Option<T>
where
T: ReplicantGenderConvert,
{
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
match self {
Some(gender) => gender.to_db_gender(),
None => Err("Gender is None"),
}
}
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
match self {
Some(gender) => gender.to_api_gender(),
None => Err("Gender is None"),
}
}
}
impl<T> UserRoleConvert for Option<T>
where
T: UserRoleConvert,
{
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
match self {
Some(role) => role.to_db_role(),
None => Err("Role is None"),
}
}
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
match self {
Some(role) => role.to_api_role(),
None => Err("Role is None"),
}
}
}

View File

@@ -0,0 +1,290 @@
use crate::services::*;
use crate::utils::AppError;
use actix_multipart::form::MultipartForm;
use actix_multipart::form::tempfile::TempFile;
use actix_session::Session;
use actix_web::HttpResponse;
use actix_web::web;
use dollhouse_api_types::*;
use dollhouse_db::Pool;
use serde::Deserialize;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
page: Option<usize>,
limit: Option<usize>,
}
#[derive(Debug, MultipartForm)]
pub struct UploadFirmwareForm {
#[multipart(limit = "2MB")]
file: TempFile,
}
pub async fn create_user(
pool: web::Data<Pool>,
data: web::Json<CreateUserRequest>,
) -> Result<HttpResponse, AppError> {
let req = data.into_inner();
let pool = pool.into_inner();
let pool_ref = pool.as_ref();
match AuthService::register(pool_ref, req).await {
Ok(()) => {
log::info!("User created successfully");
Ok(HttpResponse::Created().finish())
}
Err(e) => {
log::error!("Registration error: {:?}", e);
Err(e)
}
}
}
pub async fn login_user(
pool: web::Data<Pool>,
session: Session,
data: web::Json<CreateUserRequest>,
) -> Result<HttpResponse, AppError> {
let req = data.into_inner();
let pool = pool.into_inner();
match AuthService::login(&pool, req).await {
Ok(user) => {
session
.insert("user_id", &user.id)
.map_err(|_| AppError::InternalServerError)?;
session
.insert("role", &user.role)
.map_err(|_| AppError::InternalServerError)?;
session
.insert("username", &user.username)
.map_err(|_| AppError::InternalServerError)?;
session
.insert("corp_id", &user.corp_id)
.map_err(|_| AppError::InternalServerError)?;
Ok(HttpResponse::Ok().json(user))
}
Err(e) => {
log::error!("Login error: {}", e);
Err(e)
}
}
}
pub async fn logout_user(mut session: Session) -> Result<HttpResponse, AppError> {
AuthService::logout(&mut session);
Ok(HttpResponse::Ok().finish())
}
pub async fn get_current_user(
pool: web::Data<Pool>,
session: Session,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session.clone())?;
let user = UserService::get_user(&pool.into_inner(), user_id).await?;
Ok(HttpResponse::Ok().json(user))
}
pub async fn create_corp(
pool: web::Data<Pool>,
data: web::Json<CreateCorpRequest>,
session: Session,
) -> Result<HttpResponse, AppError> {
let req = data.into_inner();
let user_id = AuthService::check_session(session)?;
match CorpService::create(&pool.into_inner(), user_id, req).await {
Ok(corp) => Ok(HttpResponse::Ok().json(corp)),
Err(e) => Err(e),
}
}
pub async fn get_user_corp(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
let session_user_id = AuthService::check_session(session)?;
if session_user_id != path.into_inner() {
return Err(AppError::Unauthorized);
}
match CorpService::get_user_corp(&pool.into_inner(), session_user_id).await {
Ok(corp) => Ok(HttpResponse::Ok().json(corp)),
Err(e) => Err(e),
}
}
pub async fn join_corp(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
data: web::Json<JoinCorpRequest>,
) -> Result<HttpResponse, AppError> {
let session_user_id = AuthService::check_session(session)?;
let requested_user_id = path.into_inner();
if session_user_id != requested_user_id {
return Err(AppError::Unauthorized);
}
match CorpService::join(&pool.into_inner(), session_user_id, &data.into_inner()).await {
Ok(()) => Ok(HttpResponse::Ok().finish()),
Err(e) => Err(e),
}
}
pub async fn create_replicant(
pool: web::Data<Pool>,
session: Session,
data: web::Json<CreateReplicantRequest>,
path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
match ReplicantService::create(
&pool.into_inner(),
user_id,
path.into_inner(),
data.into_inner(),
)
.await
{
Ok(replicant) => Ok(HttpResponse::Ok().json(replicant)),
Err(e) => Err(e),
}
}
pub async fn get_replicant(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
match ReplicantService::get_replicant(&pool.into_inner(), user_id, path.into_inner()).await {
Ok(replicant) => Ok(HttpResponse::Ok().json(replicant)),
Err(e) => Err(e),
}
}
pub async fn get_replicants(
pool: web::Data<Pool>,
session: Session,
query: web::Query<PaginationParams>,
) -> Result<HttpResponse, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.limit.unwrap_or(10);
let offset = (page - 1) * page_size;
let _ = AuthService::check_session(session)?;
match ReplicantService::get_replicants(&pool.into_inner(), page_size, offset).await {
Ok(replicants) => Ok(HttpResponse::Ok().json(replicants)),
Err(e) => Err(e),
}
}
pub async fn upload_replicant_firmware(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
MultipartForm(form): MultipartForm<UploadFirmwareForm>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
let pool = pool.into_inner();
match ReplicantService::load_firmware(pool.as_ref(), user_id, path.into_inner(), form.file)
.await
{
Ok(()) => Ok(HttpResponse::Ok().finish()),
Err(e) => Err(e),
}
}
pub async fn get_corp_replicants(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
query: web::Query<PaginationParams>,
) -> Result<HttpResponse, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.limit.unwrap_or(10);
let offset = (page - 1) * page_size;
let user_id = AuthService::check_session(session)?;
match ReplicantService::get_corp_replicants(
&pool.into_inner(),
user_id,
path.into_inner(),
page_size,
offset,
)
.await
{
Ok(replicants) => Ok(HttpResponse::Ok().json(replicants)),
Err(e) => Err(e),
}
}
pub async fn change_replicant_privacy(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
data: web::Json<ChangePrivacyRequest>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
match ReplicantService::change_privacy(
&pool.into_inner(),
user_id,
path.into_inner(),
data.into_inner().is_private,
)
.await
{
Ok(_) => Ok(HttpResponse::Ok().finish()),
Err(e) => Err(e),
}
}
pub async fn change_replicant_owner(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
data: web::Json<ChangeReplicantOwnerRequest>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
let replicant_id = path.into_inner();
match ReplicantService::change_owner(
&pool.into_inner(),
user_id,
replicant_id,
data.into_inner().new_corp,
)
.await
{
Ok(_) => Ok(HttpResponse::Ok().finish()),
Err(e) => Err(e),
}
}
pub async fn run_firmware(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
let pool = pool.into_inner();
match LuaService::run(&pool, user_id, path.into_inner()).await {
Ok(output) => Ok(HttpResponse::Ok().json(output)),
Err(e) => Err(e),
}
}
pub async fn download_firmware(
pool: web::Data<Pool>,
session: Session,
path: web::Path<Uuid>,
) -> Result<HttpResponse, AppError> {
let user_id = AuthService::check_session(session)?;
let pool = pool.into_inner();
match ReplicantService::download_firmware(&pool, user_id, path.into_inner()).await {
Ok(firmware) => Ok(HttpResponse::Ok().json(firmware)),
Err(e) => Err(e),
}
}

View File

@@ -0,0 +1,99 @@
use crate::utils::AppError;
use actix_cors::Cors;
use actix_multipart::form::MultipartFormConfig;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::Key;
use actix_web::middleware::Logger;
use actix_web::{App, HttpServer, web};
use dollhouse_db::create_db_pool;
use env_logger::Env;
use log;
mod conversions;
mod handlers;
mod services;
mod utils;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("debug"))
.format_timestamp_millis()
.format_module_path(false)
.format_target(false)
.init();
log::info!("Starting server");
let db_pool = create_db_pool().await;
let key = Key::generate();
HttpServer::new(move || {
let key = key.clone();
let cors = Cors::permissive();
App::new()
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), key)
.cookie_secure(false)
.cookie_http_only(false)
.cookie_same_site(actix_web::cookie::SameSite::Lax)
.cookie_name("session".to_string())
.cookie_path("/".to_string())
.build(),
)
.wrap(Logger::new("%a %t \"%r\" %s"))
.app_data(web::Data::new(db_pool.clone()))
.app_data(
web::PathConfig::default().error_handler(|_, _| AppError::InvalidUuidFormat.into()),
)
.app_data(
MultipartFormConfig::default()
.total_limit(10 * 1024 * 1024)
.error_handler(|err, _| {
log::error!("Multipart error: {:?}", err);
AppError::MultipartError(err.to_string()).into()
}),
)
.service(
web::scope("/api")
.route("/auth/login", web::post().to(handlers::login_user))
.route("/auth/logout", web::post().to(handlers::logout_user))
.route("/auth/register", web::post().to(handlers::create_user))
.route("/auth/me", web::get().to(handlers::get_current_user))
.route("/user/{id}/corp", web::get().to(handlers::get_user_corp))
.route("/user/{id}/corp", web::post().to(handlers::create_corp))
.route("/user/{id}/join-corp", web::post().to(handlers::join_corp))
.route(
"/corp/{id}/replicants",
web::get().to(handlers::get_corp_replicants),
)
.route(
"/corp/{id}/replicant",
web::post().to(handlers::create_replicant),
)
.route("/replicants", web::get().to(handlers::get_replicants))
.route(
"/replicant/{id}/firmware",
web::post().to(handlers::upload_replicant_firmware),
)
.route("/replicant/{id}", web::get().to(handlers::get_replicant))
.route(
"/replicant/{id}/change-privacy",
web::post().to(handlers::change_replicant_privacy),
)
.route(
"/replicant/{id}/change-owner",
web::post().to(handlers::change_replicant_owner),
)
.route("/replicant/{id}/run", web::get().to(handlers::run_firmware))
.route(
"/replicant/{id}/firmware",
web::get().to(handlers::download_firmware),
),
)
.wrap(cors)
})
.bind("0.0.0.0:5555")?
.run()
.await
}

View File

@@ -0,0 +1,124 @@
use crate::conversions::UserRoleConvert;
use crate::utils::*;
use actix_session::Session;
use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use dollhouse_api_types::{CreateUserRequest, UserResponse, UserRole};
use dollhouse_db::{AsyncPgConnection, NewUser, Pool, repositories::UserRepository};
use uuid::Uuid;
pub struct AuthService;
impl AuthService {
pub async fn login(pool: &Pool, req: CreateUserRequest) -> Result<UserResponse, AppError> {
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
let user = UserRepository::find_by_username(&mut conn, &req.username)
.await
.map_err(|_| AppError::RepositoryError)?;
match user {
Some(user) => {
if Self::verify_password(&user.password, &req.password)? {
Ok(UserResponse {
id: user.id,
role: user.role.to_api_role().unwrap_or(UserRole::User),
username: user.username,
corp_id: user.corp_id,
})
} else {
Err(AppError::Unauthorized)
}
}
None => Err(AppError::Unauthorized),
}
}
pub fn logout(session: &mut Session) {
session.remove("user_id");
session.remove("username");
session.remove("role");
}
pub async fn register(pool: &Pool, req: CreateUserRequest) -> Result<(), AppError> {
let hashed_password = Self::hash_password(&req.password)?;
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
if Self::is_username_taken(&mut conn, &req.username).await? {
return Err(AppError::BadRequest("Username already exists".to_string()));
}
let new_user = NewUser {
username: req.username,
password: hashed_password,
};
UserRepository::create_user(&mut conn, new_user)
.await
.map_err(|_| AppError::RepositoryError)?;
Ok(())
}
async fn is_username_taken(
conn: &mut AsyncPgConnection,
username: &str,
) -> Result<bool, AppError> {
let user = UserRepository::find_by_username(conn, username)
.await
.map_err(|_| AppError::RepositoryError)?;
Ok(user.is_some())
}
fn hash_password(password: &str) -> Result<String, PasswordError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
match argon2.hash_password(password.as_bytes(), &salt) {
Ok(hash) => {
log::debug!("Password hashed successfully");
Ok(hash.to_string())
}
Err(e) => {
let error_msg = e.to_string();
log::error!("Password hashing failed: {}", error_msg);
Err(PasswordError::HashError(error_msg))
}
}
}
pub fn check_session(session: Session) -> Result<Uuid, AppError> {
match session.get::<Uuid>("user_id") {
Ok(Some(id)) => Ok(id),
Ok(None) => Err(AppError::Unauthorized),
Err(_) => Err(AppError::InternalServerError),
}
}
fn verify_password(hash: &str, password: &str) -> Result<bool, PasswordError> {
match PasswordHash::new(hash) {
Ok(parsed_hash) => {
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
Ok(_) => {
log::debug!("Password verification successful");
Ok(true)
}
Err(argon2::password_hash::Error::Password) => {
log::debug!("Password verification failed - incorrect password");
Ok(false)
}
Err(e) => {
let error_msg = e.to_string();
log::error!("Password verification error: {}", error_msg);
Err(PasswordError::VerificationFailed)
}
}
}
Err(e) => {
let error_msg = e.to_string();
log::error!("Failed to parse password hash: {}", error_msg);
Err(PasswordError::HashError(error_msg))
}
}
}
}

View File

@@ -0,0 +1,114 @@
use crate::{conversions::UserRoleConvert, utils::AppError};
use base64::prelude::*;
use dollhouse_api_types::{CorpResponse, CreateCorpRequest, JoinCorpRequest, StaffResponse};
use dollhouse_db::{
NewCorp, Pool,
errors::DbError,
repositories::{UserRepository, corp::CorpRepository},
};
use rand::Rng;
use sha2::{Digest, Sha256};
use uuid::{Context, Timestamp, Uuid};
pub struct CorpService;
impl CorpService {
pub async fn create(
pool: &Pool,
user_id: Uuid,
req: CreateCorpRequest,
) -> Result<CorpResponse, AppError> {
let mut rng = rand::rng();
let count = rng.random::<u16>();
let corp_id = Self::gen_uuid(count);
let invite_code = Self::gen_uuid(count + 1);
let new_corp = NewCorp {
id: corp_id,
invite_code: BASE64_STANDARD.encode(&invite_code.to_string()),
description: req.description.clone(),
name: req.name.clone(),
};
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
match CorpRepository::create(&mut conn, new_corp).await {
Err(e) => {
log::error!("Some error during creating corp: {}", e);
Err(AppError::InternalServerError)
}
Ok(new_corp) => {
UserRepository::update_role(&mut conn, user_id, dollhouse_db::UserRole::CorpAdmin)
.await?;
UserRepository::update_corp_id(&mut conn, user_id, Some(new_corp.id)).await?;
let staff = CorpRepository::get_corp_user_ids_with_names(&mut conn, new_corp.id)
.await?
.iter()
.map(|(id, name, role)| StaffResponse {
id: *id,
username: name.clone(),
role: role.to_api_role().unwrap(),
})
.collect();
Ok(CorpResponse {
id: new_corp.id,
invite_code: new_corp.invite_code,
staff,
description: req.description,
name: req.name,
})
}
}
}
fn gen_uuid(rng: u16) -> Uuid {
let context = Context::new(rng);
let ts = Timestamp::now(&context);
let (ticks, counter) = ts.to_gregorian();
let mut hasher = Sha256::new();
hasher.update(ticks.to_be_bytes());
hasher.update(counter.to_be_bytes());
let hash = hasher.finalize();
let node_id = hash[hash.len() - 6..].try_into().unwrap();
Uuid::new_v1(ts, &node_id)
}
pub async fn get_user_corp(pool: &Pool, user_id: Uuid) -> Result<CorpResponse, AppError> {
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
match UserRepository::get_user_corp(&mut conn, user_id).await {
Ok(Some(corp)) => {
let staff = CorpRepository::get_corp_user_ids_with_names(&mut conn, corp.id)
.await?
.iter()
.map(|(id, name, role)| StaffResponse {
id: *id,
username: name.clone(),
role: role.to_api_role().unwrap(),
})
.collect();
Ok(CorpResponse {
id: corp.id,
name: corp.name,
description: corp.description,
staff,
invite_code: corp.invite_code,
})
}
Ok(None) => Err(AppError::NotFound),
Err(_) => Err(AppError::RepositoryError),
}
}
pub async fn join(pool: &Pool, user_id: Uuid, req: &JoinCorpRequest) -> Result<(), AppError> {
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
match CorpRepository::join_by_invite(&mut conn, user_id, req.invite_code.as_str()).await {
Ok(()) => Ok(()),
Err(DbError::NotFound) => Err(AppError::NotFound),
Err(_) => Err(AppError::RepositoryError),
}
}
}

View File

@@ -0,0 +1,226 @@
use crate::{services::replicant::ReplicantService, utils::AppError};
use base64::prelude::*;
use dollhouse_api_types::FirmwareOutputResponse;
use dollhouse_db::{Pool, repositories::ReplicantRepository};
use mlua::{Lua, Table, Value};
use std::sync::Arc;
use tokio::time::{Duration, timeout};
use uuid::Uuid;
pub struct LuaService;
const MEMORY_LIMIT: usize = 10 * 1024 * 1024;
const TIME_LIMIT_MS: u64 = 1000;
impl LuaService {
fn create_lua_instance() -> Result<Lua, AppError> {
let lua = Lua::new();
lua.set_memory_limit(MEMORY_LIMIT)?;
Ok(lua)
}
fn setup_sandbox(lua: &Lua) -> Result<(), AppError> {
let globals = lua.globals();
let dangerous_libs = [
"os",
"io",
"debug",
"load",
"loadstring",
"dofile",
"loadfile",
];
for lib in &dangerous_libs {
globals.set(*lib, Value::Nil)?;
}
let g_mt = lua.create_table()?;
let allowed_globals = vec![
"_VERSION",
"print",
"type",
"assert",
"error",
"pairs",
"ipairs",
"next",
"select",
"pcall",
"xpcall",
"table",
"string",
"math",
"tonumber",
"tostring",
"setmetatable",
"getmetatable",
"rawset",
"rawget",
"rawequal",
];
let allowed_globals_clone1 = allowed_globals.clone();
g_mt.set(
"__newindex",
lua.create_function(move |_, (t, name, value): (Table, String, Value)| {
if !allowed_globals_clone1.contains(&name.as_str()) {
return Err(mlua::Error::RuntimeError(format!(
"Security: creating global '{}' is not allowed",
name
)));
}
t.raw_set(name, value)?;
Ok(())
})?,
)?;
let allowed_globals_clone2 = allowed_globals.clone();
let dangerous = vec!["io", "os", "debug", "package"];
g_mt.set(
"__index",
lua.create_function(move |lua, (t, name): (Table, String)| {
if dangerous.contains(&name.as_str()) {
return Err(mlua::Error::RuntimeError(format!(
"Security: access to '{}' is prohibited",
name
)));
}
if allowed_globals_clone2.contains(&name.as_str()) {
let globals = lua.globals();
return Ok(globals.raw_get::<Value>(name)?);
}
Ok(Value::Nil)
})?,
)?;
globals.set_metatable(Some(g_mt));
Self::setup_safe_print(lua)?;
Ok(())
}
fn setup_safe_print(lua: &Lua) -> Result<(), AppError> {
let safe_print = lua.create_function(|_, args: mlua::MultiValue| {
let output: String = args
.into_iter()
.map(|v| v.to_string())
.collect::<Result<Vec<_>, _>>()?
.join("\t");
println!("{}", output);
Ok(())
})?;
lua.globals().set("print", safe_print)?;
Ok(())
}
async fn execute_with_timeout(
lua: Arc<Lua>,
bytecode: Vec<u8>,
) -> Result<mlua::Value, AppError> {
let task = tokio::task::spawn_blocking(move || {
let lua_clone = Arc::clone(&lua);
lua_clone
.load(&bytecode)
.set_name("[[user_firmware]]")
.eval()
});
match timeout(Duration::from_millis(TIME_LIMIT_MS), task).await {
Ok(Ok(result)) => result.map_err(|e| {
let err_str = e.to_string();
if err_str.contains("not enough memory") {
log::error!("Memory limit exceeded");
AppError::InternalServerError
} else {
AppError::LuaExecutionError(e)
}
}),
Ok(Err(join_err)) => {
log::error!("Join error: {}", join_err);
Err(AppError::InternalServerError)
}
Err(_) => {
tokio::task::yield_now().await;
Err(AppError::InternalServerError)
}
}
}
fn value_to_string(value: mlua::Value) -> Result<String, AppError> {
match value {
mlua::Value::String(s) => Ok(s.to_str()?.to_string()),
mlua::Value::Nil => Ok("nil".to_string()),
mlua::Value::Boolean(b) => Ok(b.to_string()),
mlua::Value::Number(n) => Ok(n.to_string()),
mlua::Value::Integer(i) => Ok(i.to_string()),
mlua::Value::Table(t) => {
let mut parts = Vec::new();
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (key, value) = pair?;
parts.push(format!("{}: {}", key.to_string()?, value.to_string()?));
}
Ok(format!("{{{}}}", parts.join(", ")))
}
_ => Ok(format!("{:?}", value)),
}
}
pub async fn run(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
) -> Result<FirmwareOutputResponse, AppError> {
let lua = Arc::new(Self::create_lua_instance()?);
Self::setup_sandbox(&lua)?;
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
.await
.map_err(|_| AppError::RepositoryError)?;
ReplicantService::check_replicant_access(
&mut conn,
user_id,
replicant.is_private,
replicant.corp_id,
)
.await?;
match replicant.firmware_file {
Some(filename) => {
let firmware_path = std::path::Path::new("firmware").join(filename);
let firmware_data = tokio::fs::read(&firmware_path).await.map_err(|e| {
log::error!(
"Failed to read firmware file from {:?}: {}",
firmware_path,
e
);
AppError::InternalServerError
})?;
if firmware_data.is_empty() {
return Err(AppError::InternalServerError);
}
let result = Self::execute_with_timeout(Arc::clone(&lua), firmware_data).await?;
let mut output = Self::value_to_string(result)?;
output = BASE64_STANDARD.encode(output);
Ok(FirmwareOutputResponse { output })
}
None => Err(AppError::NotFound),
}
}
}

View File

@@ -0,0 +1,11 @@
pub mod auth;
pub mod corp;
pub mod lua;
pub mod replicant;
pub mod user;
pub use self::auth::*;
pub use self::corp::*;
pub use self::lua::*;
pub use self::replicant::*;
pub use self::user::*;

View File

@@ -0,0 +1,466 @@
use crate::{
conversions::{ReplicantGenderConvert, ReplicantStatusConvert},
utils::AppError,
};
use actix_multipart::form::tempfile::TempFile;
use base64::prelude::*;
use dollhouse_api_types::{CreateReplicantRequest, FirmwareOutputResponse, ReplicantFullResponse};
use dollhouse_db::{
AsyncPgConnection, NewReplicant, Pool, ReplicantStatus, UserRole,
repositories::{CorpRepository, ReplicantRepository, UserRepository},
};
use std::io::Read;
use std::path::Path;
use uuid::Uuid;
pub struct ReplicantService;
impl ReplicantService {
pub async fn create(
pool: &Pool,
user_id: Uuid,
corp_id: Uuid,
data: CreateReplicantRequest,
) -> Result<ReplicantFullResponse, AppError> {
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
if !Self::is_admin(&mut conn, user_id).await? {
log::warn!(
"User {} attempted to create replicant in corp {} without admin rights",
user_id,
corp_id
);
return Err(AppError::Unauthorized);
}
let new_replicant = NewReplicant {
name: data.name,
description: data.description,
status: ReplicantStatus::Active,
gender: data.gender.to_db_gender().unwrap(),
corp_id,
};
match ReplicantRepository::create(&mut conn, new_replicant).await {
Ok(replicant) => Ok(ReplicantFullResponse {
id: replicant.id,
name: replicant.name,
description: replicant.description,
health: replicant.health,
strength: replicant.strength,
intelligence: replicant.intelligence,
gender: replicant.gender.to_api_gender().unwrap(),
status: replicant.status.to_api_status().unwrap(),
is_private: replicant.is_private,
firmware_file: replicant.firmware_file,
corp_id: replicant.corp_id,
}),
Err(err) => Err(err.into()),
}
}
pub async fn get_replicant(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
) -> Result<ReplicantFullResponse, AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicant = ReplicantRepository::get_replicant_full(&mut conn, replicant_id)
.await
.map_err(|e| {
log::error!("Some error: {}", e);
AppError::RepositoryError
})?;
Self::check_replicant_access(&mut conn, user_id, replicant.is_private, replicant.corp_id)
.await?;
Ok(ReplicantFullResponse {
id: replicant.id,
name: replicant.name,
description: replicant.description,
health: replicant.health,
strength: replicant.strength,
intelligence: replicant.intelligence,
gender: replicant.gender.to_api_gender().unwrap(),
status: replicant.status.to_api_status().unwrap(),
is_private: replicant.is_private,
firmware_file: replicant.firmware_file,
corp_id: replicant.corp_id,
})
}
pub async fn get_replicants(
pool: &Pool,
limit: usize,
offset: usize,
) -> Result<Vec<ReplicantFullResponse>, AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicants = ReplicantRepository::get_much(&mut conn, limit, offset)
.await
.map_err(|e| {
log::error!("Failed to get replicants: {}", e);
AppError::RepositoryError
})?;
Ok(replicants
.into_iter()
.map(|replicant| ReplicantFullResponse {
id: replicant.id,
name: replicant.name,
description: replicant.description,
gender: replicant.gender.to_api_gender().unwrap(),
status: replicant.status.to_api_status().unwrap(),
is_private: replicant.is_private,
firmware_file: replicant.firmware_file,
health: replicant.health,
strength: replicant.strength,
intelligence: replicant.intelligence,
corp_id: replicant.corp_id,
})
.collect())
}
pub async fn get_corp_replicants(
pool: &Pool,
user_id: Uuid,
corp_id: Uuid,
limit: usize,
offset: usize,
) -> Result<Vec<ReplicantFullResponse>, AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
let replicants =
ReplicantRepository::get_corp_replicants_full(&mut conn, corp_id, limit, offset)
.await
.map_err(|e| {
log::error!("Failed to get corp replicants: {}", e);
AppError::RepositoryError
})?;
log::debug!("Number of replicants: {}", replicants.len());
Ok(replicants
.into_iter()
.map(|replicant| ReplicantFullResponse {
id: replicant.id,
name: replicant.name,
description: replicant.description,
gender: replicant.gender.to_api_gender().unwrap(),
status: replicant.status.to_api_status().unwrap(),
is_private: replicant.is_private,
firmware_file: replicant.firmware_file,
corp_id: replicant.corp_id,
health: replicant.health,
strength: replicant.strength,
intelligence: replicant.intelligence,
})
.collect())
}
pub async fn change_privacy(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
privacy: bool,
) -> Result<(), AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
.await
.map_err(|e| {
log::error!("Failed to change privacy: {}", e);
AppError::RepositoryError
})?;
Self::check_user_corp(&mut conn, user_id, replicant.corp_id).await?;
let is_admin = Self::is_admin(&mut conn, user_id).await?;
if !is_admin {
log::warn!(
"User {} attempted to change privacy without admin rights",
user_id
);
return Err(AppError::Unauthorized);
}
ReplicantRepository::change_privacy(&mut conn, replicant_id, privacy).await?;
Ok(())
}
pub async fn change_owner(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
new_owner_id: Uuid,
) -> Result<(), AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
.await
.map_err(|e| {
log::error!("Failed to change owner: {}", e);
AppError::RepositoryError
})?;
Self::check_user_corp(&mut conn, user_id, replicant.corp_id).await?;
if !Self::is_admin(&mut conn, user_id).await? {
log::warn!(
"User {} attempted to change owner of replicant {} without admin rights",
user_id,
replicant_id
);
return Err(AppError::Unauthorized);
}
let _ = CorpRepository::get_corp(&mut conn, new_owner_id).await.map_err(|e| {
log::error!("New corp {} not found: {}", new_owner_id, e);
AppError::RepositoryError
})?;
ReplicantRepository::change_owner(&mut conn, replicant_id, new_owner_id)
.await
.map_err(|e| {
log::error!("Failed to change owner: {}", e);
AppError::RepositoryError
})?;
Ok(())
}
pub(crate) async fn check_replicant_access(
conn: &mut AsyncPgConnection,
user_id: Uuid,
is_private: bool,
corp_id: Uuid,
) -> Result<(), AppError> {
if !is_private {
return Ok(());
}
let user_corp = UserRepository::get_user_corp(conn, user_id)
.await
.map_err(|e| {
log::error!("Database error while checking user corp: {}", e);
AppError::InternalServerError
})?;
log::debug!("User {} corporation: {:?}", user_id, user_corp);
match user_corp {
Some(corp) if corp.id == corp_id => Ok(()),
Some(_) => {
log::warn!(
"User {} attempted to access unauthorized replicant",
user_id
);
Err(AppError::Unauthorized)
}
None => {
log::warn!("User {} has no corporation", user_id);
Err(AppError::Unauthorized)
}
}
}
async fn check_user_corp(
conn: &mut AsyncPgConnection,
user_id: Uuid,
corp_id: Uuid,
) -> Result<(), AppError> {
let user_corp = CorpRepository::get_corp_by_user(conn, user_id)
.await
.map_err(|e| {
log::error!("Database error while checking user corp: {}", e);
AppError::InternalServerError
})?;
match user_corp {
Some(corp) if corp.id == corp_id => Ok(()),
Some(_) => {
log::warn!(
"User {} attempted to access unauthorized corp {}",
user_id,
corp_id
);
Err(AppError::Unauthorized)
}
None => {
log::warn!("User {} has no corporation", user_id);
Err(AppError::Unauthorized)
}
}
}
async fn is_admin(conn: &mut AsyncPgConnection, user_id: Uuid) -> Result<bool, AppError> {
let user = UserRepository::get_user(conn, user_id).await?;
match user {
Some(user) => Ok(user.role == UserRole::CorpAdmin),
None => {
log::warn!("User {} not found while checking admin status", user_id);
Ok(false)
}
}
}
pub async fn load_firmware(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
firmware: TempFile,
) -> Result<(), AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
.await
.map_err(|e| {
log::error!("Failed to load firmware: {}", e);
AppError::RepositoryError
})?;
let corp_id = replicant.corp_id;
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
if !Self::is_admin(&mut conn, user_id).await? {
log::warn!(
"User {} attempted to upload firmware for replicant {} without admin rights",
user_id,
replicant_id
);
return Err(AppError::Unauthorized);
}
let file_content = Self::validate_firmware_file(&firmware)?;
let filename = format!("firmware_{}", replicant.id);
let firmware_dir = Path::new("firmware");
tokio::fs::create_dir_all(firmware_dir).await.map_err(|e| {
log::error!("Failed to create firmware directory: {}", e);
AppError::InternalServerError
})?;
let file_path = firmware_dir.join(&filename);
tokio::fs::write(&file_path, file_content)
.await
.map_err(|e| {
log::error!(
"Failed to write firmware file {}: {}",
file_path.display(),
e
);
AppError::InternalServerError
})?;
ReplicantRepository::update_firmware(&mut conn, replicant_id, filename)
.await
.map_err(|e| {
log::error!("Failed to update firmware in database: {}", e);
AppError::RepositoryError
})?;
log::info!(
"Firmware loaded successfully for replicant {} by user {}, file: {}",
replicant_id,
user_id,
file_path.display()
);
Ok(())
}
fn validate_firmware_file(firmware_file: &TempFile) -> Result<Vec<u8>, AppError> {
let file = firmware_file.file.as_ref();
let metadata = file.metadata().map_err(|e| {
log::error!("Failed to get file metadata: {}", e);
AppError::InternalServerError
})?;
if metadata.len() > 10 * 1024 * 1024 {
log::warn!("Firmware file too large: {} bytes", metadata.len());
return Err(AppError::BadRequest(
"Firmware file too large (max 10MB)".to_string(),
));
}
let mut file_content = Vec::new();
let mut file_handle = std::fs::File::open(&file).map_err(|e| {
log::error!("Failed to open firmware file: {}", e);
AppError::InternalServerError
})?;
file_handle.read_to_end(&mut file_content).map_err(|e| {
log::error!("Failed to read firmware file: {}", e);
AppError::InternalServerError
})?;
if file_content.is_empty() {
log::warn!("Empty firmware file provided");
return Err(AppError::BadRequest("Firmware file is empty".to_string()));
}
Ok(file_content)
}
pub async fn download_firmware(
pool: &Pool,
user_id: Uuid,
replicant_id: Uuid,
) -> Result<FirmwareOutputResponse, AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Failed to get connection from pool: {}", e);
AppError::RepositoryError
})?;
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
.await
.map_err(|_| AppError::RepositoryError)?;
Self::check_replicant_access(&mut conn, user_id, replicant.is_private, replicant.corp_id)
.await?;
match replicant.firmware_file {
Some(filename) => {
let firmware_path = std::path::Path::new("firmware").join(filename);
let firmware_data = tokio::fs::read(&firmware_path).await.map_err(|e| {
log::error!(
"Failed to read firmware file from {:?}: {}",
firmware_path,
e
);
AppError::InternalServerError
})?;
if firmware_data.is_empty() {
return Err(AppError::InternalServerError);
}
let output = BASE64_STANDARD.encode(firmware_data);
Ok(FirmwareOutputResponse { output })
}
None => Err(AppError::NotFound),
}
}
}

View File

@@ -0,0 +1,25 @@
use crate::{conversions::UserRoleConvert, utils::AppError};
use dollhouse_api_types::UserResponse;
use dollhouse_db::{Pool, repositories::UserRepository};
use uuid::Uuid;
pub struct UserService;
impl UserService {
pub async fn get_user(pool: &Pool, user_id: Uuid) -> Result<UserResponse, AppError> {
let mut conn = pool.get().await.map_err(|e| {
log::error!("Some error with pool: {}", e);
AppError::RepositoryError
})?;
match UserRepository::get_user(&mut conn, user_id).await {
Ok(Some(user)) => Ok(UserResponse {
id: user.id,
role: user.role.to_api_role().unwrap(),
username: user.username,
corp_id: user.corp_id,
}),
Ok(None) => Err(AppError::NotFound),
Err(e) => Err(AppError::RepositoryError),
}
}
}

View File

@@ -0,0 +1,114 @@
use actix_web::HttpResponse;
use actix_web::error::ResponseError;
use dollhouse_db::errors::DbError;
use log::{debug, error};
use serde_json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PasswordError {
#[error("Failed to verify password")]
VerificationFailed,
#[error("Failed to hash password: {0}")]
HashError(String),
}
#[derive(Error, Debug)]
pub enum AppError {
#[error("Password error: {0}")]
PasswordError(#[from] PasswordError),
#[error("Database error")]
RepositoryError,
#[error("Not Found")]
NotFound,
#[error("Unauthorized")]
Unauthorized,
#[error("Internal server error")]
InternalServerError,
#[error("Invalid UUID format")]
InvalidUuidFormat,
#[error("Lua execution error: {0}")]
LuaExecutionError(#[from] mlua::Error),
#[error("Diesel error: {0}")]
DieselError(#[from] DbError),
#[error("Bad Request: {0}")]
BadRequest(String),
#[error("Multipart form error: {0}")]
MultipartError(String),
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
match self {
AppError::RepositoryError => {
error!("Database error");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Database error occurred",
"message": "An error occurred while accessing the database"
}))
}
AppError::PasswordError(e) => {
error!("Password error: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Authentication error",
"message": "An error occurred during authentication"
}))
}
AppError::NotFound => {
debug!("Resource not found");
HttpResponse::NotFound().json(serde_json::json!({
"error": "Not found",
"message": "The requested resource was not found"
}))
}
AppError::Unauthorized => {
debug!("Unauthorized access attempt");
HttpResponse::Unauthorized().json(serde_json::json!({
"error": "Unauthorized",
"message": "Access denied"
}))
}
AppError::InternalServerError => {
error!("Internal server error");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Internal server error",
"message": "An unexpected error occurred"
}))
}
AppError::InvalidUuidFormat => {
error!("Invalid UUID format");
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid UUID format",
}))
}
AppError::LuaExecutionError(err) => {
error!("Lua execution error: {}", err.to_string());
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Lua execution error",
"message": err.to_string()
}))
}
AppError::DieselError(_) => {
error!("Diesel error");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Diesel error",
"message": "An error occurred during Diesel execution"
}))
}
AppError::BadRequest(msg) => {
error!("Bad Request: {}", msg);
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Bad Request",
"message": msg
}))
}
AppError::MultipartError(msg) => {
error!("Multipart error: {}", msg);
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Multipart error",
"message": msg
}))
}
}
}
}

View File

@@ -0,0 +1 @@
DATABASE_URL=postgres://dollhouse_user:hahahadollhouse@localhost:5432/dollhouse_db

View File

@@ -0,0 +1,16 @@
[package]
name = "dollhouse-db"
version = "0.1.0"
edition = "2024"
[dependencies]
bb8 = "0.9.0"
chrono = "0.4.42"
diesel = { version = "2.2.0", features = ["postgres", "chrono", "uuid"] }
diesel-async = { version = "0.7.4", features = ["postgres", "pool", "bb8"] }
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
diesel_migrations = "2.3.0"
r2d2 = "0.8.10"
thiserror = { workspace = true }
uuid = { workspace = true }
dotenv = "0.15"

View File

@@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "migrations"

View File

View File

@@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,11 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS corps_replicants;
DROP TABLE IF EXISTS replicants_stats;
DROP TABLE IF EXISTS replicants;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS corps;
DROP TYPE IF EXISTS replicant_gender;
DROP TYPE IF EXISTS replicant_status;
DROP TYPE IF EXISTS user_role;

View File

@@ -0,0 +1,44 @@
-- Your SQL goes here
CREATE TYPE user_role AS ENUM ('corp_admin', 'user');
CREATE TYPE replicant_status AS ENUM ('active', 'decommissioned');
CREATE TYPE replicant_gender AS ENUM ('male', 'female', 'non-binary');
CREATE TABLE IF NOT EXISTS corps (
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
invite_code VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
corp_id UUID REFERENCES corps(id)
);
CREATE TABLE IF NOT EXISTS replicants (
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
status replicant_status NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
gender replicant_gender NOT NULL,
corp_id UUID REFERENCES corps(id) NOT NULL,
is_private BOOLEAN NOT NULL DEFAULT TRUE,
firmware_file VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS replicants_stats (
replicant_id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid() REFERENCES replicants(id),
health INTEGER NOT NULL DEFAULT 100,
strength INTEGER NOT NULL DEFAULT 100,
intelligence INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_replicant_stats_replicant_id ON replicants_stats(replicant_id);
CREATE INDEX idx_replicants_status ON replicants(status);

View File

@@ -0,0 +1,20 @@
ALTER TABLE replicants_stats
DROP CONSTRAINT IF EXISTS replicants_stats_replicant_id_fkey;
ALTER TABLE replicants_stats
ADD CONSTRAINT replicants_stats_replicant_id_fkey
FOREIGN KEY (replicant_id) REFERENCES replicants(id);
ALTER TABLE replicants
DROP CONSTRAINT IF EXISTS replicants_corp_id_fkey;
ALTER TABLE replicants
ADD CONSTRAINT replicants_corp_id_fkey
FOREIGN KEY (corp_id) REFERENCES corps(id);
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_corp_id_fkey;
ALTER TABLE users
ADD CONSTRAINT users_corp_id_fkey
FOREIGN KEY (corp_id) REFERENCES corps(id);

View File

@@ -0,0 +1,20 @@
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_corp_id_fkey;
ALTER TABLE users
ADD CONSTRAINT users_corp_id_fkey
FOREIGN KEY (corp_id) REFERENCES corps(id) ON DELETE CASCADE;
ALTER TABLE replicants
DROP CONSTRAINT IF EXISTS replicants_corp_id_fkey;
ALTER TABLE replicants
ADD CONSTRAINT replicants_corp_id_fkey
FOREIGN KEY (corp_id) REFERENCES corps(id) ON DELETE CASCADE;
ALTER TABLE replicants_stats
DROP CONSTRAINT IF EXISTS replicants_stats_replicant_id_fkey;
ALTER TABLE replicants_stats
ADD CONSTRAINT replicants_stats_replicant_id_fkey
FOREIGN KEY (replicant_id) REFERENCES replicants(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,35 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DbError {
#[error("Database query error")]
QueryError,
#[error("Not found")]
NotFound,
#[error("Already exists")]
AlreadyExists,
#[error("Unique constraint violation")]
UniqueViolation,
#[error("Foreign key violation")]
ForeignKeyViolation,
}
impl From<diesel::result::Error> for DbError {
fn from(err: diesel::result::Error) -> Self {
match err {
diesel::result::Error::NotFound => DbError::NotFound,
diesel::result::Error::DatabaseError(kind, _) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => DbError::UniqueViolation,
diesel::result::DatabaseErrorKind::ForeignKeyViolation => {
DbError::ForeignKeyViolation
}
_ => DbError::QueryError,
},
_ => DbError::QueryError,
}
}
}

View File

@@ -0,0 +1,33 @@
pub mod errors;
mod models;
pub mod repositories;
mod schema;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_migrations::{EmbeddedMigrations, embed_migrations};
pub use models::{NewCorp, NewReplicant, NewUser, ReplicantGender, ReplicantStatus, UserRole};
use std::env;
pub use diesel_async::AsyncPgConnection;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub type Pool = bb8::Pool<AsyncDieselConnectionManager<AsyncPgConnection>>;
fn database_url() -> String {
#[cfg(debug_assertions)]
{
dotenv::dotenv().ok();
}
env::var("DATABASE_URL").expect("DATABASE_URL must be set")
}
pub async fn create_db_pool() -> Pool {
let database_url = database_url();
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
Pool::builder()
.build(config)
.await
.expect("Failed to create pool")
}

View File

@@ -0,0 +1,175 @@
use crate::schema::users;
use chrono::NaiveDateTime;
use diesel::prelude::*;
use std::convert::TryFrom;
use uuid::Uuid;
#[derive(diesel_derive_enum::DbEnum, Debug, Clone, PartialEq)]
#[ExistingTypePath = "crate::schema::sql_types::ReplicantStatus"]
pub enum ReplicantStatus {
#[db_rename = "active"]
Active,
#[db_rename = "decommissioned"]
Decommissioned,
}
#[derive(diesel_derive_enum::DbEnum, Debug, Clone, PartialEq)]
#[ExistingTypePath = "crate::schema::sql_types::ReplicantGender"]
pub enum ReplicantGender {
#[db_rename = "male"]
Male,
#[db_rename = "female"]
Female,
#[db_rename = "non-binary"]
NonBinary,
}
#[derive(Debug, PartialEq, Clone, diesel_derive_enum::DbEnum)]
#[ExistingTypePath = "crate::schema::sql_types::UserRole"]
pub enum UserRole {
#[db_rename = "corp_admin"]
CorpAdmin,
#[db_rename = "user"]
User,
}
#[derive(Insertable, AsChangeset)]
#[diesel(table_name = crate::schema::users)]
pub struct NewUser {
pub username: String,
pub password: String,
}
#[derive(Queryable, Selectable)]
#[diesel(belongs_to(Corp))]
pub struct User {
pub id: Uuid,
pub username: String,
pub password: String,
pub role: UserRole,
pub created_at: NaiveDateTime,
pub corp_id: Option<Uuid>,
}
#[derive(Insertable, AsChangeset)]
#[diesel(table_name = crate::schema::corps)]
pub struct NewCorp {
pub id: Uuid,
pub name: String,
pub description: String,
pub invite_code: String,
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = crate::schema::corps)]
#[diesel(check_for_backend(diesel::pg::Pg))]
#[diesel(belongs_to(User))]
pub struct Corp {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: NaiveDateTime,
pub invite_code: String,
}
#[derive(Insertable, AsChangeset)]
#[diesel(table_name = crate::schema::replicants)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct NewReplicant {
pub name: String,
pub description: String,
pub status: ReplicantStatus,
pub gender: ReplicantGender,
pub corp_id: Uuid,
}
#[derive(Insertable, AsChangeset)]
#[diesel(table_name = crate::schema::replicants_stats)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct NewReplicantStats {
pub replicant_id: Uuid,
pub health: i32,
pub strength: i32,
pub intelligence: i32,
}
#[derive(Queryable, Selectable)]
#[diesel(table_name = crate::schema::replicants)]
#[diesel(check_for_backend(diesel::pg::Pg))]
#[diesel(belongs_to(Corp))]
pub struct Replicant {
pub id: Uuid,
pub name: String,
pub description: String,
pub status: ReplicantStatus,
pub created_at: NaiveDateTime,
pub gender: ReplicantGender,
pub corp_id: Uuid,
pub is_private: bool,
pub firmware_file: Option<String>,
}
#[derive(Queryable, Selectable)]
#[diesel(table_name = crate::schema::replicants_stats)]
#[diesel(check_for_backend(diesel::pg::Pg))]
#[diesel(belongs_to(Replicant))]
pub struct ReplicantStats {
pub replicant_id: Uuid,
pub health: i32,
pub strength: i32,
pub intelligence: i32,
pub created_at: NaiveDateTime,
}
#[derive(Debug)]
pub struct ReplicantFull {
pub id: Uuid,
pub name: String,
pub description: String,
pub gender: ReplicantGender,
pub status: ReplicantStatus,
pub created_at: NaiveDateTime,
pub firmware_file: Option<String>,
pub is_private: bool,
pub corp_id: Uuid,
pub health: i32,
pub strength: i32,
pub intelligence: i32,
}
impl TryFrom<String> for ReplicantStatus {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"active" => Ok(ReplicantStatus::Active),
"decommissioned" => Ok(ReplicantStatus::Decommissioned),
_ => Err("Invalid status"),
}
}
}
impl TryFrom<String> for ReplicantGender {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"male" => Ok(ReplicantGender::Male),
"female" => Ok(ReplicantGender::Female),
"non_binary" => Ok(ReplicantGender::NonBinary),
_ => Err("Invalid gender"),
}
}
}
impl TryFrom<String> for UserRole {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"corp_admin" => Ok(UserRole::CorpAdmin),
"user" => Ok(UserRole::User),
_ => Err("Invalid role"),
}
}
}

View File

@@ -0,0 +1,173 @@
use crate::errors::DbError;
use crate::models::{Corp, NewCorp, UserRole};
use diesel::prelude::*;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use uuid::Uuid;
pub struct CorpRepository;
impl CorpRepository {
pub async fn create(conn: &mut AsyncPgConnection, new_corp: NewCorp) -> Result<Corp, DbError> {
use crate::schema::corps::dsl::*;
diesel::insert_into(corps)
.values(&new_corp)
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp(conn: &mut AsyncPgConnection, corp_id: Uuid) -> Result<Corp, DbError> {
use crate::schema::corps::dsl::*;
corps
.find(corp_id)
.select(Corp::as_select())
.first(conn)
.await
.map_err(Into::into)
}
pub async fn get_corps(
conn: &mut AsyncPgConnection,
limit: usize,
offset: usize,
) -> Result<Vec<Corp>, DbError> {
use crate::schema::corps::dsl::*;
corps
.select(Corp::as_select())
.limit(limit as i64)
.offset(offset as i64)
.get_results(conn)
.await
.map_err(Into::into)
}
pub async fn get_corps_by_admin_id(
conn: &mut AsyncPgConnection,
admin_id: Uuid,
) -> Result<Vec<Corp>, DbError> {
use crate::schema::{corps, users};
corps::table
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
.filter(users::id.eq(admin_id))
.filter(users::role.eq(UserRole::CorpAdmin))
.select(corps::all_columns)
.get_results(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_by_user(
conn: &mut AsyncPgConnection,
user_id: Uuid,
) -> Result<Option<Corp>, DbError> {
use crate::schema::{corps, users};
corps::table
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
.filter(users::id.eq(user_id))
.select(corps::all_columns)
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn get_corp_user_ids_with_names(
conn: &mut AsyncPgConnection,
c_id: Uuid,
) -> Result<Vec<(Uuid, String, UserRole)>, DbError> {
use crate::schema::users::dsl::*;
users
.filter(corp_id.eq(c_id))
.select((id, username, role))
.load(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_user_ids(
conn: &mut AsyncPgConnection,
c_id: Uuid,
) -> Result<Vec<Uuid>, DbError> {
use crate::schema::users::dsl::*;
users
.filter(corp_id.eq(c_id))
.select(id)
.load(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_user_count(
conn: &mut AsyncPgConnection,
c_id: Uuid,
) -> Result<i64, DbError> {
use crate::schema::users::dsl::*;
users
.filter(corp_id.eq(c_id))
.count()
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn join_by_invite(
conn: &mut AsyncPgConnection,
user_id: Uuid,
invite_code: &str,
) -> Result<(), DbError> {
use crate::schema::{corps, users};
let user_exists = users::table
.find(user_id)
.select(users::id)
.first::<Uuid>(conn)
.await
.optional()?;
if user_exists.is_none() {
return Err(DbError::NotFound);
}
let current_corp = users::table
.find(user_id)
.select(users::corp_id)
.first::<Option<Uuid>>(conn)
.await?;
if current_corp.is_some() {
return Err(DbError::UniqueViolation);
}
let corp = corps::table
.filter(corps::invite_code.eq(invite_code))
.first::<Corp>(conn)
.await?;
diesel::update(users::table.find(user_id))
.set(users::corp_id.eq(corp.id))
.execute(conn)
.await?;
Ok(())
}
pub async fn find_by_invite_code(
conn: &mut AsyncPgConnection,
code: &str,
) -> Result<Corp, DbError> {
use crate::schema::corps::dsl::*;
corps
.filter(invite_code.eq(code))
.first(conn)
.await
.map_err(Into::into)
}
}

View File

@@ -0,0 +1,7 @@
pub mod corp;
pub mod replicant;
pub mod user;
pub use corp::CorpRepository;
pub use replicant::ReplicantRepository;
pub use user::UserRepository;

View File

@@ -0,0 +1,334 @@
use crate::errors::DbError;
use crate::models::{
NewReplicant, NewReplicantStats, Replicant, ReplicantFull, ReplicantStats, ReplicantStatus,
};
use diesel::prelude::*;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use uuid::Uuid;
pub struct ReplicantRepository;
impl ReplicantRepository {
pub async fn create(
conn: &mut AsyncPgConnection,
new_replicant: NewReplicant,
) -> Result<ReplicantFull, DbError> {
use crate::schema::{replicants, replicants_stats};
let replicant = diesel::insert_into(replicants::table)
.values(&new_replicant)
.get_result::<Replicant>(conn)
.await?;
let stats = NewReplicantStats {
replicant_id: replicant.id,
health: 100,
strength: 100,
intelligence: 100,
};
let stats = diesel::insert_into(replicants_stats::table)
.values(&stats)
.get_result::<ReplicantStats>(conn)
.await?;
Ok(ReplicantFull {
id: replicant.id,
name: replicant.name,
description: replicant.description,
gender: replicant.gender,
status: replicant.status,
created_at: replicant.created_at,
is_private: replicant.is_private,
firmware_file: replicant.firmware_file,
corp_id: replicant.corp_id,
health: stats.health,
strength: stats.strength,
intelligence: stats.intelligence,
})
}
pub async fn get(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
) -> Result<Replicant, DbError> {
use crate::schema::replicants::dsl::*;
replicants
.find(replicant_id)
.first(conn)
.await
.map_err(Into::into)
}
pub async fn get_optional(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
) -> Result<Option<Replicant>, DbError> {
use crate::schema::replicants::dsl::*;
replicants
.find(replicant_id)
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn get_much(
conn: &mut AsyncPgConnection,
limit: usize,
offset: usize,
) -> Result<Vec<ReplicantFull>, DbError> {
use crate::schema::{replicants, replicants_stats};
let results = replicants::table
.inner_join(replicants_stats::table)
.filter(replicants::is_private.eq(false))
.select((Replicant::as_select(), ReplicantStats::as_select()))
.limit(limit as i64)
.offset(offset as i64)
.load::<(Replicant, ReplicantStats)>(conn)
.await?;
Ok(results
.into_iter()
.map(|(rep, stats)| ReplicantFull {
id: rep.id,
name: rep.name,
description: rep.description,
gender: rep.gender,
status: rep.status,
created_at: rep.created_at,
is_private: rep.is_private,
firmware_file: rep.firmware_file,
corp_id: rep.corp_id,
health: stats.health,
strength: stats.strength,
intelligence: stats.intelligence,
})
.collect())
}
pub async fn apply_mission_damage(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
damage: i32,
) -> Result<(i32, ReplicantStatus), DbError> {
use crate::schema::{replicants, replicants_stats};
let (current_health, current_status): (i32, ReplicantStatus) = replicants::table
.inner_join(replicants_stats::table)
.filter(replicants::id.eq(replicant_id))
.select((replicants_stats::health, replicants::status))
.first(conn)
.await?;
let new_health = (current_health - damage).max(0);
diesel::update(replicants_stats::table.find(replicant_id))
.set(replicants_stats::health.eq(new_health))
.execute(conn)
.await?;
let mut new_status = current_status.clone();
if new_health <= 0 && current_status != ReplicantStatus::Decommissioned {
diesel::update(replicants::table.find(replicant_id))
.set(replicants::status.eq(ReplicantStatus::Decommissioned))
.execute(conn)
.await?;
new_status = ReplicantStatus::Decommissioned;
}
Ok((new_health, new_status))
}
pub async fn get_firmware(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
) -> Result<Option<String>, DbError> {
use crate::schema::replicants::dsl::*;
replicants
.filter(id.eq(replicant_id))
.select(firmware_file)
.first(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_replicants(
conn: &mut AsyncPgConnection,
c_id: Uuid,
limit: usize,
offset: usize,
) -> Result<Vec<Replicant>, DbError> {
use crate::schema::replicants::dsl::*;
replicants
.filter(corp_id.eq(c_id))
.select(Replicant::as_select())
.limit(limit as i64)
.offset(offset as i64)
.load(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_replicants_full(
conn: &mut AsyncPgConnection,
c_id: Uuid,
limit: usize,
offset: usize,
) -> Result<Vec<ReplicantFull>, DbError> {
use crate::schema::{replicants, replicants_stats};
let results = replicants::table
.inner_join(replicants_stats::table)
.filter(replicants::corp_id.eq(c_id))
.select((Replicant::as_select(), ReplicantStats::as_select()))
.limit(limit as i64)
.offset(offset as i64)
.load::<(Replicant, ReplicantStats)>(conn)
.await?;
Ok(results
.into_iter()
.map(|(rep, stats)| ReplicantFull {
id: rep.id,
name: rep.name,
description: rep.description,
gender: rep.gender,
status: rep.status,
created_at: rep.created_at,
is_private: rep.is_private,
firmware_file: rep.firmware_file,
corp_id: rep.corp_id,
health: stats.health,
strength: stats.strength,
intelligence: stats.intelligence,
})
.collect())
}
pub async fn get_replicant_full(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
) -> Result<ReplicantFull, DbError> {
use crate::schema::{replicants, replicants_stats};
let (rep, stats) = replicants::table
.inner_join(replicants_stats::table)
.filter(replicants::id.eq(replicant_id))
.select((Replicant::as_select(), ReplicantStats::as_select()))
.first(conn)
.await
.map_err(|e| match e {
diesel::result::Error::NotFound => DbError::NotFound,
_ => DbError::from(e),
})?;
Ok(ReplicantFull {
id: rep.id,
name: rep.name,
description: rep.description,
gender: rep.gender,
status: rep.status,
created_at: rep.created_at,
is_private: rep.is_private,
firmware_file: rep.firmware_file,
corp_id: rep.corp_id,
health: stats.health,
strength: stats.strength,
intelligence: stats.intelligence,
})
}
pub async fn get_stats(
conn: &mut AsyncPgConnection,
r_id: Uuid,
) -> Result<ReplicantStats, DbError> {
use crate::schema::replicants_stats::dsl::*;
replicants_stats
.find(r_id)
.first(conn)
.await
.map_err(Into::into)
}
pub async fn update_stats(
conn: &mut AsyncPgConnection,
r_id: Uuid,
stats: ReplicantStats,
) -> Result<ReplicantStats, DbError> {
use crate::schema::replicants_stats::dsl::*;
diesel::update(replicants_stats.find(r_id))
.set((
health.eq(stats.health),
strength.eq(stats.strength),
intelligence.eq(stats.intelligence),
))
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn get_count_by_status(
conn: &mut AsyncPgConnection,
status_query: ReplicantStatus,
) -> Result<i64, DbError> {
use crate::schema::replicants::dsl::*;
replicants
.filter(status.eq(status_query))
.count()
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn change_privacy(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
privacy: bool,
) -> Result<(), DbError> {
use crate::schema::replicants::dsl::*;
diesel::update(replicants.find(replicant_id))
.set(is_private.eq(privacy))
.execute(conn)
.await?;
Ok(())
}
pub async fn change_owner(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
new_owner_id: Uuid,
) -> Result<(), DbError> {
use crate::schema::replicants::dsl::*;
diesel::update(replicants.find(replicant_id))
.set((corp_id.eq(new_owner_id), is_private.eq(true)))
.execute(conn)
.await?;
Ok(())
}
pub async fn update_firmware(
conn: &mut AsyncPgConnection,
replicant_id: Uuid,
filename: String,
) -> Result<Replicant, DbError> {
use crate::schema::replicants::dsl::*;
diesel::update(replicants.find(replicant_id))
.set(firmware_file.eq(filename))
.get_result(conn)
.await
.map_err(Into::into)
}
}

View File

@@ -0,0 +1,137 @@
use crate::errors::DbError;
use crate::models::{Corp, NewUser, User, UserRole};
use diesel::prelude::*;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use uuid::Uuid;
pub struct UserRepository;
impl UserRepository {
pub async fn create_user(
conn: &mut AsyncPgConnection,
new_user: NewUser,
) -> Result<User, DbError> {
use crate::schema::users;
diesel::insert_into(users::table)
.values(&new_user)
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn get_user(
conn: &mut AsyncPgConnection,
user_id: Uuid,
) -> Result<Option<User>, DbError> {
use crate::schema::users::dsl::*;
users
.find(user_id)
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn find_by_username(
conn: &mut AsyncPgConnection,
username_query: &str,
) -> Result<Option<User>, DbError> {
use crate::schema::users::dsl::*;
users
.filter(username.eq(username_query))
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn update_role(
conn: &mut AsyncPgConnection,
user_id: Uuid,
new_role: UserRole,
) -> Result<User, DbError> {
use crate::schema::users::dsl::*;
diesel::update(users.find(user_id))
.set(role.eq(new_role))
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn update_corp_id(
conn: &mut AsyncPgConnection,
user_id: Uuid,
c_id: Option<Uuid>,
) -> Result<User, DbError> {
use crate::schema::users::dsl::*;
diesel::update(users.find(user_id))
.set(corp_id.eq(c_id))
.get_result(conn)
.await
.map_err(Into::into)
}
pub async fn get_user_corp(
conn: &mut AsyncPgConnection,
user_id: Uuid,
) -> Result<Option<Corp>, DbError> {
use crate::schema::{corps, users};
corps::table
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
.filter(users::id.eq(user_id))
.select(corps::all_columns)
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn get_user_with_corp(
conn: &mut AsyncPgConnection,
user_id: Uuid,
) -> Result<Option<(User, Corp)>, DbError> {
use crate::schema::{corps, users};
users::table
.find(user_id)
.inner_join(corps::table)
.first(conn)
.await
.optional()
.map_err(Into::into)
}
pub async fn get_users_by_corp_id(
conn: &mut AsyncPgConnection,
c_id: Uuid,
) -> Result<Vec<User>, DbError> {
use crate::schema::users::dsl::*;
users
.filter(corp_id.eq(c_id))
.load(conn)
.await
.map_err(Into::into)
}
pub async fn get_corp_admin(
conn: &mut AsyncPgConnection,
c_id: Uuid,
) -> Result<Option<User>, DbError> {
use crate::schema::users::dsl::*;
users
.filter(corp_id.eq(c_id))
.filter(role.eq(UserRole::CorpAdmin))
.first(conn)
.await
.optional()
.map_err(Into::into)
}
}

View File

@@ -0,0 +1,79 @@
// @generated automatically by Diesel CLI.
pub mod sql_types {
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "replicant_gender"))]
pub struct ReplicantGender;
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "replicant_status"))]
pub struct ReplicantStatus;
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "user_role"))]
pub struct UserRole;
}
diesel::table! {
corps (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
description -> Text,
created_at -> Timestamp,
#[max_length = 255]
invite_code -> Varchar,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::ReplicantStatus;
use super::sql_types::ReplicantGender;
replicants (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
description -> Text,
status -> ReplicantStatus,
created_at -> Timestamp,
gender -> ReplicantGender,
corp_id -> Uuid,
is_private -> Bool,
#[max_length = 255]
firmware_file -> Nullable<Varchar>,
}
}
diesel::table! {
replicants_stats (replicant_id) {
replicant_id -> Uuid,
health -> Int4,
strength -> Int4,
intelligence -> Int4,
created_at -> Timestamp,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::UserRole;
users (id) {
id -> Uuid,
#[max_length = 255]
username -> Varchar,
#[max_length = 255]
password -> Varchar,
role -> UserRole,
created_at -> Timestamp,
corp_id -> Nullable<Uuid>,
}
}
diesel::joinable!(replicants -> corps (corp_id));
diesel::joinable!(replicants_stats -> replicants (replicant_id));
diesel::joinable!(users -> corps (corp_id));
diesel::allow_tables_to_appear_in_same_query!(corps, replicants, replicants_stats, users,);

View File

@@ -0,0 +1,24 @@
[package]
name = "dollhouse-frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
yew = { version = "0.21", features = ["csr"] }
validator = { version = "0.20.0", features = ["derive"] }
gloo-net = "0.4"
gloo-timers = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
gloo-storage = "0.3"
gloo-events = "0.2"
wasm-logger = "0.2"
log = "0.4"
yew-router = "0.18"
once_cell = "1.21.3"
dollhouse-api-types = { path = "../dollhouse-api-types" }
uuid = { workspace = true }

View File

@@ -0,0 +1,14 @@
[build]
target = "index.html"
dist = "dist"
[watch]
watch = ["src", "index.html", "styles.css"]
[serve]
address = "127.0.0.1"
port = 3000
[[proxy]]
backend = "http://localhost:5555/api"

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOLLHOUSE - Replicant Management System</title>
<link data-trunk rel="sass" href="styles.css"/>
<link data-trunk rel="copy-dir" href="./static"/>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,212 @@
use crate::routes::Route;
use crate::services::{auth::AuthContext, ApiService};
use dollhouse_api_types::{CreateUserRequest, LoginRequest, UserResponse};
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Properties, PartialEq)]
pub struct AuthFormProps {
pub on_authenticated: Callback<UserResponse>,
pub context: AuthContext,
pub default_mode: AuthMode,
}
#[derive(PartialEq, Clone)]
pub enum AuthMode {
Login,
Register,
}
#[function_component]
pub fn AuthForm(props: &AuthFormProps) -> Html {
let username = use_state(|| String::new());
let password = use_state(|| String::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let mode = use_state(|| props.default_mode.clone());
let navigator = use_navigator().unwrap();
let on_toggle = {
let mode = mode.clone();
let username = username.clone();
let password = password.clone();
let error = error.clone();
let navigator = navigator.clone();
Callback::from(move |_| {
match *mode {
AuthMode::Login => navigator.push(&Route::Register),
AuthMode::Register => navigator.push(&Route::Login),
};
username.set(String::new());
password.set(String::new());
error.set(None);
})
};
let is_form_valid = !username.is_empty() && !password.is_empty();
let on_submit = {
let username = username.clone();
let password = password.clone();
let error = error.clone();
let loading = loading.clone();
let on_authenticated = props.on_authenticated.clone();
let mode = mode.clone();
let navigator = navigator.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let navigator = navigator.clone();
if !is_form_valid {
error.set(Some("Please fill all fields".to_string()));
return;
}
let username = (*username).clone();
let password = (*password).clone();
let error = error.clone();
let loading = loading.clone();
let on_authenticated = on_authenticated.clone();
let current_mode = (*mode).clone();
loading.set(true);
error.set(None);
spawn_local(async move {
let navigator = navigator.clone();
match current_mode {
AuthMode::Login => {
match ApiService::login(LoginRequest {
username: username.clone(),
password: password.clone(),
})
.await
{
Ok(user) => {
on_authenticated.emit(user);
navigator.push(&Route::Replicants);
}
Err(e) => {
error.set(Some(e));
}
}
}
AuthMode::Register => {
if let Err(e) = ApiService::register(CreateUserRequest {
username: username.clone(),
password: password.clone(),
})
.await
{
error.set(Some(e));
} else {
navigator.push(&Route::Login);
}
}
};
loading.set(false);
});
})
};
let is_login = matches!(*mode, AuthMode::Login);
html! {
<div class="auth-form-container">
<div class="auth-form">
<h2 class="auth-title">
{ if is_login { "REPLICANT LOGIN" } else { "CREATE ACCOUNT" } }
</h2>
<div class="auth-subtitle">
{ if is_login {
"Access the Dollhouse system"
} else {
"Register new user account"
}}
</div>
{ if let Some(err) = &*error {
html!{ <div class="auth-error">{err}</div> }
} else {
html!{}
} }
<form class="auth-fields" onsubmit={on_submit}>
<div class="form-field">
<label>{"USERNAME"}</label>
<input
type="text"
value={(*username).clone()}
placeholder="Enter your username"
oninput={{
let username = username.clone();
Callback::from(move |e: InputEvent| {
let input = e.target_unchecked_into::<web_sys::HtmlInputElement>();
username.set(input.value());
})
}}
disabled={*loading}
/>
</div>
<div class="form-field">
<label>{"PASSWORD"}</label>
<input
type="password"
value={(*password).clone()}
placeholder="Enter your password"
oninput={{
let password = password.clone();
Callback::from(move |e: InputEvent| {
let input = e.target_unchecked_into::<web_sys::HtmlInputElement>();
password.set(input.value());
})
}}
disabled={*loading}
/>
</div>
<button
class="auth-submit-btn"
type="submit"
disabled={*loading || !is_form_valid}
>
{ if *loading {
"PROCESSING..."
} else if is_login {
"LOGIN"
} else {
"REGISTER"
}}
</button>
</form>
<div class="auth-toggle">
<span class="toggle-text">
{ if is_login {
"New to the system?"
} else {
"Already have an account?"
}}
</span>
<button
class="toggle-btn"
onclick={on_toggle}
disabled={*loading}
>
{ if is_login {
"CREATE ACCOUNT"
} else {
"LOGIN"
}}
</button>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,90 @@
use dollhouse_api_types::{CorpResponse, StaffResponse, UserRole};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CorpInfoTabProps {
pub corp_data: CorpResponse,
}
#[function_component(CorpInfoTab)]
pub fn corp_info_tab(props: &CorpInfoTabProps) -> Html {
html! {
<div class="info-tab">
<div class="corp-content">
<div class="corp-header">
<div class="corp-basic-info">
<div class="corp-name">{&props.corp_data.name}</div>
<div class="corp-description">{&props.corp_data.description}</div>
</div>
</div>
<div class="info-content">
<div class="corp-details dashboard-card">
<h3>{"CORPORATION DETAILS"}</h3>
<div class="corp-details-grid">
<div class="detail-item">
<span class="detail-label">{"Corporation ID"}</span>
<span class="detail-value">{props.corp_data.id.to_string()}</span>
</div>
<div class="detail-item">
<span class="detail-label">{"Name"}</span>
<span class="detail-value">{&props.corp_data.name}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">{"Description"}</span>
<span class="detail-value">{&props.corp_data.description}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">{"Invite Code"}</span>
<div class="invite-section">
<div class="invite-code-display">
<code class="invite-code">{&props.corp_data.invite_code}</code>
</div>
<div class="invite-hint">
{"Share this code to invite members to your corporation"}
</div>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<h3>{"CORP STAFF"}</h3>
<div class="staff-list">
{if props.corp_data.staff.is_empty() {
html! {
<div class="no-staff-message">
<p>{"No staff members yet"}</p>
<p class="hint">{"Share the invite code above to add members"}</p>
</div>
}
} else {
props.corp_data.staff.iter().map(|staff: &StaffResponse| {
html! {
<div class="staff-item">
<div class="staff-info">
<span class="staff-username">{&staff.username}</span>
<span class="staff-id">{"ID: "}{staff.id}</span>
<span class={if staff.role == UserRole::CorpAdmin {
"status-badge"
} else {
"status-badge staff-member"
}}>
{if staff.role == UserRole::CorpAdmin {
"ADMIN"
} else {
"STAFF"
}}
</span>
</div>
</div>
}
}).collect::<Html>()
}}
</div>
</div>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,220 @@
use crate::components::CardType;
use crate::components::ReplicantCard;
use crate::services::ApiService;
use dollhouse_api_types::{CorpResponse, ReplicantFullResponse};
use uuid::Uuid;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
const PAGE_SIZE: usize = 10;
#[derive(Properties, PartialEq)]
pub struct CorpReplicantsTabProps {
pub corp_data: CorpResponse,
pub on_create_replicant: Callback<MouseEvent>,
}
#[function_component(CorpReplicantsTab)]
pub fn corp_replicants_tab(props: &CorpReplicantsTabProps) -> Html {
let replicants_data = use_state(Vec::<ReplicantFullResponse>::new);
let replicants_loading = use_state(|| true);
let replicants_error = use_state(|| None::<String>);
let current_page = use_state(|| 1);
let has_more = use_state(|| true);
{
let replicants_data = replicants_data.clone();
let replicants_loading = replicants_loading.clone();
let replicants_error = replicants_error.clone();
let current_page = current_page.clone();
let has_more = has_more.clone();
let corp_id = props.corp_data.id;
use_effect_with((corp_id, *current_page), move |(corp_id, page)| {
let corp_id_for_async = *corp_id;
let page_for_async = *page;
spawn_local(async move {
replicants_loading.set(true);
replicants_error.set(None);
match ApiService::get_corp_replicants(
corp_id_for_async,
Some(page_for_async),
Some(PAGE_SIZE),
)
.await
{
Ok(replicants) => {
replicants_data.set(replicants.clone());
if replicants.len() < PAGE_SIZE {
has_more.set(false);
} else {
has_more.set(true);
}
replicants_loading.set(false);
}
Err(e) => {
replicants_error.set(Some(format!("Failed to load replicants: {}", e)));
replicants_loading.set(false);
}
}
});
|| {}
});
}
let load_next_page = {
let current_page = current_page.clone();
let has_more = has_more.clone();
let replicants_loading = replicants_loading.clone();
Callback::from(move |_| {
if *has_more && !*replicants_loading {
replicants_loading.set(true);
current_page.set(*current_page + 1);
}
})
};
let load_prev_page = {
let current_page = current_page.clone();
let replicants_loading = replicants_loading.clone();
Callback::from(move |_| {
if *current_page > 1 && !*replicants_loading {
replicants_loading.set(true);
current_page.set(*current_page - 1);
}
})
};
html! {
<div class="replicants-tab">
<div class="replicants-content">
<div class="replicants-header">
<h3>{"REPLICANTS"}</h3>
<button class="btn-primary" onclick={props.on_create_replicant.clone()}>
{"Add New Replicant"}
</button>
</div>
{if *replicants_loading {
html! {
<div class="loading-container">
<div class="neural-spinner"></div>
<p class="loading-text">{"Accessing database..."}</p>
<div class="system-message">
{"[SYSTEM] Scanning replicant"}
</div>
</div>
}
} else if let Some(err) = &*replicants_error {
html! {
<div class="error-card">
<div class="error-header">
<span class="error-icon">{""}</span>
<span class="error-title">{"CONNECTION ERROR"}</span>
</div>
<p class="error-message">{err}</p>
<div class="system-message error">
{"[ERROR] Connection failed"}
</div>
<button
class="retry-btn"
onclick={Callback::from(move |_| {
replicants_loading.set(true);
current_page.set(1);
})}
>
{"Retry Connection"}
</button>
</div>
}
} else if replicants_data.is_empty() {
html! {
<div class="empty-state">
<div class="empty-icon">{"🔍"}</div>
<h3 class="empty-title">{"NO REPLICANTS FOUND"}</h3>
<p class="empty-message">
{"The corporation database is empty. Create your first replicant to get started."}
</p>
</div>
}
} else {
html! {
<>
<div class="database-header">
<h3 class="database-title">
<span class="title-accent">{"[CORPORATION REPLICANTS]"}</span>
<span class="title-page">
{format!(" [PAGE {:02}]", *current_page)}
</span>
</h3>
</div>
<div class="replicants-grid">
{replicants_data.iter().map(|replicant| {
html! {
<ReplicantCard
key={replicant.id.to_string()}
card_type={CardType::Corp}
replicant={replicant.clone()}
user_corp_id={None}
/>
}
}).collect::<Html>()}
</div>
<div class="fixed-pagination">
<div class="pagination-container">
<div class="pagination-info">
<span class="pagination-text">
{format!("PAGE {:02}", *current_page)}
</span>
</div>
<div class="pagination-controls">
<button
onclick={load_prev_page.clone()}
disabled={*current_page == 1 || *replicants_loading}
class="pagination-btn pagination-prev"
>
<span class="btn-icon">{""}</span>
<span class="btn-text">{"PREV"}</span>
<span class="btn-glow"></span>
</button>
<div class="pagination-indicator">
<div class="indicator-dots">
<div class="dot active"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span class="indicator-text">
{format!("{:02}", *current_page)}
</span>
</div>
<button
onclick={load_next_page.clone()}
disabled={!*has_more || *replicants_loading}
class="pagination-btn pagination-next"
>
<span class="btn-text">{"NEXT"}</span>
<span class="btn-icon">{""}</span>
<span class="btn-glow"></span>
</button>
</div>
</div>
</div>
</>
}
}}
</div>
</div>
}
}

View File

@@ -0,0 +1,158 @@
use crate::services::ApiService;
use uuid::Uuid;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CreateCorpModalProps {
pub on_close: Callback<MouseEvent>,
pub on_success: Callback<()>,
pub user_id: Uuid,
}
#[function_component(CreateCorpModal)]
pub fn create_corp_modal(props: &CreateCorpModalProps) -> Html {
let name = use_state(|| String::new());
let description = use_state(|| String::new());
let loading = use_state(|| false);
let error = use_state(|| None::<String>);
let on_name_input = {
let name = name.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
name.set(input.value());
})
};
let on_description_input = {
let description = description.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
description.set(input.value());
})
};
let on_close = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(MouseEvent::new("click").unwrap());
})
};
let on_submit = {
let name = name.clone();
let description = description.clone();
let loading = loading.clone();
let error = error.clone();
let on_success = props.on_success.clone();
let on_close = props.on_close.clone();
let user_id = props.user_id;
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if name.is_empty() || description.is_empty() {
error.set(Some("All fields are required".to_string()));
return;
}
loading.set(true);
error.set(None);
let name = name.to_string();
let description = description.to_string();
let loading = loading.clone();
let error = error.clone();
let on_success = on_success.clone();
let on_close = on_close.clone();
let user_id = user_id.clone();
spawn_local(async move {
match ApiService::create_corp(user_id, name, description).await {
Ok(_corp) => {
loading.set(false);
on_success.emit(());
on_close.emit(MouseEvent::new("click").unwrap());
}
Err(err) => {
loading.set(false);
error.set(Some(err));
}
}
});
})
};
html! {
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{"ESTABLISH CORPORATION"}</h2>
<button class="modal-close" onclick={on_close.clone()}>
{"×"}
</button>
</div>
<form class="corp-form" onsubmit={on_submit}>
{if let Some(error_msg) = &*error {
html! {
<div class="auth-error">
{error_msg}
</div>
}
} else {
html! {}
}}
<div class="form-field">
<label for="corp-name">{"CORPORATION NAME"}</label>
<input
type="text"
id="corp-name"
value={(*name).clone()}
oninput={on_name_input}
placeholder="Enter corporation name"
disabled={*loading}
autofocus=true
/>
</div>
<div class="form-field">
<label for="corp-description">{"DESCRIPTION"}</label>
<textarea
id="corp-description"
value={(*description).clone()}
oninput={on_description_input}
placeholder="Describe your corporation's purpose"
rows=4
disabled={*loading}
/>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={on_close}
disabled={*loading}
>
{"CANCEL"}
</button>
<button
type="submit"
class="btn-primary"
disabled={*loading || name.is_empty() || description.is_empty()}
>
if *loading {
<span class="btn-loading">{"PROCESSING..."}</span>
} else {
{"ESTABLISH"}
}
</button>
</div>
</form>
</div>
</div>
}
}

View File

@@ -0,0 +1,205 @@
use crate::services::ApiService;
use dollhouse_api_types::*;
use uuid::Uuid;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CreateReplicantModalProps {
pub on_close: Callback<MouseEvent>,
pub on_success: Callback<()>,
pub corp_id: Uuid,
}
#[function_component(CreateReplicantModal)]
pub fn create_replicant_modal(props: &CreateReplicantModalProps) -> Html {
let name = use_state(|| String::new());
let description = use_state(|| String::new());
let gender = use_state(|| ReplicantGender::NonBinary);
let loading = use_state(|| false);
let error = use_state(|| None::<String>);
let on_name_input = {
let name = name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
name.set(input.value());
})
};
let on_description_input = {
let description = description.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
description.set(input.value());
})
};
let on_gender_change = {
let gender = gender.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
match input.value().as_str() {
"male" => gender.set(ReplicantGender::Male),
"female" => gender.set(ReplicantGender::Female),
"non-binary" => gender.set(ReplicantGender::NonBinary),
_ => {}
}
})
};
let on_close = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_close.emit(e);
})
};
let on_submit = {
let name = name.clone();
let description = description.clone();
let gender = gender.clone();
let loading = loading.clone();
let error = error.clone();
let on_success = props.on_success.clone();
let corp_id = props.corp_id;
let on_close = on_close.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if name.is_empty() || description.is_empty() {
error.set(Some("Name and description are required".to_string()));
return;
}
loading.set(true);
error.set(None);
let name = name.to_string();
let description = description.to_string();
let gender = (*gender).clone();
let loading = loading.clone();
let error = error.clone();
let on_success = on_success.clone();
let on_close = on_close.clone();
spawn_local(async move {
let on_close = on_close.clone();
let new_replicant = CreateReplicantRequest {
name: name.clone(),
description: description.clone(),
gender: gender.clone(),
corp_id,
};
match ApiService::create_replicant(corp_id, new_replicant).await {
Ok(_) => {
loading.set(false);
on_success.emit(());
on_close.emit(MouseEvent::new("click").unwrap());
}
Err(err) => {
loading.set(false);
error.set(Some(err.to_string()));
}
}
});
})
};
html! {
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{"CREATE REPLICANT"}</h2>
<button class="modal-close" onclick={on_close.clone()}>{"×"}</button>
</div>
<form class="replicant-form" onsubmit={on_submit}>
{if let Some(error_msg) = &*error {
html! {
<div class="auth-error">
{error_msg}
</div>
}
} else {
html! {}
}}
<div class="form-row">
<div class="form-field">
<label for="replicant-name">{"NAME"}</label>
<input
type="text"
id="replicant-name"
value={(*name).clone()}
oninput={on_name_input}
placeholder="Enter replicant name"
disabled={*loading}
autofocus=true
/>
</div>
<div class="form-field">
<label for="replicant-gender">{"GENDER"}</label>
<select
id="replicant-gender"
oninput={on_gender_change}
disabled={*loading}
>
<option value="male" selected={*gender == ReplicantGender::Male}>
{"Male"}
</option>
<option value="female" selected={*gender == ReplicantGender::Female}>
{"Female"}
</option>
<option value="non-binary" selected={*gender == ReplicantGender::NonBinary}>
{"Non-binary"}
</option>
</select>
</div>
</div>
<div class="form-field">
<label for="replicant-description">{"DESCRIPTION"}</label>
<textarea
id="replicant-description"
value={(*description).clone()}
oninput={on_description_input}
placeholder="Describe the replicant's purpose and characteristics"
rows=4
disabled={*loading}
/>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={on_close}
disabled={*loading}
>
{"CANCEL"}
</button>
<button
type="submit"
class="btn-primary"
disabled={*loading || name.is_empty() || description.is_empty()}
>
if *loading {
<span class="btn-loading">{"CREATING..."}</span>
} else {
{"CREATE REPLICANT"}
}
</button>
</div>
</form>
</div>
</div>
}
}

View File

@@ -0,0 +1,136 @@
use crate::services::ApiService;
use uuid::Uuid;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct JoinCorpModalProps {
pub on_close: Callback<MouseEvent>,
pub on_success: Callback<()>,
pub user_id: Uuid,
}
#[function_component(JoinCorpModal)]
pub fn join_corp_modal(props: &JoinCorpModalProps) -> Html {
let invite_code = use_state(|| String::new());
let loading = use_state(|| false);
let error = use_state(|| None::<String>);
let on_invite_code_input = {
let invite_code = invite_code.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
invite_code.set(input.value());
})
};
let on_close = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_close.emit(e);
})
};
let on_submit = {
let invite_code = invite_code.clone();
let loading = loading.clone();
let error = error.clone();
let on_success = props.on_success.clone();
let on_close = props.on_close.clone();
let user_id = props.user_id;
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if invite_code.is_empty() {
error.set(Some("All fields are required".to_string()));
return;
}
loading.set(true);
error.set(None);
let invite_code = invite_code.to_string();
let loading = loading.clone();
let error = error.clone();
let on_success = on_success.clone();
let on_close = on_close.clone();
let user_id = user_id.clone();
spawn_local(async move {
match ApiService::join_corp(user_id, invite_code).await {
Ok(_corp) => {
loading.set(false);
on_success.emit(());
on_close.emit(MouseEvent::new("click").unwrap());
}
Err(err) => {
loading.set(false);
error.set(Some(err));
}
}
});
})
};
html! {
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{"ESTABLISH CORPORATION"}</h2>
<button class="modal-close" onclick={on_close.clone()}>
{"×"}
</button>
</div>
<form class="corp-form" onsubmit={on_submit}>
{if let Some(error_msg) = &*error {
html! {
<div class="auth-error">
{error_msg}
</div>
}
} else {
html! {}
}}
<div class="form-field">
<label for="corp-name">{"INVITE CODE"}</label>
<input
type="text"
id="corp-name"
value={(*invite_code).clone()}
oninput={on_invite_code_input}
placeholder="Enter invite code"
disabled={*loading}
autofocus=true
/>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={on_close}
disabled={*loading}
>
{"CANCEL"}
</button>
<button
type="submit"
class="btn-primary"
disabled={*loading || invite_code.is_empty()}
>
if *loading {
<span class="btn-loading">{"PROCESSING..."}</span>
} else {
{"ESTABLISH"}
}
</button>
</div>
</form>
</div>
</div>
}
}

View File

@@ -0,0 +1,5 @@
pub mod corp_info_tab;
pub mod corp_replicants_tab;
pub mod create_corp_form;
pub mod create_replicant_modal;
pub mod join_corp_modal;

View File

@@ -0,0 +1,34 @@
use chrono::Utc;
use gloo_timers::callback::Interval;
use yew::prelude::*;
#[function_component]
pub fn Header() -> Html {
let current_time = use_state(|| Utc::now());
{
let current_time = current_time.clone();
use_effect_with((), move |_| {
let interval = Interval::new(1000, move || {
current_time.set(Utc::now());
});
move || {
interval.cancel();
}
});
}
html! {
<header class="header">
<div class="logo">
<h1>{"DOLLHOUSE"}</h1>
<p>{"Replicant Management System"}</p>
</div>
<div class="status-bar">
<span class="status-indicator">{"SYSTEM ONLINE"}</span>
<span class="time">{current_time.format("%Y-%m-%d %H:%M:%S UTC").to_string()}</span>
</div>
</header>
}
}

View File

@@ -0,0 +1,79 @@
use crate::{
components::{Header, Sidebar},
routes::Route,
services::auth::use_auth,
};
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Properties, PartialEq)]
pub struct LayoutProps {
pub children: Children,
}
#[function_component(Layout)]
pub fn layout(props: &LayoutProps) -> Html {
let active_page = use_state(|| "replicants".to_string());
let auth_context = use_auth();
let navigator = use_navigator().unwrap();
use_effect_with(
(auth_context.is_loading, auth_context.is_authenticated()),
{
let navigator = navigator.clone();
move |(loading, authenticated)| {
if !loading && !authenticated {
web_sys::console::log_1(&"Layout: No auth, redirecting to login".into());
navigator.replace(&Route::Login);
}
|| {}
}
},
);
if auth_context.is_loading {
return html! {
<div class="app">
<div class="auth-loading">
<div class="spinner"></div>
<p>{"Loading session..."}</p>
</div>
</div>
};
}
if !auth_context.is_authenticated() {
return html! {
<div class="app">
<div class="auth-redirecting">
<div class="spinner"></div>
<p>{"Redirecting to login..."}</p>
</div>
</div>
};
}
web_sys::console::log_1(&"Layout: User authenticated, rendering content".into());
let on_navigation = {
let active_page = active_page.clone();
Callback::from(move |page: String| {
active_page.set(page);
})
};
html! {
<div class="app">
<Header />
<main class="main-content">
<Sidebar
active_page={(*active_page).clone()}
on_navigation={on_navigation}
/>
{props.children.clone()}
</main>
</div>
}
}

View File

@@ -0,0 +1,20 @@
pub mod auth_form;
pub mod corp;
pub mod header;
pub mod layout;
pub mod pagination;
pub mod replicant_card;
pub mod sidebar;
pub use auth_form::{AuthForm, AuthMode};
pub use corp::corp_info_tab::CorpInfoTab;
pub use corp::corp_replicants_tab::CorpReplicantsTab;
pub use corp::create_corp_form::CreateCorpModal;
pub use corp::create_replicant_modal::CreateReplicantModal;
pub use corp::join_corp_modal::JoinCorpModal;
pub use header::Header;
pub use layout::Layout;
pub use pagination::Pagination;
pub use replicant_card::CardType;
pub use replicant_card::ReplicantCard;
pub use sidebar::Sidebar;

View File

@@ -0,0 +1,108 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct PaginationProps {
pub current_page: usize,
pub total_pages: usize,
pub on_page_change: Callback<usize>,
pub on_next: Callback<MouseEvent>,
pub on_prev: Callback<MouseEvent>,
pub on_first: Callback<MouseEvent>,
pub on_last: Callback<MouseEvent>,
}
#[function_component]
pub fn Pagination(props: &PaginationProps) -> Html {
let current = props.current_page;
let total = props.total_pages;
let page_numbers = {
let mut pages = Vec::new();
if current > 3 {
pages.push(1);
if current > 4 {
pages.push(0);
}
}
let start = (current as i32 - 2).max(1) as usize;
let end = (current + 2).min(total);
for page in start..=end {
pages.push(page);
}
if current < total - 2 {
if current < total - 3 {
pages.push(0);
}
pages.push(total);
}
pages
};
html! {
<div class="pagination">
<div class="pagination-info">
<span class="page-info">
{"Page "}<strong>{current}</strong>{" of "}<strong>{total}</strong>
</span>
</div>
<div class="pagination-buttons">
<button
class="pagination-btn first"
onclick={props.on_first.clone()}
disabled={current <= 1}
>
{"« First"}
</button>
<button
class="pagination-btn prev"
onclick={props.on_prev.clone()}
disabled={current <= 1}
>
{" Prev"}
</button>
{for page_numbers.iter().map(|&page| {
if page == 0 {
html! { <span class="pagination-ellipsis">{"..."}</span> }
} else {
let is_current = page == current;
let page_callback = props.on_page_change.clone();
html! {
<button
class={classes!("pagination-btn", "page-number", is_current.then_some("active"))}
onclick={Callback::from(move |_| page_callback.emit(page))}
disabled={is_current}
>
{page}
</button>
}
}
})}
<button
class="pagination-btn next"
onclick={props.on_next.clone()}
disabled={current >= total}
>
{"Next "}
</button>
<button
class="pagination-btn last"
onclick={props.on_last.clone()}
disabled={current >= total}
>
{"Last »"}
</button>
</div>
</div>
}
}

View File

@@ -0,0 +1,199 @@
use crate::routes::Route;
use crate::AuthContext;
use dollhouse_api_types::ReplicantFullResponse;
use dollhouse_api_types::{ReplicantGender, ReplicantStatus};
use uuid::Uuid;
use yew::platform::spawn_local;
use yew::prelude::*;
use yew_router::hooks::use_navigator;
use crate::services::ApiService;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CardType {
Corp,
Public,
}
fn status_display_name(status: ReplicantStatus) -> String {
match status {
ReplicantStatus::Active => "Active".to_string(),
ReplicantStatus::Decommissioned => "Decommissioned".to_string(),
}
}
pub fn status_color(status: ReplicantStatus) -> &'static str {
match status {
ReplicantStatus::Active => "#00ff41",
ReplicantStatus::Decommissioned => "#ff0000",
}
}
pub fn gender_color(gender: &ReplicantGender) -> &'static str {
match gender {
ReplicantGender::Male => "#00ff41",
ReplicantGender::Female => "#ff6b35",
ReplicantGender::NonBinary => "#ff0000",
}
}
fn gender_svg(gender: &ReplicantGender) -> Html {
match gender {
ReplicantGender::Male => html! {
<img src="/static/male.svg" alt="Male" class="gender-icon" />
},
ReplicantGender::Female => html! {
<img src="/static/female.svg" alt="Female" class="gender-icon" />
},
ReplicantGender::NonBinary => html! {
<img src="/static/non-binary.svg" alt="Non-binary" class="gender-icon" />
},
}
}
fn stat_color(value: i32) -> &'static str {
match value {
0..=30 => "#ff4444",
31..=70 => "#ffaa00",
_ => "#00ff41",
}
}
fn stat_bar(value: i32, max: i32) -> Html {
let percentage = (value as f32 / max as f32 * 100.0) as i32;
html! {
<div class="stat-bar">
<div
class="stat-bar-fill"
style={format!("width: {}%; background-color: {}", percentage, stat_color(value))}
/>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ReplicantCardProps {
pub card_type: CardType,
pub replicant: ReplicantFullResponse,
pub user_corp_id: Option<Uuid>,
}
#[function_component]
pub fn ReplicantCard(props: &ReplicantCardProps) -> Html {
let auth_context = use_context::<AuthContext>().expect("AuthContext not found");
let user_corp_id = auth_context.user.as_ref().and_then(|user| user.corp_id);
let on_take = {
let replicant = props.replicant.clone();
let corp_id = props.user_corp_id.clone();
Callback::from(move |_: MouseEvent| {
spawn_local(async move {
match ApiService::change_replicant_owner(replicant.id, corp_id.unwrap()).await {
Ok(_) => {}
Err(_) => {}
}
});
})
};
let on_change_privacy = {
let replicant = props.replicant.clone();
Callback::from(move |_: MouseEvent| {
spawn_local(async move {
match ApiService::change_replicant_privacy(replicant.id, !replicant.is_private)
.await
{
Ok(_) => {}
Err(_) => {}
}
});
})
};
let on_edit = {
let replicant = props.replicant.clone();
let nav = use_navigator().unwrap();
Callback::from(move |_: MouseEvent| nav.push(&Route::ReplicantDetail { id: replicant.id }))
};
html! {
<div class="replicant-card">
<div class="card-header">
<div class="replicant-title">
<h3>{&props.replicant.name}</h3>
{gender_svg(&props.replicant.gender)}
</div>
</div>
<div class="card-body">
<div class="status-line">
<span class="status-label">{"Status:"}</span>
<span class="status-badge" style={format!("background-color: {}", status_color(props.replicant.status.clone()))}>
{status_display_name(props.replicant.status.clone())}
</span>
</div>
<div class="status-line">
<span class="status-label">{"is private:"}</span>
<span class="status-label">
{props.replicant.is_private}
</span>
</div>
<p class="replicant-description">{&props.replicant.description}</p>
<div class="stats-section">
<h4>{"STATISTICS"}</h4>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"HEALTH"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.health))}>
{props.replicant.health}
</span>
</div>
{stat_bar(props.replicant.health, 100)}
</div>
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"STRENGTH"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.strength))}>
{props.replicant.strength}
</span>
</div>
{stat_bar(props.replicant.strength, 100)}
</div>
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"INTELLIGENCE"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.intelligence))}>
{props.replicant.intelligence}
</span>
</div>
{stat_bar(props.replicant.intelligence, 100)}
</div>
</div>
</div>
</div>
{ if user_corp_id.is_none() {
html! {}
} else if props.card_type == CardType::Public {
html! {
<div class="card-actions">
<button class="btn-secondary" onclick={on_take}>{"TAKE"}</button>
</div>
}
} else {
html! {
<div class="card-actions">
<button class="btn-secondary" onclick={on_change_privacy}>{"CHANGE PRIVACY"}</button>
<button class="btn-secondary" onclick={on_edit}>{"EDIT"}</button>
</div>
}
}}
</div>
}
}

View File

@@ -0,0 +1,74 @@
use crate::routes::Route;
use crate::services::auth::AuthContext;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub active_page: String,
pub on_navigation: Callback<String>,
}
#[function_component]
pub fn Sidebar(props: &SidebarProps) -> Html {
let auth_context = use_context::<AuthContext>().unwrap();
let nav_items = vec![
("replicants".to_string(), "Replicants"),
("corp".to_string(), "Corp"),
("logout".to_string(), "Logout"),
];
let navigator = use_navigator().unwrap();
let on_nav_click = {
let navigator = navigator.clone();
let on_navigation = props.on_navigation.clone();
let auth_context = auth_context.clone();
Callback::from(move |page: String| {
let navigator = navigator.clone();
let on_navigation = on_navigation.clone();
let auth_context = auth_context.clone();
match page.as_str() {
"replicants" => navigator.push(&Route::Replicants),
"logout" => {
spawn_local(async move {
let _ = auth_context.logout().await;
navigator.push(&Route::Login);
});
}
"corp" => navigator.push(&Route::Corp),
_ => {}
}
on_navigation.emit(page.clone());
})
};
html! {
<div class="sidebar">
<nav class="nav">
{nav_items.iter().filter_map(|(id, label)| {
let is_active = props.active_page == *id;
let nav_id = id.clone();
let on_click = {
let on_nav_click = on_nav_click.clone();
let nav_id = nav_id.clone();
Callback::from(move |_: MouseEvent| {
on_nav_click.emit(nav_id.clone());
})
};
Some(html! {
<button
class={if is_active { "nav-btn active" } else { "nav-btn" }}
onclick={on_click}
>
{label}
</button>
})
}).collect::<Html>()}
</nav>
</div>
}
}

View File

@@ -0,0 +1,28 @@
use crate::components::Layout;
use crate::routes::switch;
use crate::routes::Route;
use crate::services::auth::{use_auth, AuthContext};
use yew::prelude::*;
use yew_router::prelude::*;
mod components;
mod pages;
mod routes;
mod services;
#[function_component(App)]
fn app() -> Html {
let auth_context = use_auth();
html! {
<ContextProvider<AuthContext> context={auth_context}>
<BrowserRouter>
<Switch<Route> render={switch} />
</BrowserRouter>
</ContextProvider<AuthContext>>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}

View File

@@ -0,0 +1,267 @@
use crate::components::{
CorpInfoTab, CorpReplicantsTab, CreateCorpModal, CreateReplicantModal, JoinCorpModal,
};
use crate::services::{auth::use_auth, ApiService};
use dollhouse_api_types::CorpResponse;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[derive(PartialEq, Clone)]
enum ActiveTab {
Info,
Replicants,
}
#[function_component(CorpPage)]
pub fn corp_page() -> Html {
let corp_data = use_state(|| None::<CorpResponse>);
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let auth_context = use_auth();
let show_create_modal = use_state(|| false);
let show_join_modal = use_state(|| false);
let show_create_replicant_modal = use_state(|| false);
let active_tab = use_state(|| ActiveTab::Info);
let switch_to_info = {
let active_tab = active_tab.clone();
Callback::from(move |_| {
active_tab.set(ActiveTab::Info);
})
};
let switch_to_replicants = {
let active_tab = active_tab.clone();
Callback::from(move |_| {
active_tab.set(ActiveTab::Replicants);
})
};
let open_create_replicant_modal = {
let show_create_replicant_modal = show_create_replicant_modal.clone();
Callback::from(move |_: MouseEvent| {
show_create_replicant_modal.set(true);
})
};
let close_modal = {
let show_create_modal = show_create_modal.clone();
let show_join_modal = show_join_modal.clone();
let show_create_replicant_modal = show_create_replicant_modal.clone();
Callback::from(move |_: MouseEvent| {
show_create_modal.set(false);
show_join_modal.set(false);
show_create_replicant_modal.set(false);
})
};
let on_success = {
let corp_data = corp_data.clone();
let loading = loading.clone();
let show_create_modal = show_create_modal.clone();
let show_join_modal = show_join_modal.clone();
let auth_context = auth_context.clone();
Callback::from(move |_| {
show_create_modal.set(false);
show_join_modal.set(false);
if let Some(user) = &auth_context.user {
let user_id = user.id;
let corp_data = corp_data.clone();
let loading = loading.clone();
spawn_local(async move {
match ApiService::get_user_corp(user_id).await {
Ok(Some(corp)) => {
corp_data.set(Some(corp));
loading.set(false);
}
Ok(None) => {
corp_data.set(None);
loading.set(false);
}
Err(_) => {
corp_data.set(None);
loading.set(false);
}
}
});
}
})
};
{
let corp_data = corp_data.clone();
let loading = loading.clone();
use_effect_with(auth_context.user.clone(), move |user| {
if let Some(user) = user {
let user_id = user.id;
spawn_local(async move {
match ApiService::get_user_corp(user_id).await {
Ok(Some(corp)) => {
corp_data.set(Some(corp));
loading.set(false);
}
Ok(None) => {
corp_data.set(None);
loading.set(false);
}
Err(_) => {
corp_data.set(None);
loading.set(false);
}
}
});
}
|| {}
});
}
let open_create_corp_modal = {
let show_modal = show_create_modal.clone();
Callback::from(move |_: MouseEvent| {
show_modal.set(true);
})
};
let open_join_corp_modal = {
let show_modal = show_join_modal.clone();
Callback::from(move |_: MouseEvent| {
show_modal.set(true);
})
};
let tab_content = if let Some(corp) = &*corp_data {
match &*active_tab {
ActiveTab::Info => html! {
<CorpInfoTab corp_data={corp.clone()} />
},
ActiveTab::Replicants => html! {
<CorpReplicantsTab
corp_data={corp.clone()}
on_create_replicant={open_create_replicant_modal.clone()}
/>
},
}
} else {
html! {}
};
let modal = if *show_create_modal {
if let Some(user) = &auth_context.user {
html! {
<CreateCorpModal
on_close={close_modal}
on_success={on_success.clone()}
user_id={user.id}
/>
}
} else {
html! {}
}
} else if *show_join_modal {
if let Some(user) = &auth_context.user {
html! {
<JoinCorpModal
on_close={close_modal}
on_success={on_success.clone()}
user_id={user.id}
/>
}
} else {
html! {}
}
} else if *show_create_replicant_modal {
if let Some(corp) = &*corp_data {
html! {
<CreateReplicantModal
on_close={close_modal}
on_success={on_success.clone()}
corp_id={corp.id}
/>
}
} else {
html! {}
}
} else {
html! {}
};
html! {
<div class="content">
<div class="page-header-with-tabs">
<div class="header-main">
<div class="content-header">
<h2>{"Corporation"}</h2>
</div>
{if corp_data.is_some() && *active_tab == ActiveTab::Replicants {
html! {}
} else {
html! {}
}}
</div>
{if corp_data.is_some() {
html! {
<div class="tabs-container">
<button
class={if *active_tab == ActiveTab::Info { "btn-primary active" } else { "btn-secondary" }}
onclick={switch_to_info}
>
{"INFO"}
</button>
<button
class={if *active_tab == ActiveTab::Replicants { "btn-primary active" } else { "btn-secondary" }}
onclick={switch_to_replicants}
>
{"REPLICANTS"}
</button>
</div>
}
} else {
html! {}
}}
</div>
{if *loading {
html! {
<div class="loading">
<div class="loading-spinner"></div>
<p>{"loading..."}</p>
</div>
}
} else if let Some(error_msg) = &*error {
html! {
<div class="error">
<h2>{"ERROR"}</h2>
<p>{error_msg}</p>
<button class="btn-primary" onclick={Callback::from(|_| ())}>
{"REPEAT"}
</button>
</div>
}
} else if let Some(_corp) = &*corp_data {
html! {
<div class="corp-content">
{tab_content}
</div>
}
} else {
html! {
<div class="no-corp">
<h2>{"CORP NOT FOUND"}</h2>
<p>{"You don't have an active corporation"}</p>
<div class="no-corp-actions">
<button class="btn-primary" onclick={open_create_corp_modal}>{"CREATE CORP"}</button>
<button class="btn-secondary" onclick={open_join_corp_modal}>{"JOIN CORP"}</button>
</div>
</div>
}
}}
{modal}
</div>
}
}

View File

@@ -0,0 +1,30 @@
use crate::components::{AuthForm, AuthMode};
use crate::routes::Route;
use crate::services::auth::use_auth;
use dollhouse_api_types::UserResponse;
use yew::prelude::*;
use yew_router::hooks::use_navigator;
#[function_component]
pub fn LoginPage() -> Html {
let auth_context = use_auth();
let navigator = use_navigator().unwrap();
let on_authenticated = {
let auth_context = auth_context.clone();
Callback::from(move |user: UserResponse| {
auth_context.set_user.emit(Some(user));
navigator.push(&Route::Corp);
})
};
html! {
<div class="login-page">
<AuthForm
default_mode={AuthMode::Login}
on_authenticated={on_authenticated}
context={auth_context}
/>
</div>
}
}

View File

@@ -0,0 +1,13 @@
pub mod corp;
pub mod login_page;
pub mod not_found;
pub mod register_page;
pub mod replicant;
pub mod replicants;
pub use corp::CorpPage;
pub use login_page::LoginPage;
pub use not_found::NotFound;
pub use register_page::RegisterPage;
pub use replicant::ReplicantDetail;
pub use replicants::ReplicantsPage;

View File

@@ -0,0 +1,11 @@
use yew::prelude::*;
#[function_component(NotFound)]
pub fn not_found() -> Html {
html! {
<div class="not-found">
<h1>{"404 Not Found"}</h1>
<p>{"The page you are looking for does not exist."}</p>
</div>
}
}

View File

@@ -0,0 +1,30 @@
use crate::components::{AuthForm, AuthMode};
use crate::routes::Route;
use crate::services::auth::use_auth;
use dollhouse_api_types::UserResponse;
use yew::prelude::*;
use yew_router::hooks::use_navigator;
#[function_component]
pub fn RegisterPage() -> Html {
let auth_context = use_auth();
let nav = use_navigator().unwrap();
let on_register = {
let auth_context = auth_context.clone();
Callback::from(move |user: UserResponse| {
auth_context.set_user.emit(Some(user));
nav.push(&Route::Login)
})
};
html! {
<div class="login-page">
<AuthForm
default_mode={AuthMode::Register}
on_authenticated={on_register}
context={auth_context}
/>
</div>
}
}

View File

@@ -0,0 +1,454 @@
use crate::services::ApiService;
use dollhouse_api_types::ReplicantFullResponse;
use dollhouse_api_types::{ReplicantGender, ReplicantStatus};
use uuid::Uuid;
use wasm_bindgen_futures::spawn_local;
use web_sys::{File, HtmlInputElement};
use yew::prelude::*;
#[derive(Clone, PartialEq, Properties)]
pub struct ReplicantDetailProps {
pub replicant_id: Uuid,
}
fn status_display_name(status: ReplicantStatus) -> String {
match status {
ReplicantStatus::Active => "Active".to_string(),
ReplicantStatus::Decommissioned => "Decommissioned".to_string(),
}
}
pub fn status_color(status: ReplicantStatus) -> &'static str {
match status {
ReplicantStatus::Active => "var(--primary-neon)",
ReplicantStatus::Decommissioned => "var(--danger-neon)",
}
}
pub fn gender_color(gender: &ReplicantGender) -> &'static str {
match gender {
ReplicantGender::Male => "var(--primary-neon)",
ReplicantGender::Female => "var(--secondary-neon)",
ReplicantGender::NonBinary => "var(--accent-neon)",
}
}
fn stat_color(value: i32) -> &'static str {
match value {
0..=30 => "var(--danger-neon)",
31..=70 => "var(--accent-neon)",
_ => "var(--primary-neon)",
}
}
fn gender_svg(gender: &ReplicantGender) -> Html {
match gender {
ReplicantGender::Male => html! {
<img src="/static/male.svg" alt="Male" class="gender-icon" />
},
ReplicantGender::Female => html! {
<img src="/static/female.svg" alt="Female" class="gender-icon" />
},
ReplicantGender::NonBinary => html! {
<img src="/static/non-binary.svg" alt="Non-binary" class="gender-icon" />
},
}
}
fn stat_bar(value: i32, max: i32) -> Html {
let percentage = (value as f32 / max as f32 * 100.0) as i32;
html! {
<div class="stat-bar">
<div
class="stat-bar-fill"
style={format!("width: {}%; background-color: {}", percentage, stat_color(value))}
/>
</div>
}
}
#[function_component(ReplicantDetail)]
pub fn replicant_detail(props: &ReplicantDetailProps) -> Html {
let replicant_data = use_state(|| None::<ReplicantFullResponse>);
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let running_firmware = use_state(|| false);
let firmware_output = use_state(|| None::<String>);
let show_firmware_output = use_state(|| false);
let show_firmware_form = use_state(|| false);
let selected_file = use_state(|| None::<File>);
let uploading = use_state(|| false);
{
let replicant_data = replicant_data.clone();
let loading = loading.clone();
let error = error.clone();
let replicant_id = props.replicant_id;
use_effect_with(props.replicant_id, move |_| {
spawn_local(async move {
match ApiService::get_replicant(replicant_id).await {
Ok(replicant) => {
replicant_data.set(Some(replicant));
loading.set(false);
}
Err(err) => {
error.set(Some(err.to_string()));
loading.set(false);
}
}
});
|| {}
});
}
let toggle_load_firmware_form = {
let show_firmware_form = show_firmware_form.clone();
Callback::from(move |_: MouseEvent| {
show_firmware_form.set(!*show_firmware_form);
})
};
let on_run_firmware_click = {
let replicant_id = props.replicant_id;
let running_firmware = running_firmware.clone();
let firmware_output = firmware_output.clone();
let error = error.clone();
let show_firmware_output = show_firmware_output.clone();
Callback::from(move |_: MouseEvent| {
running_firmware.set(true);
firmware_output.set(None);
show_firmware_output.set(true);
let replicant_id = replicant_id;
let running_firmware = running_firmware.clone();
let firmware_output = firmware_output.clone();
let error = error.clone();
spawn_local(async move {
match ApiService::run_firmware(replicant_id).await {
Ok(output) => {
firmware_output.set(Some(output.output));
}
Err(err) => {
error.set(Some(err.to_string()));
}
}
running_firmware.set(false);
});
})
};
let toggle_firmware_output = {
let show_firmware_output = show_firmware_output.clone();
Callback::from(move |_: MouseEvent| {
show_firmware_output.set(!*show_firmware_output);
})
};
let clear_firmware_output = {
let firmware_output = firmware_output.clone();
let show_firmware_output = show_firmware_output.clone();
Callback::from(move |_: MouseEvent| {
firmware_output.set(None);
show_firmware_output.set(false);
})
};
let on_file_change = {
let selected_file = selected_file.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Some(files) = input.files() {
if files.length() > 0 {
if let Some(file) = files.get(0) {
selected_file.set(Some(file));
}
} else {
selected_file.set(None);
}
}
})
};
let upload_firmware = {
let selected_file = selected_file.clone();
let uploading = uploading.clone();
let replicant_id = props.replicant_id;
let show_firmware_form = show_firmware_form.clone();
let replicant_data = replicant_data.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
if let Some(file) = &*selected_file {
uploading.set(true);
let file = file.clone();
let uploading = uploading.clone();
let show_firmware_form = show_firmware_form.clone();
let replicant_data = replicant_data.clone();
let replicant_id = replicant_id;
let error = error.clone();
spawn_local(async move {
match ApiService::load_firmware(replicant_id, file).await {
Ok(_) => {
uploading.set(false);
show_firmware_form.set(false);
match ApiService::get_replicant(replicant_id).await {
Ok(updated_replicant) => {
replicant_data.set(Some(updated_replicant));
}
Err(err) => error.set(Some(err.to_string())),
}
}
Err(err) => {
uploading.set(false);
error.set(Some(err.to_string()));
}
}
});
}
})
};
if *loading {
return html! {
<div class="loading-container">
<div class="loading-spinner"></div>
<p>{"Loading replicant data..."}</p>
</div>
};
}
if let Some(error_msg) = &*error {
return html! {
<div class="error-container">
<h2>{"ERROR"}</h2>
<p>{error_msg}</p>
</div>
};
}
if replicant_data.is_none() {
return html! {
<div class="error-container">
<h2>{"REPLICANT NOT FOUND"}</h2>
</div>
};
}
let replicant = replicant_data.as_ref().unwrap();
let has_firmware = replicant.firmware_file.is_some();
html! {
<div class="replicant-detail-container">
<div class="replicant-detail">
<div class="detail-header">
<div class="replicant-title">
{gender_svg(&replicant.gender)}
<h1>{&replicant.name}</h1>
</div>
<div class="header-actions">
<button
class="btn-secondary"
onclick={toggle_load_firmware_form.clone()}
disabled={*uploading}
>
{"LOAD FIRMWARE"}
</button>
<button
class="btn-primary"
onclick={on_run_firmware_click.clone()}
disabled={*running_firmware || !has_firmware}
title={if !has_firmware { "No firmware loaded" } else { "" }}
>
{if *running_firmware { "RUNNING..." } else { "RUN FIRMWARE" }}
</button>
</div>
</div>
<div class="detail-content">
<div class="info-section">
<h2>{"BASIC INFORMATION"}</h2>
<div class="info-grid">
<div class="info-item">
<label>{"ID"}</label>
<span>{replicant.id.to_string()}</span>
</div>
<div class="info-item">
<label>{"GENDER"}</label>
<span style={format!("color: {}", gender_color(&replicant.gender))}>
{format!("{:?}", &replicant.gender)}
</span>
</div>
<div class="info-item">
<label>{"STATUS"}</label>
<span class="status-badge" style={format!("background-color: {}", status_color(replicant.status.clone()))}>
{status_display_name(replicant.status.clone())}
</span>
</div>
</div>
</div>
<div class="info-section">
<h2>{"DESCRIPTION"}</h2>
<div class="description-box">
{&replicant.description}
</div>
</div>
<div class="info-section">
<h2>{"FIRMWARE"}</h2>
<div class="firmware-panel">
{if has_firmware {
let firmware_filename = replicant.firmware_file.as_ref().unwrap();
html! {
<>
<div class="firmware-info-grid">
<div class="info-item">
<label>{"FILE NAME"}</label>
<span class="firmware-filename">{firmware_filename}</span>
</div>
<div class="info-item">
<label>{"STATUS"}</label>
<span class="firmware-status">{"Loaded"}</span>
</div>
</div>
{if firmware_output.is_some() {
html! {
<div class="firmware-output-status">
<span class="output-label">{"Output available"}</span>
<button
class="btn-secondary"
onclick={toggle_firmware_output.clone()}
>
{if *show_firmware_output { "Hide output" } else { "Show output" }}
</button>
</div>
}
} else {
html! {}
}}
</>
}
} else {
html! {
<div class="no-firmware">
<p>{"No firmware loaded"}</p>
</div>
}
}}
</div>
</div>
<div class="info-section">
<h2>{"STATISTICS"}</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"HEALTH"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(replicant.health))}>
{replicant.health}
</span>
</div>
{stat_bar(replicant.health, 100)}
</div>
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"STRENGTH"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(replicant.strength))}>
{replicant.strength}
</span>
</div>
{stat_bar(replicant.strength, 100)}
</div>
<div class="stat-item">
<div class="stat-header">
<span class="stat-label">{"INTELLIGENCE"}</span>
<span class="stat-value" style={format!("color: {}", stat_color(replicant.intelligence))}>
{replicant.intelligence}
</span>
</div>
{stat_bar(replicant.intelligence, 100)}
</div>
</div>
</div>
</div>
{if *show_firmware_output && firmware_output.is_some() {
html! {
<div class="firmware-output-section">
<div class="output-header">
<h3>{"FIRMWARE OUTPUT"}</h3>
<div class="output-actions">
<button
class="btn-secondary"
onclick={clear_firmware_output.clone()}
>
{"CLEAR"}
</button>
</div>
</div>
<div class="output-content">
<pre>{firmware_output.as_ref().unwrap()}</pre>
</div>
</div>
}
} else {
html! {}
}}
if *show_firmware_form {
<div class="firmware-form-section">
<h3>{"UPLOAD FIRMWARE"}</h3>
<div class="form-content">
<div class="form-field">
<label>{"FIRMWARE FILE"}</label>
<input
type="file"
onchange={on_file_change}
accept=".lua,.luac"
disabled={*uploading}
/>
</div>
if selected_file.is_some() {
<div class="file-info">
{"Selected: "}
<span class="file-name">
{selected_file.as_ref().unwrap().name()}
</span>
</div>
}
<div class="form-actions">
<button
class="btn-secondary"
onclick={toggle_load_firmware_form.clone()}
disabled={*uploading}
>
{"CANCEL"}
</button>
<button
class="btn-primary"
onclick={upload_firmware}
disabled={*uploading || selected_file.is_none()}
>
{if *uploading { "UPLOADING..." } else { "UPLOAD FIRMWARE" }}
</button>
</div>
</div>
</div>
}
</div>
</div>
}
}

View File

@@ -0,0 +1,209 @@
use crate::{
components::{replicant_card::CardType, ReplicantCard},
services::ApiService,
AuthContext,
};
use dollhouse_api_types::ReplicantFullResponse;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
const PAGE_SIZE: usize = 10;
#[function_component]
pub fn ReplicantsPage() -> Html {
let replicants = use_state(Vec::new);
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let auth_context = use_context::<AuthContext>().expect("AuthContext not found");
let current_page = use_state(|| 1);
let has_more = use_state(|| true);
{
let replicants = replicants.clone();
let loading = loading.clone();
let error = error.clone();
let current_page = current_page.clone();
let has_more = has_more.clone();
use_effect_with((*current_page,), move |(page,)| {
let page = *page;
spawn_local(async move {
match ApiService::get_replicants(Some(page), Some(PAGE_SIZE)).await {
Ok(fetched_replicants) => {
replicants.set(fetched_replicants.clone());
if fetched_replicants.len() < PAGE_SIZE {
has_more.set(false);
} else {
has_more.set(true);
}
loading.set(false);
error.set(None);
}
Err(e) => {
error.set(Some(format!("Failed to load replicants: {}", e)));
loading.set(false);
}
}
});
|| ()
});
}
let load_next_page = {
let current_page = current_page.clone();
let has_more = has_more.clone();
let loading = loading.clone();
Callback::from(move |_| {
if *has_more && !*loading {
loading.set(true);
current_page.set(*current_page + 1);
}
})
};
let load_prev_page = {
let current_page = current_page.clone();
let loading = loading.clone();
Callback::from(move |_| {
if *current_page > 1 && !*loading {
loading.set(true);
current_page.set(*current_page - 1);
}
})
};
let user_corp_id = auth_context.user.as_ref().and_then(|user| user.corp_id);
html! {
<div class="content">
<div class="content-header">
<h2 class="page-title">{"REPLICANT DATABASE"}</h2>
<div class="page-subtitle">
</div>
</div>
{if *loading {
html! {
<div class="loading-container">
<div class="neural-spinner"></div>
<p class="loading-text">{"Accessing database..."}</p>
<div class="system-message">
{"[SYSTEM] Scanning replicant"}
</div>
</div>
}
} else if let Some(err) = &*error {
html! {
<div class="error-card">
<div class="error-header">
<span class="error-icon">{""}</span>
<span class="error-title">{"CONNECTION ERROR"}</span>
</div>
<p class="error-message">{err}</p>
<div class="system-message error">
{"[ERROR] Neural network connection failed"}
</div>
<button
class="retry-btn"
onclick={Callback::from(move |_| {
loading.set(true);
current_page.set(1);
})}
>
{"Retry Connection"}
</button>
</div>
}
} else if replicants.is_empty() {
html! {
<div class="empty-state">
<h3 class="empty-title">{"NO REPLICANTS FOUND"}</h3>
<button
class="refresh-btn"
onclick={Callback::from(move |_| {
loading.set(true);
current_page.set(1);
})}
>
{"Refresh Database"}
</button>
</div>
}
} else {
html! {
<>
<div class="database-header">
<h3 class="database-title">
<span class="title-accent">{"[REPLICANTS]"}</span>
<span class="title-page">
{format!(" [PAGE {:02}]", *current_page)}
</span>
</h3>
</div>
<div class="replicant-grid">
{(*replicants).iter().map(|replicant: &ReplicantFullResponse| {
html! {
<ReplicantCard
key={replicant.id.to_string()}
card_type={CardType::Public}
replicant={replicant.clone()}
user_corp_id={user_corp_id}
/>
}
}).collect::<Html>()}
</div>
<div class="fixed-pagination">
<div class="pagination-container">
<div class="pagination-info">
<span class="pagination-text">
{format!("PAGE {:02}", *current_page)}
</span>
</div>
<div class="pagination-controls">
<button
onclick={load_prev_page.clone()}
disabled={*current_page == 1 || *loading}
class="pagination-btn pagination-prev"
>
<span class="btn-icon">{""}</span>
<span class="btn-text">{"PREV"}</span>
<span class="btn-glow"></span>
</button>
<div class="pagination-indicator">
<div class="indicator-dots">
<div class="dot active"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span class="indicator-text">
{format!("{:02}", *current_page)}
</span>
</div>
<button
onclick={load_next_page.clone()}
disabled={!*has_more || *loading}
class="pagination-btn pagination-next"
>
<span class="btn-text">{"NEXT"}</span>
<span class="btn-icon">{""}</span>
<span class="btn-glow"></span>
</button>
</div>
</div>
</div>
</>
}
}}
</div>
}
}

View File

@@ -0,0 +1,50 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::pages::{CorpPage, LoginPage, NotFound, RegisterPage, ReplicantDetail, ReplicantsPage};
use crate::Layout;
use uuid::Uuid;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Replicants,
#[at("/replicants/:id")]
ReplicantDetail { id: Uuid },
#[not_found]
#[at("/404")]
NotFound,
#[at("/login")]
Login,
#[at("/register")]
Register,
#[at("/corp")]
Corp,
}
pub fn switch(routes: Route) -> Html {
match routes {
Route::Login => html! { <LoginPage /> },
Route::Register => html! { <RegisterPage /> },
Route::Replicants => html! {
<Layout>
<ReplicantsPage />
</Layout>
},
Route::ReplicantDetail { id } => {
html! {
<Layout>
<ReplicantDetail replicant_id={id} />
</Layout>
}
}
Route::NotFound => html! {
<NotFound />
},
Route::Corp => html! {
<Layout>
<CorpPage />
</Layout>
},
}
}

View File

@@ -0,0 +1,305 @@
use dollhouse_api_types::*;
use gloo_net::http::Request;
use gloo_net::Error;
use uuid::Uuid;
use web_sys::{File, FormData, RequestCredentials};
const API_BASE_URL: &str = "/api";
pub struct ApiService;
impl ApiService {
pub async fn login(req: LoginRequest) -> Result<UserResponse, String> {
let response = Request::post(&format!("{}/auth/login", API_BASE_URL))
.credentials(RequestCredentials::Include)
.json(&req)
.map_err(|e| format!("Serialization error: {}", e))?
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
match response.status() {
201 | 200 => response
.json()
.await
.map_err(|e| format!("JSON parse error: {}", e)),
401 => Err("Invalid credentials".to_string()),
400 => Err("User already exists".to_string()),
404 => Err("Not Found".to_string()),
500 => Err("Server error".to_string()),
status => Err(format!("HTTP error: {}", status)),
}
}
pub async fn register(req: CreateUserRequest) -> Result<(), String> {
let response = Request::post(&format!("{}/auth/register", API_BASE_URL))
.credentials(RequestCredentials::Include)
.json(&req)
.map_err(|e| format!("Serialization error: {}", e))?
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
match response.status() {
201 => Ok(()),
400 => Err("Invalid request format".to_string()),
404 => Err("Not Found".to_string()),
500 => Err("Server error".to_string()),
status => Err(format!("HTTP error: {}", status)),
}
}
pub async fn get_replicants(
page: Option<usize>,
limit: Option<usize>,
) -> Result<Vec<ReplicantFullResponse>, Error> {
let req = Request::get(&format!(
"{}/replicants?page={}&limit={}",
API_BASE_URL,
page.unwrap_or(0),
limit.unwrap_or(10)
));
req.credentials(RequestCredentials::Include)
.send()
.await?
.json()
.await
}
pub async fn get_corp_replicants(
corp_id: Uuid,
page: Option<usize>,
limit: Option<usize>,
) -> Result<Vec<ReplicantFullResponse>, String> {
let response = Request::get(&format!(
"{}/corp/{}/replicants?page={}&limit={}",
API_BASE_URL,
corp_id,
page.unwrap_or(0),
limit.unwrap_or(10)
))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
match response.status() {
201 | 200 => response
.json()
.await
.map_err(|e| format!("JSON parse error: {}", e)),
401 => Err("Unauthorized".to_string()),
400 => Err("Invalid request format".to_string()),
404 => Err("Not Found".to_string()),
500 => Err("Server error".to_string()),
status => Err(format!("HTTP error: {}", status)),
}
}
pub async fn get_replicant(id: Uuid) -> Result<ReplicantFullResponse, String> {
let response = Request::get(&format!("{}/replicant/{}", API_BASE_URL, id))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
match response.status() {
200 => response
.json()
.await
.map_err(|e| format!("JSON parse error: {}", e)),
400 => Err("Invalid request format".to_string()),
404 => Err("Not Found".to_string()),
500 => Err("Server error".to_string()),
status => Err(format!("HTTP error: {}", status)),
}
}
pub async fn create_replicant(
corp_id: Uuid,
request: CreateReplicantRequest,
) -> Result<ReplicantResponse, Error> {
let req = Request::post(&format!("{}/corp/{}/replicant", API_BASE_URL, corp_id));
req.credentials(RequestCredentials::Include)
.json(&request)?
.send()
.await?
.json()
.await
}
pub async fn get_current_user() -> Result<UserResponse, String> {
let response = Request::get(&format!("{}/auth/me", API_BASE_URL))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
if response.ok() {
let user_data: UserResponse = response
.json()
.await
.map_err(|e| format!("JSON parse error: {:?}", e))?;
Ok(user_data)
} else {
Err(format!("HTTP error: {}", response.status()))
}
}
pub async fn logout_user() -> Result<(), String> {
let response = Request::post(&format!("{}/auth/logout", API_BASE_URL))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
if response.ok() {
Ok(())
} else {
Err(format!("HTTP error: {}", response.status()))
}
}
pub async fn get_user_corp(user_id: Uuid) -> Result<Option<CorpResponse>, String> {
let response = Request::get(&format!("{}/user/{}/corp", API_BASE_URL, user_id))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => {
let corp_data: CorpResponse = response
.json()
.await
.map_err(|e| format!("JSON parse error: {:?}", e))?;
Ok(Some(corp_data))
}
404 => Ok(None),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
pub async fn create_corp(
user_id: Uuid,
name: String,
description: String,
) -> Result<CorpResponse, String> {
let req = Request::post(&format!("{}/user/{}/corp", API_BASE_URL, user_id))
.credentials(RequestCredentials::Include)
.json(&CreateCorpRequest { name, description })
.unwrap()
.send()
.await;
match req {
Ok(response) => {
if response.ok() {
let corp_data: CorpResponse = response
.json()
.await
.map_err(|e| format!("JSON parse error: {:?}", e))?;
Ok(corp_data)
} else {
Err(format!("HTTP error: {}", response.status()))
}
}
Err(e) => Err(format!("Network error: {:?}", e)),
}
}
pub async fn join_corp(user_id: Uuid, invite_code: String) -> Result<(), String> {
let response = Request::post(&format!("{}/user/{}/join-corp", API_BASE_URL, user_id))
.credentials(RequestCredentials::Include)
.json(&JoinCorpRequest { invite_code })
.unwrap()
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => Ok(()),
404 => Err(format!("Corp with this invite code does not exists")),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
pub async fn change_replicant_privacy(
replicant_id: Uuid,
is_private: bool,
) -> Result<(), String> {
let response = Request::post(&format!(
"{}/replicant/{}/change-privacy",
API_BASE_URL, replicant_id
))
.credentials(RequestCredentials::Include)
.json(&ChangePrivacyRequest { is_private })
.unwrap()
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => Ok(()),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
pub async fn change_replicant_owner(replicant_id: Uuid, new_corp: Uuid) -> Result<(), String> {
let response = Request::post(&format!(
"{}/replicant/{}/change-owner",
API_BASE_URL, replicant_id
))
.credentials(RequestCredentials::Include)
.json(&ChangeReplicantOwnerRequest { new_corp })
.unwrap()
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => Ok(()),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
pub async fn load_firmware(replicant_id: Uuid, file: File) -> Result<(), String> {
let form_data = FormData::new().map_err(|_| "Failed to create form data".to_string())?;
form_data
.append_with_blob("file", &file)
.map_err(|_| "Failed to append file".to_string())?;
let response = Request::post(&format!(
"{}/replicant/{}/firmware",
API_BASE_URL, replicant_id
))
.credentials(RequestCredentials::Include)
.body(form_data)
.unwrap()
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => Ok(()),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
pub async fn run_firmware(replicant_id: Uuid) -> Result<FirmwareOutputResponse, String> {
let response = Request::get(&format!("{}/replicant/{}/run", API_BASE_URL, replicant_id))
.credentials(RequestCredentials::Include)
.send()
.await
.map_err(|e| format!("Network error: {:?}", e))?;
match response.status() {
200 => Ok(response
.json()
.await
.map_err(|e| format!("Failed to parse response: {:?}", e))?),
_ => Err(format!("HTTP error: {}", response.status())),
}
}
}

View File

@@ -0,0 +1,62 @@
use crate::services::api::ApiService;
use dollhouse_api_types::UserResponse;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[derive(Clone, PartialEq)]
pub struct AuthContext {
pub user: Option<UserResponse>,
pub set_user: Callback<Option<UserResponse>>,
pub is_loading: bool,
}
impl AuthContext {
pub fn is_authenticated(&self) -> bool {
self.user.is_some()
}
pub async fn logout(&self) -> Result<(), String> {
ApiService::logout_user().await?;
self.set_user.emit(None);
Ok(())
}
}
#[hook]
pub fn use_auth() -> AuthContext {
let user: UseStateHandle<Option<UserResponse>> = use_state(|| None);
let is_loading = use_state(|| true);
let set_user = {
let user = user.clone();
Callback::from(move |new_user: Option<UserResponse>| {
user.set(new_user);
})
};
{
let set_user = set_user.clone();
let is_loading = is_loading.clone();
use_effect_with((), move |_| {
spawn_local(async move {
match ApiService::get_current_user().await {
Ok(user_data) => {
set_user.emit(Some(user_data));
}
Err(_) => {
set_user.emit(None);
}
}
is_loading.set(false);
});
|| {}
});
}
AuthContext {
user: (*user).clone(),
set_user,
is_loading: (*is_loading).clone(),
}
}

View File

@@ -0,0 +1,4 @@
pub mod api;
pub mod auth;
pub use api::ApiService;

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-3.5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icomoon-ignore">
</g>
<path d="M12.584 3.412c-6.953 0-12.588 5.636-12.588 12.588s5.635 12.588 12.588 12.588c6.952 0 12.588-5.636 12.588-12.588s-5.636-12.588-12.588-12.588zM5.679 25.24c0.77-0.283 1.615-0.569 2.368-0.822 2.568-0.863 2.964-0.996 2.964-1.862v-2.026l-0.877-0.146c-0.063-0.011-1.54-0.255-2.532-0.255-0.512 0-0.803-0.013-1.084-0.197 0.722-1.581 1.469-4.054 1.752-6.010l0.054 0.019 0.078-1.386c0.123-2.221 1.96-3.961 4.183-3.961 2.222 0 4.059 1.74 4.183 3.961l0.091 1.381 0.040-0.014c0.283 1.956 1.030 4.429 1.752 6.010-0.28 0.184-0.572 0.197-1.083 0.197-1.007 0-2.434 0.318-2.593 0.354l-0.817 0.185v1.887c0 0.857 0.41 1.002 3.077 1.944 0.692 0.245 1.465 0.519 2.182 0.79-1.915 1.411-4.278 2.248-6.833 2.248-2.587 0-4.978-0.855-6.905-2.299zM20.349 24.528c-2.14-0.847-5.143-1.777-5.143-1.971 0-0.24 0-1.050 0-1.050s1.442-0.328 2.36-0.328 1.574-0.057 2.36-1.041c-0.984-1.737-2.098-5.647-2.098-7.646l-0.015 0.005c-0.153-2.76-2.432-4.952-5.23-4.952s-5.077 2.192-5.231 4.952l-0.014-0.005c0 2-1.115 5.909-2.098 7.646 0.787 0.983 1.442 1.041 2.36 1.041s2.36 0.24 2.36 0.24 0 0.897 0 1.137c0 0.197-3.071 1.081-5.206 1.911-2.28-2.11-3.711-5.124-3.711-8.468 0-6.363 5.176-11.539 11.539-11.539s11.539 5.177 11.539 11.539c0 3.375-1.456 6.416-3.774 8.528z" fill="#000000">
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icomoon-ignore">
</g>
<path d="M16 3.205c-7.067 0-12.795 5.728-12.795 12.795s5.728 12.795 12.795 12.795 12.795-5.728 12.795-12.795c0-7.067-5.728-12.795-12.795-12.795zM16 4.271c6.467 0 11.729 5.261 11.729 11.729 0 2.845-1.019 5.457-2.711 7.49-1.169-0.488-3.93-1.446-5.638-1.951-0.146-0.046-0.169-0.053-0.169-0.66 0-0.501 0.206-1.005 0.407-1.432 0.218-0.464 0.476-1.244 0.569-1.944 0.259-0.301 0.612-0.895 0.839-2.026 0.199-0.997 0.106-1.36-0.026-1.7-0.014-0.036-0.028-0.071-0.039-0.107-0.050-0.234 0.019-1.448 0.189-2.391 0.118-0.647-0.030-2.022-0.921-3.159-0.562-0.719-1.638-1.601-3.603-1.724l-1.078 0.001c-1.932 0.122-3.008 1.004-3.57 1.723-0.89 1.137-1.038 2.513-0.92 3.159 0.172 0.943 0.239 2.157 0.191 2.387-0.010 0.040-0.025 0.075-0.040 0.111-0.131 0.341-0.225 0.703-0.025 1.7 0.226 1.131 0.579 1.725 0.839 2.026 0.092 0.7 0.35 1.48 0.569 1.944 0.159 0.339 0.234 0.801 0.234 1.454 0 0.607-0.023 0.614-0.159 0.657-1.767 0.522-4.579 1.538-5.628 1.997-1.725-2.042-2.768-4.679-2.768-7.555 0-6.467 5.261-11.729 11.729-11.729zM7.811 24.386c1.201-0.49 3.594-1.344 5.167-1.808 0.914-0.288 0.914-1.058 0.914-1.677 0-0.513-0.035-1.269-0.335-1.908-0.206-0.438-0.442-1.189-0.494-1.776-0.011-0.137-0.076-0.265-0.18-0.355-0.151-0.132-0.458-0.616-0.654-1.593-0.155-0.773-0.089-0.942-0.026-1.106 0.027-0.070 0.053-0.139 0.074-0.216 0.128-0.468-0.015-2.005-0.17-2.858-0.068-0.371 0.018-1.424 0.711-2.311 0.622-0.795 1.563-1.238 2.764-1.315l1.011-0.001c1.233 0.078 2.174 0.521 2.797 1.316 0.694 0.887 0.778 1.94 0.71 2.312-0.154 0.852-0.298 2.39-0.17 2.857 0.022 0.078 0.047 0.147 0.074 0.217 0.064 0.163 0.129 0.333-0.025 1.106-0.196 0.977-0.504 1.461-0.655 1.593-0.103 0.091-0.168 0.218-0.18 0.355-0.051 0.588-0.286 1.338-0.492 1.776-0.236 0.502-0.508 1.171-0.508 1.886 0 0.619 0 1.389 0.924 1.68 1.505 0.445 3.91 1.271 5.18 1.77-2.121 2.1-5.035 3.4-8.248 3.4-3.183 0-6.073-1.277-8.188-3.342z" fill="#000000">
</path>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icomoon-ignore">
</g>
<path d="M16 3.205c-7.067 0-12.795 5.728-12.795 12.795s5.728 12.795 12.795 12.795 12.795-5.728 12.795-12.795c0-7.067-5.728-12.795-12.795-12.795zM16 4.271c6.467 0 11.729 5.261 11.729 11.729 0 2.845-1.019 5.457-2.711 7.49-1.169-0.488-3.93-1.446-5.638-1.951-0.146-0.046-0.169-0.053-0.169-0.66 0-0.501 0.206-1.005 0.407-1.432 0.218-0.464 0.476-1.244 0.569-1.944 0.259-0.301 0.612-0.895 0.839-2.026 0.199-0.997 0.106-1.36-0.026-1.7-0.014-0.036-0.028-0.071-0.039-0.107-0.050-0.234 0.019-1.448 0.189-2.391 0.118-0.647-0.030-2.022-0.921-3.159-0.562-0.719-1.638-1.601-3.603-1.724l-1.078 0.001c-1.932 0.122-3.008 1.004-3.57 1.723-0.89 1.137-1.038 2.513-0.92 3.159 0.172 0.943 0.239 2.157 0.191 2.387-0.010 0.040-0.025 0.075-0.040 0.111-0.131 0.341-0.225 0.703-0.025 1.7 0.226 1.131 0.579 1.725 0.839 2.026 0.092 0.7 0.35 1.48 0.569 1.944 0.159 0.339 0.234 0.801 0.234 1.454 0 0.607-0.023 0.614-0.159 0.657-1.767 0.522-4.579 1.538-5.628 1.997-1.725-2.042-2.768-4.679-2.768-7.555 0-6.467 5.261-11.729 11.729-11.729zM7.811 24.386c1.201-0.49 3.594-1.344 5.167-1.808 0.914-0.288 0.914-1.058 0.914-1.677 0-0.513-0.035-1.269-0.335-1.908-0.206-0.438-0.442-1.189-0.494-1.776-0.011-0.137-0.076-0.265-0.18-0.355-0.151-0.132-0.458-0.616-0.654-1.593-0.155-0.773-0.089-0.942-0.026-1.106 0.027-0.070 0.053-0.139 0.074-0.216 0.128-0.468-0.015-2.005-0.17-2.858-0.068-0.371 0.018-1.424 0.711-2.311 0.622-0.795 1.563-1.238 2.764-1.315l1.011-0.001c1.233 0.078 2.174 0.521 2.797 1.316 0.694 0.887 0.778 1.94 0.71 2.312-0.154 0.852-0.298 2.39-0.17 2.857 0.022 0.078 0.047 0.147 0.074 0.217 0.064 0.163 0.129 0.333-0.025 1.106-0.196 0.977-0.504 1.461-0.655 1.593-0.103 0.091-0.168 0.218-0.18 0.355-0.051 0.588-0.286 1.338-0.492 1.776-0.236 0.502-0.508 1.171-0.508 1.886 0 0.619 0 1.389 0.924 1.68 1.505 0.445 3.91 1.271 5.18 1.77-2.121 2.1-5.035 3.4-8.248 3.4-3.183 0-6.073-1.277-8.188-3.342z" fill="#000000">
</path>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.91.1-slim-trixie AS chef
WORKDIR /app
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY crates/ ./crates/
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS dependencies
COPY --from=planner /app/recipe.json recipe.json
RUN apt-get update && apt-get install -y \
pkg-config \
libpq-dev \
liblua5.3-dev \
lua5.3 \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN cargo chef cook --release --recipe-path recipe.json
FROM dependencies AS base

View File

@@ -0,0 +1,24 @@
FROM dollhouse-base:latest AS backend-builder
RUN cargo install diesel_cli --no-default-features --features postgres
COPY . .
RUN cargo build --release --bin dollhouse-backend
FROM ubuntu:24.04 AS runtime
WORKDIR /app
COPY docker/dollhouse-backend/entrypoint.sh .
COPY --from=backend-builder /app/target/release/dollhouse-backend /usr/local/bin/backend
COPY --from=backend-builder /usr/local/cargo/bin/diesel ./diesel
COPY crates/dollhouse-db/migrations ./migrations
COPY crates/dollhouse-db/diesel.toml .
RUN apt-get update && apt install -y \
libpq-dev \
liblua5.3-dev \
lua5.3 && \
rm -rf /var/lib/apt/lists/* \
&& chmod +x ./entrypoint.sh
ENTRYPOINT [ "./entrypoint.sh" ]

View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -x
./diesel setup || exit 1
exec /usr/local/bin/backend

View File

@@ -0,0 +1,8 @@
FROM alpine:latest
RUN apk add --no-cache postgresql-client bash
COPY cleaner.sh /cleaner.sh
RUN chmod +x /cleaner.sh
ENTRYPOINT ["/cleaner.sh"]

View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
DIR="/firmware"
while true; do
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] Starting cleanup"
if [ -d "${DIR}" ]; then
find "${DIR}" -type f -mmin +5 -delete 2>/dev/null
fi
if [ -n "${DATABASE_URL}" ]; then
psql "${DATABASE_URL}" -v "ON_ERROR_STOP=1" <<'SQL'
BEGIN;
DELETE FROM replicants_stats WHERE created_at <= NOW() - INTERVAL '5 minutes';
DELETE FROM replicants WHERE created_at <= NOW() - INTERVAL '5 minutes';
DELETE FROM users WHERE created_at <= NOW() - INTERVAL '5 minutes';
DELETE FROM corps WHERE created_at <= NOW() - INTERVAL '5 minutes';
COMMIT;
SQL
else
echo " DATABASE_URL not set, skipping DB cleanup"
fi
echo "[$TIMESTAMP] Cleanup completed"
sleep 60
done

View File

@@ -0,0 +1,19 @@
FROM dollhouse-base:latest AS frontend-builder
RUN wget https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-x86_64-unknown-linux-gnu.tar.gz && \
tar -xvf trunk-x86_64-unknown-linux-gnu.tar.gz && \
mv trunk /usr/local/bin/ && \
rm trunk-x86_64-unknown-linux-gnu.tar.gz
RUN rustup target add wasm32-unknown-unknown
COPY . .
RUN cd crates/dollhouse-frontend && trunk build --release --no-sri
FROM nginx:1.24-alpine AS runtime
COPY --from=frontend-builder /app/crates/dollhouse-frontend/dist /usr/share/nginx/html
COPY docker/dollhouse-frontend/nginx.conf /etc/nginx/nginx.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,53 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:5555/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = OPTIONS) {
return 204;
}
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location ~* \.(wasm)$ {
add_header Content-Type application/wasm;
default_type application/wasm;
expires max;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}

4
dollhouse/run-me.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
docker compose build base
docker compose up --build -d