init
This commit is contained in:
BIN
Services.zip
Executable file
BIN
Services.zip
Executable file
Binary file not shown.
2
darkbazaar/.gitignore
vendored
Executable file
2
darkbazaar/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
.venv
|
||||
__pycache__/
|
||||
13
darkbazaar/Dockerfile
Executable file
13
darkbazaar/Dockerfile
Executable 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
16
darkbazaar/Dockerfile.cleaner
Executable 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
45
darkbazaar/docker-compose.yml
Executable 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
14
darkbazaar/docker-entrypoint.sh
Executable 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
BIN
darkbazaar/requirements.txt
Executable file
Binary file not shown.
0
darkbazaar/src/__init__.py
Executable file
0
darkbazaar/src/__init__.py
Executable file
9
darkbazaar/src/auth.py
Executable file
9
darkbazaar/src/auth.py
Executable 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
44
darkbazaar/src/cleaner.py
Executable 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
324
darkbazaar/src/db.py
Executable 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
330
darkbazaar/src/main.py
Executable 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
47
darkbazaar/src/models.py
Executable 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
493
darkbazaar/src/static/style.css
Executable 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;
|
||||
}
|
||||
|
||||
|
||||
32
darkbazaar/src/templates/header.html
Executable file
32
darkbazaar/src/templates/header.html
Executable 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>
|
||||
|
||||
|
||||
22
darkbazaar/src/templates/index.html
Executable file
22
darkbazaar/src/templates/index.html
Executable 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>
|
||||
29
darkbazaar/src/templates/login.html
Executable file
29
darkbazaar/src/templates/login.html
Executable 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>
|
||||
154
darkbazaar/src/templates/profile.html
Executable file
154
darkbazaar/src/templates/profile.html
Executable 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>
|
||||
36
darkbazaar/src/templates/register.html
Executable file
36
darkbazaar/src/templates/register.html
Executable 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>
|
||||
59
darkbazaar/src/templates/relationships.html
Executable file
59
darkbazaar/src/templates/relationships.html
Executable 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>
|
||||
21
darkbazaar/src/templates/succesful_reject.html
Executable file
21
darkbazaar/src/templates/succesful_reject.html
Executable 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>
|
||||
21
darkbazaar/src/templates/successful_accept.html
Executable file
21
darkbazaar/src/templates/successful_accept.html
Executable 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>
|
||||
23
darkbazaar/src/templates/successful_request.html
Executable file
23
darkbazaar/src/templates/successful_request.html
Executable 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
3
dollhouse/.env
Executable file
@@ -0,0 +1,3 @@
|
||||
POSTGRES_USER=dollhouse_user
|
||||
POSTGRES_PASSWORD=hahahadollhouse
|
||||
POSTGRES_DB=dollhouse_db
|
||||
3770
dollhouse/Cargo.lock
generated
Executable file
3770
dollhouse/Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
12
dollhouse/Cargo.toml
Executable file
12
dollhouse/Cargo.toml
Executable 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
80
dollhouse/compose.yaml
Executable 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:
|
||||
9
dollhouse/crates/dollhouse-api-types/Cargo.toml
Executable file
9
dollhouse/crates/dollhouse-api-types/Cargo.toml
Executable 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 }
|
||||
163
dollhouse/crates/dollhouse-api-types/src/lib.rs
Executable file
163
dollhouse/crates/dollhouse-api-types/src/lib.rs
Executable 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,
|
||||
}
|
||||
26
dollhouse/crates/dollhouse-backend/Cargo.toml
Executable file
26
dollhouse/crates/dollhouse-backend/Cargo.toml
Executable 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"
|
||||
159
dollhouse/crates/dollhouse-backend/src/conversions.rs
Executable file
159
dollhouse/crates/dollhouse-backend/src/conversions.rs
Executable 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
290
dollhouse/crates/dollhouse-backend/src/handlers.rs
Executable file
290
dollhouse/crates/dollhouse-backend/src/handlers.rs
Executable 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),
|
||||
}
|
||||
}
|
||||
99
dollhouse/crates/dollhouse-backend/src/main.rs
Executable file
99
dollhouse/crates/dollhouse-backend/src/main.rs
Executable 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
|
||||
}
|
||||
124
dollhouse/crates/dollhouse-backend/src/services/auth.rs
Executable file
124
dollhouse/crates/dollhouse-backend/src/services/auth.rs
Executable 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
dollhouse/crates/dollhouse-backend/src/services/corp.rs
Executable file
114
dollhouse/crates/dollhouse-backend/src/services/corp.rs
Executable 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
226
dollhouse/crates/dollhouse-backend/src/services/lua.rs
Executable file
226
dollhouse/crates/dollhouse-backend/src/services/lua.rs
Executable 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
11
dollhouse/crates/dollhouse-backend/src/services/mod.rs
Executable file
11
dollhouse/crates/dollhouse-backend/src/services/mod.rs
Executable 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::*;
|
||||
466
dollhouse/crates/dollhouse-backend/src/services/replicant.rs
Executable file
466
dollhouse/crates/dollhouse-backend/src/services/replicant.rs
Executable 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
25
dollhouse/crates/dollhouse-backend/src/services/user.rs
Executable file
25
dollhouse/crates/dollhouse-backend/src/services/user.rs
Executable 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
114
dollhouse/crates/dollhouse-backend/src/utils.rs
Executable file
114
dollhouse/crates/dollhouse-backend/src/utils.rs
Executable 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
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
dollhouse/crates/dollhouse-db/.env
Executable file
1
dollhouse/crates/dollhouse-db/.env
Executable file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=postgres://dollhouse_user:hahahadollhouse@localhost:5432/dollhouse_db
|
||||
16
dollhouse/crates/dollhouse-db/Cargo.toml
Executable file
16
dollhouse/crates/dollhouse-db/Cargo.toml
Executable 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"
|
||||
9
dollhouse/crates/dollhouse-db/diesel.toml
Executable file
9
dollhouse/crates/dollhouse-db/diesel.toml
Executable 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"
|
||||
0
dollhouse/crates/dollhouse-db/migrations/.diesel_lock
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.diesel_lock
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.keep
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.keep
Executable 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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
35
dollhouse/crates/dollhouse-db/src/errors.rs
Executable file
35
dollhouse/crates/dollhouse-db/src/errors.rs
Executable 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
33
dollhouse/crates/dollhouse-db/src/lib.rs
Executable file
33
dollhouse/crates/dollhouse-db/src/lib.rs
Executable 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")
|
||||
}
|
||||
175
dollhouse/crates/dollhouse-db/src/models.rs
Executable file
175
dollhouse/crates/dollhouse-db/src/models.rs
Executable 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
173
dollhouse/crates/dollhouse-db/src/repositories/corp.rs
Executable file
173
dollhouse/crates/dollhouse-db/src/repositories/corp.rs
Executable 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)
|
||||
}
|
||||
}
|
||||
7
dollhouse/crates/dollhouse-db/src/repositories/mod.rs
Executable file
7
dollhouse/crates/dollhouse-db/src/repositories/mod.rs
Executable 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;
|
||||
334
dollhouse/crates/dollhouse-db/src/repositories/replicant.rs
Executable file
334
dollhouse/crates/dollhouse-db/src/repositories/replicant.rs
Executable 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)
|
||||
}
|
||||
}
|
||||
137
dollhouse/crates/dollhouse-db/src/repositories/user.rs
Executable file
137
dollhouse/crates/dollhouse-db/src/repositories/user.rs
Executable 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)
|
||||
}
|
||||
}
|
||||
79
dollhouse/crates/dollhouse-db/src/schema.rs
Executable file
79
dollhouse/crates/dollhouse-db/src/schema.rs
Executable 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,);
|
||||
24
dollhouse/crates/dollhouse-frontend/Cargo.toml
Executable file
24
dollhouse/crates/dollhouse-frontend/Cargo.toml
Executable 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 }
|
||||
14
dollhouse/crates/dollhouse-frontend/Trunk.toml
Executable file
14
dollhouse/crates/dollhouse-frontend/Trunk.toml
Executable 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"
|
||||
14
dollhouse/crates/dollhouse-frontend/index.html
Executable file
14
dollhouse/crates/dollhouse-frontend/index.html
Executable 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>
|
||||
212
dollhouse/crates/dollhouse-frontend/src/components/auth_form.rs
Executable file
212
dollhouse/crates/dollhouse-frontend/src/components/auth_form.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
90
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_info_tab.rs
Executable file
90
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_info_tab.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
220
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_replicants_tab.rs
Executable file
220
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_replicants_tab.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
158
dollhouse/crates/dollhouse-frontend/src/components/corp/create_corp_form.rs
Executable file
158
dollhouse/crates/dollhouse-frontend/src/components/corp/create_corp_form.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
136
dollhouse/crates/dollhouse-frontend/src/components/corp/join_corp_modal.rs
Executable file
136
dollhouse/crates/dollhouse-frontend/src/components/corp/join_corp_modal.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
5
dollhouse/crates/dollhouse-frontend/src/components/corp/mod.rs
Executable file
5
dollhouse/crates/dollhouse-frontend/src/components/corp/mod.rs
Executable 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;
|
||||
34
dollhouse/crates/dollhouse-frontend/src/components/header.rs
Executable file
34
dollhouse/crates/dollhouse-frontend/src/components/header.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
79
dollhouse/crates/dollhouse-frontend/src/components/layout.rs
Executable file
79
dollhouse/crates/dollhouse-frontend/src/components/layout.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
20
dollhouse/crates/dollhouse-frontend/src/components/mod.rs
Executable file
20
dollhouse/crates/dollhouse-frontend/src/components/mod.rs
Executable 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;
|
||||
108
dollhouse/crates/dollhouse-frontend/src/components/pagination.rs
Executable file
108
dollhouse/crates/dollhouse-frontend/src/components/pagination.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
199
dollhouse/crates/dollhouse-frontend/src/components/replicant_card.rs
Executable file
199
dollhouse/crates/dollhouse-frontend/src/components/replicant_card.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
74
dollhouse/crates/dollhouse-frontend/src/components/sidebar.rs
Executable file
74
dollhouse/crates/dollhouse-frontend/src/components/sidebar.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
28
dollhouse/crates/dollhouse-frontend/src/main.rs
Executable file
28
dollhouse/crates/dollhouse-frontend/src/main.rs
Executable 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();
|
||||
}
|
||||
267
dollhouse/crates/dollhouse-frontend/src/pages/corp.rs
Executable file
267
dollhouse/crates/dollhouse-frontend/src/pages/corp.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
30
dollhouse/crates/dollhouse-frontend/src/pages/login_page.rs
Executable file
30
dollhouse/crates/dollhouse-frontend/src/pages/login_page.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
13
dollhouse/crates/dollhouse-frontend/src/pages/mod.rs
Executable file
13
dollhouse/crates/dollhouse-frontend/src/pages/mod.rs
Executable 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;
|
||||
11
dollhouse/crates/dollhouse-frontend/src/pages/not_found.rs
Executable file
11
dollhouse/crates/dollhouse-frontend/src/pages/not_found.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
30
dollhouse/crates/dollhouse-frontend/src/pages/register_page.rs
Executable file
30
dollhouse/crates/dollhouse-frontend/src/pages/register_page.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
454
dollhouse/crates/dollhouse-frontend/src/pages/replicant.rs
Executable file
454
dollhouse/crates/dollhouse-frontend/src/pages/replicant.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
209
dollhouse/crates/dollhouse-frontend/src/pages/replicants.rs
Executable file
209
dollhouse/crates/dollhouse-frontend/src/pages/replicants.rs
Executable 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>
|
||||
}
|
||||
}
|
||||
50
dollhouse/crates/dollhouse-frontend/src/routes.rs
Executable file
50
dollhouse/crates/dollhouse-frontend/src/routes.rs
Executable 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>
|
||||
},
|
||||
}
|
||||
}
|
||||
305
dollhouse/crates/dollhouse-frontend/src/services/api.rs
Executable file
305
dollhouse/crates/dollhouse-frontend/src/services/api.rs
Executable 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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
62
dollhouse/crates/dollhouse-frontend/src/services/auth.rs
Executable file
62
dollhouse/crates/dollhouse-frontend/src/services/auth.rs
Executable 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(),
|
||||
}
|
||||
}
|
||||
4
dollhouse/crates/dollhouse-frontend/src/services/mod.rs
Executable file
4
dollhouse/crates/dollhouse-frontend/src/services/mod.rs
Executable file
@@ -0,0 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
|
||||
pub use api::ApiService;
|
||||
11
dollhouse/crates/dollhouse-frontend/static/female.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/female.svg
Executable 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 |
11
dollhouse/crates/dollhouse-frontend/static/male.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/male.svg
Executable 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 |
11
dollhouse/crates/dollhouse-frontend/static/non-binary.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/non-binary.svg
Executable 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 |
2232
dollhouse/crates/dollhouse-frontend/styles.css
Executable file
2232
dollhouse/crates/dollhouse-frontend/styles.css
Executable file
File diff suppressed because it is too large
Load Diff
22
dollhouse/docker/Dockerfile.base
Executable file
22
dollhouse/docker/Dockerfile.base
Executable 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
|
||||
24
dollhouse/docker/dollhouse-backend/Dockerfile
Executable file
24
dollhouse/docker/dollhouse-backend/Dockerfile
Executable 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" ]
|
||||
6
dollhouse/docker/dollhouse-backend/entrypoint.sh
Executable file
6
dollhouse/docker/dollhouse-backend/entrypoint.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -x
|
||||
|
||||
./diesel setup || exit 1
|
||||
exec /usr/local/bin/backend
|
||||
8
dollhouse/docker/dollhouse-cleaner/Dockerfile
Executable file
8
dollhouse/docker/dollhouse-cleaner/Dockerfile
Executable 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"]
|
||||
29
dollhouse/docker/dollhouse-cleaner/cleaner.sh
Executable file
29
dollhouse/docker/dollhouse-cleaner/cleaner.sh
Executable 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
|
||||
19
dollhouse/docker/dollhouse-frontend/Dockerfile
Executable file
19
dollhouse/docker/dollhouse-frontend/Dockerfile
Executable 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;"]
|
||||
53
dollhouse/docker/dollhouse-frontend/nginx.conf
Executable file
53
dollhouse/docker/dollhouse-frontend/nginx.conf
Executable 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
4
dollhouse/run-me.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
docker compose build base
|
||||
docker compose up --build -d
|
||||
4
neuralink/.env
Executable file
4
neuralink/.env
Executable 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
Reference in New Issue
Block a user