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 @@
+
Terms of Service
-
Terms of Service
-
By accessing or using dicta2stream.net (the “Service”), you agree to be bound by these Terms of Service (“Terms”). If you do not agree, do not use the Service.
+
By accessing or using dicta2stream.net (the "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree, do not use the Service.
You must be at least 18 years old to register.
Each account must be unique and used by only one person.
@@ -77,13 +87,12 @@
Uploads are limited to 100 MB and must be voice only.
Music/singing will be rejected.
-
+
Privacy Policy
-
Privacy Policy
Users: Session uses both cookies and localStorage to store UID and authentication state.
Guests: No cookies are set. No persistent identifiers are stored.
@@ -91,22 +100,20 @@
Uploads are scanned via Whisper+Ollama but not stored as transcripts.
Data is never sold. Contact us for account deletion.
-
+
Imprint
-
Imprint
Andreas Michael Fleckl
Johnstrassse 7/6 1140 Vienna Austria / Europe
-
+
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 @@
-
-
🎧 Public Streams
-
-
Loading...
-
+
Public Streams
+
+
Loading...
+
Account
-
Login or Register
-
+
You’ll receive a magic login link via email. No password required.
Your session expires after 1 hour. Shareable links redirect to homepage.