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

2
darkbazaar/.gitignore vendored Executable file
View File

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

13
darkbazaar/Dockerfile Executable file
View File

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

16
darkbazaar/Dockerfile.cleaner Executable file
View File

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

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

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

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

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

BIN
darkbazaar/requirements.txt Executable file

Binary file not shown.

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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