diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..8936215 --- /dev/null +++ b/DATABASE.md @@ -0,0 +1,93 @@ +# Database Setup and Migrations + +This document explains how to set up and manage the database for the dicta2stream application. + +## Prerequisites + +- PostgreSQL database server +- Python 3.8+ +- Required Python packages (install with `pip install -r requirements.txt`) + +## Initial Setup + +1. Create a PostgreSQL database: + ```bash + createdb dicta2stream + ``` + +2. Set up the database URL in your environment: + ```bash + echo "DATABASE_URL=postgresql://username:password@localhost/dicta2stream" > .env + ``` + Replace `username` and `password` with your PostgreSQL credentials. + +3. Initialize the database: + ```bash + python init_db.py + ``` + +## Running Migrations + +After making changes to the database models, you can create and apply migrations: + +1. Install the required dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Run the migrations: + ```bash + python run_migrations.py + ``` + +## Database Models + +The application uses the following database models: + +### User +- Stores user account information +- Fields: username, email, hashed_password, is_active, created_at, updated_at + +### Session +- Manages user sessions +- Fields: id, user_id, token, ip_address, user_agent, created_at, expires_at, last_used_at, is_active + +### PublicStream +- Tracks publicly available audio streams +- Fields: uid, filename, size, mtime, created_at, updated_at + +### UserQuota +- Tracks user storage quotas +- Fields: uid, storage_bytes, updated_at + +### UploadLog +- Logs file uploads +- Fields: id, uid, filename, size, ip_address, user_agent, created_at + +## Backing Up the Database + +To create a backup of your database: + +```bash +pg_dump -U username -d dicta2stream -f backup.sql +``` + +To restore from a backup: + +```bash +psql -U username -d dicta2stream -f backup.sql +``` + +## Troubleshooting + +- If you encounter connection issues, verify that: + - The PostgreSQL server is running + - The database URL in your .env file is correct + - The database user has the necessary permissions + +- If you need to reset the database: + ```bash + dropdb dicta2stream + createdb dicta2stream + python init_db.py + ``` diff --git a/account_router.py b/account_router.py new file mode 100644 index 0000000..de8ac10 --- /dev/null +++ b/account_router.py @@ -0,0 +1,98 @@ +# account_router.py — Account management endpoints + +from fastapi import APIRouter, Request, HTTPException, Depends +from fastapi.responses import JSONResponse +from sqlmodel import Session, select +from models import User, UserQuota, UploadLog, DBSession +from database import get_db +import os +from typing import Dict, Any + +router = APIRouter(prefix="/api", tags=["account"]) + +@router.post("/delete-account") +async def delete_account(data: Dict[str, Any], request: Request, db: Session = Depends(get_db)): + try: + # Get UID from request data + uid = data.get("uid") + if not uid: + print(f"[DELETE_ACCOUNT] Error: Missing UID in request data") + raise HTTPException(status_code=400, detail="Missing UID") + + ip = request.client.host + print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}") + + # Verify user exists and IP matches + user = db.exec(select(User).where(User.username == uid)).first() + if not user: + print(f"[DELETE_ACCOUNT] Error: User {uid} not found") + raise HTTPException(status_code=404, detail="User not found") + + if user.ip != ip: + print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request IP: {ip}") + raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match") + + # Start transaction + try: + # Delete user's upload logs + uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all() + for upload in uploads: + db.delete(upload) + print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {uid}") + + # Delete user's quota + quota = db.get(UserQuota, uid) + if quota: + db.delete(quota) + print(f"[DELETE_ACCOUNT] Deleted quota for user {uid}") + + # Delete user's active sessions + sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all() + for session in sessions: + db.delete(session) + print(f"[DELETE_ACCOUNT] Deleted {len(sessions)} active sessions for user {uid}") + + # Delete user account + user_obj = db.get(User, user.email) + if user_obj: + db.delete(user_obj) + print(f"[DELETE_ACCOUNT] Deleted user account {uid} ({user.email})") + + db.commit() + print(f"[DELETE_ACCOUNT] Database changes committed for user {uid}") + + except Exception as e: + db.rollback() + print(f"[DELETE_ACCOUNT] Database error during account deletion: {str(e)}") + raise HTTPException(status_code=500, detail="Database error during account deletion") + + # Delete user's files + try: + user_dir = os.path.join('data', user.username) + real_user_dir = os.path.realpath(user_dir) + + # Security check to prevent directory traversal + if not real_user_dir.startswith(os.path.realpath('data')): + print(f"[DELETE_ACCOUNT] Security alert: Invalid user directory path: {user_dir}") + raise HTTPException(status_code=400, detail="Invalid user directory") + + if os.path.exists(real_user_dir): + import shutil + shutil.rmtree(real_user_dir, ignore_errors=True) + print(f"[DELETE_ACCOUNT] Deleted user directory: {real_user_dir}") + else: + print(f"[DELETE_ACCOUNT] User directory not found: {real_user_dir}") + + except Exception as e: + print(f"[DELETE_ACCOUNT] Error deleting user files: {str(e)}") + # Continue even if file deletion fails, as the account is already deleted from the DB + + print(f"[DELETE_ACCOUNT] Successfully deleted account for user {uid}") + return {"status": "success", "message": "Account and all associated data have been deleted"} + + except HTTPException as he: + print(f"[DELETE_ACCOUNT] HTTP Error {he.status_code}: {he.detail}") + raise + except Exception as e: + print(f"[DELETE_ACCOUNT] Unexpected error: {str(e)}") + raise HTTPException(status_code=500, detail="An unexpected error occurred") diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..532e61e --- /dev/null +++ b/alembic.ini @@ -0,0 +1,140 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql://postgres:postgres@localhost/dicta2stream + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..e7dc43d --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +from logging.config import fileConfig +import os +import sys +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Add the project root to the Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# Import your SQLAlchemy models and engine +from models import SQLModel +from database import engine + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Import all your SQLModel models here so that Alembic can detect them +from models import User, DBSession + +# Set the target metadata to SQLModel.metadata +target_metadata = SQLModel.metadata + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/1ab2db0e4b5e_make_username_unique.py b/alembic/versions/1ab2db0e4b5e_make_username_unique.py new file mode 100644 index 0000000..36b489b --- /dev/null +++ b/alembic/versions/1ab2db0e4b5e_make_username_unique.py @@ -0,0 +1,86 @@ +"""make username unique + +Revision ID: 1ab2db0e4b5e +Revises: +Create Date: 2025-06-27 13:04:10.085253 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '1ab2db0e4b5e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 1. First, add the unique constraint to the username column + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_unique_constraint('uq_user_username', ['username']) + + # 2. Now create the dbsession table with the foreign key + op.create_table('dbsession', + sa.Column('token', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_agent', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('last_activity', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.username'], ), + sa.PrimaryKeyConstraint('token') + ) + + # 3. Drop old tables if they exist + if op.get_bind().engine.dialect.has_table(op.get_bind(), 'session'): + op.drop_index(op.f('ix_session_token'), table_name='session') + op.drop_index(op.f('ix_session_user_id'), table_name='session') + op.drop_table('session') + + if op.get_bind().engine.dialect.has_table(op.get_bind(), 'publicstream'): + op.drop_table('publicstream') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # 1. First drop the dbsession table + op.drop_table('dbsession') + + # 2. Recreate the old tables + op.create_table('publicstream', + sa.Column('uid', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('mtime', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('uid', name=op.f('publicstream_pkey')) + ) + + op.create_table('session', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('token', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('ip_address', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('user_agent', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('last_used_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('session_pkey')) + ) + + op.create_index(op.f('ix_session_user_id'), 'session', ['user_id'], unique=False) + op.create_index(op.f('ix_session_token'), 'session', ['token'], unique=True) + + # 3. Finally, remove the unique constraint from username + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint('uq_user_username', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/versions/f86c93c7a872_add_processed_filename_to_uploadlog.py b/alembic/versions/f86c93c7a872_add_processed_filename_to_uploadlog.py new file mode 100644 index 0000000..0439b64 --- /dev/null +++ b/alembic/versions/f86c93c7a872_add_processed_filename_to_uploadlog.py @@ -0,0 +1,30 @@ +"""add_processed_filename_to_uploadlog + +Revision ID: f86c93c7a872 +Revises: 1ab2db0e4b5e +Create Date: 2025-06-28 15:56:29.169668 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f86c93c7a872' +down_revision: Union[str, Sequence[str], None] = '1ab2db0e4b5e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('uploadlog', + sa.Column('processed_filename', sa.String(), nullable=True), + schema=None) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('uploadlog', 'processed_filename', schema=None) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..e74286c --- /dev/null +++ b/auth.py @@ -0,0 +1,73 @@ +"""Authentication middleware and utilities for dicta2stream""" +from fastapi import Request, HTTPException, Depends, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlmodel import Session +from typing import Optional + +from models import User, Session as DBSession, verify_session +from database import get_db + +security = HTTPBearer() + +def get_current_user( + request: Request, + db: Session = Depends(get_db), + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> User: + """Dependency to get the current authenticated user""" + token = credentials.credentials + db_session = verify_session(db, token) + + if not db_session: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get the user from the session + user = db.exec( + select(User).where(User.username == db_session.user_id) + ).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Attach the session to the request state for later use + request.state.session = db_session + return user + + +def get_optional_user( + request: Request, + db: Session = Depends(get_db), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security, use_cache=False) +) -> Optional[User]: + """Dependency that returns the current user if authenticated, None otherwise""" + if not credentials: + return None + + try: + return get_current_user(request, db, credentials) + except HTTPException: + return None + + +def create_session(db: Session, user: User, request: Request) -> DBSession: + """Create a new session for the user""" + user_agent = request.headers.get("user-agent") + ip_address = request.client.host if request.client else "0.0.0.0" + + session = DBSession.create_for_user( + user_id=user.username, + ip_address=ip_address, + user_agent=user_agent + ) + + db.add(session) + db.commit() + return session diff --git a/auth_router.py b/auth_router.py new file mode 100644 index 0000000..ac0596c --- /dev/null +++ b/auth_router.py @@ -0,0 +1,106 @@ +"""Authentication routes for dicta2stream""" +from fastapi import APIRouter, Depends, Request, Response, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlmodel import Session + +from models import Session as DBSession, User +from database import get_db +from auth import get_current_user + +router = APIRouter() +security = HTTPBearer() + +@router.post("/logout") +async def logout( + request: Request, + response: Response, + db: Session = Depends(get_db), + credentials: HTTPAuthorizationCredentials = Depends(security) +): + """Log out by invalidating the current session""" + token = credentials.credentials + + # Find and invalidate the session + session = db.exec( + select(DBSession) + .where(DBSession.token == token) + .where(DBSession.is_active == True) # noqa: E712 + ).first() + + if session: + session.is_active = False + db.add(session) + db.commit() + + # Clear the session cookie + response.delete_cookie( + key="sessionid", # Must match the cookie name in main.py + httponly=True, + secure=True, # Must match the cookie settings from login + samesite="lax", + path="/" + ) + + return {"message": "Successfully logged out"} + + +@router.get("/me") +async def get_current_user_info( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get current user information""" + return { + "username": current_user.username, + "email": current_user.email, + "created_at": current_user.token_created.isoformat(), + "is_confirmed": current_user.confirmed + } + + +@router.get("/sessions") +async def list_sessions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List all active sessions for the current user""" + sessions = DBSession.get_active_sessions(db, current_user.username) + return [ + { + "id": s.id, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + "created_at": s.created_at.isoformat(), + "last_used_at": s.last_used_at.isoformat(), + "expires_at": s.expires_at.isoformat() + } + for s in sessions + ] + + +@router.post("/sessions/{session_id}/revoke") +async def revoke_session( + session_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Revoke a specific session""" + session = db.get(DBSession, session_id) + + if not session or session.user_id != current_user.username: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + if not session.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Session is already inactive" + ) + + session.is_active = False + db.add(session) + db.commit() + + return {"message": "Session revoked"} diff --git a/dicta2stream.service b/dicta2stream.service new file mode 100644 index 0000000..c797398 --- /dev/null +++ b/dicta2stream.service @@ -0,0 +1,22 @@ +[Unit] +Description=Dicta2Stream FastAPI application (Gunicorn) +After=network.target + +[Service] +User=oib +Group=www-data +WorkingDirectory=/home/oib/games/dicta2stream +Environment="PATH=/home/oib/games/dicta2stream/venv/bin" +Environment="PYTHONPATH=/home/oib/games/dicta2stream" +ExecStart=/home/oib/games/dicta2stream/venv/bin/gunicorn -c gunicorn_config.py main:app +Restart=always +RestartSec=5 + +# Security +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=read-only + +[Install] +WantedBy=multi-user.target diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..21d0d84 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,35 @@ +# Gunicorn configuration file +import multiprocessing +import os + +# Server socket +bind = "0.0.0.0:8000" + +# Worker processes +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "uvicorn.workers.UvicornWorker" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 +timeout = 120 +keepalive = 5 + +# Security +limit_request_line = 4094 +limit_request_fields = 50 +limit_request_field_size = 8190 + +# Debugging +debug = os.getenv("DEBUG", "false").lower() == "true" +reload = debug + +# Logging +loglevel = "debug" if debug else "info" +accesslog = "-" # Log to stdout +errorlog = "-" # Log to stderr + +# Server mechanics +preload_app = True + +# Process naming +proc_name = "dicta2stream" diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..7dcde40 --- /dev/null +++ b/init_db.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Initialize the database with required tables""" +import os +import sys +from sqlmodel import SQLModel, create_engine +from dotenv import load_dotenv + +# Add the parent directory to the path so we can import our models +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from models import User, UserQuota, UploadLog, PublicStream, Session + +def init_db(): + """Initialize the database with required tables""" + # Load environment variables + load_dotenv() + + # Get database URL from environment or use default + database_url = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost/dicta2stream" + ) + + print(f"Connecting to database: {database_url}") + + # Create database engine + engine = create_engine(database_url) + + # Create all tables + print("Creating database tables...") + SQLModel.metadata.create_all(engine) + + print("Database initialized successfully!") + +if __name__ == "__main__": + init_db() diff --git a/list_streams.py b/list_streams.py index 0c3ab54..9e36366 100644 --- a/list_streams.py +++ b/list_streams.py @@ -1,48 +1,116 @@ # list_streams.py — FastAPI route to list all public streams (users with stream.opus) -from fastapi import APIRouter +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse, Response from pathlib import Path -from fastapi.responses import StreamingResponse import asyncio router = APIRouter() DATA_ROOT = Path("./data") @router.get("/streams-sse") -def streams_sse(): - return list_streams_sse() +async def streams_sse(request: Request): + print(f"[SSE] New connection from {request.client.host}") + print(f"[SSE] Request headers: {dict(request.headers)}") + + # Add CORS headers for SSE + origin = request.headers.get('origin', '') + allowed_origins = ["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"] + + # Use the request origin if it's in the allowed list, otherwise use the first allowed origin + cors_origin = origin if origin in allowed_origins else allowed_origins[0] + + headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": cors_origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Expose-Headers": "Content-Type", + "X-Accel-Buffering": "no" # Disable buffering for nginx + } + + # Handle preflight requests + if request.method == "OPTIONS": + print("[SSE] Handling OPTIONS preflight request") + headers.update({ + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": request.headers.get("access-control-request-headers", "*"), + "Access-Control-Max-Age": "86400" # 24 hours + }) + return Response(status_code=204, headers=headers) + + print("[SSE] Starting SSE stream") + + async def event_wrapper(): + try: + async for event in list_streams_sse(): + yield event + except Exception as e: + print(f"[SSE] Error in event generator: {str(e)}") + import traceback + traceback.print_exc() + yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n" + + return StreamingResponse( + event_wrapper(), + media_type="text/event-stream", + headers=headers + ) import json - import datetime -def list_streams_sse(): - async def event_generator(): - txt_path = Path("./public_streams.txt") - if not txt_path.exists(): - print(f"[{datetime.datetime.now()}] [SSE] No public_streams.txt found") - yield f"data: {json.dumps({'end': True})}\n\n" - return - try: - with txt_path.open("r") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - stream = json.loads(line) - print(f"[{datetime.datetime.now()}] [SSE] Yielding stream: {stream}") - yield f"data: {json.dumps(stream)}\n\n" - await asyncio.sleep(0) # Yield control to event loop - except Exception as e: - print(f"[{datetime.datetime.now()}] [SSE] JSON decode error: {e}") - continue # skip malformed lines - print(f"[{datetime.datetime.now()}] [SSE] Yielding end event") - yield f"data: {json.dumps({'end': True})}\n\n" - except Exception as e: - print(f"[{datetime.datetime.now()}] [SSE] Exception: {e}") - yield f"data: {json.dumps({'end': True, 'error': True})}\n\n" - return StreamingResponse(event_generator(), media_type="text/event-stream") +async def list_streams_sse(): + print("[SSE] Starting stream generator") + txt_path = Path("./public_streams.txt") + + if not txt_path.exists(): + print(f"[SSE] No public_streams.txt found") + yield f"data: {json.dumps({'end': True})}\n\n" + return + + try: + # Send initial ping + print("[SSE] Sending initial ping") + yield ":ping\n\n" + + # Read and send the file contents + with txt_path.open("r") as f: + for line in f: + line = line.strip() + if not line: + continue + + try: + # Parse the JSON to validate it + stream = json.loads(line) + print(f"[SSE] Sending stream data: {stream}") + + # Send the data as an SSE event + event = f"data: {json.dumps(stream)}\n\n" + yield event + + # Small delay to prevent overwhelming the client + await asyncio.sleep(0.1) + + except json.JSONDecodeError as e: + print(f"[SSE] JSON decode error: {e} in line: {line}") + continue + except Exception as e: + print(f"[SSE] Error processing line: {e}") + continue + + print("[SSE] Sending end event") + yield f"data: {json.dumps({'end': True})}\n\n" + + except Exception as e: + print(f"[SSE] Error in stream generator: {str(e)}") + import traceback + traceback.print_exc() + yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n" + finally: + print("[SSE] Stream generator finished") def list_streams(): txt_path = Path("./public_streams.txt") diff --git a/magic.py b/magic.py index be6efea..99138c3 100644 --- a/magic.py +++ b/magic.py @@ -1,16 +1,18 @@ # magic.py — handle magic token login confirmation -from fastapi import APIRouter, Form, HTTPException, Depends, Request -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Form, HTTPException, Depends, Request, Response +from fastapi.responses import RedirectResponse, JSONResponse from sqlmodel import Session, select from database import get_db -from models import User +from models import User, DBSession from datetime import datetime, timedelta +import secrets +import json router = APIRouter() @router.post("/magic-login") -def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)): +async def magic_login(request: Request, response: Response, db: Session = Depends(get_db), token: str = Form(...)): print(f"[magic-login] Received token: {token}") user = db.exec(select(User).where(User.token == token)).first() print(f"[magic-login] User lookup: {'found' if user else 'not found'}") @@ -23,12 +25,45 @@ def magic_login(request: Request, db: Session = Depends(get_db), token: str = Fo print(f"[magic-login] Token expired for user: {user.username}") return RedirectResponse(url="/?error=Token%20expired", status_code=302) + # Mark user as confirmed if not already if not user.confirmed: user.confirmed = True user.ip = request.client.host - db.commit() - print(f"[magic-login] User {user.username} confirmed. Redirecting to /?login=success&confirmed_uid={user.username}") - else: - print(f"[magic-login] Token already used for user: {user.username}, but allowing multi-use login.") + db.add(user) + print(f"[magic-login] User {user.username} confirmed.") - return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302) + # Create a new session for the user (valid for 1 hour) + session_token = secrets.token_urlsafe(32) + expires_at = datetime.utcnow() + timedelta(hours=1) + + # Create new session + session = DBSession( + token=session_token, + user_id=user.username, + ip_address=request.client.host or "", + user_agent=request.headers.get("user-agent", ""), + expires_at=expires_at, + is_active=True + ) + db.add(session) + db.commit() + + # Set cookie with the session token (valid for 1 hour) + response.set_cookie( + key="sessionid", + value=session_token, + httponly=True, + secure=not request.url.hostname == "localhost", + samesite="lax", + max_age=3600, # 1 hour + path="/" + ) + + print(f"[magic-login] Session created for user: {user.username}") + + # Redirect to success page + return RedirectResponse( + url=f"/?login=success&confirmed_uid={user.username}", + status_code=302, + headers=dict(response.headers) + ) diff --git a/main.py b/main.py index 1dedab7..b9b09f0 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ # main.py — FastAPI backend entrypoint for dicta2stream -from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends +from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware @@ -40,12 +40,20 @@ app = FastAPI(debug=debug_mode) # --- CORS Middleware for SSE and API access --- from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware + +# Add GZip middleware for compression +app.add_middleware(GZipMiddleware, minimum_size=1000) + +# Configure CORS app.add_middleware( CORSMiddleware, allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"], allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], + expose_headers=["Content-Type", "Content-Length", "Cache-Control", "ETag", "Last-Modified"], + max_age=3600, # 1 hour ) from fastapi.staticfiles import StaticFiles @@ -125,7 +133,9 @@ from list_user_files import router as list_user_files_router app.include_router(streams_router) from list_streams import router as list_streams_router +from account_router import router as account_router +app.include_router(account_router) app.include_router(register_router) app.include_router(magic_router) app.include_router(upload_router) @@ -135,6 +145,10 @@ app.include_router(list_streams_router) # Serve static files app.mount("/static", StaticFiles(directory="static"), name="static") +# Serve audio files +os.makedirs("data", exist_ok=True) # Ensure the data directory exists +app.mount("/audio", StaticFiles(directory="data"), name="audio") + @app.post("/log-client") async def log_client(request: Request): try: @@ -224,68 +238,7 @@ def debug(request: Request): MAX_QUOTA_BYTES = 100 * 1024 * 1024 -@app.post("/delete-account") -async def delete_account(data: dict, request: Request, db: Session = Depends(get_db)): - uid = data.get("uid") - if not uid: - raise HTTPException(status_code=400, detail="Missing UID") - - ip = request.client.host - user = get_user_by_uid(uid) - if not user or user.ip != ip: - raise HTTPException(status_code=403, detail="Unauthorized") - - # Delete user quota and user using ORM - quota = db.get(UserQuota, uid) - if quota: - db.delete(quota) - user_obj = db.get(User, user.email) - if user_obj: - db.delete(user_obj) - db.commit() - - import shutil - user_dir = os.path.join('data', user.username) - real_user_dir = os.path.realpath(user_dir) - if not real_user_dir.startswith(os.path.realpath('data')): - raise HTTPException(status_code=400, detail="Invalid user directory") - if os.path.exists(real_user_dir): - shutil.rmtree(real_user_dir, ignore_errors=True) - - return {"message": "User deleted"} - - from fastapi.concurrency import run_in_threadpool - # from detect_content_type_whisper_ollama import detect_content_type_whisper_ollama # Broken import: module not found - content_type = None - if content_type in ["music", "singing"]: - os.remove(raw_path) - log_violation("UPLOAD", ip, uid, f"Rejected content: {content_type}") - return JSONResponse(status_code=403, content={"error": f"{content_type.capitalize()} uploads are not allowed."}) - - try: - subprocess.run([ - "ffmpeg", "-y", "-i", raw_path, - "-ac", "1", "-ar", "48000", - "-c:a", "libopus", "-b:a", "60k", - final_path - ], check=True) - except subprocess.CalledProcessError as e: - os.remove(raw_path) - log_violation("FFMPEG", ip, uid, f"ffmpeg failed: {e}") - raise HTTPException(status_code=500, detail="Encoding failed") - os.remove(raw_path) - - try: - actual_bytes = int(subprocess.check_output(["du", "-sb", user_dir]).split()[0]) - q = db.get(UserQuota, uid) - if q: - q.storage_bytes = actual_bytes - db.add(q) - db.commit() - except Exception as e: - log_violation("QUOTA", ip, uid, f"Quota update failed: {e}") - - return {} +# Delete account endpoint has been moved to account_router.py @app.delete("/uploads/{uid}/{filename}") def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)): @@ -333,24 +286,51 @@ def confirm_user(uid: str, request: Request): @app.get("/me/{uid}") def get_me(uid: str, request: Request, db: Session = Depends(get_db)): - ip = request.client.host - user = get_user_by_uid(uid) - if not user or user.ip != ip: - raise HTTPException(status_code=403, detail="Unauthorized access") + print(f"[DEBUG] GET /me/{uid} - Client IP: {request.client.host}") + try: + # Get user info + user = get_user_by_uid(uid) + if not user: + print(f"[ERROR] User with UID {uid} not found") + raise HTTPException(status_code=403, detail="User not found") + + if user.ip != request.client.host: + print(f"[ERROR] IP mismatch for UID {uid}: {request.client.host} != {user.ip}") + raise HTTPException(status_code=403, detail="IP address mismatch") - user_dir = os.path.join('data', user.username) - files = [] - if os.path.exists(user_dir): - for f in os.listdir(user_dir): - path = os.path.join(user_dir, f) - if os.path.isfile(path): - files.append({"name": f, "size": os.path.getsize(path)}) + # Get all upload logs for this user + upload_logs = db.exec( + select(UploadLog) + .where(UploadLog.uid == uid) + .order_by(UploadLog.created_at.desc()) + ).all() + print(f"[DEBUG] Found {len(upload_logs)} upload logs for UID {uid}") + + # Build file list from database records + files = [] + for log in upload_logs: + if log.filename and log.processed_filename: + # The actual filename on disk might have the log ID prepended + stored_filename = f"{log.id}_{log.processed_filename}" + files.append({ + "name": stored_filename, + "original_name": log.filename, + "size": log.size_bytes + }) + print(f"[DEBUG] Added file from DB: {log.filename} (stored as {stored_filename}, {log.size_bytes} bytes)") + + # Get quota info + q = db.get(UserQuota, uid) + quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0 + print(f"[DEBUG] Quota for UID {uid}: {quota_mb} MB") - q = db.get(UserQuota, uid) - quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0 - - return { - - "files": files, - "quota": quota_mb - } + response_data = { + "files": files, + "quota": quota_mb + } + print(f"[DEBUG] Returning {len(files)} files and quota info") + return response_data + + except Exception as e: + print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}", exc_info=True) + raise diff --git a/middleware.py b/middleware.py new file mode 100644 index 0000000..228ea88 --- /dev/null +++ b/middleware.py @@ -0,0 +1,73 @@ +"""Custom middleware for the dicta2stream application""" +import time +from fastapi import Request, HTTPException +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response +from starlette.types import ASGIApp + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Middleware to implement rate limiting""" + def __init__(self, app: ASGIApp, limit: int = 100, window: int = 60): + super().__init__(app) + self.limit = limit + self.window = window + self.requests = {} + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + # Get client IP + if "x-forwarded-for" in request.headers: + ip = request.headers["x-forwarded-for"].split(",")[0] + else: + ip = request.client.host or "unknown" + + # Get current timestamp + current_time = int(time.time()) + + # Clean up old entries + self.requests = { + k: v + for k, v in self.requests.items() + if current_time - v["timestamp"] < self.window + } + + # Check rate limit + if ip in self.requests: + self.requests[ip]["count"] += 1 + if self.requests[ip]["count"] > self.limit: + raise HTTPException( + status_code=429, + detail="Too many requests. Please try again later." + ) + else: + self.requests[ip] = {"count": 1, "timestamp": current_time} + + # Process the request + response = await call_next(request) + return response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Middleware to add security headers to responses""" + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + # Add security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Content Security Policy + csp_parts = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "media-src 'self' blob: data:", + "connect-src 'self' https: wss:", + "frame-ancestors 'none'" + ] + + response.headers["Content-Security-Policy"] = "; ".join(csp_parts) + + return response diff --git a/migrations/0002_add_session_tables.py b/migrations/0002_add_session_tables.py new file mode 100644 index 0000000..de03907 --- /dev/null +++ b/migrations/0002_add_session_tables.py @@ -0,0 +1,67 @@ +"""Add session and public_stream tables + +Revision ID: 0002 +Revises: 0001 +Create Date: 2023-04-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0002' +down_revision = '0001' +branch_labels = None +depends_on = None + +def upgrade(): + # Create public_stream table + op.create_table( + 'public_stream', + sa.Column('uid', sa.String(), nullable=False, comment='User ID of the stream owner'), + sa.Column('filename', sa.String(), nullable=False, comment='Name of the audio file'), + sa.Column('size', sa.BigInteger(), nullable=False, comment='File size in bytes'), + sa.Column('mtime', sa.Float(), nullable=False, comment='Last modified time as Unix timestamp'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')), + sa.PrimaryKeyConstraint('uid', 'filename') + ) + + # Create session table + op.create_table( + 'session', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(), nullable=False, index=True, comment='Reference to user.username'), + sa.Column('token', sa.Text(), nullable=False, index=True, comment='Random session token'), + sa.Column('ip_address', sa.String(45), nullable=False, comment='IP address of the client'), + sa.Column('user_agent', sa.Text(), nullable=True, comment='User-Agent header from the client'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False, comment='When the session expires'), + sa.Column('last_used_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')), + sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False, comment='Whether the session is active'), + sa.ForeignKeyConstraint(['user_id'], ['user.username'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes + op.create_index('ix_session_user_id', 'session', ['user_id'], unique=False) + op.create_index('ix_session_token', 'session', ['token'], unique=True) + op.create_index('ix_session_expires_at', 'session', ['expires_at'], unique=False) + op.create_index('ix_session_last_used_at', 'session', ['last_used_at'], unique=False) + op.create_index('ix_public_stream_uid', 'public_stream', ['uid'], unique=False) + op.create_index('ix_public_stream_updated_at', 'public_stream', ['updated_at'], unique=False) + + +def downgrade(): + # Drop indexes first + op.drop_index('ix_session_user_id', table_name='session') + op.drop_index('ix_session_token', table_name='session') + op.drop_index('ix_session_expires_at', table_name='session') + op.drop_index('ix_session_last_used_at', table_name='session') + op.drop_index('ix_public_stream_uid', table_name='public_stream') + op.drop_index('ix_public_stream_updated_at', table_name='public_stream') + + # Drop tables + op.drop_table('session') + op.drop_table('public_stream') diff --git a/migrations/add_processed_filename_to_uploadlog.py b/migrations/add_processed_filename_to_uploadlog.py new file mode 100644 index 0000000..f4e421e --- /dev/null +++ b/migrations/add_processed_filename_to_uploadlog.py @@ -0,0 +1,24 @@ +"""Add processed_filename to UploadLog + +Revision ID: add_processed_filename_to_uploadlog +Revises: +Create Date: 2025-06-28 13:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'add_processed_filename_to_uploadlog' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # Add the processed_filename column to the uploadlog table + op.add_column('uploadlog', + sa.Column('processed_filename', sa.String(), nullable=True)) + +def downgrade(): + # Remove the processed_filename column if rolling back + op.drop_column('uploadlog', 'processed_filename') diff --git a/models.py b/models.py index def072f..b28dc8b 100644 --- a/models.py +++ b/models.py @@ -8,7 +8,7 @@ from database import engine class User(SQLModel, table=True): token_created: datetime = Field(default_factory=datetime.utcnow) email: str = Field(primary_key=True) - username: str + username: str = Field(unique=True, index=True) token: str confirmed: bool = False ip: str = Field(default="") @@ -23,11 +23,23 @@ class UploadLog(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) uid: str ip: str - filename: Optional[str] + filename: Optional[str] # Original filename + processed_filename: Optional[str] # Processed filename (UUID.opus) size_bytes: int created_at: datetime = Field(default_factory=datetime.utcnow) +class DBSession(SQLModel, table=True): + token: str = Field(primary_key=True) + user_id: str = Field(foreign_key="user.username") + ip_address: str + user_agent: str + created_at: datetime = Field(default_factory=datetime.utcnow) + expires_at: datetime + is_active: bool = True + last_activity: datetime = Field(default_factory=datetime.utcnow) + + def get_user_by_uid(uid: str) -> Optional[User]: with Session(engine) as session: statement = select(User).where(User.username == uid) diff --git a/public_streams.txt b/public_streams.txt index 3a243cf..46633cf 100644 --- a/public_streams.txt +++ b/public_streams.txt @@ -1 +1,3 @@ {"uid":"devuser","size":22455090,"mtime":1747563720} +{"uid":"oib9","size":2019706,"mtime":1751124547} +{"uid":"orangeicebear","size":1734396,"mtime":1748767975} diff --git a/run_migrations.py b/run_migrations.py new file mode 100644 index 0000000..4102bd4 --- /dev/null +++ b/run_migrations.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Run database migrations""" +import os +import sys +from alembic.config import Config +from alembic import command +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +def run_migrations(): + # Get database URL from environment or use default + database_url = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost/dicta2stream" + ) + + # Set up Alembic config + alembic_cfg = Config() + alembic_cfg.set_main_option("script_location", "migrations") + alembic_cfg.set_main_option("sqlalchemy.url", database_url) + + # Run migrations + command.upgrade(alembic_cfg, "head") + print("Database migrations completed successfully.") + +if __name__ == "__main__": + run_migrations() diff --git a/static/app.js b/static/app.js index d198c77..194b80a 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,15 @@ // app.js — Frontend upload + minimal native player logic with slide-in and pulse effect +import { playBeep } from "./sound.js"; +import { showToast } from "./toast.js"; + +// Global audio state +let globalAudio = null; +let currentStreamUid = null; +let audioPlaying = false; +let lastPosition = 0; + +// Utility functions function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); @@ -7,10 +17,6 @@ function getCookie(name) { return null; } -import { playBeep } from "./sound.js"; -import { showToast } from "./toast.js"; - - // Log debug messages to server export function logToServer(msg) { const xhr = new XMLHttpRequest(); @@ -19,396 +25,476 @@ export function logToServer(msg) { xhr.send(JSON.stringify({ msg })); } -// Expose for debugging -window.logToServer = logToServer; - // Handle magic link login redirect -(function handleMagicLoginRedirect() { +function handleMagicLoginRedirect() { const params = new URLSearchParams(window.location.search); if (params.get('login') === 'success' && params.get('confirmed_uid')) { const username = params.get('confirmed_uid'); localStorage.setItem('uid', username); - logToServer(`[DEBUG] localStorage.setItem('uid', '${username}')`); localStorage.setItem('confirmed_uid', username); - logToServer(`[DEBUG] localStorage.setItem('confirmed_uid', '${username}')`); - const uidTime = Date.now().toString(); - localStorage.setItem('uid_time', uidTime); - logToServer(`[DEBUG] localStorage.setItem('uid_time', '${uidTime}')`); - // Set uid as cookie for backend authentication - document.cookie = "uid=" + encodeURIComponent(username) + "; path=/"; - // Remove query params from URL + localStorage.setItem('uid_time', Date.now().toString()); + document.cookie = `uid=${encodeURIComponent(username)}; path=/`; + + // Update UI state immediately without reload + const guestDashboard = document.getElementById('guest-dashboard'); + const userDashboard = document.getElementById('user-dashboard'); + const registerPage = document.getElementById('register-page'); + + if (guestDashboard) guestDashboard.style.display = 'none'; + if (userDashboard) userDashboard.style.display = 'block'; + if (registerPage) registerPage.style.display = 'none'; + + // Update URL and history without reloading window.history.replaceState({}, document.title, window.location.pathname); - // Reload to show dashboard as logged in - location.reload(); - return; - } -})(); - -document.addEventListener("DOMContentLoaded", () => { - // (Removed duplicate logToServer definition) - - // Guest vs. logged-in toggling is now handled by dashboard.js - // --- Public profile view logic --- - function showProfilePlayerFromUrl() { - const params = new URLSearchParams(window.location.search); - const profileUid = params.get("profile"); - if (profileUid) { - const mePage = document.getElementById("me-page"); - if (mePage) { - document.querySelectorAll("main > section").forEach(sec => sec.hidden = sec.id !== "me-page"); - // Hide upload/delete/copy-url controls for guest view - const uploadArea = document.getElementById("upload-area"); - if (uploadArea) uploadArea.hidden = true; - const copyUrlBtn = document.getElementById("copy-url"); - if (copyUrlBtn) copyUrlBtn.style.display = "none"; - const deleteBtn = document.getElementById("delete-account"); - if (deleteBtn) deleteBtn.style.display = "none"; - // Update heading and description for guest view - const meHeading = document.querySelector("#me-page h2"); - if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`; - const meDesc = document.querySelector("#me-page p"); - if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`; - // Show a Play Stream button for explicit user action - const streamInfo = document.getElementById("stream-info"); - if (streamInfo) { - streamInfo.innerHTML = ""; - const playBtn = document.createElement('button'); - playBtn.textContent = "▶ Play Stream"; - playBtn.onclick = () => { - loadProfileStream(profileUid); - playBtn.disabled = true; - }; - streamInfo.appendChild(playBtn); - streamInfo.hidden = false; - } - // Do NOT call loadProfileStream(profileUid) automatically! - } + + // Navigate to user's profile page + if (window.showOnly) { + window.showOnly('me-page'); + } else if (window.location.hash !== '#me') { + window.location.hash = '#me'; } } +} - // --- Only run showProfilePlayerFromUrl after session/profile checks are complete --- - function runProfilePlayerIfSessionValid() { - if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return; - showProfilePlayerFromUrl(); - } - document.addEventListener("DOMContentLoaded", () => { - setTimeout(runProfilePlayerIfSessionValid, 200); - }); - window.addEventListener('popstate', () => { - setTimeout(runProfilePlayerIfSessionValid, 200); - }); - window.showProfilePlayerFromUrl = showProfilePlayerFromUrl; - - // Global audio state - let globalAudio = null; - let currentStreamUid = null; - let audioPlaying = false; - let lastPosition = 0; - - // Expose main audio element for other scripts - window.getMainAudio = () => globalAudio; - window.stopMainAudio = () => { - if (globalAudio) { - globalAudio.pause(); +// Audio player functions +function getOrCreateAudioElement() { + if (!globalAudio) { + globalAudio = document.getElementById('me-audio'); + if (!globalAudio) { + console.error('Audio element not found'); + return null; + } + + globalAudio.preload = 'metadata'; + globalAudio.crossOrigin = 'use-credentials'; + globalAudio.setAttribute('crossorigin', 'use-credentials'); + + // Set up event listeners + globalAudio.addEventListener('play', () => { + audioPlaying = true; + updatePlayPauseButton(); + }); + + globalAudio.addEventListener('pause', () => { audioPlaying = false; updatePlayPauseButton(); - } - }; - - function getOrCreateAudioElement() { - if (!globalAudio) { - globalAudio = document.getElementById('me-audio'); - if (!globalAudio) { - console.error('Audio element not found'); - return null; - } - // Set up audio element properties - globalAudio.preload = 'metadata'; // Preload metadata for better performance - globalAudio.crossOrigin = 'use-credentials'; // Use credentials for authenticated requests - globalAudio.setAttribute('crossorigin', 'use-credentials'); // Explicitly set the attribute - - // Set up event listeners - globalAudio.addEventListener('play', () => { - audioPlaying = true; - updatePlayPauseButton(); - }); - globalAudio.addEventListener('pause', () => { - audioPlaying = false; - updatePlayPauseButton(); - }); - globalAudio.addEventListener('timeupdate', () => lastPosition = globalAudio.currentTime); - - // Add error handling - globalAudio.addEventListener('error', (e) => { - console.error('Audio error:', e); - showToast('❌ Audio playback error'); - }); - } - return globalAudio; - } - - // Function to update play/pause button state - function updatePlayPauseButton() { - const audio = getOrCreateAudioElement(); - if (playPauseButton && audio) { - playPauseButton.textContent = audio.paused ? '▶' : '⏸️'; - } - } - - // Initialize play/pause button - const playPauseButton = document.getElementById('play-pause'); - if (playPauseButton) { - // Set initial state - updatePlayPauseButton(); + }); - // Add click handler - playPauseButton.addEventListener('click', () => { - const audio = getOrCreateAudioElement(); - if (audio) { - if (audio.paused) { - // Stop any playing public streams first - const publicPlayers = document.querySelectorAll('.stream-player audio'); - publicPlayers.forEach(player => { - if (!player.paused) { - player.pause(); - const button = player.closest('.stream-player').querySelector('.play-pause'); - if (button) { - button.textContent = '▶'; - } - } - }); - - audio.play().catch(e => { - console.error('Play failed:', e); - audioPlaying = false; - }); - } else { - audio.pause(); + globalAudio.addEventListener('timeupdate', () => { + lastPosition = globalAudio.currentTime; + }); + + globalAudio.addEventListener('error', handleAudioError); + } + return globalAudio; +} + +function handleAudioError(e) { + const error = this.error; + let errorMessage = 'Audio playback error'; + let shouldShowToast = true; + + if (error) { + switch(error.code) { + case MediaError.MEDIA_ERR_ABORTED: + errorMessage = 'Audio playback was aborted'; + shouldShowToast = false; // Don't show toast for aborted operations + break; + case MediaError.MEDIA_ERR_NETWORK: + errorMessage = 'Network error while loading audio'; + break; + case MediaError.MEDIA_ERR_DECODE: + errorMessage = 'Error decoding audio. The file may be corrupted.'; + break; + case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + // Don't show error for missing audio files on new accounts + if (this.currentSrc && this.currentSrc.includes('stream.opus')) { + console.log('Audio format not supported or file not found:', this.currentSrc); + return; } - updatePlayPauseButton(); + errorMessage = 'Audio format not supported. Please upload a supported format (Opus/OGG).'; + break; + } + + console.error('Audio error:', errorMessage, error); + + // Only show error toast if we have a valid error and it's not a missing file + if (shouldShowToast && !(error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && !this.src)) { + showToast(errorMessage, 'error'); + } + } + + console.error('Audio error:', { + error: error, + src: this.currentSrc, + networkState: this.networkState, + readyState: this.readyState + }); + + if (errorMessage !== 'Audio format not supported') { + showToast(`❌ ${errorMessage}`, 'error'); + } +} + +function updatePlayPauseButton(audio, button) { + if (button && audio) { + button.textContent = audio.paused ? '▶️' : '⏸️'; + } +} + +// Stream loading and playback +async function loadProfileStream(uid) { + const audio = getOrCreateAudioElement(); + if (!audio) { + console.error('Failed to initialize audio element'); + return null; + } + + // Hide playlist controls + const mePrevBtn = document.getElementById("me-prev"); + const meNextBtn = document.getElementById("me-next"); + if (mePrevBtn) mePrevBtn.style.display = "none"; + if (meNextBtn) meNextBtn.style.display = "none"; + + // Reset current stream and update audio source + currentStreamUid = uid; + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + + // Wait a moment to ensure the previous source is cleared + await new Promise(resolve => setTimeout(resolve, 50)); + + const username = localStorage.getItem('username') || uid; + const audioUrl = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`; + + try { + console.log('Checking audio file at:', audioUrl); + + // First check if the audio file exists and get its content type + const response = await fetch(audioUrl, { + method: 'HEAD', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' } }); - } - - - - // Preload audio without playing it - function preloadAudio(src) { - return new Promise((resolve) => { - const audio = new Audio(); - audio.preload = 'auto'; - audio.crossOrigin = 'anonymous'; - audio.src = src; + + if (!response.ok) { + console.log('No audio file found for user:', username); + updatePlayPauseButton(audio, document.querySelector('.play-pause-btn')); + return null; + } + + const contentType = response.headers.get('content-type'); + console.log('Audio content type:', contentType); + + if (!contentType || !contentType.includes('audio/')) { + throw new Error(`Invalid content type: ${contentType || 'unknown'}`); + } + + // Set the audio source with proper type hint + const source = document.createElement('source'); + source.src = audioUrl; + source.type = 'audio/ogg; codecs=opus'; + + // Clear any existing sources + while (audio.firstChild) { + audio.removeChild(audio.firstChild); + } + audio.appendChild(source); + + // Load the new source + await new Promise((resolve, reject) => { audio.load(); - audio.oncanplaythrough = () => resolve(audio); + audio.oncanplaythrough = resolve; + audio.onerror = () => { + reject(new Error('Failed to load audio source')); + }; + // Set a timeout in case the audio never loads + setTimeout(() => reject(new Error('Audio load timeout')), 10000); }); - } - - // Load and play a stream - async function loadProfileStream(uid) { - const audio = getOrCreateAudioElement(); - if (!audio) return null; - // Always reset current stream and update audio source - currentStreamUid = uid; - audio.pause(); - audio.src = ''; - - // Wait a moment to ensure the previous source is cleared - await new Promise(resolve => setTimeout(resolve, 50)); - - // Set new source with cache-busting timestamp - audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; + console.log('Audio loaded, attempting to play...'); // Try to play immediately try { await audio.play(); audioPlaying = true; + console.log('Audio playback started successfully'); } catch (e) { - console.error('Play failed:', e); + console.log('Auto-play failed, waiting for user interaction:', e); audioPlaying = false; + // Don't show error for autoplay restrictions + if (!e.message.includes('play() failed because the user')) { + showToast('Click the play button to start playback', 'info'); + } } - // Show stream info + // Show stream info if available const streamInfo = document.getElementById("stream-info"); if (streamInfo) streamInfo.hidden = false; - // Update button state - updatePlayPauseButton(); - - return audio; + } catch (error) { + console.error('Error checking/loading audio:', error); + // Don't show error toasts for missing audio files or aborted requests + if (error.name !== 'AbortError' && + !error.message.includes('404') && + !error.message.includes('Failed to load')) { + showToast('Error loading audio: ' + (error.message || 'Unknown error'), 'error'); + } + return null; } - -// Load and play a stream -async function loadProfileStream(uid) { - const audio = getOrCreateAudioElement(); - if (!audio) return null; - - // Hide playlist controls - const mePrevBtn = document.getElementById("me-prev"); - if (mePrevBtn) mePrevBtn.style.display = "none"; - const meNextBtn = document.getElementById("me-next"); - if (meNextBtn) meNextBtn.style.display = "none"; - - // Handle navigation to "Your Stream" - const mePageLink = document.getElementById("show-me"); - if (mePageLink) { - mePageLink.addEventListener("click", async (e) => { - e.preventDefault(); - const uid = localStorage.getItem("uid"); - if (!uid) return; - - // Show loading state - const streamInfo = document.getElementById("stream-info"); - if (streamInfo) { - streamInfo.hidden = false; - streamInfo.innerHTML = '

Loading stream...

'; - } - - try { - // Load the stream but don't autoplay - await loadProfileStream(uid); - - // Update URL without triggering a full page reload - if (window.location.pathname !== '/') { - window.history.pushState({}, '', '/'); - } - - // Show the me-page section - const mePage = document.getElementById('me-page'); - if (mePage) { - document.querySelectorAll('main > section').forEach(s => s.hidden = s.id !== 'me-page'); - } - - // Clear loading state - const streamInfo = document.getElementById('stream-info'); - if (streamInfo) { - streamInfo.innerHTML = ''; - } - } catch (error) { - console.error('Error loading stream:', error); - const streamInfo = document.getElementById('stream-info'); - if (streamInfo) { - streamInfo.innerHTML = '

Error loading stream. Please try again.

'; - } - } - }); - } - - // Always reset current stream and update audio source - currentStreamUid = uid; - audio.pause(); - audio.src = ''; - - // Wait a moment to ensure the previous source is cleared - await new Promise(resolve => setTimeout(resolve, 50)); - - // Set new source with cache-busting timestamp - audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; - - // Try to play immediately - try { - await audio.play(); - audioPlaying = true; - } catch (e) { - console.error('Play failed:', e); - audioPlaying = false; - } - - // Show stream info - const streamInfo = document.getElementById("stream-info"); - if (streamInfo) streamInfo.hidden = false; - + // Update button state - updatePlayPauseButton(); - + updatePlayPauseButton(audio, document.querySelector('.play-pause-btn')); return audio; } -// Export the function for use in other modules -window.loadProfileStream = loadProfileStream; - -document.addEventListener("DOMContentLoaded", () => { - // Initialize play/pause button - const playPauseButton = document.getElementById('play-pause'); - if (playPauseButton) { - // Set initial state - audioPlaying = false; - updatePlayPauseButton(); +// Navigation and UI functions +function showProfilePlayerFromUrl() { + const params = new URLSearchParams(window.location.search); + const profileUid = params.get("profile"); + + if (profileUid) { + const mePage = document.getElementById("me-page"); + if (!mePage) return; - // Add event listener - playPauseButton.addEventListener('click', () => { - const audio = getMainAudio(); - if (audio) { + document.querySelectorAll("main > section").forEach(sec => + sec.hidden = sec.id !== "me-page" + ); + + // Hide upload/delete/copy-url controls for guest view + const uploadArea = document.getElementById("upload-area"); + if (uploadArea) uploadArea.hidden = true; + + const copyUrlBtn = document.getElementById("copy-url"); + if (copyUrlBtn) copyUrlBtn.style.display = "none"; + + const deleteBtn = document.getElementById("delete-account"); + if (deleteBtn) deleteBtn.style.display = "none"; + + // Update UI for guest view + const meHeading = document.querySelector("#me-page h2"); + if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`; + + const meDesc = document.querySelector("#me-page p"); + if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`; + + // Show a Play Stream button for explicit user action + const streamInfo = document.getElementById("stream-info"); + if (streamInfo) { + streamInfo.innerHTML = ''; + const playBtn = document.createElement('button'); + playBtn.textContent = "▶ Play Stream"; + playBtn.onclick = () => { + loadProfileStream(profileUid); + playBtn.disabled = true; + }; + streamInfo.appendChild(playBtn); + streamInfo.hidden = false; + } + } +} + +function initNavigation() { + const navLinks = document.querySelectorAll('nav a'); + navLinks.forEach(link => { + link.addEventListener('click', (e) => { + const href = link.getAttribute('href'); + + // Skip if href is empty or doesn't start with '#' + if (!href || !href.startsWith('#')) { + return; // Let the browser handle the link normally + } + + const sectionId = href.substring(1); // Remove the '#' + + // Skip if sectionId is empty after removing '#' + if (!sectionId) { + console.warn('Empty section ID in navigation link:', link); + return; + } + + const section = document.getElementById(sectionId); + if (section) { + e.preventDefault(); + + // Hide all sections first + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== sectionId; + }); + + // Special handling for me-page + if (sectionId === 'me-page') { + const registerPage = document.getElementById('register-page'); + if (registerPage) registerPage.hidden = true; + + // Show the upload box in me-page + const uploadBox = document.querySelector('#me-page #user-upload-area'); + if (uploadBox) uploadBox.style.display = 'block'; + } else if (sectionId === 'register-page') { + // Ensure me-page is hidden when register-page is shown + const mePage = document.getElementById('me-page'); + if (mePage) mePage.hidden = true; + } + + section.scrollIntoView({ behavior: "smooth" }); + + // Close mobile menu if open + const burger = document.getElementById('burger-toggle'); + if (burger && burger.checked) burger.checked = false; + } + }); + }); +} + +function initProfilePlayer() { + if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return; + showProfilePlayerFromUrl(); +} + +// Initialize the application when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + // Handle magic link redirect if needed + handleMagicLoginRedirect(); + + // Initialize components + initNavigation(); + + // Initialize profile player after a short delay + setTimeout(() => { + initProfilePlayer(); + + // Set up play/pause button click handler + document.addEventListener('click', (e) => { + const playPauseBtn = e.target.closest('.play-pause-btn'); + if (!playPauseBtn || playPauseBtn.id === 'logout-button') return; + + const audio = getOrCreateAudioElement(); + if (!audio) return; + + try { if (audio.paused) { - audio.play(); + // Stop any currently playing audio first + if (window.currentlyPlayingAudio && window.currentlyPlayingAudio !== audio) { + window.currentlyPlayingAudio.pause(); + if (window.currentlyPlayingButton) { + updatePlayPauseButton(window.currentlyPlayingAudio, window.currentlyPlayingButton); + } + } + + // Stop any playing public streams + const publicPlayers = document.querySelectorAll('.stream-player audio'); + publicPlayers.forEach(player => { + if (!player.paused) { + player.pause(); + const btn = player.closest('.stream-player').querySelector('.play-pause-btn'); + if (btn) updatePlayPauseButton(player, btn); + } + }); + + // Check if audio has a valid source before attempting to play + // Only show this message for the main player, not public streams + if (!audio.src && !playPauseBtn.closest('.stream-player')) { + console.log('No audio source available for main player'); + showToast('No audio file available. Please upload an audio file first.', 'info'); + audioPlaying = false; + updatePlayPauseButton(audio, playPauseBtn); + return; + } + + // Store the current play promise to handle aborts + const playPromise = audio.play(); + + // Handle successful play + playPromise.then(() => { + // Only update state if this is still the current play action + if (audio === getMainAudio()) { + window.currentlyPlayingAudio = audio; + window.currentlyPlayingButton = playPauseBtn; + updatePlayPauseButton(audio, playPauseBtn); + } + }).catch(e => { + // Don't log aborted errors as they're normal during rapid play/pause + if (e.name !== 'AbortError') { + console.error('Play failed:', e); + } else { + console.log('Playback was aborted as expected'); + return; // Skip UI updates for aborted play + } + + // Only update state if this is still the current audio element + if (audio === getMainAudio()) { + audioPlaying = false; + updatePlayPauseButton(audio, playPauseBtn); + + // Provide more specific error messages + if (e.name === 'NotSupportedError' || e.name === 'NotAllowedError') { + showToast('Could not play audio. The format may not be supported.', 'error'); + } else if (e.name !== 'AbortError') { // Skip toast for aborted errors + showToast('Failed to play audio. Please try again.', 'error'); + } + } + }); } else { audio.pause(); + if (window.currentlyPlayingAudio === audio) { + window.currentlyPlayingAudio = null; + window.currentlyPlayingButton = null; + } + updatePlayPauseButton(audio, playPauseBtn); } - updatePlayPauseButton(); + } catch (e) { + console.error('Audio error:', e); + updatePlayPauseButton(audio, playPauseBtn); } }); - } - - // Add bot protection for registration form - const registerForm = document.getElementById('register-form'); - if (registerForm) { - registerForm.addEventListener('submit', (e) => { - const botTrap = e.target.elements.bot_trap; - if (botTrap && botTrap.value) { + + // Set up delete account button if it exists + const deleteAccountBtn = document.getElementById('delete-account'); + if (deleteAccountBtn) { + deleteAccountBtn.addEventListener('click', async (e) => { e.preventDefault(); - showToast('❌ Bot detected! Please try again.'); - return false; - } - return true; - }); + + if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) { + return; + } + + try { + const response = await fetch('/api/delete-account', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (response.ok) { + // Clear local storage and redirect to home page + localStorage.clear(); + window.location.href = '/'; + } else { + const error = await response.json(); + throw new Error(error.detail || 'Failed to delete account'); + } + } catch (error) { + console.error('Error deleting account:', error); + showToast(`❌ ${error.message || 'Failed to delete account'}`, 'error'); + } + }); + } + + }, 200); // End of setTimeout +}); + +// Expose functions for global access +window.logToServer = logToServer; +window.getMainAudio = () => globalAudio; +window.stopMainAudio = () => { + if (globalAudio) { + globalAudio.pause(); + audioPlaying = false; + updatePlayPauseButton(); } - - // Initialize navigation - document.querySelectorAll('#links a[data-target]').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const target = link.getAttribute('data-target'); - // Only hide other sections when not opening #me-page - if (target !== 'me-page') fadeAllSections(); - const section = document.getElementById(target); - if (section) { - section.hidden = false; - section.classList.add("slide-in"); - section.scrollIntoView({ behavior: "smooth" }); - } - const burger = document.getElementById('burger-toggle'); - if (burger && burger.checked) burger.checked = false; - }); - }); - - // Initialize profile player if valid session - setTimeout(runProfilePlayerIfSessionValid, 200); - window.addEventListener('popstate', () => { - setTimeout(runProfilePlayerIfSessionValid, 200); - }); -}); - // Initialize navigation - document.querySelectorAll('#links a[data-target]').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const target = link.getAttribute('data-target'); - // Only hide other sections when not opening #me-page - if (target !== 'me-page') fadeAllSections(); - const section = document.getElementById(target); - if (section) { - section.hidden = false; - section.classList.add("slide-in"); - section.scrollIntoView({ behavior: "smooth" }); - } - const burger = document.getElementById('burger-toggle'); - if (burger && burger.checked) burger.checked = false; - }); - }); - - // Initialize profile player if valid session - setTimeout(runProfilePlayerIfSessionValid, 200); - window.addEventListener('popstate', () => { - setTimeout(runProfilePlayerIfSessionValid, 200); - }); -}); +}; +window.loadProfileStream = loadProfileStream; diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..20b5a59 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,208 @@ +/* Base styles and resets */ +:root { + /* Colors */ + --color-primary: #4a90e2; + --color-primary-dark: #2a6fc9; + --color-text: #333; + --color-text-light: #666; + --color-bg: #f8f9fa; + --color-border: #e9ecef; + --color-white: #fff; + --color-black: #000; + + /* Typography */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --font-size-base: 1rem; + --line-height-base: 1.5; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + + /* Border radius */ + --border-radius-sm: 4px; + --border-radius-md: 8px; + --border-radius-lg: 12px; + + /* Transitions */ + --transition-base: all 0.2s ease; + --transition-slow: all 0.3s ease; +} + +/* Reset and base styles */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + height: 100%; + font-size: 16px; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +body { + margin: 0; + min-height: 100%; + font-family: var(--font-family); + line-height: var(--line-height-base); + color: var(--color-text); + background: var(--color-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Main content */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 6rem 1.5rem 2rem; /* Add top padding to account for fixed header */ + min-height: calc(100vh - 200px); /* Ensure footer stays at bottom */ +} + +/* Sections */ +section { + margin: 2rem 0; + padding: 2rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +section h2 { + color: var(--color-primary); + margin-top: 0; + margin-bottom: 1.5rem; + font-size: 2rem; +} + +section p { + color: var(--color-text); + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.main-heading { + font-size: 2.5rem; + margin: 0 0 2rem 0; + color: var(--color-text); + font-weight: 700; + line-height: 1.2; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + text-align: center; +} + +.main-heading .mic-icon { + display: inline-flex; + animation: pulse 2s infinite; + transform-origin: center; +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: var(--spacing-md); + font-weight: 600; + line-height: 1.2; +} + +p { + margin-top: 0; + margin-bottom: var(--spacing-md); +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: var(--transition-base); +} + +a:hover { + color: var(--color-primary-dark); + text-decoration: underline; +} + +/* Images */ +img { + max-width: 100%; + height: auto; + vertical-align: middle; + border-style: none; +} + +/* Lists */ +ul, ol { + padding-left: var(--spacing-lg); + margin-bottom: var(--spacing-md); +} + +/* Loading animation */ +.app-loading { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-white); + z-index: 9999; + transition: opacity var(--transition-slow); + text-align: center; + padding: 2rem; + color: var(--color-text); +} + +.app-loading > div:first-child { + margin-bottom: 1rem; + font-size: 2rem; +} + +.app-loading.hidden { + opacity: 0; + pointer-events: none; +} + +.app-content { + opacity: 1; + transition: opacity var(--transition-slow); +} + +/* This class can be used for initial fade-in if needed */ +.app-content.initial-load { + opacity: 0; +} + +.app-content.loaded { + opacity: 1; +} + +/* Utility classes */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/components/forms.css b/static/css/components/forms.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/layout/footer.css b/static/css/layout/footer.css new file mode 100644 index 0000000..8def7f8 --- /dev/null +++ b/static/css/layout/footer.css @@ -0,0 +1,80 @@ +/* Footer styles */ +footer { + background: #2c3e50; + color: #ecf0f1; + padding: 2rem 0; + margin-top: 3rem; + width: 100%; +} + +.footer-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.footer-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + margin-top: 1rem; +} + +.footer-links a { + color: #ecf0f1; + text-decoration: none; + transition: color 0.2s; +} + +.footer-links a:hover, +.footer-links a:focus { + color: #3498db; + text-decoration: underline; +} + +.separator { + color: #7f8c8d; + margin: 0 0.25rem; +} + +.footer-hint { + margin-top: 1rem; + font-size: 0.9rem; + color: #bdc3c7; +} + +.footer-hint a { + color: #3498db; + text-decoration: none; +} + +.footer-hint a:hover, +.footer-hint a:focus { + text-decoration: underline; +} + +/* Responsive adjustments */ +@media (max-width: 767px) { + footer { + padding: 1.5rem 1rem; + } + + .footer-links { + flex-direction: column; + gap: 0.5rem; + } + + .separator { + display: none; + } + + .footer-hint { + font-size: 0.85rem; + line-height: 1.5; + } +} diff --git a/static/css/layout/header.css b/static/css/layout/header.css new file mode 100644 index 0000000..eb9d305 --- /dev/null +++ b/static/css/layout/header.css @@ -0,0 +1,149 @@ +/* Header and navigation styles */ +header { + width: 100%; + background: rgba(33, 37, 41, 0.95); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + position: fixed; + top: 0; + left: 0; + z-index: 1000; + padding: 0.5rem 0; +} + +.header-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; +} + +/* Logo */ +.logo { + color: white; + font-size: 1.5rem; + font-weight: bold; + text-decoration: none; + padding: 0.5rem 0; +} + +.logo:hover { + text-decoration: none; + opacity: 0.9; +} + +/* Navigation */ +.nav-wrapper { + display: flex; + align-items: center; + height: 100%; +} + +/* Menu toggle button */ +.menu-toggle { + background: none; + border: none; + color: white; + font-size: 1.5rem; + cursor: pointer; + padding: 0.5rem; + display: none; /* Hidden by default, shown on mobile */ +} + +/* Navigation list */ +.nav-list { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 1rem; + align-items: center; +} + +.nav-item { + margin: 0; +} + +.nav-link { + color: white; + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s; + display: block; +} + +.nav-link:hover, +.nav-link:focus { + background: rgba(255, 255, 255, 0.1); + text-decoration: none; + color: #fff; +} + +/* Active navigation item */ +.nav-link.active { + background: rgba(255, 255, 255, 0.2); + font-weight: 500; +} + +/* Mobile menu */ +@media (max-width: 767px) { + .menu-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: transparent; + border: none; + color: white; + font-size: 1.5rem; + cursor: pointer; + z-index: 1001; + } + + .nav-wrapper { + position: fixed; + top: 0; + right: -100%; + width: 80%; + max-width: 300px; + height: 100vh; + background: rgba(33, 37, 41, 0.98); + padding: 5rem 1.5rem 2rem; + transition: right 0.3s ease-in-out; + z-index: 1000; + overflow-y: auto; + display: block; + } + + .nav-wrapper.active { + right: 0; + } + + .nav-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0; + } + + .nav-item { + width: 100%; + } + + .nav-link { + display: block; + padding: 0.75rem 1rem; + border-radius: 4px; + } + + .nav-link:hover, + .nav-link:focus { + background: rgba(255, 255, 255, 0.15); + } +} diff --git a/static/css/pages/auth.css b/static/css/pages/auth.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/pages/home.css b/static/css/pages/home.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/pages/stream.css b/static/css/pages/stream.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/utilities/spacing.css b/static/css/utilities/spacing.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/utilities/typography.css b/static/css/utilities/typography.css new file mode 100644 index 0000000..e69de29 diff --git a/static/dashboard.js b/static/dashboard.js index 915abd6..d60a19a 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -8,44 +8,250 @@ function getCookie(name) { } // dashboard.js — toggle guest vs. user dashboard and reposition streams link +// Logout function +let isLoggingOut = false; + +async function handleLogout(event) { + // Prevent multiple simultaneous logout attempts + if (isLoggingOut) return; + isLoggingOut = true; + + // Prevent default button behavior + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + try { + console.log('[LOGOUT] Starting logout process'); + + // Clear user data from localStorage + localStorage.removeItem('uid'); + localStorage.removeItem('uid_time'); + localStorage.removeItem('confirmed_uid'); + localStorage.removeItem('last_page'); + + // Clear cookie + document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + // Update UI state immediately + const userDashboard = document.getElementById('user-dashboard'); + const guestDashboard = document.getElementById('guest-dashboard'); + const logoutButton = document.getElementById('logout-button'); + const deleteAccountButton = document.getElementById('delete-account-button'); + + if (userDashboard) userDashboard.style.display = 'none'; + if (guestDashboard) guestDashboard.style.display = 'block'; + if (logoutButton) logoutButton.style.display = 'none'; + if (deleteAccountButton) deleteAccountButton.style.display = 'none'; + + // Show success message (only once) + if (window.showToast) { + showToast('Logged out successfully'); + } else { + console.log('Logged out successfully'); + } + + // Navigate to register page + if (window.showOnly) { + window.showOnly('register-page'); + } else { + // Fallback to URL change if showOnly isn't available + window.location.href = '/#register-page'; + } + + console.log('[LOGOUT] Logout completed'); + + } catch (error) { + console.error('[LOGOUT] Logout failed:', error); + if (window.showToast) { + showToast('Logout failed. Please try again.'); + } + } finally { + isLoggingOut = false; + } +} + +// Delete account function +async function handleDeleteAccount() { + try { + const uid = localStorage.getItem('uid'); + if (!uid) { + showToast('No user session found. Please log in again.'); + return; + } + + // Show confirmation dialog + const confirmed = confirm('⚠️ WARNING: This will permanently delete your account and all your data. This action cannot be undone.\n\nAre you sure you want to delete your account?'); + + if (!confirmed) { + return; // User cancelled the deletion + } + + // Show loading state + const deleteButton = document.getElementById('delete-account-button'); + const originalText = deleteButton.textContent; + deleteButton.disabled = true; + deleteButton.textContent = 'Deleting...'; + + // Call the delete account endpoint + const response = await fetch(`/api/delete-account`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ uid }), + }); + + const result = await response.json(); + + if (response.ok) { + showToast('Account deleted successfully'); + + // Clear user data + localStorage.removeItem('uid'); + localStorage.removeItem('uid_time'); + localStorage.removeItem('confirmed_uid'); + document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + // Redirect to home page + setTimeout(() => { + window.location.href = '/'; + }, 1000); + } else { + throw new Error(result.detail || 'Failed to delete account'); + } + } catch (error) { + console.error('Delete account failed:', error); + showToast(`Failed to delete account: ${error.message}`); + + // Reset button state + const deleteButton = document.getElementById('delete-account-button'); + if (deleteButton) { + deleteButton.disabled = false; + deleteButton.textContent = '🗑️ Delete Account'; + } + } +} + async function initDashboard() { - // New dashboard toggling logic + console.log('[DASHBOARD] Initializing dashboard...'); + + // Get all dashboard elements const guestDashboard = document.getElementById('guest-dashboard'); const userDashboard = document.getElementById('user-dashboard'); const userUpload = document.getElementById('user-upload-area'); - - // Hide all by default - if (guestDashboard) guestDashboard.style.display = 'none'; - if (userDashboard) userDashboard.style.display = 'none'; - if (userUpload) userUpload.style.display = 'none'; + const logoutButton = document.getElementById('logout-button'); + const deleteAccountButton = document.getElementById('delete-account-button'); + + console.log('[DASHBOARD] Elements found:', { + guestDashboard: !!guestDashboard, + userDashboard: !!userDashboard, + userUpload: !!userUpload, + logoutButton: !!logoutButton, + deleteAccountButton: !!deleteAccountButton + }); + + // Add click event listeners for logout and delete account buttons + if (logoutButton) { + console.log('[DASHBOARD] Adding logout button handler'); + logoutButton.addEventListener('click', handleLogout); + } + + if (deleteAccountButton) { + console.log('[DASHBOARD] Adding delete account button handler'); + deleteAccountButton.addEventListener('click', (e) => { + e.preventDefault(); + handleDeleteAccount(); + }); + } const uid = getCookie('uid'); + console.log('[DASHBOARD] UID from cookie:', uid); + + // Guest view if (!uid) { - // Guest view: only nav - if (guestDashboard) guestDashboard.style.display = ''; - if (userDashboard) userDashboard.style.display = 'none'; - if (userUpload) userUpload.style.display = 'none'; + console.log('[DASHBOARD] No UID found, showing guest dashboard'); + if (guestDashboard) guestDashboard.style.display = 'block'; + if (userDashboard) userDashboard.style.display = 'none'; + if (userUpload) userUpload.style.display = 'none'; + if (logoutButton) logoutButton.style.display = 'none'; + if (deleteAccountButton) deleteAccountButton.style.display = 'none'; + const mePage = document.getElementById('me-page'); + if (mePage) mePage.style.display = 'none'; + return; + } + + // Logged-in view - show user dashboard by default + console.log('[DASHBOARD] User is logged in, showing user dashboard'); + + // Log current display states + console.log('[DASHBOARD] Current display states:', { + guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found', + userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found', + userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found', + logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found', + deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found' + }); + + // Show delete account button for logged-in users + if (deleteAccountButton) { + deleteAccountButton.style.display = 'block'; + console.log('[DASHBOARD] Showing delete account button'); + } + + // Hide guest dashboard + if (guestDashboard) { + console.log('[DASHBOARD] Hiding guest dashboard'); + guestDashboard.style.display = 'none'; + } + + // Show user dashboard + if (userDashboard) { + console.log('[DASHBOARD] Showing user dashboard'); + userDashboard.style.display = 'block'; + userDashboard.style.visibility = 'visible'; + userDashboard.hidden = false; + + // Debug: Check if the element is actually in the DOM + console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement); + console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display); + } else { + console.error('[DASHBOARD] userDashboard element not found!'); + } + + // Show essential elements for logged-in users + const linksSection = document.getElementById('links'); + if (linksSection) { + console.log('[DASHBOARD] Showing links section'); + linksSection.style.display = 'block'; + } + + const showMeLink = document.getElementById('show-me'); + if (showMeLink && showMeLink.parentElement) { + console.log('[DASHBOARD] Showing show-me link'); + showMeLink.parentElement.style.display = 'block'; + } + + // Show me-page for logged-in users const mePage = document.getElementById('me-page'); - if (mePage) mePage.style.display = 'none'; - return; -} + if (mePage) { + console.log('[DASHBOARD] Showing me-page'); + mePage.style.display = 'block'; + } try { + console.log(`[DEBUG] Fetching user data for UID: ${uid}`); const res = await fetch(`/me/${uid}`); - if (!res.ok) throw new Error('Not authorized'); + if (!res.ok) { + const errorText = await res.text(); + console.error(`[ERROR] Failed to fetch user data: ${res.status} ${res.statusText}`, errorText); + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } const data = await res.json(); - - // Logged-in view - // Restore links section and show-me link - const linksSection = document.getElementById('links'); - if (linksSection) linksSection.style.display = ''; - const showMeLink = document.getElementById('show-me'); - if (showMeLink && showMeLink.parentElement) showMeLink.parentElement.style.display = ''; - // Show me-page for logged-in users - const mePage = document.getElementById('me-page'); - if (mePage) mePage.style.display = ''; + console.log('[DEBUG] User data loaded:', data); + // Ensure upload area is visible if last_page was me-page - const userUpload = document.getElementById('user-upload-area'); if (userUpload && localStorage.getItem('last_page') === 'me-page') { // userUpload visibility is now only controlled by nav.js SPA logic } @@ -53,19 +259,40 @@ async function initDashboard() { // Remove guest warning if present const guestMsg = document.getElementById('guest-warning-msg'); if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg); - userDashboard.style.display = ''; + // Show user dashboard and logout button + if (userDashboard) userDashboard.style.display = ''; + if (logoutButton) { + logoutButton.style.display = 'block'; + logoutButton.onclick = handleLogout; + } // Set audio source const meAudio = document.getElementById('me-audio'); - if (meAudio && uid) { - meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`; + if (meAudio && data && data.username) { + // Use username instead of UID for the audio file path + meAudio.src = `/audio/${encodeURIComponent(data.username)}/stream.opus?t=${Date.now()}`; + console.log('Setting audio source to:', meAudio.src); + } else if (meAudio && uid) { + // Fallback to UID if username is not available + meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; + console.warn('Using UID fallback for audio source:', meAudio.src); } - // Update quota + // Update quota and ensure quota meter is visible + const quotaMeter = document.getElementById('quota-meter'); const quotaBar = document.getElementById('quota-bar'); const quotaText = document.getElementById('quota-text'); if (quotaBar) quotaBar.value = data.quota; if (quotaText) quotaText.textContent = `${data.quota} MB used`; + if (quotaMeter) { + quotaMeter.hidden = false; + quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none + } + + // Fetch and display the list of uploaded files if the function is available + if (window.fetchAndDisplayFiles) { + window.fetchAndDisplayFiles(uid); + } // Ensure Streams link remains in nav, not moved // (No action needed if static) diff --git a/static/desktop.css b/static/desktop.css new file mode 100644 index 0000000..8e6d414 --- /dev/null +++ b/static/desktop.css @@ -0,0 +1,97 @@ +/* Desktop-specific styles for screens 960px and wider */ +@media (min-width: 960px) { + html { + background-color: #111 !important; + background-image: + repeating-linear-gradient( + 45deg, + rgba(188, 183, 107, 0.1) 0, /* Olive color */ + rgba(188, 183, 107, 0.1) 1px, + transparent 1px, + transparent 20px + ), + repeating-linear-gradient( + -45deg, + rgba(188, 183, 107, 0.1) 0, /* Olive color */ + rgba(188, 183, 107, 0.1) 1px, + transparent 1px, + transparent 20px + ) !important; + background-size: 40px 40px !important; + background-repeat: repeat !important; + background-attachment: fixed !important; + min-height: 100% !important; + } + + body { + background: transparent !important; + min-height: 100vh !important; + } + /* Section styles are now handled in style.css */ + + nav.dashboard-nav a { + padding: 5px; + margin: 0 0.5em; + border-radius: 3px; + } + + /* Reset mobile-specific styles for desktop */ + .dashboard-nav { + padding: 0.5em; + max-width: none; + justify-content: center; + } + + .dashboard-nav a { + min-width: auto; + font-size: 1rem; + } + + /* Global article styles */ + main > section > article, + #stream-page > article { + max-width: 600px; + margin: 0 auto 2em auto; + padding: 2em; + background: #1e1e1e; + border: 1px solid #333; + border-radius: 8px; + transition: all 0.2s ease; + } + + /* Stream player styles */ + #stream-page #stream-list > li { + list-style: none; + margin-bottom: 1.5em; + } + + #stream-page #stream-list > li .stream-player { + padding: 1.5em; + background: #1e1e1e; + border: none; + border-radius: 8px; + transition: all 0.2s ease; + } + + /* Hover states - only apply to direct article children of sections */ + main > section > article:hover { + transform: translateY(-2px); + background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02)); + border: 1px solid #ff6600; + } + + /* Stream list desktop styles */ + #stream-list { + max-width: 600px; + margin: 0 auto; + padding: 0 1rem; + } + + /* User upload area desktop styles */ + #user-upload-area { + max-width: 600px !important; + width: 100% !important; + margin: 1.5rem auto !important; + box-sizing: border-box !important; + } +} diff --git a/static/footer.html b/static/footer.html new file mode 100644 index 0000000..3a8684b --- /dev/null +++ b/static/footer.html @@ -0,0 +1,12 @@ + + diff --git a/static/generate-test-audio.sh b/static/generate-test-audio.sh new file mode 100755 index 0000000..9f9e0b1 --- /dev/null +++ b/static/generate-test-audio.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Create a 1-second silent audio file in Opus format +ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a libopus -b:a 60k /home/oib/games/dicta2stream/static/test-audio.opus + +# Verify the file was created +if [ -f "/home/oib/games/dicta2stream/static/test-audio.opus" ]; then + echo "Test audio file created successfully at /home/oib/games/dicta2stream/static/test-audio.opus" + echo "File size: $(du -h /home/oib/games/dicta2stream/static/test-audio.opus | cut -f1)" +else + echo "Failed to create test audio file" + exit 1 +fi diff --git a/static/index.html b/static/index.html index 78d96ed..4371ea4 100644 --- a/static/index.html +++ b/static/index.html @@ -3,6 +3,8 @@ + + @@ -32,24 +34,32 @@
+
+

+ Your Stream +

+
+ + +
+
-

Your Stream 🎙️

This is your personal stream. Only you can upload to it.

- +
@@ -65,9 +75,9 @@
+

Welcome

-

Welcome

dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop.

What you can do here:

    @@ -119,16 +126,14 @@