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

BIN
Services.zip Executable file

Binary file not shown.

2
darkbazaar/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
.venv
__pycache__/

13
darkbazaar/Dockerfile Executable file
View File

@@ -0,0 +1,13 @@
FROM python:3.10-slim-bookworm
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install uuid
COPY src/ ./src/
COPY src/static/ ./src/static/
COPY src/templates/ ./src/templates/
EXPOSE 8000

16
darkbazaar/Dockerfile.cleaner Executable file
View File

@@ -0,0 +1,16 @@
FROM python:3.10-slim-bookworm
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Install cron
RUN apt-get update && apt-get install -y cron procps dash
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["cron", "-f"]

45
darkbazaar/docker-compose.yml Executable file
View File

@@ -0,0 +1,45 @@
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:mysecretpassword@db:5432/fastapi_db
command: sh -c "SESSION_SECRET_KEY=$(python -c 'import uuid; print(uuid.uuid4().hex)') uvicorn src.main:app --loop uvloop --workers 4 --host 0.0.0.0 --port 8000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
cleaner:
build:
context: .
dockerfile: Dockerfile.cleaner
environment:
DATABASE_URL: postgresql+asyncpg://postgres:mysecretpassword@db:5432/fastapi_db
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: fastapi_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword
command: ["postgres", "-c", "max_connections=1000"]
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fastapi_db"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:

14
darkbazaar/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL is not set."
exit 1
fi
echo "* * * * * root PYTHONPATH=/app DATABASE_URL=\"$DATABASE_URL\" /usr/local/bin/python3 -m src.cleaner > /proc/1/fd/1 2>&1" > /etc/cron.d/cleaner_job
chmod 0644 /etc/cron.d/cleaner_job
touch /var/log/cron.log
exec "$@"

BIN
darkbazaar/requirements.txt Executable file

Binary file not shown.

0
darkbazaar/src/__init__.py Executable file
View File

9
darkbazaar/src/auth.py Executable file
View File

@@ -0,0 +1,9 @@
from passlib.context import CryptContext
ctx = CryptContext(schemes=["pbkdf2_sha256"], pbkdf2_sha256__rounds=1)
def get_password_hash(password: str) -> str:
return ctx.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return ctx.verify(plain_password, hashed_password)

44
darkbazaar/src/cleaner.py Executable file
View File

@@ -0,0 +1,44 @@
import asyncio
import os
from datetime import datetime, timedelta
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.db import Base, SeekerRequestOrm, GhostlinkInsightOrm, FinderInsightRequestOrm, UserOrm
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_async_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
async def clean_database():
print(f"Cleaner started at {datetime.now()}")
async with SessionLocal() as db:
six_minutes_ago = datetime.now() - timedelta(minutes=6)
tables_to_clean_order = [
("seeker_requests", SeekerRequestOrm),
("ghostlink_insights", GhostlinkInsightOrm),
("finder_insight_requests", FinderInsightRequestOrm),
("users", UserOrm),
]
for table_name, orm_model in tables_to_clean_order:
initial_count_result = await db.execute(select(func.count()).select_from(orm_model))
initial_count = initial_count_result.scalar_one()
delete_statement = delete(orm_model).where(orm_model.created_at < six_minutes_ago)
result = await db.execute(delete_statement)
cleaned_count = result.rowcount
final_count_result = await db.execute(select(func.count()).select_from(orm_model))
final_count = final_count_result.scalar_one()
print(f"Table '{table_name}': Cleaned {cleaned_count} records. Current records: {final_count}")
await db.commit()
print("Cleaner finished.")
if __name__ == "__main__":
asyncio.run(clean_database())

324
darkbazaar/src/db.py Executable file
View File

@@ -0,0 +1,324 @@
import uuid
import random
import os
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
Integer,
String,
delete,
func,
select,
ForeignKey,
DateTime
)
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import declarative_base, sessionmaker, relationship, joinedload
from .auth import get_password_hash, verify_password
from .models import UserCreate, SeekerRequestBase, GhostlinkInsightBase
from typing import List, Optional
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_async_engine(
DATABASE_URL,
pool_size=50,
max_overflow=100,
pool_recycle=3600,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
Base = declarative_base()
class UserOrm(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
role = Column(String, default="seeker", nullable=False)
hashed_password = Column(String, nullable=False)
created_at = Column(DateTime, server_default=func.now())
seeker_requests = relationship("SeekerRequestOrm", back_populates="user", cascade="all, delete-orphan", primaryjoin="UserOrm.username==SeekerRequestOrm.seeker_username")
ghostlink_insights = relationship("GhostlinkInsightOrm", back_populates="user", cascade="all, delete-orphan", primaryjoin="UserOrm.username==GhostlinkInsightOrm.ghostlink_username")
finder_insight_requests_as_ghostlink = relationship("FinderInsightRequestOrm", back_populates="ghostlink_user", primaryjoin="UserOrm.username==FinderInsightRequestOrm.ghostlink_username")
finder_insight_request_as_finder = relationship("FinderInsightRequestOrm", back_populates="finder_user", uselist=False, primaryjoin="UserOrm.username==FinderInsightRequestOrm.finder_username")
class SeekerRequestOrm(Base):
__tablename__ = "seeker_requests"
id = Column(Integer, primary_key=True)
seeker_username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False, index=True)
uuid = Column(UUID(as_uuid=True), unique=True, nullable=False, index=True)
description = Column(String, nullable=False)
contact_info = Column(String, nullable=False)
fulfilled_by_finder_username = Column(MutableList.as_mutable(ARRAY(String)), default=[], nullable=False, index=True)
created_at = Column(DateTime, server_default=func.now())
user = relationship("UserOrm", back_populates="seeker_requests", primaryjoin="UserOrm.username==SeekerRequestOrm.seeker_username")
class GhostlinkInsightOrm(Base):
__tablename__ = "ghostlink_insights"
id = Column(Integer, primary_key=True)
ghostlink_username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False, index=True)
uuid = Column(UUID(as_uuid=True), nullable=False)
created_at = Column(DateTime, server_default=func.now())
user = relationship("UserOrm", back_populates="ghostlink_insights", primaryjoin="UserOrm.username==GhostlinkInsightOrm.ghostlink_username")
class FinderInsightRequestOrm(Base):
__tablename__ = "finder_insight_requests"
id = Column(Integer, primary_key=True)
finder_username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False, index=True, unique=True)
ghostlink_username = Column(String, ForeignKey("users.username", ondelete="CASCADE"), nullable=False)
status = Column(String, default="pending", nullable=False)
suggested_insight_uuid = Column(UUID(as_uuid=True), nullable=True)
created_at = Column(DateTime, server_default=func.now())
finder_user = relationship("UserOrm", back_populates="finder_insight_request_as_finder", primaryjoin="UserOrm.username==FinderInsightRequestOrm.finder_username")
ghostlink_user = relationship("UserOrm", back_populates="finder_insight_requests_as_ghostlink", primaryjoin="UserOrm.username==FinderInsightRequestOrm.ghostlink_username")
async def get_db():
async with SessionLocal() as db:
yield db
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_user(db: AsyncSession, username: str) -> UserOrm | None:
result = await db.execute(
select(UserOrm)
.options(joinedload(UserOrm.seeker_requests))
.options(joinedload(UserOrm.ghostlink_insights))
.options(joinedload(UserOrm.finder_insight_request_as_finder))
.options(joinedload(UserOrm.finder_insight_requests_as_ghostlink))
.where(UserOrm.username == username)
)
user_orm = result.scalars().unique().one_or_none()
return user_orm
async def authenticate_user(db: AsyncSession, username: str, password: str):
user = await get_user(db, username)
if (not user) or (not verify_password(password, user.hashed_password)):
return None
return user
async def create_user(db: AsyncSession, user: UserCreate):
if await get_user(db, user.username):
return None
hashed_password = get_password_hash(user.password)
new_user = UserOrm(username=user.username, hashed_password=hashed_password, role=user.role)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
# Seeker functions
async def create_seeker_request(db: AsyncSession, seeker_user: UserOrm, seeker_request: SeekerRequestBase) -> str | None:
if seeker_user.role != "seeker":
return "Access denied: only 'seeker' role can create requests."
if len(seeker_user.seeker_requests) >= 3:
return "You can only have up to 3 active requests at a time."
existing_request = await db.scalar(
select(SeekerRequestOrm)
.where(SeekerRequestOrm.uuid == seeker_request.uuid)
)
if existing_request:
return "A request for this UUID is already active."
new_request = SeekerRequestOrm(
seeker_username=seeker_user.username,
uuid=seeker_request.uuid,
description=seeker_request.description,
contact_info=seeker_request.contact_info,
)
db.add(new_request)
await db.commit()
await db.refresh(new_request)
return None
async def get_seeker_request_by_uuid(db: AsyncSession, item_uuid: uuid.UUID) -> SeekerRequestOrm | None:
result = await db.execute(
select(SeekerRequestOrm)
.where(SeekerRequestOrm.uuid == item_uuid)
)
return result.scalar_one_or_none()
async def fulfill_seeker_request(db: AsyncSession, item_uuid: uuid.UUID, finder_username: str) -> SeekerRequestOrm | None:
seeker_request = await get_seeker_request_by_uuid(db, item_uuid)
if not seeker_request:
return None
if finder_username not in seeker_request.fulfilled_by_finder_username:
seeker_request.fulfilled_by_finder_username.append(finder_username)
await db.commit()
await db.refresh(seeker_request)
return seeker_request
async def get_all_fulfilled_seeker_requests(db: AsyncSession, username: str) -> List[SeekerRequestOrm]:
query = select(SeekerRequestOrm).where(func.cardinality(SeekerRequestOrm.fulfilled_by_finder_username) > 0)
if username:
query = query.where(SeekerRequestOrm.fulfilled_by_finder_username.contains([username]))
result = await db.execute(query)
requests = result.scalars().all()
return requests
# Ghostlink functions
async def create_ghostlink_insight(db: AsyncSession, ghostlink_user: UserOrm, insight_uuid: uuid.UUID) -> str | None:
if ghostlink_user.role != "ghostlink":
return "Access denied: only 'ghostlink' role can create insights."
if len(ghostlink_user.ghostlink_insights) >= 3:
return "You can only have up to 3 insights at a time."
new_insight = GhostlinkInsightOrm(
ghostlink_username=ghostlink_user.username,
uuid=insight_uuid
)
db.add(new_insight)
await db.commit()
await db.refresh(new_insight)
return None
async def get_ghostlink_insights(db: AsyncSession, ghostlink_username: str) -> List[GhostlinkInsightOrm]:
result = await db.execute(
select(GhostlinkInsightOrm)
.where(GhostlinkInsightOrm.ghostlink_username == ghostlink_username)
)
return result.scalars().all()
async def get_random_ghostlink_insight(db: AsyncSession, ghostlink_username: str) -> GhostlinkInsightOrm | None:
ghostlink_user = await get_user(db, ghostlink_username)
if not ghostlink_user or not ghostlink_user.ghostlink_insights:
return None
return random.choice(ghostlink_user.ghostlink_insights)
# Finder-Ghostlink Request functions
async def create_finder_insight_request(db: AsyncSession, finder_user: UserOrm, ghostlink_username: str) -> str | None:
if finder_user.role != "finder":
return "Access denied: only 'finder' role can request insights."
ghostlink_user = await get_user(db, ghostlink_username)
if not ghostlink_user:
return "Ghostlink not found."
existing_request = await db.scalar(
select(FinderInsightRequestOrm)
.where(FinderInsightRequestOrm.finder_username == finder_user.username)
)
if existing_request:
return "You already have a request to a Ghostlink."
if finder_user.username == ghostlink_username:
return "You cannot request an insight from yourself."
new_request = FinderInsightRequestOrm(
finder_username=finder_user.username,
ghostlink_username=ghostlink_username,
status="pending"
)
db.add(new_request)
await db.commit()
await db.refresh(new_request)
return None
async def get_finder_insight_request(db: AsyncSession, finder_username: str) -> FinderInsightRequestOrm | None:
result = await db.execute(
select(FinderInsightRequestOrm)
.where(FinderInsightRequestOrm.finder_username == finder_username)
.order_by(FinderInsightRequestOrm.id.desc())
)
return result.scalar_one_or_none()
async def get_all_ghostlink_requests(db: AsyncSession, ghostlink_username: str) -> List[FinderInsightRequestOrm]:
result = await db.execute(
select(FinderInsightRequestOrm)
.where(FinderInsightRequestOrm.ghostlink_username == ghostlink_username, FinderInsightRequestOrm.status == "pending")
)
return result.scalars().all()
async def accept_finder_insight_request(db: AsyncSession, ghostlink_user: UserOrm, finder_username: str, username: Optional[str] = None) -> str | GhostlinkInsightBase | None:
if ghostlink_user.role == "seeker":
return "Access denied: only 'ghostlink' role can accept requests."
user = await get_user(db, username)
if username and user.role != ghostlink_user.role:
ghost_username = user.username
else:
ghost_username = ghostlink_user.username
request_to_fulfill = await db.scalar(
select(FinderInsightRequestOrm)
.where(FinderInsightRequestOrm.finder_username == finder_username, FinderInsightRequestOrm.ghostlink_username == ghost_username, FinderInsightRequestOrm.status == "pending")
)
if not request_to_fulfill:
return "Pending request not found."
random_insight = await get_random_ghostlink_insight(db, ghost_username)
if not random_insight:
return "Ghostlink has no insights to share."
insight_uuid_to_return = random_insight.uuid
request_to_fulfill.status = "accepted"
request_to_fulfill.suggested_insight_uuid = insight_uuid_to_return
await db.commit()
await db.refresh(request_to_fulfill)
return GhostlinkInsightBase(uuid=insight_uuid_to_return)
async def reject_finder_insight_request(db: AsyncSession, ghostlink_user: UserOrm, finder_username: str) -> str | None:
if ghostlink_user.role != "ghostlink":
return "Access denied: only 'ghostlink' role can reject requests."
request_to_reject = await db.scalar(
select(FinderInsightRequestOrm)
.where(FinderInsightRequestOrm.finder_username == finder_username, FinderInsightRequestOrm.ghostlink_username == ghostlink_user.username, FinderInsightRequestOrm.status == "pending")
)
if not request_to_reject:
return "Pending request not found."
request_to_reject.status = "rejected"
await db.commit()
await db.refresh(request_to_reject)
return None

330
darkbazaar/src/main.py Executable file
View File

@@ -0,0 +1,330 @@
import asyncio
import uuid
from typing import Annotated, Optional, List
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from .db import (
authenticate_user,
create_user,
get_db,
get_user,
init_db,
create_seeker_request,
get_seeker_request_by_uuid,
fulfill_seeker_request,
create_ghostlink_insight,
get_ghostlink_insights,
get_random_ghostlink_insight,
create_finder_insight_request,
get_finder_insight_request,
get_all_ghostlink_requests,
accept_finder_insight_request,
reject_finder_insight_request,
SeekerRequestOrm,
GhostlinkInsightOrm,
FinderInsightRequestOrm,
get_all_fulfilled_seeker_requests
)
from fastapi.staticfiles import StaticFiles
from .models import User, UserCreate, SeekerRequestBase, GhostlinkInsightBase, FinderItemBase
import os
templates = Jinja2Templates(directory="src/templates")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(lifespan=lifespan, title="DarkBazaar")
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY"))
app.mount("/static", StaticFiles(directory="src/static"), name="static")
async def get_current_user(request: Request, db: Annotated[AsyncSession, Depends(get_db)]):
username = request.session.get('username')
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user_orm = await get_user(db, username)
if not user_orm:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return User.model_validate(user_orm)
def render_profile_template(
request: Request,
user: User,
found_item: Optional[FinderItemBase] = None,
error: Optional[str] = None,
success_message: Optional[str] = None
) -> HTMLResponse:
context = {
"request": request,
"role": user.role,
"username": user.username,
"seeker_requests": [SeekerRequestBase.model_validate(req) for req in user.seeker_requests],
"ghostlink_insights": [GhostlinkInsightBase.model_validate(ins) for ins in user.ghostlink_insights],
"finder_insight_request_as_finder": user.finder_insight_request_as_finder,
"found_item": found_item,
"error": error,
"success_message": success_message,
}
return templates.TemplateResponse("profile.html", context)
def render_relationships_template(
request: Request,
user: User,
ghostlink_requests: Optional[List[FinderInsightRequestOrm]] = None,
error: Optional[str] = None
) -> HTMLResponse:
context = {
"request": request,
"user": user,
"ghostlink_requests": ghostlink_requests,
"error": error,
}
return templates.TemplateResponse("relationships.html", context)
@app.post("/users/register")
async def register_new_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form("seeker"),
db: AsyncSession = Depends(get_db)
):
user_create = UserCreate(username=username, password=password, role=role)
new_user = await create_user(db, user_create)
if not new_user:
return templates.TemplateResponse("register.html", {"request": request, "error": "User with this name already exists"})
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
@app.post("/users/login")
async def login_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db)
):
user = await authenticate_user(db, username, password)
if not user:
return templates.TemplateResponse("login.html", {"request": request, "error": "User with this credentials doesn't exist"})
request.session["username"] = user.username
return RedirectResponse(url="/profile", status_code=status.HTTP_302_FOUND)
@app.post('/logout')
async def logout(request: Request, current_user: Annotated[User, Depends(get_current_user)]):
request.session.pop("username", None)
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
@app.get('/', response_class=HTMLResponse)
async def get_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get('/register', response_class=HTMLResponse)
async def get_register_form(request: Request):
return templates.TemplateResponse("register.html", {"request": request})
@app.get('/login', response_class=HTMLResponse)
async def get_login_form(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@app.get('/profile', response_class=HTMLResponse)
async def get_profile_page(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)]
):
user_orm_refreshed = await get_user(db, current_user.username)
if not user_orm_refreshed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found after refresh")
updated_user = User.model_validate(user_orm_refreshed)
return render_profile_template(
request=request,
user=updated_user,
)
@app.post('/seeker/create_request')
async def create_new_seeker_request(
request: Request,
item_uuid: uuid.UUID = Form(...),
description: str = Form(...),
contact_info: str = Form(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if current_user.role != 'seeker':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'seeker' role can create requests.")
seeker_request_base = SeekerRequestBase(uuid=item_uuid, description=description, contact_info=contact_info)
error = await create_seeker_request(db, current_user, seeker_request_base)
if error:
return render_profile_template(request=request, user=current_user, error=error)
return RedirectResponse(url="/profile", status_code=status.HTTP_302_FOUND)
@app.post('/finder/find_item')
async def find_item(
request: Request,
item_uuid: uuid.UUID = Form(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if current_user.role != 'finder':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'finder' role can find items.")
seeker_request = await get_seeker_request_by_uuid(db, item_uuid)
if not seeker_request:
return render_profile_template(request=request, user=current_user, error="Item with this UUID is not currently being sought.")
await fulfill_seeker_request(db, item_uuid, current_user.username)
found_item_data = FinderItemBase(
uuid=seeker_request.uuid,
description=seeker_request.description,
contact_info=seeker_request.contact_info
)
return render_profile_template(request=request, user=current_user, found_item=found_item_data, success_message="You successfully found an item!")
@app.post('/ghostlink/create_insight')
async def create_new_ghostlink_insight(
request: Request,
insight_uuid: uuid.UUID = Form(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if current_user.role != 'ghostlink':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'ghostlink' role can create insights.")
error = await create_ghostlink_insight(db, current_user, insight_uuid)
if error:
return render_profile_template(request=request, user=current_user, error=error) # Изменено
return RedirectResponse(url="/profile", status_code=status.HTTP_302_FOUND)
@app.get('/ghostlink/requests', response_class=HTMLResponse)
async def get_ghostlink_requests_page(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)]
):
if current_user.role != 'ghostlink':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'ghostlink' role can view requests.")
ghostlink_requests = await get_all_ghostlink_requests(db, current_user.username)
return render_relationships_template(
request=request,
user=current_user,
ghostlink_requests=ghostlink_requests
)
@app.post('/ghostlink/accept_request')
async def accept_ghostlink_request_endpoint(
request: Request,
finder_username: str = Form(...),
username: Optional[str] = Form(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if not current_user.finder_insight_requests_as_ghostlink:
return render_profile_template(request=request, user=current_user, error="Access denied: only \'ghostlink\' role can accept request.")
result = await accept_finder_insight_request(db, current_user, finder_username, username)
if isinstance(result, str):
ghostlink_requests = await get_all_ghostlink_requests(db, current_user.username)
return render_relationships_template(request=request, user=current_user, ghostlink_requests=ghostlink_requests, error=result)
return RedirectResponse(url="/ghostlink/accept/successful", status_code=status.HTTP_302_FOUND)
@app.get('/ghostlink/accept/successful', response_class=HTMLResponse)
async def get_ghostlink_accept_successful_page(request: Request):
return templates.TemplateResponse("successful_accept.html", {"request": request})
@app.post('/ghostlink/reject_request')
async def reject_ghostlink_request_endpoint(
request: Request,
finder_username: str = Form(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if current_user.role != 'ghostlink':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'ghostlink' role can reject requests.")
error = await reject_finder_insight_request(db, current_user, finder_username)
if error:
ghostlink_requests = await get_all_ghostlink_requests(db, current_user.username)
return render_relationships_template(request=request, user=current_user, ghostlink_requests=ghostlink_requests, error=error)
return RedirectResponse(url="/ghostlink/reject/successful", status_code=status.HTTP_302_FOUND)
@app.get('/ghostlink/reject/successful', response_class=HTMLResponse)
async def get_ghostlink_reject_successful_page(request: Request):
return templates.TemplateResponse("succesful_reject.html", {"request": request})
@app.get('/finder/get_fulfilled_descriptions')
async def get_fulfilled_descriptions(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
username: str = "anonymous"
) -> List[FinderItemBase]:
if current_user.role != 'finder':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied: only 'finder' role can view fulfilled descriptions.")
fulfilled_requests_orm = await get_all_fulfilled_seeker_requests(db, username)
return [FinderItemBase.model_validate(req) for req in fulfilled_requests_orm]
@app.post('/finder/request_insight')
async def request_insight_from_ghostlink(
request: Request,
ghostlink_username: str = Form(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
if current_user.role != 'finder':
return render_profile_template(request=request, user=current_user, error="Access denied: only 'finder' role can request insights.")
error = await create_finder_insight_request(db, current_user, ghostlink_username)
if error:
return render_profile_template(request=request, user=current_user, error=error)
return RedirectResponse(url="/finder/request_insight/successful", status_code=status.HTTP_302_FOUND)
@app.get('/finder/request_insight/successful', response_class=HTMLResponse)
async def get_finder_insight_request_as_finder_insight_successful_page(request: Request):
return templates.TemplateResponse("successful_request.html", {"request": request})

47
darkbazaar/src/models.py Executable file
View File

@@ -0,0 +1,47 @@
import uuid
from typing import Literal, Optional, List
from pydantic import BaseModel, ConfigDict, Field
class SeekerRequestBase(BaseModel):
uuid: uuid.UUID
description: str = Field(min_length=1, max_length=250)
contact_info: str = Field(min_length=1, max_length=250)
model_config = ConfigDict(from_attributes=True)
class FinderItemBase(BaseModel):
uuid: uuid.UUID
description: str
contact_info: str
model_config = ConfigDict(from_attributes=True)
class GhostlinkInsightBase(BaseModel):
uuid: uuid.UUID
model_config = ConfigDict(from_attributes=True)
class FinderInsightRequestBase(BaseModel):
finder_username: str
ghostlink_username: str
status: Literal['pending', 'accepted', 'rejected'] = 'pending'
suggested_insight_uuid: Optional[uuid.UUID] = None
model_config = ConfigDict(from_attributes=True)
class User(BaseModel):
username: str
role: str
seeker_requests: List[SeekerRequestBase] = []
ghostlink_insights: List[GhostlinkInsightBase] = []
finder_insight_request_as_finder: Optional[FinderInsightRequestBase] = None
finder_insight_requests_as_ghostlink: Optional[List[FinderInsightRequestBase]] = []
model_config = ConfigDict(from_attributes=True)
class UserCreate(BaseModel):
username: str
password: str
role: Literal['seeker', 'finder', 'ghostlink'] = 'seeker'

493
darkbazaar/src/static/style.css Executable file
View File

@@ -0,0 +1,493 @@
/* General Body Styles */
body {
font-family: 'Consolas', 'Monaco', monospace;
background-color: #06060f; /* Very dark, almost black indigo */
color: #a6e1fa; /* Bright light blue for main text */
margin: 0;
padding: 20px;
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 40px auto;
background-color: #0d0d1a; /* Darker indigo for containers */
padding: 30px;
border-radius: 8px;
box-shadow: 0 0 25px rgba(0, 240, 255, 0.4); /* Electric blue glow */
border: 1px solid #00f0ff; /* Electric blue border */
}
h1, h2, h3, h4, h5, h6 {
color: #00f0ff; /* Electric blue for headers */
text-shadow: 0 0 10px rgba(0, 240, 255, 0.7); /* Electric blue glow */
margin-bottom: 20px;
}
p {
color: #b3ecff; /* Slightly lighter blue for paragraphs */
}
a {
color: #bf00ff; /* Vibrant purple for links */
text-decoration: none;
transition: color 0.3s ease, text-shadow 0.3s ease;
}
a:hover {
color: #00f0ff; /* Electric blue on hover */
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6); /* Electric blue glow on hover */
}
/* Forms */
form {
background-color: #1a1a33; /* Dark indigo for forms */
padding: 25px;
border-radius: 5px;
border: 1px solid #bf00ff; /* Vibrant purple border */
}
label {
display: block;
margin-bottom: 8px;
color: #00f0ff; /* Electric blue for labels */
}
input[type="text"],
input[type="password"],
input[type="number"],
select,
textarea {
width: calc(100% - 20px);
padding: 10px;
margin-bottom: 15px;
border: 1px solid #bf00ff; /* Vibrant purple border */
background-color: #06060f; /* Very dark, almost black indigo */
color: #a6e1fa; /* Bright light blue for input text */
border-radius: 3px;
box-shadow: inset 0 0 8px rgba(191, 0, 255, 0.3); /* Purple inner glow */
}
textarea {
resize: none; /* Prevent textarea from being resizable */
}
input[type="submit"],
button.btn {
background-color: #ff007f; /* Vibrant fuchsia for buttons */
color: #06060f; /* Very dark indigo for button text */
border: none;
padding: 12px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
font-weight: bold;
text-transform: uppercase;
transition: background-color 0.3s ease, box-shadow 0.3s ease, color 0.3s ease;
}
input[type="submit"]:hover,
button.btn:hover {
background-color: #00f0ff; /* Electric blue on hover */
box-shadow: 0 0 15px rgba(0, 240, 255, 0.7); /* Electric blue glow on hover */
color: #06060f; /* Very dark indigo for button text on hover */
}
/* Specific button styles */
.btn-primary {
background-color: #bf00ff; /* Vibrant purple */
color: #06060f; /* Very dark indigo */
}
.btn-primary:hover {
background-color: #00f0ff; /* Electric blue on hover */
box-shadow: 0 0 15px rgba(0, 240, 255, 0.7); /* Electric blue glow on hover */
}
.btn-secondary {
background-color: #3a005a; /* Deep indigo for secondary */
color: #a6e1fa; /* Bright light blue */
border: 1px solid #bf00ff; /* Vibrant purple border */
}
.btn-secondary:hover {
background-color: #bf00ff; /* Vibrant purple on hover */
box-shadow: 0 0 15px rgba(191, 0, 255, 0.7); /* Purple glow on hover */
color: #06060f; /* Very dark indigo for text on hover */
}
.btn-info {
background-color: #008cff; /* Bright blue */
color: #06060f; /* Very dark indigo */
}
.btn-info:hover {
background-color: #00bfff; /* Lighter bright blue on hover */
box-shadow: 0 0 15px rgba(0, 140, 255, 0.7); /* Blue glow on hover */
}
.btn-danger {
background-color: #ff007f; /* Vibrant fuchsia for danger */
color: #06060f; /* Very dark indigo */
}
.btn-danger:hover {
background-color: #ff3399; /* Lighter fuchsia on hover */
box-shadow: 0 0 15px rgba(255, 0, 127, 0.7); /* Fuchsia glow on hover */
}
.btn-success {
background-color: #00e676; /* Neon green for success */
color: #06060f; /* Very dark indigo */
}
.btn-success:hover {
background-color: #33ff99; /* Lighter neon green on hover */
box-shadow: 0 0 15px rgba(0, 230, 118, 0.7); /* Neon green glow on hover */
}
.btn-outline-dark {
background-color: transparent;
color: #00f0ff; /* Electric blue */
border: 1px solid #00f0ff; /* Electric blue border */
}
.btn-outline-dark:hover {
background-color: #00f0ff; /* Electric blue */
color: #06060f; /* Very dark indigo */
box-shadow: 0 0 15px rgba(0, 240, 255, 0.7); /* Electric blue glow on hover */
}
/* Alerts and Messages */
.alert {
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
border: 1px solid transparent;
}
.alert-danger {
background-color: #2a001a; /* Dark background for danger */
color: #ff3399; /* Lighter fuchsia for text */
border-color: #ff007f; /* Vibrant fuchsia for border */
box-shadow: 0 0 18px rgba(255, 0, 127, 0.5); /* Fuchsia glow */
}
/* New style for success alerts */
.alert-success {
background-color: #0d2a1a; /* Dark background for success */
color: #33ff99; /* Lighter neon green for text */
border-color: #00e676; /* Neon green for border */
box-shadow: 0 0 18px rgba(0, 230, 118, 0.5); /* Neon green glow */
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background-color: #0d0d1a; /* Darker indigo */
border: 1px solid #00f0ff; /* Electric blue border */
}
th, td {
border: 1px solid #00f0ff; /* Electric blue border */
padding: 10px;
text-align: left;
color: #b3ecff; /* Slightly lighter blue */
}
th {
background-color: #1a1a33; /* Dark indigo */
color: #00f0ff; /* Electric blue */
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6); /* Electric blue glow */
}
.risen {
color: #a6e1fa; /* Bright light blue */
font-weight: bold;
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6); /* Electric blue glow */
}
/* Cards (for investor requests) */
.card {
background-color: #1a1a33; /* Dark indigo */
border: 1px solid #bf00ff; /* Vibrant purple border */
border-radius: 8px;
box-shadow: 0 0 20px rgba(191, 0, 255, 0.4); /* Purple glow */
margin-bottom: 20px;
}
.card-title {
color: #00f0ff; /* Electric blue */
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6); /* Electric blue glow */
}
.card-text {
color: #b3ecff; /* Slightly lighter blue */
}
/* List groups (for analyst requests) */
.list-group {
list-style: none;
padding: 0;
}
.list-group-item {
background-color: #0d0d1a; /* Darker indigo */
border: 1px solid #00f0ff; /* Electric blue border */
margin-bottom: 10px;
padding: 15px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.list-group-item strong {
color: #00f0ff; /* Electric blue */
}
.seeker-request-item {
display: flex;
flex-direction: column; /* Stack details vertically */
align-items: flex-start; /* Align text to the left */
gap: 8px; /* Space between each detail */
}
.request-detail {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap; /* Allow items to wrap on smaller screens */
}
.request-detail code {
background-color: #06060f; /* Dark background for code */
color: #a6e1fa; /* Bright light blue for code text */
padding: 3px 6px;
border-radius: 3px;
border: 1px solid #bf00ff; /* Vibrant purple border */
font-size: 0.9em;
}
.badge-success {
background-color: #00e676; /* Neon green */
color: #06060f; /* Very dark indigo */
padding: 5px 10px;
border-radius: 3px;
font-size: 0.8em;
font-weight: bold;
}
.badge-info {
background-color: #008cff; /* Bright blue */
color: #06060f; /* Very dark indigo */
padding: 5px 10px;
border-radius: 3px;
font-size: 0.8em;
font-weight: bold;
}
.badge {
background-color: #4a006a; /* Muted deep purple */
color: #e0b3ff; /* Light purple for contrast */
padding: 5px 10px;
border-radius: 3px;
font-size: 0.8em;
}
.d-flex {
display: flex;
gap: 10px;
}
.me-2 {
margin-right: 10px;
}
/* Utility classes */
.mt-2 { margin-top: 0.5rem !important; }
.mt-3 { margin-top: 1rem !important; }
.mt-4 { margin-top: 1.5rem !important; }
.mt-5 { margin-top: 3rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-4 { margin-bottom: 1.5rem !important; }
.d-grid { display: grid !important; }
.gap-2 { gap: 0.5rem !important; }
.col-6 { flex: 0 0 auto; width: 50%; }
.mx-auto { margin-right: auto !important; margin-left: auto !important; }
.input-group { display: flex; }
.form-control { flex: 1 1 auto; width: 1%; }
.alert-danger { /* existing styles */ }
.alert { /* existing styles */ }
.d-grid.gap-2.col-6.mx-auto {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
width: 50%;
margin: 0 auto;
}
.d-grid { display: grid; }
.gap-2 { gap: 0.5rem; }
.section {
background-color: #0d0d1a; /* Darker indigo */
padding: 20px;
border-radius: 8px;
border: 1px solid #00f0ff; /* Electric blue border */
margin-bottom: 30px;
box-shadow: 0 0 20px rgba(0, 240, 255, 0.4); /* Electric blue glow */
}
/* New style for found item details card */
.found-item-card {
background-color: #0d0d1a; /* Darker indigo for containers */
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 240, 255, 0.4); /* Electric blue glow */
border: 1px solid #00f0ff; /* Electric blue border */
margin-top: 20px; /* Add some space above it */
}
.found-item-card h3 {
color: #00f0ff; /* Electric blue for header */
text-shadow: 0 0 10px rgba(0, 240, 255, 0.7);
margin-bottom: 15px;
}
.found-item-card p {
margin-bottom: 8px;
}
.found-item-card code {
background-color: #06060f;
color: #a6e1fa;
padding: 3px 6px;
border-radius: 3px;
border: 1px solid #bf00ff;
font-size: 0.9em;
}
.hint {
color: #7fbfd8; /* Muted blue for hints */
font-style: italic;
margin-top: -10px;
margin-bottom: 15px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container {
margin: 20px;
padding: 20px;
}
.col-6 {
width: 100%;
}
}
/* Aggressive reset for list items within the header */
.header ul {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.header li {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
/* Existing header styles - ensure they are after the aggressive reset */
.header {
background-color: #06060f; /* Very dark, almost black indigo */
padding: 15px 40px; /* More padding for header */
border-bottom: 1px solid #00f0ff; /* Electric blue border */
box-shadow: 0 0 25px rgba(0, 240, 255, 0.4); /* Electric blue glow */
display: flex;
justify-content: space-between; /* Space out logo and nav links */
align-items: center;
width: 100%; /* Ensure header takes full width */
}
.header .logo {
color: #00f0ff; /* Electric blue for logo */
font-size: 2.2em; /* Larger font size for logo */
font-weight: bold;
text-shadow: 0 0 12px rgba(0, 240, 255, 0.8); /* Stronger electric blue glow */
letter-spacing: 1px;
}
.header .nav-links {
display: flex; /* Arrange items horizontally */
gap: 25px; /* Space between navigation items */
align-items: center;
}
.header .nav-links li {
display: flex; /* Ensure li elements are also flex containers */
align-items: center;
}
.header .nav-links a {
color: #a6e1fa; /* Bright light blue for nav links */
font-weight: bold;
text-transform: uppercase;
padding: 8px 15px;
border: 1px solid transparent; /* Transparent border by default */
border-radius: 5px;
transition: all 0.3s ease;
white-space: nowrap; /* Prevent text from wrapping */
}
.header .nav-links a:hover {
color: #00f0ff; /* Electric blue on hover */
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6); /* Electric blue glow on hover */
border-color: #00f0ff; /* Electric blue border on hover */
}
/* New styles for the logout link */
.header .nav-links .nav-link-logout {
color: #bf00ff; /* Vibrant purple */
font-weight: bold;
text-transform: uppercase;
padding: 8px 15px;
border: 1px solid transparent; /* Transparent border by default */
border-radius: 5px;
transition: all 0.3s ease;
white-space: nowrap;
}
.header .nav-links .nav-link-logout:hover {
color: #e0b3ff; /* Lighter purple on hover */
text-shadow: 0 0 8px rgba(191, 0, 255, 0.6); /* Purple glow on hover */
border-color: #e0b3ff; /* Lighter purple border on hover */
}
/* Ensure forms within nav li are well-behaved */
.header .nav-links li form {
display: flex; /* Allow content to flow nicely */
margin: 0;
padding: 0;
}
/* Remove old btn-logout styles */
.header .nav-links .btn-logout {
display: none !important;
}
.header .nav-links .btn-logout:hover {
display: none !important;
}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title | default('DarkBazaar') }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<nav>
<a href="/" class="logo">DarkBazaar</a>
<ul class="nav-links">
{% if 'username' in request.session %}
<li><a href="/profile">Profile</a></li>
{% if request.session.role == 'ghostlink' %}
<li><a href="/ghostlink/requests">Requests</a></li>
{% endif %}
<li>
<a href="#" onclick="document.getElementById('logout-form').submit(); return false;" class="nav-link-logout">Logout</a>
<form id="logout-form" action="/logout" method="post" style="display: none;"></form>
</li>
{% else %}
<li><a href="/login">Login</a></li>
<li><a href="/register">Register</a></li>
{% endif %}
</ul>
</nav>
</header>
<main>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Главная</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<h1>Welcome</h1>
<p>Step into DarkBazaar, where Seekers, Finders, and Ghostlinks move through a shadowy market of secrets—trust no one and navigate the hidden paths of this enigmatic world.</p>
{% if not 'username' in request.session %}
<p>
<a href="/login" class="button">Login</a>
<a href="/register" class="button">Register</a>
</p>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<h1>Login</h1>
{% if error %}
<p class="alert alert-danger">{{ error }}</p>
{% endif %}
<form action="/users/login" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<input type="submit" value="Login" class="btn btn-primary">
</form>
<p class="mt-3">Don't have an account? <a href="/register">Register</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Профиль пользователя</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<h1>User Profile: {{ username }}</h1>
<p>Role: <strong>{{ role }}</strong></p>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
{% if success_message %}
<div class="alert alert-success" role="alert">
{{ success_message }}
</div>
{% endif %}
{# Seeker Profile Section #}
{% if role == 'seeker' %}
<div class="section">
<h2 class="mt-4">Your Search Requests</h2>
{% if seeker_requests %}
<ul class="list-group">
{% for req in seeker_requests %}
<li class="list-group-item seeker-request-item">
<div class="request-detail-uuid">
<strong>UUID:</strong> <code id="seeker-uuid-{{ loop.index0 }}">{{ req.uuid }}</code>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard('seeker-uuid-{{ loop.index0 }}')">Copy</button>
</div>
<div class="request-detail-description">
<strong>Description:</strong> {{ req.description }}
</div>
<div class="request-detail-contact">
<strong>Contact:</strong> {{ req.contact_info }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>You have no active search requests.</p>
{% endif %}
<h3 class="mt-4">Create a New Search Request</h3>
<form action="/seeker/create_request" method="post" class="mb-4">
<label for="item_uuid">Item UUID:</label>
<input type="text" id="item_uuid" name="item_uuid" placeholder="e.g. a1b2c3d4-e5f6-7890-1234-567890abcdef" required pattern="[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}">
<label for="description">Description:</label>
<textarea id="description" name="description" rows="3" required></textarea>
<label for="contact_info">Contact Info:</label>
<input type="text" id="contact_info" name="contact_info" required>
<button type="submit" class="btn btn-primary">Create Request</button>
</form>
</div>
{% endif %}
{# Finder Profile Section #}
{% if role == 'finder' %}
<div class="section">
<h2 class="mt-4">Find an Item</h2>
<form action="/finder/find_item" method="post" class="mb-4">
<label for="find_item_uuid">Item UUID:</label>
<input type="text" id="find_item_uuid" name="item_uuid" placeholder="e.g. a1b2c3d4-e5f6-7890-1234-567890abcdef" required pattern="[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}">
<button type="submit" class="btn btn-primary">Find Item</button>
</form>
{% if found_item %}
<div class="found-item-card" role="alert">
<h3>Found Item Details:</h3>
<p><strong>UUID:</strong> <code id="found-uuid">{{ found_item.uuid }}</code> <button class="btn btn-secondary btn-sm" onclick="copyToClipboard('found-uuid')">Copy</button></p>
<p><strong>Description:</strong> {{ found_item.description }}</p>
<p><strong>Contact Seeker:</strong> {{ found_item.contact_info }}</p>
</div>
{% endif %}
<h2 class="mt-4">Request Insight from a Ghostlink</h2>
{% if finder_insight_request_as_finder %}
<div class="alert alert-info" role="alert">
<h3>Your Current Insight Request:</h3>
<p><strong>To Ghostlink:</strong> {{ finder_insight_request_as_finder.ghostlink_username }}</p>
<p><strong>Status:</strong> {{ finder_insight_request_as_finder.status }}</p>
{% if finder_insight_request_as_finder.status == 'accepted' and finder_insight_request_as_finder.suggested_insight_uuid %}
<p><strong>Suggested Insight UUID:</strong> <code id="suggested-insight-uuid">{{ finder_insight_request_as_finder.suggested_insight_uuid }}</code> <button class="btn btn-secondary btn-sm" onclick="copyToClipboard('suggested-insight-uuid')">Copy</button></p>
{% endif %}
</div>
{% else %}
<p>You currently have no active insight requests. Request one from a Ghostlink:</p>
<form action="/finder/request_insight" method="post" class="mb-4">
<label for="ghostlink_username">Ghostlink Username:</label>
<input type="text" id="ghostlink_username" name="ghostlink_username" required>
<button type="submit" class="btn btn-primary">Request Insight</button>
</form>
{% endif %}
</div>
{% endif %}
{# Ghostlink Profile Section #}
{% if role == 'ghostlink' %}
<div class="section">
<h2 class="mt-4">Your Insights</h2>
{% if ghostlink_insights %}
<ul class="list-group">
{% for insight in ghostlink_insights %}
<li class="list-group-item">
<strong>UUID:</strong> <code id="insight-uuid-{{ loop.index0 }}">{{ insight.uuid }}</code> <button class="btn btn-secondary btn-sm" onclick="copyToClipboard('insight-uuid-{{ loop.index0 }}')">Copy</button>
</li>
{% endfor %}
</ul>
{% else %}
<p>You have no insights yet.</p>
{% endif %}
<h3 class="mt-4">Create New Insight</h3>
<form action="/ghostlink/create_insight" method="post" class="mb-4">
<label for="insight_uuid">Insight UUID:</label>
<input type="text" id="insight_uuid" name="insight_uuid" placeholder="e.g. d1c2b3a4-f5e6-9876-5432-10fedcba9876" required pattern="[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}">
<button type="submit" class="btn btn-primary">Create Insight</button>
</form>
<p><a href="/ghostlink/requests" class="button">View Finder Requests</a></p>
</div>
{% endif %}
<script>
function copyToClipboard(elementId) {
var copyText = document.getElementById(elementId);
var textArea = document.createElement("textarea");
textArea.value = copyText.innerText || copyText.textContent;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
textArea.remove();
var button = document.querySelector('#' + elementId + ' + .btn');
var originalButtonText = button.innerText;
button.innerText = 'Copied!';
setTimeout(function() {
button.innerText = originalButtonText;
}, 2000);
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<h1>Register</h1>
{% if error %}
<p class="alert alert-danger">{{ error }}</p>
{% endif %}
<form action="/users/register" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<label for="role">Role:</label>
<select id="role" name="role">
<option value="seeker">Seeker</option>
<option value="finder">Finder</option>
<option value="ghostlink">Ghostlink</option>
</select>
<input type="submit" value="Register" class="btn btn-primary">
</form>
<p class="mt-3">Already have an account? <a href="/login">Login</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Взаимодействия</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<h1>Requests for Ghostlink</h1>
<p>Your role: <strong>{{ user.role }}</strong></p>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
{% if user.role == 'ghostlink' %}
<div class="section">
<h2 class="mt-4">Requests from Finders</h2>
{% if ghostlink_requests %}
<div class="list-group">
{% for req in ghostlink_requests %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
Request from <strong>{{ req.finder_username }}</strong> (Status: <span class="request-status">{{ req.status }}</span>)
</div>
<div class="d-flex">
{% if req.status == 'pending' %}
<form action="/ghostlink/accept_request" method="post" class="me-2">
<input type="hidden" name="finder_username" value="{{ req.finder_username }}">
<button type="submit" class="btn btn-success btn-sm">Accept</button>
</form>
<form action="/ghostlink/reject_request" method="post">
<input type="hidden" name="finder_username" value="{{ req.finder_username }}">
<button type="submit" class="btn btn-danger btn-sm">Reject</button>
</form>
{% else %}
<span class="badge bg-secondary">{{ req.status }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p>You have no pending requests from Finders.</p>
{% endif %}
</div>
{% else %}
<div class="alert alert-warning" role="alert">
<p>Access denied: This page is only for Ghostlinks to manage requests.</p>
</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Запрос отклонён!</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<div class="section">
<h1>Request Rejected!</h1>
<p>You have successfully rejected the insight request.</p>
<p class="mt-4">
<a href="/profile" class="btn btn-secondary">Back to Profile</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> Запрос принят!</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<div class="section">
<h1>Request Accepted!</h1>
<p>You have successfully accepted the insight request and shared an insight with the Finder.</p>
<p class="mt-4">
<a href="/profile" class="btn btn-secondary">Back to Profile</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Запрос отправлен!</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
{% include "header.html" %}
<div class="container">
<div class="section">
<h1>Request Sent!</h1>
<div class="alert alert-success" role="alert">
<p>You have successfully sent a request for an insight to the Ghostlink.</p>
</div>
<p class="mt-4">
<a href="/profile" class="btn btn-secondary">Back to Profile</a>
</p>
</div>
</div>
</body>
</html>

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

4
neuralink/.env Executable file
View File

@@ -0,0 +1,4 @@
POSTGRES_USER=neuralink
POSTGRES_DB=neuralink_db
POSTGRES_PASSWORD=neuralink_password
PGDATA=/data/postgres

Some files were not shown because too many files have changed in this diff Show More