Compare commits

...

6 Commits

Author SHA1 Message Date
oib
f6c501030e RC2 2025-07-21 17:39:09 +02:00
oib
ab9d93d913 RC1 2025-07-20 09:26:07 +02:00
oib
da28b205e5 fix: resolve mobile navigation visibility for authenticated users
- Add fix-nav.js to handle navigation state
- Update mobile.css with more specific selectors
- Modify dashboard.js to ensure proper auth state
- Update index.html to include the new fix script
- Ensure guest navigation stays hidden during client-side navigation
2025-07-20 09:24:51 +02:00
oib
c5412b07ac Migrate from file-based to database-backed stream metadata storage
- Add PublicStream model and migration
- Update list_streams.py and upload.py to use database
- Add import script for data migration
- Remove public_streams.txt (replaced by database)
- Fix quota sync between userquota and publicstream tables
2025-07-19 10:49:16 +02:00
oib
402e920bc6 Fix double audio playback and add UID handling for personal stream
- Fixed double playback issue on stream page by properly scoping event delegation in streams-ui.js
- Added init-personal-stream.js to handle UID for personal stream playback
- Improved error handling and logging for audio playback
- Added proper event propagation control to prevent duplicate event handling
2025-07-18 16:51:39 +02:00
oib
17616ac5b8 feat: Add database migrations and auth system
- Add Alembic for database migrations
- Implement user authentication system
- Update frontend styles and components
- Add new test audio functionality
- Update stream management and UI
2025-07-02 09:37:03 +02:00
68 changed files with 10445 additions and 1168 deletions

64
.gitignore vendored
View File

@ -1,25 +1,65 @@
# Bytecode-Dateien
# Bytecode files
__pycache__/
*.py[cod]
# Virtuelle Umgebungen
# Virtual environments
.venv/
venv/
# Betriebssystem-Dateien
# System files
.DS_Store
Thumbs.db
# Logfiles und Dumps
# Logs and temporary files
*.log
*.bak
*.swp
*.tmp
# IDEs und Editoren
# Node.js dependencies
node_modules/
package.json
package-lock.json
yarn.lock
# Development documentation
PERFORMANCE-TESTING.md
# Build and distribution
dist/
build/
*.min.js
*.min.css
*.map
# Testing
coverage/
*.test.js
*.spec.js
.nyc_output/
# Environment variables
.env
.env.*
!.env.example
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDEs and editors
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# Local development
.cache/
.temp/
.tmp/
# Project specific
data/*
!data/.gitignore
@ -28,3 +68,17 @@ log/*
streams/*
!streams/.gitignore
# Test files
tests/**/*.js
!tests/*.test.js
!tests/*.spec.js
!tests/README.md
!tests/profile-auth.js
# Performance test results
performance-results/*
!performance-results/.gitkeep
# Legacy files
public_streams.txt

93
DATABASE.md Normal file
View File

@ -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
```

98
account_router.py Normal file
View File

@ -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")

140
alembic.ini Normal file
View File

@ -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 <script_location>/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

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

61
alembic/env.py Normal file
View File

@ -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()

28
alembic/script.py.mako Normal file
View File

@ -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"}

View File

@ -0,0 +1,71 @@
"""Add PublicStream model
Revision ID: 0df481ee920b
Revises: f86c93c7a872
Create Date: 2025-07-19 10:02:22.902696
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0df481ee920b'
down_revision: Union[str, Sequence[str], None] = 'f86c93c7a872'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# First create the new publicstream table
op.create_table('publicstream',
sa.Column('uid', sa.String(), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('mtime', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('uid')
)
# Drop the foreign key constraint first
op.drop_constraint('dbsession_user_id_fkey', 'dbsession', type_='foreignkey')
# Then drop the unique constraint
op.drop_constraint(op.f('uq_user_username'), 'user', type_='unique')
# Create the new index
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# Recreate the foreign key constraint
op.create_foreign_key(
'dbsession_user_id_fkey', 'dbsession', 'user',
['user_id'], ['username'], ondelete='CASCADE'
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop the foreign key constraint first
op.drop_constraint('dbsession_user_id_fkey', 'dbsession', type_='foreignkey')
# Drop the index
op.drop_index(op.f('ix_user_username'), table_name='user')
# Recreate the unique constraint
op.create_unique_constraint(op.f('uq_user_username'), 'user', ['username'])
# Recreate the foreign key constraint
op.create_foreign_key(
'dbsession_user_id_fkey', 'dbsession', 'user',
['user_id'], ['username'], ondelete='CASCADE'
)
# Drop the publicstream table
op.drop_table('publicstream')
# ### end Alembic commands ###

View File

@ -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 ###

View File

@ -0,0 +1,49 @@
"""add_display_name_to_user
Revision ID: 8be4811023d8
Revises: 0df481ee920b
Create Date: 2025-07-19 19:46:01.129412
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '8be4811023d8'
down_revision: Union[str, Sequence[str], None] = '0df481ee920b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('dbsession_user_id_fkey'), 'dbsession', type_='foreignkey')
op.create_foreign_key(None, 'dbsession', 'user', ['user_id'], ['username'])
op.alter_column('publicstream', 'storage_bytes',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.create_index(op.f('ix_publicstream_username'), 'publicstream', ['username'], unique=False)
op.drop_column('publicstream', 'size')
op.add_column('user', sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'display_name')
op.add_column('publicstream', sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=False))
op.drop_index(op.f('ix_publicstream_username'), table_name='publicstream')
op.alter_column('publicstream', 'storage_bytes',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.drop_constraint(None, 'dbsession', type_='foreignkey')
op.create_foreign_key(op.f('dbsession_user_id_fkey'), 'dbsession', 'user', ['user_id'], ['username'], ondelete='CASCADE')
# ### end Alembic commands ###

View File

@ -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)

73
auth.py Normal file
View File

@ -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

138
auth_router.py Normal file
View File

@ -0,0 +1,138 @@
"""Authentication routes for dicta2stream"""
from fastapi import APIRouter, Depends, Request, Response, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session, select
from datetime import datetime
from models import Session as DBSession, User
from database import get_db
from auth import get_current_user
router = APIRouter(prefix="/api", tags=["auth"])
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"""
try:
# Get the token from the Authorization header
token = credentials.credentials if credentials else None
if not token:
return {"message": "No session to invalidate"}
try:
# Find and invalidate the session
session = db.exec(
select(DBSession)
.where(DBSession.token == token)
.where(DBSession.is_active == True) # noqa: E712
).first()
if session:
try:
session.is_active = False
db.add(session)
db.commit()
except Exception:
db.rollback()
except Exception:
# Continue with logout even if session lookup fails
pass
# Clear the session cookie
response.delete_cookie(
key="sessionid",
httponly=True,
secure=True,
samesite="lax",
path="/"
)
# Clear any other auth-related cookies
for cookie_name in ["uid", "authToken", "isAuthenticated", "token"]:
response.delete_cookie(
key=cookie_name,
path="/",
domain=request.url.hostname,
secure=True,
httponly=True,
samesite="lax"
)
return {"message": "Successfully logged out"}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception:
# Don't expose internal errors to the client
return {"message": "Logout processed"}
@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"}

View File

@ -9,9 +9,50 @@ def concat_opus_files(user_dir: Path, output_file: Path):
Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file.
Overwrites output_file if exists. Creates it if missing.
"""
files = [f for f in user_dir.glob('*.opus') if f.name != 'stream.opus']
# Clean up any existing filelist.txt to prevent issues
filelist_path = user_dir / 'filelist.txt'
if filelist_path.exists():
try:
filelist_path.unlink()
except Exception as e:
print(f"Warning: Could not clean up old filelist.txt: {e}")
# Get all opus files except stream.opus and remove any duplicates
import hashlib
file_hashes = set()
files = []
for f in user_dir.glob('*.opus'):
if f.name == 'stream.opus':
continue
try:
# Calculate file hash for duplicate detection
hasher = hashlib.md5()
with open(f, 'rb') as file:
buf = file.read(65536) # Read in 64kb chunks
while len(buf) > 0:
hasher.update(buf)
buf = file.read(65536)
file_hash = hasher.hexdigest()
# Skip if we've seen this exact file before
if file_hash in file_hashes:
print(f"Removing duplicate file: {f.name}")
f.unlink()
continue
file_hashes.add(file_hash)
files.append(f)
except Exception as e:
print(f"Error processing {f}: {e}")
if not files:
raise FileNotFoundError(f"No opus files to concatenate in {user_dir}")
# If no files, create an empty stream.opus
output_file.write_bytes(b'')
return output_file
random.shuffle(files)
# Create a filelist for ffmpeg concat

70
create_silent_opus.py Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Create a silent OPUS audio file with 1 second of silence.
"""
import os
import opuslib
import numpy as np
import struct
# Configuration
SAMPLE_RATE = 48000
CHANNELS = 1
FRAME_SIZE = 960 # 20ms at 48kHz
SILENCE_DURATION = 1.0 # seconds
OUTPUT_FILE = "silent.opus"
# Calculate number of frames needed
num_frames = int((SAMPLE_RATE * SILENCE_DURATION) / (FRAME_SIZE * CHANNELS))
# Initialize Opus encoder
enc = opuslib.Encoder(SAMPLE_RATE, CHANNELS, 'voip')
# Create silent audio data (all zeros)
silent_frame = struct.pack('h' * FRAME_SIZE * CHANNELS, *([0] * FRAME_SIZE * CHANNELS))
# Create Ogg Opus file
with open(OUTPUT_FILE, 'wb') as f:
# Write Ogg header
f.write(b'OggS') # Magic number
f.write(b'\x00') # Version
f.write(b'\x00') # Header type (0 = normal)
f.write(b'\x00\x00\x00\x00\x00\x00\x00\x00') # Granule position
f.write(b'\x00\x00\x00\x00') # Bitstream serial number
f.write(b'\x00\x00\x00\x00') # Page sequence number
f.write(b'\x00\x00\x00\x00') # Checksum
f.write(b'\x01') # Number of segments
f.write(b'\x00') # Segment table (0 = 1 byte segment)
# Write Opus header
f.write(b'OpusHead') # Magic signature
f.write(b'\x01') # Version
f.write(chr(CHANNELS).encode('latin1')) # Channel count
f.write(struct.pack('<H', 80)) # Preskip (80 samples)
f.write(struct.pack('<I', SAMPLE_RATE)) # Input sample rate
f.write(b'\x00\x00') # Output gain
f.write(b'\x00') # Channel mapping family (0 = mono/stereo)
# Write comment header
f.write(b'OpusTags') # Magic signature
f.write(struct.pack('<I', 0)) # Vendor string length (0 for none)
f.write(struct.pack('<I', 0)) # Number of comments (0)
# Encode and write silent frames
for _ in range(num_frames):
# Encode the silent frame
encoded = enc.encode(silent_frame, FRAME_SIZE)
# Write Ogg page
f.write(b'OggS') # Magic number
f.write(b'\x00') # Version
f.write(b'\x00') # Header type (0 = normal)
f.write(struct.pack('<Q', (FRAME_SIZE * _) % (1 << 64))) # Granule position
f.write(b'\x00\x00\x00\x00') # Bitstream serial number
f.write(struct.pack('<I', _ + 2)) # Page sequence number
f.write(b'\x00\x00\x00\x00') # Checksum (0 for now)
f.write(b'\x01') # Number of segments
f.write(chr(len(encoded)).encode('latin1')) # Segment length
f.write(encoded) # The encoded data
print(f"Created silent OPUS file: {OUTPUT_FILE}")

212
deletefile.py Normal file
View File

@ -0,0 +1,212 @@
# deletefile.py — FastAPI route for file deletion
import os
import shutil
from typing import Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, Depends, status, Header
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import Session
from database import get_db
from models import UploadLog, UserQuota, User, DBSession
router = APIRouter()
# Use absolute path for security
DATA_ROOT = Path(os.path.abspath("./data"))
def get_current_user(
authorization: str = Header(None, description="Bearer token for authentication"),
db: Session = Depends(get_db)
) -> User:
"""
Get current user from authorization token with enhanced security.
Args:
authorization: The Authorization header containing the Bearer token
db: Database session dependency
Returns:
User: The authenticated user
Raises:
HTTPException: If authentication fails or user not found
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
token = authorization.split(" ")[1]
try:
with Session(db) as session:
# Check if session is valid
session_stmt = select(DBSession).where(
and_(
DBSession.token == token,
DBSession.is_active == True,
DBSession.expires_at > datetime.utcnow()
)
)
db_session = session.exec(session_stmt).first()
if not db_session:
print(f"[DELETE_FILE] Invalid or expired session token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session"
)
# Get the user
user = session.get(User, db_session.user_id)
if not user:
print(f"[DELETE_FILE] User not found for session token")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
except Exception as e:
print(f"[DELETE_FILE] Error during user authentication: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error during authentication"
)
@router.delete("/delete/{filename}")
async def delete_file(
request: Request,
filename: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Dict[str, Any]:
"""
Delete a file for the authenticated user with enhanced security and error handling.
Args:
request: The HTTP request object
filename: The name of the file to delete
db: Database session
current_user: The authenticated user
Returns:
Dict: Status and message of the operation
Raises:
HTTPException: If file not found, permission denied, or other errors
"""
print(f"[DELETE_FILE] Processing delete request for file '{filename}' from user {current_user.username}")
try:
# Security: Validate filename to prevent directory traversal
if not filename or any(c in filename for c in ['..', '/', '\\']):
print(f"[DELETE_FILE] Security alert: Invalid filename '{filename}'")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid filename"
)
# Construct full path with security checks
user_dir = DATA_ROOT / current_user.username
file_path = (user_dir / filename).resolve()
# Security: Ensure the file is within the user's directory
if not file_path.is_relative_to(user_dir.resolve()):
print(f"[DELETE_FILE] Security alert: Attempted path traversal: {file_path}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Verify file exists and is a file
if not file_path.exists() or not file_path.is_file():
print(f"[DELETE_FILE] File not found: {file_path}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get file size before deletion for quota update
file_size = file_path.stat().st_size
print(f"[DELETE_FILE] Deleting file: {file_path} (size: {file_size} bytes)")
# Start database transaction
with Session(db) as session:
try:
# Delete the file
try:
os.unlink(file_path)
print(f"[DELETE_FILE] Successfully deleted file: {file_path}")
except OSError as e:
print(f"[DELETE_FILE] Error deleting file: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete file"
)
# Clean up any associated raw files
raw_pattern = f"raw.*{filename}"
raw_files = list(file_path.parent.glob(raw_pattern))
for raw_file in raw_files:
try:
os.unlink(raw_file)
print(f"[DELETE_FILE] Deleted raw file: {raw_file}")
except OSError as e:
print(f"[DELETE_FILE] Warning: Could not delete raw file {raw_file}: {str(e)}")
# Delete the upload log entry
result = session.execute(
delete(UploadLog).where(
and_(
UploadLog.uid == current_user.username,
UploadLog.processed_filename == filename
)
)
)
if result.rowcount == 0:
print(f"[DELETE_FILE] Warning: No upload log entry found for {filename}")
else:
print(f"[DELETE_FILE] Deleted upload log entry for {filename}")
# Update user quota
quota = session.exec(
select(UserQuota)
.where(UserQuota.uid == current_user.username)
.with_for_update()
).first()
if quota:
new_quota = max(0, quota.storage_bytes - file_size)
print(f"[DELETE_FILE] Updating quota: {quota.storage_bytes} -> {new_quota}")
quota.storage_bytes = new_quota
session.add(quota)
session.commit()
print(f"[DELETE_FILE] Successfully updated database")
return {
"status": "success",
"message": "File deleted successfully",
"bytes_freed": file_size
}
except Exception as e:
session.rollback()
print(f"[DELETE_FILE] Database error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error during file deletion"
)
except HTTPException as he:
print(f"[DELETE_FILE] HTTP Error {he.status_code}: {he.detail}")
raise
except Exception as e:
print(f"[DELETE_FILE] Unexpected error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)

22
dicta2stream.service Normal file
View File

@ -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

35
gunicorn_config.py Normal file
View File

@ -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"

94
import_streams.py Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Script to import stream data from backup file into the publicstream table.
"""
import json
from datetime import datetime
from pathlib import Path
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlmodel import Session
from models import PublicStream, User, UserQuota, DBSession, UploadLog
from database import engine
# Database connection URL - using the same as in database.py
DATABASE_URL = "postgresql://d2s:kuTy4ZKs2VcjgDh6@localhost:5432/dictastream"
def import_streams_from_backup(backup_file: str):
"""Import stream data from backup file into the database."""
# Set up database connection
SessionLocal = sessionmaker(bind=engine)
with Session(engine) as session:
try:
# Read the backup file
with open(backup_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
# Parse the JSON data
stream_data = json.loads(line)
uid = stream_data.get('uid')
size = stream_data.get('size', 0)
mtime = stream_data.get('mtime', int(datetime.now().timestamp()))
if not uid:
print(f"Skipping invalid entry (missing uid): {line}")
continue
# Check if the stream already exists
existing = session.exec(
select(PublicStream).where(PublicStream.uid == uid)
).first()
now = datetime.utcnow()
if existing:
# Update existing record
existing.size = size
existing.mtime = mtime
existing.updated_at = now
session.add(existing)
print(f"Updated stream: {uid}")
else:
# Create new record
stream = PublicStream(
uid=uid,
size=size,
mtime=mtime,
created_at=now,
updated_at=now
)
session.add(stream)
print(f"Added stream: {uid}")
# Commit after each record to ensure data integrity
session.commit()
except json.JSONDecodeError as e:
print(f"Error parsing line: {line}")
print(f"Error: {e}")
session.rollback()
except Exception as e:
print(f"Error processing line: {line}")
print(f"Error: {e}")
session.rollback()
print("Import completed successfully!")
except Exception as e:
session.rollback()
print(f"Error during import: {e}")
raise
if __name__ == "__main__":
backup_file = "public_streams.txt.backup"
if not Path(backup_file).exists():
print(f"Error: Backup file '{backup_file}' not found.")
exit(1)
print(f"Starting import from {backup_file}...")
import_streams_from_backup(backup_file)

36
init_db.py Normal file
View File

@ -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()

View File

@ -1,64 +1,138 @@
# list_streams.py — FastAPI route to list all public streams (users with stream.opus)
from fastapi import APIRouter
from fastapi import APIRouter, Request, Depends
from fastapi.responses import StreamingResponse, Response
from sqlalchemy.orm import Session
from sqlalchemy import select
from models import PublicStream
from database import get_db
from pathlib import Path
from fastapi.responses import StreamingResponse
import asyncio
import os
import json
router = APIRouter()
DATA_ROOT = Path("./data")
@router.get("/streams-sse")
def streams_sse():
return list_streams_sse()
async def streams_sse(request: Request, db: Session = Depends(get_db)):
# 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":
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)
async def event_wrapper():
try:
async for event in list_streams_sse(db):
yield event
except Exception as e:
# Only log errors if DEBUG is enabled
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': 'An error occurred'})}\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")
async def list_streams_sse(db):
"""Stream public streams from the database as Server-Sent Events"""
try:
# Send initial ping
yield ":ping\n\n"
# Query all public streams from the database with required fields
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
result = db.execute(stmt)
streams = result.scalars().all()
if not streams:
print("No public streams found in the database")
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")
print(f"Found {len(streams)} public streams in the database")
# Send each stream as an SSE event
for stream in streams:
try:
# Ensure we have all required fields with fallbacks
stream_data = {
'uid': stream.uid or '',
'size': stream.storage_bytes or 0,
'mtime': int(stream.mtime) if stream.mtime is not None else 0,
'username': stream.username or stream.uid or '',
'display_name': stream.display_name or stream.username or stream.uid or '',
'created_at': stream.created_at.isoformat() if stream.created_at else None,
'updated_at': stream.updated_at.isoformat() if stream.updated_at else None
}
print(f"Sending stream data: {stream_data}")
yield f"data: {json.dumps(stream_data)}\n\n"
# Small delay to prevent overwhelming the client
await asyncio.sleep(0.1)
except Exception as e:
print(f"Error processing stream {stream.uid}: {str(e)}")
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
continue
# Send end of stream marker
print("Finished sending all streams")
yield f"data: {json.dumps({'end': True})}\n\n"
except Exception as e:
print(f"Error in list_streams_sse: {str(e)}")
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n"
def list_streams():
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
return {"streams": []}
def list_streams(db: Session = Depends(get_db)):
"""List all public streams from the database"""
try:
streams = []
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
streams.append(json.loads(line))
except Exception:
continue # skip malformed lines
return {"streams": streams}
except Exception:
stmt = select(PublicStream).order_by(PublicStream.mtime.desc())
result = db.execute(stmt)
streams = result.scalars().all()
return {
"streams": [
{
'uid': stream.uid,
'size': stream.size,
'mtime': stream.mtime,
'created_at': stream.created_at.isoformat() if stream.created_at else None,
'updated_at': stream.updated_at.isoformat() if stream.updated_at else None
}
for stream in streams
]
}
except Exception as e:
if os.getenv("DEBUG") == "1":
import traceback
traceback.print_exc()
return {"streams": []}

View File

@ -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)
)

445
main.py
View File

@ -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
@ -11,13 +11,14 @@ import traceback
import shutil
import mimetypes
from typing import Optional
from models import User, UploadLog
from models import User, UploadLog, UserQuota, get_user_by_uid
from sqlmodel import Session, select, SQLModel
from database import get_db, engine
from log import log_violation
import secrets
import time
import json
import subprocess
from datetime import datetime
from dotenv import load_dotenv
@ -36,16 +37,36 @@ from fastapi.requests import Request as FastAPIRequest
from fastapi.exception_handlers import RequestValidationError
from fastapi.exceptions import HTTPException as FastAPIHTTPException
app = FastAPI(debug=debug_mode)
app = FastAPI(debug=debug_mode, docs_url=None, redoc_url=None, openapi_url=None)
# Override default HTML error handlers to return JSON
from fastapi.exceptions import RequestValidationError, HTTPException as FastAPIHTTPException
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
# --- 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
@ -115,17 +136,47 @@ async def validation_exception_handler(request: FastAPIRequest, exc: RequestVali
async def generic_exception_handler(request: FastAPIRequest, exc: Exception):
return JSONResponse(status_code=500, content={"detail": str(exc)})
# Debug endpoint to list all routes
@app.get("/debug/routes")
async def list_routes():
routes = []
for route in app.routes:
if hasattr(route, "methods") and hasattr(route, "path"):
routes.append({
"path": route.path,
"methods": list(route.methods) if hasattr(route, "methods") else [],
"name": route.name if hasattr(route, "name") else "",
"endpoint": str(route.endpoint) if hasattr(route, "endpoint") else "",
"router": str(route) # Add router info for debugging
})
# Sort routes by path for easier reading
routes.sort(key=lambda x: x["path"])
# Also print to console for server logs
print("\n=== Registered Routes ===")
for route in routes:
print(f"{', '.join(route['methods']).ljust(20)} {route['path']}")
print("======================\n")
return {"routes": routes}
# include routers from submodules
from register import router as register_router
from magic import router as magic_router
from upload import router as upload_router
from streams import router as streams_router
from list_user_files import router as list_user_files_router
from auth_router import router as auth_router
app.include_router(streams_router)
from list_streams import router as list_streams_router
from account_router import router as account_router
# Include all routers
app.include_router(auth_router)
app.include_router(account_router)
app.include_router(register_router)
app.include_router(magic_router)
app.include_router(upload_router)
@ -135,6 +186,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,104 +279,137 @@ 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)):
user = get_user_by_uid(uid)
if not user:
raise HTTPException(status_code=403, detail="Invalid user ID")
ip = request.client.host
if user.ip != ip:
raise HTTPException(status_code=403, detail="Device/IP mismatch")
user_dir = os.path.join('data', user.username)
target_path = os.path.join(user_dir, filename)
# Prevent path traversal attacks
real_target_path = os.path.realpath(target_path)
real_user_dir = os.path.realpath(user_dir)
if not real_target_path.startswith(real_user_dir + os.sep):
raise HTTPException(status_code=403, detail="Invalid path")
if not os.path.isfile(real_target_path):
raise HTTPException(status_code=404, detail="File not found")
os.remove(real_target_path)
log_violation("DELETE", ip, uid, f"Deleted {filename}")
subprocess.run(["/root/scripts/refresh_user_playlist.sh", user.username])
async def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
"""
Delete a file for a specific user.
Args:
uid: The username of the user (used as UID in routes)
filename: The name of the file to delete
request: The incoming request object
db: Database session
Returns:
Dict with status message
"""
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 after delete failed: {e}")
# Get the user by username (which is used as UID in routes)
user = get_user_by_uid(uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "deleted"}
# Get client IP and verify it matches the user's IP
ip = request.client.host
if user.ip != ip:
raise HTTPException(status_code=403, detail="Device/IP mismatch. Please log in again.")
# Set up user directory and validate paths
user_dir = os.path.join('data', user.username)
os.makedirs(user_dir, exist_ok=True)
# Decode URL-encoded filename
from urllib.parse import unquote
filename = unquote(filename)
# Construct and validate target path
target_path = os.path.join(user_dir, filename)
real_target_path = os.path.realpath(target_path)
real_user_dir = os.path.realpath(user_dir)
# Security check: Ensure the target path is inside the user's directory
if not real_target_path.startswith(real_user_dir + os.sep):
raise HTTPException(status_code=403, detail="Invalid file path")
# Check if file exists
if not os.path.isfile(real_target_path):
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
# Delete both the target file and its UUID-only variant
deleted_files = []
try:
# First delete the requested file (with log ID prefix)
if os.path.exists(real_target_path):
os.remove(real_target_path)
deleted_files.append(filename)
log_violation("DELETE", ip, uid, f"Deleted {filename}")
# Then try to find and delete the UUID-only variant (without log ID prefix)
if '_' in filename: # If filename has a log ID prefix (e.g., "123_uuid.opus")
uuid_part = filename.split('_', 1)[1] # Get the part after the first underscore
uuid_path = os.path.join(user_dir, uuid_part)
if os.path.exists(uuid_path):
os.remove(uuid_path)
deleted_files.append(uuid_part)
log_violation("DELETE", ip, uid, f"Deleted UUID variant: {uuid_part}")
file_deleted = len(deleted_files) > 0
if not file_deleted:
log_violation("DELETE_WARNING", ip, uid, f"No files found to delete for: {filename}")
except Exception as e:
log_violation("DELETE_ERROR", ip, uid, f"Error deleting file {filename}: {str(e)}")
file_deleted = False
# Try to refresh the user's playlist, but don't fail if we can't
try:
subprocess.run(["/root/scripts/refresh_user_playlist.sh", user.username],
check=False, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
except Exception as e:
log_violation("PLAYLIST_REFRESH_WARNING", ip, uid,
f"Failed to refresh playlist: {str(e)}")
# Clean up the database record for this file
try:
# Find and delete the upload log entry
log_entry = db.exec(
select(UploadLog)
.where(UploadLog.uid == uid)
.where(UploadLog.processed_filename == filename)
).first()
if log_entry:
db.delete(log_entry)
db.commit()
log_violation("DB_CLEANUP", ip, uid, f"Removed DB record for {filename}")
except Exception as e:
log_violation("DB_CLEANUP_ERROR", ip, uid, f"Failed to clean up DB record: {str(e)}")
db.rollback()
# Regenerate stream.opus after file deletion
try:
from concat_opus import concat_opus_files
from pathlib import Path
user_dir_path = Path(user_dir)
stream_path = user_dir_path / "stream.opus"
concat_opus_files(user_dir_path, stream_path)
log_violation("STREAM_UPDATE", ip, uid, "Regenerated stream.opus after file deletion")
except Exception as e:
log_violation("STREAM_UPDATE_ERROR", ip, uid, f"Failed to regenerate stream.opus: {str(e)}")
# Update user quota in a separate try-except to not fail the entire operation
try:
# Use verify_and_fix_quota to ensure consistency between disk and DB
total_size = verify_and_fix_quota(db, user.username, user_dir)
log_violation("QUOTA_UPDATE", ip, uid,
f"Updated quota: {total_size} bytes")
except Exception as e:
log_violation("QUOTA_ERROR", ip, uid, f"Quota update failed: {str(e)}")
db.rollback()
return {"status": "deleted"}
except Exception as e:
# Log the error and re-raise with a user-friendly message
error_detail = str(e)
log_violation("DELETE_ERROR", request.client.host, uid, f"Failed to delete {filename}: {error_detail}")
if not isinstance(e, HTTPException):
raise HTTPException(status_code=500, detail=f"Failed to delete file: {error_detail}")
raise
@app.get("/confirm/{uid}")
def confirm_user(uid: str, request: Request):
@ -331,26 +419,147 @@ def confirm_user(uid: str, request: Request):
raise HTTPException(status_code=403, detail="Unauthorized")
return {"username": user.username, "email": user.email}
def verify_and_fix_quota(db: Session, uid: str, user_dir: str) -> int:
"""
Verify and fix the user's quota based on the size of stream.opus file.
Returns the size of stream.opus in bytes.
"""
stream_opus_path = os.path.join(user_dir, 'stream.opus')
total_size = 0
# Only consider stream.opus for quota
if os.path.isfile(stream_opus_path):
try:
total_size = os.path.getsize(stream_opus_path)
print(f"[QUOTA] Stream.opus size for {uid}: {total_size} bytes")
except (OSError, FileNotFoundError) as e:
print(f"[QUOTA] Error getting size for stream.opus: {e}")
else:
print(f"[QUOTA] stream.opus not found in {user_dir}")
# Update quota in database
q = db.get(UserQuota, uid) or UserQuota(uid=uid, storage_bytes=0)
q.storage_bytes = total_size
db.add(q)
# Clean up any database records for files that don't exist
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
for upload in uploads:
if upload.processed_filename: # Only check if processed_filename exists
stored_filename = f"{upload.id}_{upload.processed_filename}"
file_path = os.path.join(user_dir, stored_filename)
if not os.path.isfile(file_path):
print(f"[QUOTA] Removing orphaned DB record: {stored_filename}")
db.delete(upload)
try:
db.commit()
print(f"[QUOTA] Updated quota for {uid}: {total_size} bytes")
except Exception as e:
print(f"[QUOTA] Error committing quota update: {e}")
db.rollback()
raise
return total_size
@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")
def get_me(uid: str, request: Request, response: Response, db: Session = Depends(get_db)):
# Add headers to prevent caching
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
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=404, detail="User not found")
# Only enforce IP check in production
if not debug_mode:
if user.ip != request.client.host:
print(f"[WARNING] IP mismatch for UID {uid}: {request.client.host} != {user.ip}")
# In production, we might want to be more strict
# But for now, we'll just log a warning in development
if not debug_mode:
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 user directory
user_dir = os.path.join('data', uid)
os.makedirs(user_dir, exist_ok=True)
# 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, checking if files exist on disk
files = []
seen_files = set() # Track seen files to avoid duplicates
print(f"[DEBUG] Processing {len(upload_logs)} upload logs for UID {uid}")
for i, log in enumerate(upload_logs):
if not log.filename or not log.processed_filename:
print(f"[DEBUG] Skipping log entry {i}: missing filename or processed_filename")
continue
# The actual filename on disk has the log ID prepended
stored_filename = f"{log.id}_{log.processed_filename}"
file_path = os.path.join(user_dir, stored_filename)
# Skip if we've already seen this file
if stored_filename in seen_files:
print(f"[DEBUG] Skipping duplicate file: {stored_filename}")
continue
seen_files.add(stored_filename)
# Only include the file if it exists on disk and is not stream.opus
if os.path.isfile(file_path) and stored_filename != 'stream.opus':
try:
# Get the actual file size in case it changed
file_size = os.path.getsize(file_path)
file_info = {
"name": stored_filename,
"original_name": log.filename,
"size": file_size
}
files.append(file_info)
print(f"[DEBUG] Added file {len(files)}: {log.filename} (stored as {stored_filename}, {file_size} bytes)")
except OSError as e:
print(f"[WARNING] Could not access file {stored_filename}: {e}")
else:
print(f"[DEBUG] File not found on disk or is stream.opus: {stored_filename}")
# Log all files being returned
print("[DEBUG] All files being returned:")
for i, file_info in enumerate(files, 1):
print(f" {i}. {file_info['name']} (original: {file_info['original_name']}, size: {file_info['size']} bytes)")
# Verify and fix quota based on actual files on disk
total_size = verify_and_fix_quota(db, uid, user_dir)
quota_mb = round(total_size / (1024 * 1024), 2)
print(f"[DEBUG] Verified 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 HTTPException:
# Re-raise HTTP exceptions as they are
raise
except Exception as e:
# Log the full traceback for debugging
import traceback
error_trace = traceback.format_exc()
print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}\n{error_trace}")
# Return a 500 error with a generic message
raise HTTPException(status_code=500, detail="Internal server error")

73
middleware.py Normal file
View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -8,7 +8,8 @@ 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)
display_name: str = Field(default="", nullable=True)
token: str
confirmed: bool = False
ip: str = Field(default="")
@ -23,13 +24,85 @@ 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)
class PublicStream(SQLModel, table=True):
"""Stores public stream metadata for all users"""
uid: str = Field(primary_key=True)
username: Optional[str] = Field(default=None, index=True)
display_name: Optional[str] = Field(default=None)
storage_bytes: int = 0
mtime: int = Field(default_factory=lambda: int(datetime.utcnow().timestamp()))
last_updated: Optional[datetime] = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
def get_user_by_uid(uid: str) -> Optional[User]:
"""
Retrieve a user by their UID (username).
Note: In this application, the User model uses email as primary key,
but we're using username as UID for API routes. This function looks up
users by username.
Args:
uid: The username to look up
Returns:
User object if found, None otherwise
"""
with Session(engine) as session:
# First try to find by username (which is what we're using as UID)
statement = select(User).where(User.username == uid)
result = session.exec(statement).first()
return result
user = session.exec(statement).first()
# If not found by username, try by email (for backward compatibility)
if not user and '@' in uid:
statement = select(User).where(User.email == uid)
user = session.exec(statement).first()
return user
def verify_session(db: Session, token: str) -> DBSession:
"""Verify a session token and return the session if valid"""
from datetime import datetime
# Find the session
session = db.exec(
select(DBSession)
.where(DBSession.token == token)
.where(DBSession.is_active == True) # noqa: E712
.where(DBSession.expires_at > datetime.utcnow())
).first()
if not session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last activity
session.last_activity = datetime.utcnow()
db.add(session)
db.commit()
db.refresh(session)
return session

4
nohup.out Normal file
View File

@ -0,0 +1,4 @@
INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream']
ERROR: [Errno 98] Address already in use
INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream']
ERROR: [Errno 98] Address already in use

View File

@ -1 +0,0 @@
{"uid":"devuser","size":22455090,"mtime":1747563720}

View File

@ -0,0 +1,3 @@
{"uid":"devuser","size":90059327,"mtime":1752911461}
{"uid":"oibchello","size":16262818,"mtime":1752911899}
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}

View File

@ -7,11 +7,46 @@ from database import get_db
import uuid
import smtplib
from email.message import EmailMessage
from pathlib import Path
import os
router = APIRouter()
MAGIC_FROM = "noreply@dicta2stream.net"
MAGIC_DOMAIN = "https://dicta2stream.net"
DATA_ROOT = Path("./data")
def initialize_user_directory(username: str):
"""Initialize user directory with a silent stream.opus file"""
try:
user_dir = DATA_ROOT / username
default_stream_path = DATA_ROOT / "stream.opus"
print(f"[DEBUG] Initializing user directory: {user_dir.absolute()}")
# Create the directory if it doesn't exist
user_dir.mkdir(parents=True, exist_ok=True)
print(f"[DEBUG] Directory created or already exists: {user_dir.exists()}")
# Create stream.opus by copying the default stream.opus file
user_stream_path = user_dir / "stream.opus"
print(f"[DEBUG] Creating stream.opus at: {user_stream_path.absolute()}")
if not user_stream_path.exists():
if default_stream_path.exists():
import shutil
shutil.copy2(default_stream_path, user_stream_path)
print(f"[DEBUG] Copied default stream.opus to {user_stream_path}")
else:
print(f"[ERROR] Default stream.opus not found at {default_stream_path}")
# Fallback: create an empty file to prevent errors
with open(user_stream_path, 'wb') as f:
f.write(b'')
return True
except Exception as e:
print(f"Error initializing user directory for {username}: {str(e)}")
return False
@router.post("/register")
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
@ -40,8 +75,13 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
# Register new user
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
db.add(UserQuota(uid=user))
try:
# First commit the user to the database
db.commit()
# Only after successful commit, initialize the user directory
initialize_user_directory(user)
except Exception as e:
db.rollback()
if isinstance(e, IntegrityError):

179
run-navigation-test.js Normal file
View File

@ -0,0 +1,179 @@
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
// Configuration
const BASE_URL = 'http://localhost:8000'; // Update this if your app runs on a different URL
const TEST_ITERATIONS = 5;
const OUTPUT_DIR = path.join(__dirname, 'performance-results');
const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-');
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Helper function to save results
function saveResults(data, filename) {
const filepath = path.join(OUTPUT_DIR, `${filename}-${TIMESTAMP}.json`);
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
console.log(`Results saved to ${filepath}`);
return filepath;
}
// Test runner
async function runNavigationTest() {
const browser = await puppeteer.launch({
headless: false, // Set to true for CI/CD
devtools: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--single-process',
'--disable-gpu',
'--js-flags="--max-old-space-size=1024"'
]
});
try {
const page = await browser.newPage();
// Enable performance metrics
await page.setViewport({ width: 1280, height: 800 });
await page.setDefaultNavigationTimeout(60000);
// Set up console logging
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
// Load the performance test script
const testScript = fs.readFileSync(path.join(__dirname, 'static', 'router-perf-test.js'), 'utf8');
// Test guest mode
console.log('Testing guest mode...');
await page.goto(`${BASE_URL}`, { waitUntil: 'networkidle0' });
// Inject and run the test
const guestResults = await page.evaluate(async (script) => {
// Inject the test script
const scriptEl = document.createElement('script');
scriptEl.textContent = script;
document.head.appendChild(scriptEl);
// Run the test
const test = new RouterPerfTest();
return await test.runTest('guest');
}, testScript);
saveResults(guestResults, 'guest-results');
// Test logged-in mode (if credentials are provided)
if (process.env.LOGIN_EMAIL && process.env.LOGIN_PASSWORD) {
console.log('Testing logged-in mode...');
// Navigate to the test page with authentication token
console.log('Authenticating with provided token...');
await page.goto('https://dicta2stream.net/?token=d96561d7-6c95-4e10-80f7-62d5d3a5bd04', {
waitUntil: 'networkidle0',
timeout: 60000
});
// Wait for authentication to complete and verify
try {
await page.waitForSelector('body.authenticated', {
timeout: 30000,
visible: true
});
console.log('✅ Successfully authenticated');
// Verify user is actually logged in
const isAuthenticated = await page.evaluate(() => {
return document.body.classList.contains('authenticated');
});
if (!isAuthenticated) {
throw new Error('Authentication failed - not in authenticated state');
}
// Force a navigation to ensure the state is stable
await page.goto('https://dicta2stream.net/#welcome-page', {
waitUntil: 'networkidle0',
timeout: 30000
});
} catch (error) {
console.error('❌ Authentication failed:', error.message);
// Take a screenshot for debugging
await page.screenshot({ path: 'auth-failure.png' });
console.log('Screenshot saved as auth-failure.png');
throw error;
}
// Wait for the page to fully load after login
await page.waitForTimeout(2000);
// Run the test in logged-in mode
const loggedInResults = await page.evaluate(async (script) => {
const test = new RouterPerfTest();
return await test.runTest('loggedIn');
}, testScript);
saveResults(loggedInResults, 'loggedin-results');
// Generate comparison report
const comparison = {
timestamp: new Date().toISOString(),
guest: {
avg: guestResults.overall.avg,
min: guestResults.overall.min,
max: guestResults.overall.max
},
loggedIn: {
avg: loggedInResults.overall.avg,
min: loggedInResults.overall.min,
max: loggedInResults.overall.max
},
difference: {
ms: loggedInResults.overall.avg - guestResults.overall.avg,
percent: ((loggedInResults.overall.avg - guestResults.overall.avg) / guestResults.overall.avg) * 100
}
};
const reportPath = saveResults(comparison, 'performance-comparison');
console.log(`\nPerformance comparison report generated at: ${reportPath}`);
// Take a screenshot of the results
await page.screenshot({ path: path.join(OUTPUT_DIR, `results-${TIMESTAMP}.png`), fullPage: true });
return comparison;
}
return guestResults;
} catch (error) {
console.error('Test failed:', error);
// Take a screenshot on error
if (page) {
await page.screenshot({ path: path.join(OUTPUT_DIR, `error-${TIMESTAMP}.png`), fullPage: true });
}
throw error;
} finally {
await browser.close();
}
}
// Run the test
runNavigationTest()
.then(results => {
console.log('Test completed successfully');
console.log('Results:', JSON.stringify(results, null, 2));
process.exit(0);
})
.catch(error => {
console.error('Test failed:', error);
process.exit(1);
});

29
run_migrations.py Normal file
View File

@ -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()

BIN
silent.opus Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

208
static/css/base.css Normal file
View File

@ -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;
}

69
static/css/colors.css Normal file
View File

@ -0,0 +1,69 @@
/*
* Color System Documentation
*
* This file documents the color variables used throughout the application.
* All colors should be defined as CSS variables in :root, and these variables
* should be used consistently across all CSS and JavaScript files.
*/
:root {
/* Primary Colors */
--primary-color: #4a6fa5; /* Main brand color */
--primary-hover: #3a5a8c; /* Darker shade for hover states */
/* Text Colors */
--text-color: #f0f0f0; /* Main text color */
--text-muted: #888; /* Secondary text, less important info */
--text-light: #999; /* Lighter text for disabled states */
--text-lighter: #bbb; /* Very light text, e.g., placeholders */
/* Background Colors */
--background: #1a1a1a; /* Main background color */
--surface: #2a2a2a; /* Surface color for cards, panels, etc. */
--code-bg: #222; /* Background for code blocks */
/* Border Colors */
--border: #444; /* Default border color */
--border-light: #555; /* Lighter border */
--border-lighter: #666; /* Even lighter border */
/* Status Colors */
--success: #2e8b57; /* Success messages, confirmations */
--warning: #ff6600; /* Warnings, important notices */
--error: #ff4444; /* Error messages, destructive actions */
--error-hover: #ff6666; /* Hover state for error buttons */
--info: #1e90ff; /* Informational messages, links */
--link-hover: #74c0fc; /* Hover state for links */
/* Transitions */
--transition: all 0.2s ease; /* Default transition */
}
/*
* Usage Examples:
*
* .button {
* background-color: var(--primary-color);
* color: var(--text-color);
* border: 1px solid var(--border);
* transition: var(--transition);
* }
*
* .button:hover {
* background-color: var(--primary-hover);
* }
*
* .error-message {
* color: var(--error);
* background-color: color-mix(in srgb, var(--error) 10%, transparent);
* border-left: 3px solid var(--error);
* }
*/
/*
* Accessibility Notes:
* - Ensure text has sufficient contrast with its background
* - Use semantic color names that describe the purpose, not the color
* - Test with color blindness simulators for accessibility
* - Maintain consistent color usage throughout the application
*/

View File

View File

@ -0,0 +1,268 @@
/* File upload and list styles */
#user-upload-area {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
margin: 1rem 0;
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: var(--surface);
}
#user-upload-area:hover,
#user-upload-area.highlight {
border-color: var(--primary);
background-color: rgba(var(--primary-rgb), 0.05);
}
#user-upload-area p {
margin: 0;
color: var(--text-secondary);
}
#file-list {
list-style: none;
padding: 0;
margin: 1rem 0 0;
}
#file-list {
margin: 1.5rem 0;
padding: 0;
}
#file-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
background-color: var(--surface);
border-radius: 6px;
border: 1px solid var(--border);
transition: all 0.2s ease-in-out;
}
#file-list li:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
#file-list li.no-files,
#file-list li.loading-message,
#file-list li.error-message {
display: block;
text-align: center;
color: var(--text-muted);
padding: 2rem 1.5rem;
background-color: transparent;
border: 2px dashed var(--border);
margin: 1rem 0;
border-radius: 8px;
font-size: 1.1em;
}
#file-list li.loading-message {
color: var(--primary);
font-style: italic;
}
#file-list li.error-message {
color: var(--error);
border-color: var(--error);
}
#file-list li.error-message .login-link {
color: var(--primary);
text-decoration: none;
font-weight: bold;
margin-left: 0.3em;
}
#file-list li.error-message .login-link:hover {
text-decoration: underline;
}
#file-list li.no-files:hover {
background-color: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary);
transform: none;
box-shadow: none;
}
.file-item {
width: 100%;
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* Allows text truncation */
}
.file-icon {
margin-right: 0.75rem;
font-size: 1.2em;
flex-shrink: 0;
}
.file-name {
color: var(--primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 0.5rem;
}
.file-name:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-muted);
font-size: 0.85em;
margin-left: 0.5rem;
white-space: nowrap;
flex-shrink: 0;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
flex-shrink: 0;
}
.download-button,
.delete-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
border: 1px solid transparent;
}
.download-button {
background-color: var(--primary);
color: white;
}
.download-button:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.delete-button {
background-color: transparent;
color: var(--error);
border-color: var(--error);
}
.delete-button:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
.button-icon {
font-size: 1em;
}
.button-text {
display: none;
}
/* Show text on larger screens */
@media (min-width: 640px) {
.button-text {
display: inline;
}
.download-button,
.delete-button {
padding: 0.4rem 1rem;
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
#file-list li {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.file-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
.file-name {
max-width: 100%;
}
}
#file-list li a {
color: var(--primary);
text-decoration: none;
flex-grow: 1;
margin-right: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#file-list li a:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-secondary);
font-size: 0.9em;
margin-left: 0.5rem;
}
.delete-file {
background: none;
border: none;
color: var(--error);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.delete-file:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
/* Loading state */
#file-list.loading {
opacity: 0.7;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
#user-upload-area {
padding: 1.5rem 1rem;
}
#file-list li {
padding: 0.5rem;
font-size: 0.9rem;
}
.file-size {
display: block;
margin-left: 0;
margin-top: 0.25rem;
}
}

View File

View File

@ -0,0 +1,80 @@
/* Footer styles */
footer {
background: #2c3e50;
color: var(--text-color);
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: var(--text-color);
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover,
.footer-links a:focus {
color: var(--info);
text-decoration: underline;
}
.separator {
color: var(--text-muted);
margin: 0 0.25rem;
}
.footer-hint {
margin-top: 1rem;
font-size: 0.9rem;
color: var(--text-light);
}
.footer-hint a {
color: var(--info);
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;
}
}

View File

@ -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: var(--text-color);
}
/* 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);
}
}

View File

View File

View File

116
static/css/section.css Normal file
View File

@ -0,0 +1,116 @@
/* section.css - Centralized visibility control with class-based states */
/* Base section visibility - all sections hidden by default */
main > section {
display: none;
position: absolute;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
}
/* Active section styling - only visibility properties */
main > section.active {
display: block;
position: relative;
overflow: visible;
clip: auto;
white-space: normal;
opacity: 1;
}
/* Authentication-based visibility classes */
.guest-only { display: block; }
.auth-only {
display: none;
}
/* Show auth-only elements when authenticated */
body.authenticated .auth-only {
display: block;
}
/* Ensure me-page and its direct children are visible when me-page is active */
#me-page:not([hidden]) > .auth-only,
#me-page:not([hidden]) > section,
#me-page:not([hidden]) > article,
#me-page:not([hidden]) > div {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
/* Show auth-only elements when authenticated */
body.authenticated .auth-only {
display: block !important;
visibility: visible !important;
}
/* Account deletion section - improved width and formatting */
#account-deletion {
margin: 2.5rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
max-width: 600px;
line-height: 1.6;
color: var(--text-color);
}
#account-deletion h3 {
color: var(--color-primary);
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
#account-deletion p {
color: var(--color-text);
line-height: 1.6;
margin-bottom: 1.5rem;
}
#account-deletion ul {
margin: 1rem 0 1.5rem 1.5rem;
padding: 0;
color: var(--color-text);
}
#account-deletion .centered-container {
text-align: center;
margin-top: 2rem;
}
#delete-account-from-privacy {
background-color: #ff4d4f;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 1rem;
transition: background-color 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
#delete-account-from-privacy:hover {
background-color: #ff6b6b;
text-decoration: none;
}
/* Hide guest-only elements when authenticated */
body.authenticated .guest-only {
display: none !important;
visibility: hidden !important;
display: none;
}
.always-visible {
display: block !important;
}

View File

View File

File diff suppressed because it is too large Load Diff

231
static/desktop.css Normal file
View File

@ -0,0 +1,231 @@
/* Desktop-specific styles for screens 960px and wider */
@media (min-width: 960px) {
:root {
--content-max-width: 800px;
--content-padding: 1.25rem;
--section-spacing: 1.5rem;
}
html {
background-color: #111 !important;
background-image:
repeating-linear-gradient(
45deg,
rgba(188, 183, 107, 0.1) 0,
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
),
repeating-linear-gradient(
-45deg,
rgba(188, 183, 107, 0.1) 0,
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;
display: flex;
flex-direction: column;
}
/* Main content container */
main {
flex: 1;
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--content-padding);
box-sizing: border-box;
}
/* Ensure h2 in legal pages matches other pages */
#privacy-page > article > h2:first-child,
#imprint-page > article > h2:first-child {
margin-top: 0;
padding-top: 0;
}
/* Streams Page Specific Styles */
#streams-page section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 2rem;
box-sizing: border-box;
}
.stream-card {
margin-bottom: 1rem;
background: var(--surface);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stream-card:last-child {
margin-bottom: 0;
}
.stream-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stream-card .card-content {
padding: 1.25rem 1.5rem;
}
/* Section styles */
section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto var(--section-spacing);
background: rgba(26, 26, 26, 0.9);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
section:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
}
/* Navigation */
nav.dashboard-nav {
padding: 1rem 0;
margin-bottom: 2rem;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: block;
}
/* Desktop navigation visibility */
nav.dashboard-nav {
display: block;
}
/* Show desktop navigation */
section#links {
display: block;
}
/* Hide mobile navigation elements */
#burger-label,
#burger-toggle {
display: none !important;
}
/* Dashboard navigation */
#guest-dashboard,
#user-dashboard {
display: flex;
gap: 1rem;
}
nav.dashboard-nav a {
padding: 0.5rem 1rem;
margin: 0 0.5em;
border-radius: 4px;
transition: background-color 0.2s ease;
}
nav.dashboard-nav a:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Form elements */
input[type="email"],
input[type="text"],
input[type="password"] {
width: 100%;
max-width: 400px;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
color: #f0f0f0;
}
/* Buttons */
button,
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
background: #4a6fa5;
color: white;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover,
.button:hover {
background: #5a8ad4;
}
/* Global article styles */
main > section > article,
#stream-page > article,
#stream-page #stream-list > li .stream-player {
max-width: 600px;
margin: 2em auto 2em auto;
padding: 2em;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
transition: all 0.2s ease;
box-sizing: border-box;
}
/* Add top margin to all stream players except the first one */
#stream-page #stream-list > li:not(:first-child) .stream-player {
margin-top: 2px;
}
/* Stream player styles */
#stream-page #stream-list > li {
list-style: none;
margin: 0;
padding: 0;
border: none;
background: transparent;
}
#stream-page #stream-list {
padding: 0;
margin: 0 auto;
max-width: 600px;
width: 100%;
}
/* Stream player specific overrides can be added here if needed in the future */
/* Hover states moved to style.css for consistency */
/* Stream list desktop styles */
#stream-list {
max-width: 600px;
margin: 0 auto;
}
/* User upload area - matches article styling */
#user-upload-area {
max-width: 600px;
width: 100%;
margin: 2rem auto;
box-sizing: border-box;
}
}

140
static/fix-nav.js Normal file
View File

@ -0,0 +1,140 @@
// Debounce helper function
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Throttle helper function
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Check authentication state once and cache it
function getAuthState() {
return (
document.cookie.includes('isAuthenticated=') ||
document.cookie.includes('uid=') ||
localStorage.getItem('isAuthenticated') === 'true' ||
!!localStorage.getItem('authToken')
);
}
// Update navigation based on authentication state
function updateNavigation() {
const isAuthenticated = getAuthState();
// Only proceed if the authentication state has changed
if (isAuthenticated === updateNavigation.lastState) {
return;
}
updateNavigation.lastState = isAuthenticated;
if (isAuthenticated) {
// Hide guest navigation for authenticated users
const guestNav = document.getElementById('guest-dashboard');
if (guestNav) {
guestNav.style.cssText = `
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
height: 0 !important;
overflow: hidden !important;
position: absolute !important;
clip: rect(0, 0, 0, 0) !important;
pointer-events: none !important;
`;
}
// Show user navigation if it exists
const userNav = document.getElementById('user-dashboard');
if (userNav) {
userNav.style.cssText = `
display: block !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
position: relative !important;
clip: auto !important;
pointer-events: auto !important;
`;
userNav.classList.add('force-visible');
}
// Update body classes
document.body.classList.add('authenticated');
document.body.classList.remove('guest-mode');
} else {
// User is not authenticated - ensure guest nav is visible
const guestNav = document.getElementById('guest-dashboard');
if (guestNav) {
guestNav.style.cssText = ''; // Reset any inline styles
}
document.body.classList.remove('authenticated');
document.body.classList.add('guest-mode');
}
}
// Initialize the navigation state
updateNavigation.lastState = null;
// Handle navigation link clicks
function handleNavLinkClick(e) {
const link = e.target.closest('a[href^="#"]');
if (!link) return;
e.preventDefault();
const targetId = link.getAttribute('href');
if (targetId && targetId !== '#') {
// Update URL without triggering full page reload
history.pushState(null, '', targetId);
// Dispatch a custom event for other scripts
window.dispatchEvent(new CustomEvent('hashchange'));
}
}
// Initialize the navigation system
function initNavigation() {
// Set up event delegation for navigation links
document.body.addEventListener('click', handleNavLinkClick);
// Listen for hash changes (throttled)
window.addEventListener('hashchange', throttle(updateNavigation, 100));
// Listen for storage changes (like login/logout from other tabs)
window.addEventListener('storage', debounce(updateNavigation, 100));
// Check for authentication changes periodically (every 30 seconds)
setInterval(updateNavigation, 30000);
// Initial update
updateNavigation();
}
// Run initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initNavigation);
} else {
// DOMContentLoaded has already fired
initNavigation();
}
// Export for testing if needed
window.navigationUtils = {
updateNavigation,
getAuthState,
initNavigation
};

14
static/footer.html Normal file
View File

@ -0,0 +1,14 @@
<!-- Footer content -->
<footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<div class="footer-links">
<a href="#" data-target="terms-page">Terms</a>
<span class="separator"></span>
<a href="#" data-target="privacy-page">Privacy</a>
<span class="separator"></span>
<a href="#" data-target="imprint-page">Imprint</a>
<span class="separator auth-only" style="display: none;"></span>
<a href="#" data-target="your-stream" class="auth-only" style="display: none;">Your Stream</a>
</div>
</footer>

13
static/generate-test-audio.sh Executable file
View File

@ -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

View File

@ -3,24 +3,26 @@
<html lang="en">
<head>
<link rel="stylesheet" href="/static/style.css" media="all" />
<link rel="stylesheet" href="/static/desktop.css" media="(min-width: 960px)">
<link rel="stylesheet" href="/static/mobile.css" media="(max-width: 959px)">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
<title>dicta2stream</title>
<!-- Responsive burger menu display -->
<!-- Section visibility and navigation styles -->
<link rel="stylesheet" href="/static/css/section.css" media="all" />
<style>
#burger-label, #burger-toggle { display: none; }
@media (max-width: 959px) {
#burger-label { display: block; }
section#links { display: none; }
#burger-toggle:checked + #burger-label + section#links { display: block; }
}
/* Hide mobile menu by default on larger screens */
@media (min-width: 960px) {
section#links { display: block; }
#mobile-menu { display: none !important; }
#burger-label { display: none !important; }
}
</style>
<link rel="modulepreload" href="/static/sound.js" />
<script src="/static/streams-ui.js" type="module"></script>
<script src="/static/app.js" type="module"></script>
</head>
<body>
<header>
@ -31,43 +33,76 @@
<main>
<!-- Guest Dashboard -->
<nav id="guest-dashboard" class="dashboard-nav">
<a href="#" id="guest-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="guest-streams" data-target="stream-page">Streams</a> |
<a href="#" id="guest-login" data-target="register-page">Login or Register</a>
<nav id="guest-dashboard" class="dashboard-nav guest-only">
<a href="#welcome-page" id="guest-welcome">Welcome</a>
<a href="#stream-page" id="guest-streams">Streams</a>
<a href="#account" id="guest-login">Account</a>
</nav>
<!-- User Dashboard -->
<nav id="user-dashboard" class="dashboard-nav" style="display:none;">
<a href="#" id="user-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="user-streams" data-target="stream-page">Streams</a> |
<a href="#" id="show-me" data-target="me-page">Your Stream</a>
<nav id="user-dashboard" class="dashboard-nav auth-only">
<a href="#welcome-page" id="user-welcome">Welcome</a>
<a href="#stream-page" id="user-streams">Streams</a>
<a href="#me-page" id="show-me">Your Stream</a>
</nav>
<section id="me-page">
<section id="me-page" class="auth-only">
<div>
<h2>Your Stream</h2>
</div>
<article>
<h2>Your Stream 🎙️</h2>
<p>This is your personal stream. Only you can upload to it.</p>
<audio id="me-audio"></audio>
<div class="audio-controls">
<button id="play-pause" type="button">▶️</button>
<button class="play-pause-btn" type="button" aria-label="Play" data-uid="">▶️</button>
</div>
</article>
<section id="user-upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<section id="user-upload-area" class="auth-only">
<p>Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInputUser" accept="audio/*" hidden />
</section>
<article id="log-out" class="auth-only article--bordered logout-section">
<button id="logout-button" class="button">🚪 Log Out</button>
</article>
<section id="quota-meter" class="auth-only">
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB</span></p>
<h4>Uploaded Files</h4>
<ul id="file-list" class="file-list">
<li>Loading files...</li>
</ul>
</section>
<!-- Account Deletion Section -->
<section id="account-deletion" class="article--bordered auth-only">
<h3>Account Deletion</h3>
<p>This action is irreversible and will permanently remove:</p>
<ul>
<li>Your account information</li>
<li>All uploaded audio files</li>
</ul>
<div class="centered-container">
<button id="delete-account-from-privacy" class="button">
🗑️ Delete My Account
</button>
</div>
</section>
</section>
<div id="spinner" class="spinner"></div>
<!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden>
<article>
<h2>Terms of Service</h2>
<p>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.</p>
<section id="terms-page" class="always-visible">
<h2>Terms of Service</h2>
<article class="article--bordered">
<div class="alert alert-warning">
<strong>Beta Testing Notice:</strong> This service is currently in public beta. As such, you may encounter bugs or unexpected behavior.
Updates to the service may cause data loss. Please report any issues or suggestions to help us improve.
</div>
<p>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.</p>
<ul>
<li>You must be at least 18 years old to register.</li>
<li>Each account must be unique and used by only one person.</li>
@ -76,38 +111,40 @@
<li>The associated email address will be banned from recreating an account.</li>
<li>Uploads are limited to 100 MB and must be voice only.</li>
<li>Music/singing will be rejected.</li>
<li>This is a beta service; data may be lost during updates or maintenance.</li>
<li>Please report any bugs or suggestions to help improve the service.</li>
</ul>
</article>
</section>
<section id="privacy-page" hidden>
<article>
<section id="privacy-page" class="always-visible">
<div>
<h2>Privacy Policy</h2>
</div>
<article class="article--bordered">
<ul>
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
<li>We log IP + UID only for abuse protection and quota enforcement.</li>
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
<li>Data is never sold. Contact us for account deletion.</li>
<li>Data is never sold.</li>
</ul>
</article>
<!-- Guest login message removed as per user request -->
</section>
<section id="imprint-page" hidden>
<article>
<h2>Imprint</h2>
<section id="imprint-page" class="always-visible">
<h2>Imprint</h2>
<article class="article--bordered">
<p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
</article>
</section>
<section id="welcome-page">
<article>
<h2>Welcome</h2>
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br>
<section id="welcome-page" class="always-visible">
<h2>Welcome</h2>
<article class="article--bordered">
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <span class="text-muted">(Opus | Mono | 48kHz | 60kbps)</span><br><br>
<strong>What you can do here:</strong></p>
<ul>
<li>🎧 Listen to public voice streams from others, instantly</li>
@ -115,50 +152,43 @@
<li>🕵️ No sign-up required for listening</li>
<li>🔒 Optional registration for uploading and managing your own stream</li>
</ul>
<div class="email-section">
<a href="mailto:Andreas.Fleckl@dicta2stream.net" class="button">
Andreas.Fleckl@dicta2stream.net
</a>
</div>
</article>
</section>
<section id="stream-page" hidden>
<article>
<h2>🎧 Public Streams</h2>
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
<ul id="stream-list"><li>Loading...</li></ul>
</article>
<section id="stream-page" class="always-visible">
<h2>Public Streams</h2>
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
<ul id="stream-list"><li>Loading...</li></ul>
</section>
<section id="register-page" hidden>
<article>
<h2>Login or Register</h2>
<section id="register-page" class="guest-only">
<h2>Account</h2>
<article class="article--wide">
<form id="register-form">
<p><label>Email<br><input type="email" name="email" required /></label></p>
<p><label>Username<br><input type="text" name="user" required /></label></p>
<p style="display: none;">
<p class="bot-trap">
<label>Leave this empty:<br>
<input type="text" name="bot_trap" autocomplete="off" />
</label>
</p>
<p><button type="submit">Create Account</button></p>
<p><button type="submit">Login / Create Account</button></p>
</form>
<p><small>Youll receive a magic login link via email. No password required.</small></p>
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
<p class="form-note">You'll receive a magic login link via email. No password required.</p>
</article>
</section>
<section id="quota-meter" hidden>
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
</section>
</main>
<footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p class="footer-links">
<a href="#" id="footer-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy Policy</a> |
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
</p>
</footer>
@ -189,5 +219,8 @@
}
}
</script>
<script type="module" src="/static/init-personal-stream.js"></script>
<!-- Temporary fix for mobile navigation -->
<script src="/static/fix-nav.js"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
// Initialize the personal stream play button with the user's UID
document.addEventListener('DOMContentLoaded', () => {
// Function to update the play button with UID
function updatePersonalStreamPlayButton() {
const playButton = document.querySelector('#me-page .play-pause-btn');
const streamPlayer = document.querySelector('#me-page .stream-player');
if (!playButton || !streamPlayer) return;
// Get UID from localStorage or cookie
const uid = localStorage.getItem('uid') || getCookie('uid');
if (uid) {
// Show the player and set the UID if not already set
streamPlayer.style.display = 'block';
if (!playButton.dataset.uid) {
playButton.dataset.uid = uid;
}
} else {
// Hide the player for guests
streamPlayer.style.display = 'none';
}
}
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Initial update
updatePersonalStreamPlayButton();
// Also update when auth state changes (e.g., after login)
document.addEventListener('authStateChanged', updatePersonalStreamPlayButton);
});

184
static/inject-nav.js Normal file
View File

@ -0,0 +1,184 @@
// inject-nav.js - Handles dynamic injection and management of navigation elements
import { showOnly } from './router.js';
// Function to set up guest navigation links
function setupGuestNav() {
const guestDashboard = document.getElementById('guest-dashboard');
if (!guestDashboard) return;
const links = guestDashboard.querySelectorAll('a');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href')?.substring(1); // Remove '#'
if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
});
}
// Function to set up user navigation links
function setupUserNav() {
const userDashboard = document.getElementById('user-dashboard');
if (!userDashboard) return;
const links = userDashboard.querySelectorAll('a');
links.forEach(link => {
// Handle logout specially
if (link.getAttribute('href') === '#logout') {
link.addEventListener('click', (e) => {
e.preventDefault();
if (window.handleLogout) {
window.handleLogout();
}
});
} else {
// Handle regular navigation
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href')?.substring(1); // Remove '#'
if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
}
});
}
function createUserNav() {
const nav = document.createElement('div');
nav.className = 'dashboard-nav';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'User navigation');
const navList = document.createElement('ul');
navList.className = 'nav-list';
const links = [
{ id: 'user-stream', target: 'your-stream', text: 'Your Stream' },
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' },
{ id: 'user-logout', target: 'logout', text: 'Logout' }
];
// Create and append links
links.forEach((link) => {
const li = document.createElement('li');
li.className = 'nav-item';
const a = document.createElement('a');
a.id = link.id;
a.href = '#';
a.className = 'nav-link';
a.setAttribute('data-target', link.target);
a.textContent = link.text;
a.addEventListener('click', (e) => {
e.preventDefault();
const target = e.currentTarget.getAttribute('data-target');
if (target === 'logout') {
if (window.handleLogout) {
window.handleLogout();
}
} else if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
}
});
li.appendChild(a);
navList.appendChild(li);
});
nav.appendChild(navList);
return nav;
}
// Navigation injection function
export function injectNavigation(isAuthenticated = false) {
// Get the appropriate dashboard element based on auth state
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
if (isAuthenticated) {
// Show user dashboard, hide guest dashboard
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'block';
document.body.classList.add('authenticated');
document.body.classList.remove('guest-mode');
} else {
// Show guest dashboard, hide user dashboard
if (guestDashboard) guestDashboard.style.display = 'block';
if (userDashboard) userDashboard.style.display = 'none';
document.body.classList.add('guest-mode');
document.body.classList.remove('authenticated');
}
// Set up menu links and active state
setupMenuLinks();
updateActiveNav();
return isAuthenticated ? userDashboard : guestDashboard;
}
// Set up menu links with click handlers
function setupMenuLinks() {
// Set up guest and user navigation links
setupGuestNav();
setupUserNav();
// Handle hash changes for SPA navigation
window.addEventListener('hashchange', updateActiveNav);
}
// Update active navigation link
function updateActiveNav() {
const currentHash = window.location.hash.substring(1) || 'welcome';
// Remove active class from all links in both dashboards
document.querySelectorAll('#guest-dashboard a, #user-dashboard a').forEach(link => {
link.classList.remove('active');
// Check if this link's href matches the current hash
const linkTarget = link.getAttribute('href')?.substring(1); // Remove '#'
if (linkTarget === currentHash) {
link.classList.add('active');
}
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Check authentication state and initialize navigation
const isAuthenticated = document.cookie.includes('sessionid=') ||
localStorage.getItem('isAuthenticated') === 'true';
// Initialize navigation based on authentication state
injectNavigation(isAuthenticated);
// Set up menu links and active navigation
setupMenuLinks();
updateActiveNav();
// Update body classes based on authentication state
if (isAuthenticated) {
document.body.classList.add('authenticated');
document.body.classList.remove('guest-mode');
} else {
document.body.classList.add('guest-mode');
document.body.classList.remove('authenticated');
}
console.log('[NAV] Navigation initialized', { isAuthenticated });
});
// Make the function available globally for debugging
window.injectNavigation = injectNavigation;

View File

@ -27,10 +27,17 @@ export async function initMagicLogin() {
const url = new URL(res.url);
const confirmedUid = url.searchParams.get('confirmed_uid');
if (confirmedUid) {
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/";
// Set localStorage for SPA session logic instantly
// Generate a simple auth token (in a real app, this would come from the server)
const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(confirmedUid)}; path=/; SameSite=Lax`;
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
// Store in localStorage for client-side access
localStorage.setItem('uid', confirmedUid);
localStorage.setItem('confirmed_uid', confirmedUid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString());
}
window.location.href = res.url;
@ -42,14 +49,34 @@ export async function initMagicLogin() {
if (contentType && contentType.includes('application/json')) {
data = await res.json();
if (data && data.confirmed_uid) {
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/";
// Set localStorage for SPA session logic
// Generate a simple auth token (in a real app, this would come from the server)
const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(data.confirmed_uid)}; path=/; SameSite=Lax`;
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
// Store in localStorage for client-side access
localStorage.setItem('uid', data.confirmed_uid);
localStorage.setItem('confirmed_uid', data.confirmed_uid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString());
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
// Optionally reload or navigate
setTimeout(() => location.reload(), 700);
import('./toast.js').then(({ showToast }) => {
showToast('✅ Login successful!');
// Update UI state after login
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';
// Show the user's stream page
if (window.showOnly) {
window.showOnly('me-page');
}
});
return;
}
alert(data.detail || 'Login failed.');

522
static/mobile.css Normal file
View File

@ -0,0 +1,522 @@
/* Mobile-specific styles for screens up to 959px */
@media (max-width: 959px) {
/* Base layout adjustments */
html {
height: 100%;
min-height: 100%;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
font-size: 16px;
overflow-x: hidden;
width: 100%;
max-width: 100%;
background: transparent !important;
}
main {
padding: 0.5rem 1rem;
margin: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
box-shadow: none;
border: none;
background: none;
}
* {
box-sizing: border-box;
}
/* Mobile navigation - Enhanced with more specific selectors */
/* Show user dashboard only when authenticated */
body.authenticated #user-dashboard.dashboard-nav,
html body.authenticated #user-dashboard.dashboard-nav,
body.authenticated #user-dashboard.dashboard-nav:not(.hidden) {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
position: relative !important;
clip: auto !important;
}
/* Hide guest dashboard when authenticated - with more specific selectors */
body.authenticated #guest-dashboard.dashboard-nav,
html body.authenticated #guest-dashboard.dashboard-nav,
body.authenticated #guest-dashboard.dashboard-nav:not(.visible) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
height: 0 !important;
width: 0 !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
position: absolute !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
}
/* Show guest dashboard when not authenticated - with more specific selectors */
body:not(.authenticated) #guest-dashboard.dashboard-nav,
html body:not(.authenticated) #guest-dashboard.dashboard-nav,
body:not(.authenticated) #guest-dashboard.dashboard-nav:not(.hidden) {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
position: relative !important;
}
/* Ensure user dashboard is hidden when not authenticated */
body:not(.authenticated) #user-dashboard.dashboard-nav {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
height: 0 !important;
}
.dashboard-nav {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
margin-bottom: 1rem;
}
.dashboard-nav a {
padding: 0.5rem 0.25rem;
text-align: center;
font-size: 0.9rem;
color: var(--text-color);
text-decoration: none;
flex: 1;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.dashboard-nav a:hover,
.dashboard-nav a:focus {
background-color: var(--hover-bg);
outline: none;
}
/* Account Deletion Section */
#privacy-page.active #account-deletion,
#privacy-page:not(.active) #account-deletion {
display: block !important;
opacity: 1 !important;
position: relative !important;
clip: auto !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
.account-deletion-section {
margin: 2rem 0;
padding: 1.75rem;
background: rgba(26, 26, 26, 0.8);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.account-deletion-section h3 {
color: #fff;
font-size: 1.5rem;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.account-deletion-section h3 {
color: #fff;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.account-deletion-section ul {
margin: 1.5rem 0 2rem 1.5rem;
padding-left: 0.5rem;
}
.account-deletion-section li {
margin-bottom: 0.75rem;
color: #f0f0f0;
line-height: 1.5;
position: relative;
padding-left: 1.5rem;
}
.account-deletion-section li:before {
content: '•';
color: #ff5e57;
font-weight: bold;
font-size: 1.5rem;
position: absolute;
left: 0;
top: -0.25rem;
}
.danger-button {
background: linear-gradient(135deg, #ff3b30, #ff5e57);
color: white;
border: none;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
width: 100%;
max-width: 300px;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(255, 59, 48, 0.3);
text-align: center;
}
.danger-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 59, 48, 0.4);
}
.danger-button:active {
transform: translateY(0);
}
.text-link {
color: #4dabf7;
text-decoration: none;
transition: color 0.2s ease;
}
.text-link:hover {
color: #74c0fc;
text-decoration: underline;
}
/* Hide desktop navigation in mobile */
nav.dashboard-nav {
display: none;
}
header {
padding: 0.5rem 1rem;
}
header h1 {
font-size: 1.8rem;
margin: 0.5rem 0;
}
header p {
font-size: 1rem;
margin: 0.25rem 0 1rem;
}
.dashboard-nav {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
text-align: center;
font-size: 0.9rem;
}
.dashboard-nav a {
padding: 0.5rem;
margin: 0 0.25rem;
display: inline-block;
}
main > section {
width: 100%;
max-width: 100%;
padding: 1rem;
box-sizing: border-box;
}
.btn {
width: 100%;
height: 48px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
font-size: 1rem;
box-sizing: border-box;
}
.audio-player {
width: 100%;
margin: 1rem 0;
}
.audio-controls {
flex-direction: column;
}
.audio-controls button {
margin: 0.25rem 0;
}
.dropzone {
padding: 2rem;
}
#quota-meter {
max-width: 600px;
width: 100%;
margin: 1rem auto;
padding: 0 1rem;
box-sizing: border-box;
}
.quota-meter {
height: 20px;
}
/* Stream item styles moved to .stream-player */
.stream-item {
padding: 0;
margin: 0;
border: none;
}
.modal-content {
width: 90%;
max-width: 90%;
}
footer {
padding: 1rem;
}
.footer-hint {
font-size: 0.9rem;
}
.desktop-only {
display: none !important;
}
#burger-label {
display: block;
}
section#links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1e1e1e;
z-index: 1000;
}
#burger-toggle:checked + #burger-label + section#links {
display: block;
}
/* Make sure all interactive elements are touch-friendly */
a, [role="button"], label, select, textarea {
min-height: 44px;
min-width: 44px;
}
.dropzone {
padding: 1.5rem;
margin: 1rem 0;
}
.dropzone p {
font-size: 1rem;
margin: 0.5rem 0;
}
/* Adjust header text for better mobile display */
header h1 {
font-size: 1.5rem;
}
header p {
font-size: 0.9rem;
}
.dashboard-nav {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding: 0.5rem 0;
}
.dashboard-nav::-webkit-scrollbar {
display: none;
}
nav.dashboard-nav a {
all: unset;
display: inline-block;
background-color: var(--surface);
color: var(--text-color);
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
min-width: 100px;
box-sizing: border-box;
transition: background-color 0.2s;
}
.dashboard-nav a:active {
background-color: var(--border);
}
/* Stream page specific styles */
#stream-page {
padding: 0.5rem;
}
#stream-page h2 {
font-size: 1.5rem;
}
#stream-page article {
padding: 1rem;
margin: 0.5rem 0;
}
#stream-list {
padding: 0 1rem;
margin: 0 auto;
max-width: 600px;
width: 100%;
box-sizing: border-box;
}
#stream-list li {
margin: 0;
padding: 0;
border: none;
background: transparent;
list-style: none;
}
.stream-player {
padding: 0.75rem;
}
.stream-player h3 {
font-size: 1.1rem;
}
.stream-info {
font-size: 0.9rem;
}
/* Stream list items are now handled by the rules above */
/* User upload area - matches article styling */
#user-upload-area {
margin: 2rem auto;
padding: 1.6875rem;
background: var(--surface);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
cursor: pointer;
max-width: 600px;
width: 100%;
box-sizing: border-box;
color: var(--text-color);
}
#user-upload-area p {
margin: 0.5rem 0;
}
/* Stream player adjustments */
.stream-player {
padding: 1rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 8px;
background-color: #1e1e1e;
}
.stream-player h3 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
.stream-info {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stream-audio {
width: 100%;
}
/* Form elements */
input[type="text"],
input[type="email"],
input[type="password"],
textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
padding: 0.75rem;
margin: 0.5rem 0;
font-size: 1rem;
border-radius: 4px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #f0f0f0;
}
/* Firefox mobile specific fixes */
@-moz-document url-prefix() {
input[type="email"] {
min-height: 2.5rem;
appearance: none;
}
}
/* Adjust audio element for mobile */
audio {
width: 100% !important;
max-width: 100% !important;
}
/* Toast notifications */
.toast {
width: 90%;
max-width: 100%;
left: 5%;
right: 5%;
transform: none;
margin: 0 auto;
}
}

View File

@ -8,31 +8,274 @@ function getCookie(name) {
}
document.addEventListener("DOMContentLoaded", () => {
const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
showOnly(id) {
this.sections.forEach(sec => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
});
// Show user-upload-area only when me-page is shown and user is logged in
const userUpload = document.getElementById("user-upload-area");
if (userUpload) {
const uid = getCookie("uid");
userUpload.style.display = (id === "me-page" && uid) ? '' : 'none';
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
},
init() {
initNavLinks();
initBackButtons();
// Check authentication status
const isLoggedIn = !!getCookie('uid');
initStreamLinks();
// Update body class for CSS-based visibility
document.body.classList.toggle('logged-in', isLoggedIn);
// Get all main content sections
const mainSections = Array.from(document.querySelectorAll('main > section'));
// Show/hide sections with smooth transitions
const showSection = (sectionId) => {
// Update body class to indicate current page
document.body.className = '';
if (sectionId) {
document.body.classList.add(`page-${sectionId}`);
} else {
document.body.classList.add('page-welcome');
}
// Update active state of navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
link.classList.remove('active');
if ((!sectionId && link.getAttribute('href') === '#welcome-page') ||
(sectionId && link.getAttribute('href') === `#${sectionId}`)) {
link.classList.add('active');
}
});
mainSections.forEach(section => {
// Skip navigation sections
if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') {
return;
}
const isTarget = section.id === sectionId;
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
const isWelcomePage = !sectionId || sectionId === 'welcome-page';
if (isTarget || (isLegalPage && section.id === sectionId)) {
// Show the target section or legal page
section.classList.add('active');
section.hidden = false;
// Focus the section for accessibility with a small delay
// Only focus if the section is focusable and in the viewport
const focusSection = () => {
try {
if (section && typeof section.focus === 'function' &&
section.offsetParent !== null && // Check if element is visible
section.getBoundingClientRect().top < window.innerHeight &&
section.getBoundingClientRect().bottom > 0) {
section.focus({ preventScroll: true });
}
} catch (e) {
// Silently fail if focusing isn't possible
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
console.debug('Could not focus section:', e);
}
}
};
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => {
// Only set the timeout in debug mode or local development
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
setTimeout(focusSection, 50);
} else {
focusSection();
}
});
} else if (isWelcomePage && section.id === 'welcome-page') {
// Special handling for welcome page
section.classList.add('active');
section.hidden = false;
} else {
// Hide other sections
section.classList.remove('active');
section.hidden = true;
}
});
// Update URL hash without page scroll
if (sectionId && !['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId)) {
if (sectionId === 'welcome-page') {
history.replaceState(null, '', window.location.pathname);
} else {
history.replaceState(null, '', `#${sectionId}`);
}
}
};
const showOnly = Router.showOnly.bind(Router);
// Handle initial page load
const getValidSection = (sectionId) => {
const protectedSections = ['me-page', 'register-page'];
// If not logged in and trying to access protected section
if (!isLoggedIn && protectedSections.includes(sectionId)) {
return 'welcome-page';
}
// If section doesn't exist, default to welcome page
if (!document.getElementById(sectionId)) {
return 'welcome-page';
}
return sectionId;
};
// Process initial page load
const initialPage = window.location.hash.substring(1) || 'welcome-page';
const validSection = getValidSection(initialPage);
// Update URL if needed
if (validSection !== initialPage) {
window.location.hash = validSection;
}
// Show the appropriate section
showSection(validSection);
const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
showOnly(id) {
// Validate the section ID
const validId = getValidSection(id);
// Update URL if needed
if (validId !== id) {
window.location.hash = validId;
return;
}
// Show the requested section
showSection(validId);
// Handle the quota meter visibility - only show with 'me-page'
const quotaMeter = document.getElementById('quota-meter');
if (quotaMeter) {
quotaMeter.hidden = validId !== 'me-page';
quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1;
}
// Update navigation active states
this.updateActiveNav(validId);
},
updateActiveNav(activeId) {
// Update active states for navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
const target = link.getAttribute('href').substring(1);
if (target === activeId) {
link.setAttribute('aria-current', 'page');
link.classList.add('active');
} else {
link.removeAttribute('aria-current');
link.classList.remove('active');
}
});
}
};
// Initialize the router
const router = Router;
// Handle section visibility based on authentication
const updateSectionVisibility = (sectionId) => {
const section = document.getElementById(sectionId);
if (!section) return;
// Skip navigation sections and quota meter
if (['guest-dashboard', 'user-dashboard', 'quota-meter'].includes(sectionId)) {
return;
}
const currentHash = window.location.hash.substring(1);
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
// Special handling for legal pages - always show when in hash
if (isLegalPage) {
const isActive = sectionId === currentHash;
section.hidden = !isActive;
section.tabIndex = isActive ? 0 : -1;
if (isActive) section.focus();
return;
}
// Special handling for me-page - only show to authenticated users
if (sectionId === 'me-page') {
section.hidden = !isLoggedIn || currentHash !== 'me-page';
section.tabIndex = (isLoggedIn && currentHash === 'me-page') ? 0 : -1;
return;
}
// Special handling for register page - only show to guests
if (sectionId === 'register-page') {
section.hidden = isLoggedIn || currentHash !== 'register-page';
section.tabIndex = (!isLoggedIn && currentHash === 'register-page') ? 0 : -1;
return;
}
// For other sections, show if they match the current section ID
const isActive = sectionId === currentHash;
section.hidden = !isActive;
section.tabIndex = isActive ? 0 : -1;
if (isActive) {
section.focus();
}
};
// Initialize the router
router.init = function() {
// Update visibility for all sections
this.sections.forEach(section => {
updateSectionVisibility(section.id);
});
// Show user-upload-area only when me-page is shown and user is logged in
const userUpload = document.getElementById("user-upload-area");
if (userUpload) {
const uid = getCookie("uid");
userUpload.style.display = (window.location.hash === '#me-page' && uid) ? '' : 'none';
}
// Store the current page
localStorage.setItem("last_page", window.location.hash.substring(1));
// Initialize navigation
initNavLinks();
initBackButtons();
initStreamLinks();
// Ensure proper focus management for accessibility
const currentSection = document.querySelector('main > section:not([hidden])');
if (currentSection) {
currentSection.setAttribute('tabindex', '0');
currentSection.focus();
}
};
// Initialize the router
router.init();
// Handle footer links
document.querySelectorAll('.footer-links a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.target;
if (target) {
// Update URL hash to maintain proper history state
window.location.hash = target;
// Use the router to handle the navigation
if (router && typeof router.showOnly === 'function') {
router.showOnly(target);
} else {
// Fallback to showSection if router is not available
showSection(target);
}
}
});
});
// Export the showOnly function for global access
window.showOnly = router.showOnly.bind(router);
// Make router available globally for debugging
window.appRouter = router;
// Highlight active profile link on browser back/forward navigation
function highlightActiveProfileLink() {
@ -49,6 +292,15 @@ document.addEventListener("DOMContentLoaded", () => {
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get('profile');
const currentPage = window.location.hash.substring(1) || 'welcome-page';
// Prevent unauthorized access to me-page
if ((currentPage === 'me-page' || profileUid) && !getCookie('uid')) {
history.replaceState(null, '', '#welcome-page');
showOnly('welcome-page');
return;
}
if (profileUid) {
showOnly('me-page');
if (typeof window.showProfilePlayerFromUrl === 'function') {
@ -196,5 +448,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
// Initialize Router
document.addEventListener('visibilitychange', () => {
// Re-check authentication when tab becomes visible again
if (!document.hidden && window.location.hash === '#me-page' && !getCookie('uid')) {
window.location.hash = 'welcome-page';
showOnly('welcome-page');
}
});
Router.init();
});

View File

@ -1,15 +1,168 @@
// static/router.js — core routing for SPA navigation
export const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
sections: [],
// Map URL hashes to section IDs
sectionMap: {
'welcome': 'welcome-page',
'streams': 'stream-page',
'account': 'register-page',
'login': 'login-page',
'me': 'me-page',
'your-stream': 'me-page' // Map 'your-stream' to 'me-page'
},
init() {
this.sections = Array.from(document.querySelectorAll("main > section"));
// Set up hash change handler
window.addEventListener('hashchange', this.handleHashChange.bind(this));
// Initial route
this.handleHashChange();
},
handleHashChange() {
let hash = window.location.hash.substring(1) || 'welcome';
// First check if the hash matches any direct section ID
const directSection = this.sections.find(sec => sec.id === hash);
if (directSection) {
// If it's a direct section ID match, show it directly
this.showOnly(hash);
} else {
// Otherwise, use the section map
const sectionId = this.sectionMap[hash] || hash;
this.showOnly(sectionId);
}
},
showOnly(id) {
this.sections.forEach(sec => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
if (!id) return;
// Update URL hash without triggering hashchange
if (window.location.hash !== `#${id}`) {
window.history.pushState(null, '', `#${id}`);
}
const isAuthenticated = document.body.classList.contains('authenticated');
const isMePage = id === 'me-page' || id === 'your-stream';
// Helper function to update section visibility
const updateSection = (sec) => {
const isTarget = sec.id === id;
const isGuestOnly = sec.classList.contains('guest-only');
const isAuthOnly = sec.classList.contains('auth-only');
const isAlwaysVisible = sec.classList.contains('always-visible');
const isQuotaMeter = sec.id === 'quota-meter';
const isUserUploadArea = sec.id === 'user-upload-area';
const isLogOut = sec.id === 'log-out';
// Determine if section should be visible
let shouldShow = isTarget;
// Always show sections with always-visible class
if (isAlwaysVisible) {
shouldShow = true;
}
// Handle guest-only sections
if (isGuestOnly && isAuthenticated) {
shouldShow = false;
}
// Handle auth-only sections
if (isAuthOnly && !isAuthenticated) {
shouldShow = false;
}
// Special case for me-page and its children
const isChildOfMePage = sec.closest('#me-page') !== null;
const shouldBeActive = isTarget ||
(isQuotaMeter && isMePage) ||
(isUserUploadArea && isMePage) ||
(isLogOut && isMePage) ||
(isChildOfMePage && isMePage);
// Update visibility and tab index
sec.hidden = !shouldShow;
sec.tabIndex = shouldShow ? 0 : -1;
// Update active state and ARIA attributes
if (shouldBeActive) {
sec.setAttribute('aria-current', 'page');
sec.classList.add('active');
// Ensure target section is visible
if (sec.hidden) {
sec.style.display = 'block';
sec.hidden = false;
}
// Show all children of the active section
if (isTarget) {
sec.focus();
// Make sure all auth-only children are visible
const authChildren = sec.querySelectorAll('.auth-only');
authChildren.forEach(child => {
if (isAuthenticated) {
child.style.display = '';
child.hidden = false;
}
});
}
} else {
sec.removeAttribute('aria-current');
sec.classList.remove('active');
// Reset display property for sections when not active
if (shouldShow && !isAlwaysVisible) {
sec.style.display = ''; // Reset to default from CSS
}
}
};
// Update all sections
this.sections.forEach(updateSection);
// Update active nav links
document.querySelectorAll('[data-target], [href^="#"]').forEach(link => {
let target = link.getAttribute('data-target');
const href = link.getAttribute('href');
// If no data-target, try to get from href
if (!target && href) {
// Remove any query parameters and # from the href
const hash = href.split('?')[0].substring(1);
// Use mapped section ID or the hash as is
target = this.sectionMap[hash] || hash;
}
// Check if this link points to the current section or its mapped equivalent
const linkId = this.sectionMap[target] || target;
const currentId = this.sectionMap[id] || id;
if (linkId === currentId) {
link.setAttribute('aria-current', 'page');
link.classList.add('active');
} else {
link.removeAttribute('aria-current');
link.classList.remove('active');
}
});
// Close mobile menu if open
const menuToggle = document.querySelector('.menu-toggle');
if (menuToggle && menuToggle.getAttribute('aria-expanded') === 'true') {
menuToggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('menu-open');
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
}
};
// Initialize router when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
Router.init();
});
export const showOnly = Router.showOnly.bind(Router);

View File

@ -1,8 +1,20 @@
// static/streams-ui.js — public streams loader and profile-link handling
import { showOnly } from './router.js';
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
// Global variable to track if we should force refresh the stream list
let shouldForceRefresh = false;
// Function to refresh the stream list
window.refreshStreamList = function(force = true) {
shouldForceRefresh = force;
loadAndRenderStreams();
return new Promise((resolve) => {
// Resolve after a short delay to allow the stream list to update
setTimeout(resolve, 500);
});
};
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
export function initStreamsUI() {
initStreamLinks();
@ -24,154 +36,353 @@ function maybeLoadStreamsOnShow() {
}
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
// Global variables for audio control
let currentlyPlayingAudio = null;
let currentlyPlayingButton = null;
document.addEventListener('DOMContentLoaded', initStreamsUI);
// Global variable to track the active SSE connection
let activeSSEConnection = null;
// Global cleanup function for SSE connections
const cleanupConnections = () => {
if (window._streamsSSE) {
if (window._streamsSSE.abort) {
window._streamsSSE.abort();
}
window._streamsSSE = null;
}
if (window.connectionTimeout) {
clearTimeout(window.connectionTimeout);
window.connectionTimeout = null;
}
activeSSEConnection = null;
};
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initStreamsUI();
// Also try to load streams immediately in case the page is already loaded
setTimeout(() => {
loadAndRenderStreams();
}, 100);
});
function loadAndRenderStreams() {
const ul = document.getElementById('stream-list');
if (!ul) {
console.warn('[streams-ui] #stream-list not found in DOM');
console.error('[STREAMS-UI] Stream list element not found');
return;
}
console.debug('[streams-ui] loadAndRenderStreams (SSE mode) called');
ul.innerHTML = '<li>Loading...</li>';
console.log('[STREAMS-UI] loadAndRenderStreams called, shouldForceRefresh:', shouldForceRefresh);
// Don't start a new connection if one is already active and we're not forcing a refresh
if (activeSSEConnection && !shouldForceRefresh) {
return;
}
// If we're forcing a refresh, clean up the existing connection
if (shouldForceRefresh && activeSSEConnection) {
// Clean up any existing connections
cleanupConnections();
shouldForceRefresh = false; // Reset the flag after handling
}
// Clear any existing error messages or retry buttons
ul.innerHTML = '<li>Loading public streams...</li>';
// Add a timestamp to prevent caching issues
const timestamp = new Date().getTime();
// Use the same protocol as the current page to avoid mixed content issues
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}/streams-sse?t=${timestamp}`;
let gotAny = false;
let streams = [];
// Close previous EventSource if any
if (window._streamsSSE) {
window._streamsSSE.close();
window.connectionTimeout = null;
// Clean up any existing connections
cleanupConnections();
// Reset the retry count if we have a successful connection
window.streamRetryCount = 0;
if (window.connectionTimeout) {
clearTimeout(window.connectionTimeout);
window.connectionTimeout = null;
}
const evtSource = new window.EventSource('/streams-sse');
window._streamsSSE = evtSource;
evtSource.onmessage = function(event) {
console.debug('[streams-ui] SSE event received:', event.data);
try {
const data = JSON.parse(event.data);
if (data.end) {
if (!gotAny) {
ul.innerHTML = '<li>No active streams.</li>';
// Use fetch with ReadableStream for better CORS handling
const controller = new AbortController();
const signal = controller.signal;
// Store the controller for cleanup
window._streamsSSE = controller;
// Track the active connection
activeSSEConnection = controller;
// Set a connection timeout with debug info
const connectionStartTime = Date.now();
const connectionTimeoutId = setTimeout(() => {
if (!gotAny) {
// Only log in development (localhost) or if explicitly enabled
const isLocalDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (isLocalDevelopment || window.DEBUG_STREAMS) {
const duration = Date.now() - connectionStartTime;
console.group('[streams-ui] Connection timeout reached');
console.log(`Duration: ${duration}ms`);
console.log('Current time:', new Date().toISOString());
console.log('Streams received:', streams.length);
console.log('Active intervals:', window.activeIntervals ? window.activeIntervals.size : 'N/A');
console.log('Active timeouts:', window.activeTimeouts ? window.activeTimeouts.size : 'N/A');
console.groupEnd();
}
// Clean up and retry with backoff
controller.abort();
// Only retry if we haven't exceeded max retries
const retryCount = window.streamRetryCount || 0;
if (retryCount < 3) { // Max 3 retries
window.streamRetryCount = retryCount + 1;
const backoffTime = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
setTimeout(loadAndRenderStreams, backoffTime);
}
}
}, 15000); // 15 second timeout (increased from 10s)
// Store the timeout ID for cleanup
window.connectionTimeout = connectionTimeoutId;
// Make the fetch request with proper error handling
fetch(sseUrl, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
credentials: 'same-origin',
signal: signal,
mode: 'cors',
redirect: 'follow'
})
.then(response => {
if (!response.ok) {
// Try to get the response text for error details
return response.text().then(text => {
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText, body: text };
throw error;
}).catch(() => {
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText };
throw error;
});
}
if (!response.body) {
throw new Error('Response body is null or undefined');
}
// Get the readable stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Process the stream
function processStream({ done, value }) {
console.log('[STREAMS-UI] processStream called with done:', done);
if (done) {
console.log('[STREAMS-UI] Stream processing complete');
// Process any remaining data in the buffer
if (buffer.trim()) {
console.log('[STREAMS-UI] Processing remaining buffer data');
try {
const data = JSON.parse(buffer);
console.log('[STREAMS-UI] Parsed data from buffer:', data);
processSSEEvent(data);
} catch (e) {
console.error('[STREAMS-UI] Error parsing buffer data:', e);
}
}
evtSource.close();
highlightActiveProfileLink();
return;
}
// Remove Loading... on any valid event
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
streams.push(data);
const uid = data.uid || '';
const sizeMb = data.size ? (data.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = data.mtime ? new Date(data.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
const li = document.createElement('li');
li.innerHTML = `
<article class="stream-player">
<h3>${uid}</h3>
<audio id="audio-${uid}" class="stream-audio" preload="auto" crossOrigin="anonymous" src="/audio/${encodeURIComponent(uid)}/stream.opus"></audio>
<div class="audio-controls">
<button id="play-pause-${uid}">▶</button>
</div>
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
// Add play/pause handler after appending to DOM
ul.appendChild(li);
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Wait for DOM update
requestAnimationFrame(() => {
const playPauseButton = document.getElementById(`play-pause-${uid}`);
const audio = document.getElementById(`audio-${uid}`);
// Process complete events in the buffer
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep incomplete event in buffer
for (const event of events) {
if (!event.trim()) continue;
if (playPauseButton && audio) {
playPauseButton.addEventListener('click', () => {
try {
if (audio.paused) {
// Stop any currently playing audio first
if (currentlyPlayingAudio && currentlyPlayingAudio !== audio) {
currentlyPlayingAudio.pause();
if (currentlyPlayingButton) {
currentlyPlayingButton.textContent = '▶';
}
}
// Stop the main player if it's playing
if (typeof window.stopMainAudio === 'function') {
window.stopMainAudio();
}
audio.play().then(() => {
playPauseButton.textContent = '⏸️';
currentlyPlayingAudio = audio;
currentlyPlayingButton = playPauseButton;
}).catch(e => {
console.error('Play failed:', e);
// Reset button if play fails
playPauseButton.textContent = '▶';
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
});
} else {
audio.pause();
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
} catch (e) {
console.error('Audio error:', e);
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
// Extract data field from SSE format
const dataMatch = event.match(/^data: (\{.*\})$/m);
if (dataMatch && dataMatch[1]) {
try {
const data = JSON.parse(dataMatch[1]);
processSSEEvent(data);
} catch (e) {
console.error('[streams-ui] Error parsing event data:', e, 'Event:', event);
}
}
}
// Read the next chunk
return reader.read().then(processStream);
}
// Start reading the stream
return reader.read().then(processStream);
})
.catch(error => {
// Only handle the error if it's not an abort error
if (error.name !== 'AbortError') {
// Clean up the controller reference
window._streamsSSE = null;
activeSSEConnection = null;
// Clear the connection timeout
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
// Show a user-friendly error message
const ul = document.getElementById('stream-list');
if (ul) {
let errorMessage = 'Error loading streams. ';
if (error.message && error.message.includes('Failed to fetch')) {
errorMessage += 'Unable to connect to the server. Please check your internet connection.';
} else if (error.message && error.message.includes('CORS')) {
errorMessage += 'A server configuration issue occurred. Please try again later.';
} else {
errorMessage += 'Please try again later.';
}
ul.innerHTML = `
<li class="error">
<p>${errorMessage}</p>
<button id="retry-loading" class="retry-button">
<span class="retry-icon">↻</span> Try Again
</button>
</li>
`;
// Add retry handler
const retryButton = document.getElementById('retry-loading');
if (retryButton) {
retryButton.addEventListener('click', () => {
ul.innerHTML = '<li>Loading streams...</li>';
loadAndRenderStreams();
});
}
}
}
});
// Function to process SSE events
function processSSEEvent(data) {
console.log('[STREAMS-UI] Processing SSE event:', data);
if (data.end) {
if (streams.length === 0) {
ul.innerHTML = '<li>No active streams.</li>';
return;
}
// Sort streams by mtime in descending order (newest first)
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
// Clear the list
ul.innerHTML = '';
// Render each stream in sorted order
streams.forEach((stream, index) => {
const uid = stream.uid || `stream-${index}`;
const sizeMb = stream.size ? (stream.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
const li = document.createElement('li');
li.className = 'stream-item';
try {
li.innerHTML = `
<article class="stream-player" data-uid="${escapeHtml(uid)}">
<h3>${escapeHtml(uid)}</h3>
<div class="audio-controls">
<button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button>
</div>
<p class="stream-info" style='color:var(--text-muted);font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
ul.appendChild(li);
} catch (error) {
const errorLi = document.createElement('li');
errorLi.textContent = `Error loading stream: ${uid}`;
errorLi.style.color = 'var(--error)';
ul.appendChild(errorLi);
}
});
highlightActiveProfileLink();
ul.appendChild(li);
highlightActiveProfileLink();
} catch (e) {
// Remove Loading... even if JSON parse fails, to avoid stuck UI
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
return;
}
// Add stream to our collection
streams.push(data);
// If this is the first stream, clear the loading message
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
}
// Function to handle SSE errors
function handleSSEError(error) {
console.error('[streams-ui] SSE error:', error);
// Only show error if we haven't already loaded any streams
if (streams.length === 0) {
const errorMsg = 'Error connecting to stream server. Please try again.';
ul.innerHTML = `
<li>${errorMsg}</li>
<li><button id="reload-streams" onclick="loadAndRenderStreams()" class="retry-button">🔄 Retry</button></li>
`;
if (typeof showToast === 'function') {
showToast('❌ ' + errorMsg);
}
console.error('[streams-ui] SSE parse error', e, event.data);
// Auto-retry after 5 seconds
setTimeout(() => {
loadAndRenderStreams();
}, 5000);
}
};
}
evtSource.onerror = function(err) {
console.error('[streams-ui] SSE error', err);
ul.innerHTML = '<li>Error loading stream list</li>';
if (typeof showToast === 'function') {
showToast('❌ Error loading public streams.');
}
evtSource.close();
// Add reload button if not present
const reloadButton = document.getElementById('reload-streams');
if (!reloadButton) {
const reloadHtml = '<button id="reload-streams" onclick="loadAndRenderStreams()">Reload</button>';
ul.insertAdjacentHTML('beforeend', reloadHtml);
}
};
// Error and open handlers are now part of the fetch implementation
// Message handling is now part of the fetch implementation
// Error handling is now part of the fetch implementation
}
export function renderStreamList(streams) {
const ul = document.getElementById('stream-list');
if (!ul) {
console.warn('[streams-ui] renderStreamList: #stream-list not found');
console.warn('[STREAMS-UI] renderStreamList: #stream-list not found');
return;
}
console.debug('[streams-ui] Rendering stream list:', streams);
console.log('[STREAMS-UI] Rendering stream list with', streams.length, 'streams');
console.debug('[STREAMS-UI] Streams data:', streams);
if (Array.isArray(streams)) {
if (streams.length) {
// Sort by mtime descending (most recent first)
@ -181,7 +392,7 @@ export function renderStreamList(streams) {
const uid = stream.uid || '';
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:gray;font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:var(--text-muted);font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
})
.join('');
} else {
@ -208,7 +419,6 @@ export function highlightActiveProfileLink() {
}
export function initStreamLinks() {
const ul = document.getElementById('stream-list');
if (!ul) return;
@ -232,3 +442,368 @@ export function initStreamLinks() {
}
});
}
// Helper function to safely escape HTML
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Function to update play/pause button state
function updatePlayPauseButton(button, isPlaying) {
if (!button) return;
button.textContent = isPlaying ? '⏸️' : '▶️';
button.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
// Audio context for Web Audio API
let audioContext = null;
let audioSource = null;
let audioBuffer = null;
let isPlaying = false;
let currentUid = null;
let currentlyPlayingButton = null; // Controls the currently active play/pause button
let startTime = 0;
let pauseTime = 0;
let audioStartTime = 0;
let audioElement = null; // HTML5 Audio element for Opus playback
// Initialize audio context
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
// Stop current playback completely
function stopPlayback() {
console.log('[streams-ui] Stopping playback');
// Stop Web Audio API if active
if (audioSource) {
try {
// Don't try to stop if already stopped
if (audioSource.context && audioSource.context.state !== 'closed') {
audioSource.stop();
audioSource.disconnect();
}
} catch (e) {
// Ignore errors when stopping already stopped sources
if (!e.message.includes('has already been stopped') &&
!e.message.includes('has already finished playing')) {
console.warn('Error stopping audio source:', e);
}
}
audioSource = null;
}
// Stop HTML5 Audio element if active
if (audioElement) {
try {
// Remove all event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and reset the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Clear references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
// Nullify the element to allow garbage collection
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
audioBuffer = null;
isPlaying = false;
startTime = 0;
pauseTime = 0;
audioStartTime = 0;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
// Clear current playing reference
currentlyPlayingAudio = null;
}
// Load and play audio using HTML5 Audio element for Opus
async function loadAndPlayAudio(uid, playPauseBtn) {
// If we already have an audio element for this UID and it's paused, just resume it
if (audioElement && currentUid === uid && audioElement.paused) {
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
return;
} catch (error) {
// Fall through to reload if resume fails
}
}
// Stop any current playback
stopPlayback();
// Update UI
updatePlayPauseButton(playPauseBtn, true);
currentlyPlayingButton = playPauseBtn;
currentUid = uid;
try {
// Create a new audio element with the correct MIME type
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
// Create a new audio element with a small delay to prevent race conditions
await new Promise(resolve => setTimeout(resolve, 50));
audioElement = new Audio(audioUrl);
audioElement.preload = 'auto';
audioElement.crossOrigin = 'anonymous'; // Important for CORS
// Set up event handlers with proper binding
const onPlay = () => {
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
};
const onPause = () => {
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
};
const onEnded = () => {
isPlaying = false;
cleanupAudio();
};
const onError = (e) => {
// Ignore errors from previous audio elements that were cleaned up
if (!audioElement || audioElement.readyState === 0) {
return;
}
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
// Don't show error to user for aborted requests
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
return;
}
// Show error to user for other errors
if (typeof showToast === 'function') {
showToast('Error playing audio. The format may not be supported.', 'error');
}
};
// Add event listeners
audioElement.addEventListener('play', onPlay, { once: true });
audioElement.addEventListener('pause', onPause);
audioElement.addEventListener('ended', onEnded, { once: true });
audioElement.addEventListener('error', onError);
// Store references for cleanup
audioElement._eventHandlers = { onPlay, onPause, onEnded, onError };
// Start playback with error handling
try {
const playPromise = audioElement.play();
if (playPromise !== undefined) {
await playPromise.catch(error => {
// Ignore abort errors when switching between streams
if (error.name !== 'AbortError') {
throw error;
}
});
}
isPlaying = true;
} catch (error) {
// Only log unexpected errors
if (error.name !== 'AbortError') {
console.error('[streams-ui] Error during playback:', error);
throw error;
}
}
} catch (error) {
console.error('[streams-ui] Error loading/playing audio:', error);
if (playPauseBtn) {
updatePlayPauseButton(playPauseBtn, false);
}
// Only show error if it's not an abort error
if (error.name !== 'AbortError' && typeof showToast === 'function') {
showToast('Error playing audio. Please try again.', 'error');
}
}
}
// Handle audio ended event
function handleAudioEnded() {
isPlaying = false;
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
cleanupAudio();
}
// Clean up audio resources
function cleanupAudio() {
console.log('[streams-ui] Cleaning up audio resources');
// Clean up Web Audio API resources if they exist
if (audioSource) {
try {
if (isPlaying) {
audioSource.stop();
}
audioSource.disconnect();
} catch (e) {
console.warn('Error cleaning up audio source:', e);
}
audioSource = null;
}
// Clean up HTML5 Audio element if it exists
if (audioElement) {
try {
// Remove event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and clean up the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Force garbage collection by removing references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
isPlaying = false;
currentUid = null;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
}
// Event delegation for play/pause buttons - only handle buttons within the stream list
const streamList = document.getElementById('stream-list');
if (streamList) {
streamList.addEventListener('click', async (e) => {
const playPauseBtn = e.target.closest('.play-pause-btn');
// Skip if not a play button or if it's the personal stream's play button
if (!playPauseBtn || playPauseBtn.closest('#me-page')) return;
// Prevent event from bubbling up to document-level handlers
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
const uid = playPauseBtn.dataset.uid;
if (!uid) {
return;
}
// If clicking the currently playing button, toggle pause/play
if (currentUid === uid) {
if (isPlaying) {
await audioElement.pause();
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
} else {
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
} catch (error) {
// If resume fails, try reloading the audio
await loadAndPlayAudio(uid, playPauseBtn);
}
}
return;
}
// If a different stream is playing, stop it and start the new one
stopPlayback();
await loadAndPlayAudio(uid, playPauseBtn);
});
}
// Handle audio end event to update button state
document.addEventListener('play', (e) => {
if (e.target.tagName === 'AUDIO' && e.target !== currentlyPlayingAudio) {
if (currentlyPlayingAudio) {
currentlyPlayingAudio.pause();
}
currentlyPlayingAudio = e.target;
// Update the play/pause button state
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
if (currentlyPlayingButton && currentlyPlayingButton !== playBtn) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
updatePlayPauseButton(playBtn, true);
currentlyPlayingButton = playBtn;
}
}
}
}, true);
// Handle audio pause event
document.addEventListener('pause', (e) => {
if (e.target.tagName === 'AUDIO' && e.target === currentlyPlayingAudio) {
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
updatePlayPauseButton(playBtn, false);
}
}
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}, true);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
.success { color: var(--success); }
.error { color: var(--error); }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
.audio-container {
margin: 20px 0;
}
audio {
width: 100%;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Direct Audio Element</h2>
<div class="audio-container">
<audio id="direct-audio" controls>
<source src="/audio/devuser/stream.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
</div>
<div>
<button onclick="document.getElementById('direct-audio').play()">Play</button>
<button onclick="document.getElementById('direct-audio').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="dynamic-audio-container">
<button onclick="setupDynamicAudio()">Initialize Dynamic Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="load-profile-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
<div class="audio-container">
<audio id="profile-audio" controls></audio>
</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupDynamicAudio() {
log('Setting up dynamic audio element...');
const container = document.getElementById('dynamic-audio-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
const source = document.createElement('source');
source.src = '/audio/devuser/stream.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => {
audio.play().catch(e => log(`Play error: ${e}`, 'error'));
};
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Import the loadProfileStream function from app.js
const { loadProfileStream } = await import('./app.js');
if (typeof loadProfileStream !== 'function') {
throw new Error('loadProfileStream function not found');
}
// Call loadProfileStream with test user
const audio = await loadProfileStream('devuser');
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
// Add the audio element to the page
const audioContainer = document.querySelector('#load-profile-container .audio-container');
audioContainer.innerHTML = '';
audio.controls = true;
audioContainer.appendChild(audio);
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
console.error(e);
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Log audio element events for debugging
const audioElements = document.getElementsByTagName('audio');
Array.from(audioElements).forEach((audio, index) => {
['play', 'pause', 'error', 'stalled', 'suspend', 'abort', 'emptied', 'ended'].forEach(event => {
audio.addEventListener(event, (e) => {
log(`Audio ${index + 1} ${event} event: ${e.type}`);
});
});
});
});
</script>
</body>
</html>

210
static/test-audio.html Normal file
View File

@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
}
.success { color: var(--success); }
.error { color: var(--error); }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Basic Audio Element</h2>
<audio id="test1" controls>
<source src="/static/test-audio.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
<div>
<button onclick="document.getElementById('test1').play()">Play</button>
<button onclick="document.getElementById('test1').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="test2-container">
<button onclick="setupTest2()">Initialize Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="test3-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupTest2() {
log('Setting up dynamic audio element...');
const container = document.getElementById('test2-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
const source = document.createElement('source');
source.src = '/static/test-audio.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => audio.play().catch(e => log(`Play error: ${e}`, 'error'));
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Create a test user ID
const testUid = 'test-user-' + Math.random().toString(36).substr(2, 8);
log(`Testing with user: ${testUid}`);
// Call loadProfileStream
const audio = await window.loadProfileStream(testUid);
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Expose loadProfileStream for testing
if (!window.loadProfileStream) {
log('Warning: loadProfileStream not found in global scope', 'warning');
}
});
</script>
</body>
</html>

BIN
static/test-audio.opus Normal file

Binary file not shown.

View File

@ -19,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
const streamInfo = document.getElementById("stream-info");
const streamUrlEl = document.getElementById("streamUrl");
const spinner = document.getElementById("spinner");
const spinner = document.getElementById("spinner") || { style: { display: 'none' } };
let abortController;
// Upload function
@ -78,10 +78,31 @@ document.addEventListener('DOMContentLoaded', () => {
dropzone.classList.remove("uploading");
showToast("✅ Upload successful.");
// Refresh the audio player and file list
const uid = localStorage.getItem("uid");
if (uid) {
try {
if (window.loadProfileStream) {
await window.loadProfileStream(uid);
}
// Refresh the file list
if (window.fetchAndDisplayFiles) {
await window.fetchAndDisplayFiles(uid);
}
// Refresh the stream list to update the last update time
if (window.refreshStreamList) {
await window.refreshStreamList();
}
} catch (e) {
console.error('Failed to refresh:', e);
}
}
playBeep(432, 0.25, "sine");
} else {
streamInfo.hidden = true;
spinner.style.display = "none";
if (streamInfo) streamInfo.hidden = true;
if (spinner) spinner.style.display = "none";
if ((data.detail || data.error || "").includes("music")) {
showToast("🎵 Upload rejected: singing or music detected.");
} else {
@ -95,8 +116,161 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Export the upload function for use in other modules
// Function to fetch and display uploaded files
async function fetchAndDisplayFiles(uidFromParam) {
console.log('[UPLOAD] fetchAndDisplayFiles called with uid:', uidFromParam);
// Get the file list element
const fileList = document.getElementById('file-list');
if (!fileList) {
const errorMsg = 'File list element not found in DOM';
console.error(errorMsg);
return showErrorInUI(errorMsg);
}
// Get UID from parameter, localStorage, or cookie
const uid = uidFromParam || localStorage.getItem('uid') || getCookie('uid');
const authToken = localStorage.getItem('authToken');
const headers = {
'Accept': 'application/json',
};
// Include auth token in headers if available, but don't fail if it's not
// The server should handle both token-based and UID-based auth
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
} else {
console.debug('[UPLOAD] No auth token available, using UID-only authentication');
}
console.log('[UPLOAD] Auth state - UID:', uid, 'Token exists:', !!authToken);
if (!uid) {
console.error('[UPLOAD] No UID found in any source');
fileList.innerHTML = '<li class="error-message">User session expired. Please refresh the page.</li>';
return;
}
// Log the authentication method being used
if (!authToken) {
console.debug('[UPLOAD] No auth token found, using UID-only authentication');
} else {
console.debug('[UPLOAD] Using token-based authentication');
}
// Show loading state
fileList.innerHTML = '<li class="loading-message">Loading files...</li>';
try {
console.log(`[DEBUG] Fetching files for user: ${uid}`);
const response = await fetch(`/me/${uid}`, {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
},
});
console.log('[DEBUG] Response status:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
const errorMsg = `Failed to fetch files: ${response.status} ${response.statusText} - ${errorText}`;
console.error(`[ERROR] ${errorMsg}`);
throw new Error(errorMsg);
}
const data = await response.json();
console.log('[DEBUG] Received files data:', data);
if (!data.files) {
throw new Error('Invalid response format: missing files array');
}
if (data.files.length > 0) {
// Sort files by name
const sortedFiles = [...data.files].sort((a, b) => a.name.localeCompare(b.name));
fileList.innerHTML = sortedFiles.map(file => {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
const displayName = file.original_name || file.name;
const isRenamed = file.original_name && file.original_name !== file.name;
return `
<li class="file-item" data-filename="${file.name}">
<div class="file-name" title="${isRenamed ? `Stored as: ${file.name}` : displayName}">
${displayName}
${isRenamed ? `<div class="stored-as"><button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_name}" title="Delete file">🗑️</button></div>` :
`<button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_name}" title="Delete file">🗑️</button>`}
</div>
<span class="file-size">${sizeMB} MB</span>
</li>
`;
}).join('');
} else {
fileList.innerHTML = '<li class="empty-message">No files uploaded yet</li>';
}
// Delete button handling is now managed by dashboard.js
// Update quota display if available
if (data.quota !== undefined) {
const bar = document.getElementById('quota-bar');
const text = document.getElementById('quota-text');
const quotaSec = document.getElementById('quota-meter');
if (bar && text && quotaSec) {
quotaSec.hidden = false;
bar.value = data.quota;
bar.max = 100;
text.textContent = `${data.quota.toFixed(1)} MB`;
}
}
} catch (error) {
const errorMessage = `Error loading file list: ${error.message || 'Unknown error'}`;
console.error('[ERROR]', errorMessage, error);
showErrorInUI(errorMessage, fileList);
}
// Helper function to show error messages in the UI
function showErrorInUI(message, targetElement = null) {
const errorHtml = `
<div style="
padding: 10px;
margin: 5px 0;
background: #2a0f0f;
border-left: 3px solid #f55;
color: var(--error-hover);
font-family: monospace;
font-size: 0.9em;
white-space: pre-wrap;
word-break: break-word;
">
<div style="font-weight: bold; color: var(--error);">Error loading files</div>
<div style="margin-top: 5px;">${message}</div>
<div style="margin-top: 10px; font-size: 0.8em; color: var(--text-muted);">
Check browser console for details
</div>
</div>
`;
if (targetElement) {
targetElement.innerHTML = errorHtml;
} else {
// If no target element, try to find it
const fileList = document.getElementById('file-list');
if (fileList) fileList.innerHTML = errorHtml;
}
}
}
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Export functions for use in other modules
window.upload = upload;
window.fetchAndDisplayFiles = fetchAndDisplayFiles;
if (dropzone && fileInput) {
dropzone.addEventListener("click", () => {

374
tests/profile-auth.js Normal file
View File

@ -0,0 +1,374 @@
/**
* Authentication Profiling Tool for dicta2stream
*
* This script profiles the authentication-related operations during navigation
* to identify performance bottlenecks in the logged-in experience.
*
* Usage:
* 1. Open browser console (F12 → Console)
* 2. Copy and paste this entire script
* 3. Run: profileAuthNavigation()
*/
(function() {
'use strict';
// Store original methods we want to profile
const originalFetch = window.fetch;
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
// Track authentication-related operations with detailed metrics
const authProfile = {
// Core metrics
startTime: null,
operations: [],
navigationEvents: [],
// Counters
fetchCount: 0,
xhrCount: 0,
domUpdates: 0,
authChecks: 0,
// Performance metrics
totalTime: 0,
maxFrameTime: 0,
longTasks: [],
// Memory tracking
memorySamples: [],
maxMemory: 0,
// Navigation tracking
currentNavigation: null,
navigationStart: null
};
// Track long tasks and performance metrics
if (window.PerformanceObserver) {
// Check which entry types are supported
const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
// Only set up observers for supported types
const perfObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
for (const entry of entries) {
// Track any task longer than 50ms as a long task
if (entry.duration > 50) {
authProfile.longTasks.push({
startTime: entry.startTime,
duration: entry.duration,
name: entry.name || 'unknown',
type: entry.entryType
});
}
}
});
// Try to observe supported entry types
try {
// Check for longtask support (not available in all browsers)
if (supportedEntryTypes.includes('longtask')) {
perfObserver.observe({ entryTypes: ['longtask'] });
}
// Always try to observe paint timing
try {
if (supportedEntryTypes.includes('paint')) {
perfObserver.observe({ entryTypes: ['paint'] });
} else {
// Fallback to buffered paint observation
perfObserver.observe({ type: 'paint', buffered: true });
}
} catch (e) {
console.debug('Paint timing not supported:', e.message);
}
} catch (e) {
console.debug('Performance observation not supported:', e.message);
}
}
// Instrument fetch API
window.fetch = async function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
const isAuthRelated = isAuthOperation(url);
if (isAuthRelated) {
const start = performance.now();
authProfile.fetchCount++;
try {
const response = await originalFetch.apply(this, args);
const duration = performance.now() - start;
authProfile.operations.push({
type: 'fetch',
url,
duration,
timestamp: new Date().toISOString(),
status: response.status,
size: response.headers.get('content-length') || 'unknown'
});
return response;
} catch (error) {
const duration = performance.now() - start;
authProfile.operations.push({
type: 'fetch',
url,
duration,
timestamp: new Date().toISOString(),
error: error.message,
status: 'error'
});
throw error;
}
}
return originalFetch.apply(this, args);
};
// Instrument XHR
XMLHttpRequest.prototype.open = function(method, url) {
this._authProfile = isAuthOperation(url);
if (this._authProfile) {
this._startTime = performance.now();
this._url = url;
authProfile.xhrCount++;
}
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._authProfile) {
this.addEventListener('load', () => {
const duration = performance.now() - this._startTime;
authProfile.operations.push({
type: 'xhr',
url: this._url,
duration,
timestamp: new Date().toISOString(),
status: this.status,
size: this.getResponseHeader('content-length') || 'unknown'
});
});
this.addEventListener('error', (error) => {
const duration = performance.now() - this._startTime;
authProfile.operations.push({
type: 'xhr',
url: this._url,
duration,
timestamp: new Date().toISOString(),
error: error.message,
status: 'error'
});
});
}
return originalXHRSend.apply(this, arguments);
};
// Track DOM updates after navigation with more details
const observer = new MutationObserver((mutations) => {
if (document.body.classList.contains('authenticated')) {
const now = performance.now();
const updateInfo = {
timestamp: now,
mutations: mutations.length,
addedNodes: 0,
removedNodes: 0,
attributeChanges: 0,
characterDataChanges: 0
};
mutations.forEach(mutation => {
updateInfo.addedNodes += mutation.addedNodes.length || 0;
updateInfo.removedNodes += mutation.removedNodes.length || 0;
updateInfo.attributeChanges += mutation.type === 'attributes' ? 1 : 0;
updateInfo.characterDataChanges += mutation.type === 'characterData' ? 1 : 0;
});
authProfile.domUpdates += mutations.length;
// Track memory usage if supported
if (window.performance && window.performance.memory) {
updateInfo.memoryUsed = performance.memory.usedJSHeapSize;
updateInfo.memoryTotal = performance.memory.totalJSHeapSize;
updateInfo.memoryLimit = performance.memory.jsHeapSizeLimit;
authProfile.memorySamples.push({
timestamp: now,
memory: performance.memory.usedJSHeapSize
});
authProfile.maxMemory = Math.max(
authProfile.maxMemory,
performance.memory.usedJSHeapSize
);
}
// Track frame time
requestAnimationFrame(() => {
const frameTime = performance.now() - now;
authProfile.maxFrameTime = Math.max(authProfile.maxFrameTime, frameTime);
});
}
});
// Track authentication state changes
const originalAddClass = DOMTokenList.prototype.add;
const originalRemoveClass = DOMTokenList.prototype.remove;
DOMTokenList.prototype.add = function(...args) {
if (this === document.body.classList && args[0] === 'authenticated') {
authProfile.authChecks++;
if (!authProfile.startTime) {
authProfile.startTime = performance.now();
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
characterData: true
});
}
}
return originalAddClass.apply(this, args);
};
DOMTokenList.prototype.remove = function(...args) {
if (this === document.body.classList && args[0] === 'authenticated') {
authProfile.totalTime = performance.now() - (authProfile.startTime || performance.now());
observer.disconnect();
}
return originalRemoveClass.apply(this, args);
};
// Helper to identify auth-related operations
function isAuthOperation(url) {
if (!url) return false;
const authKeywords = [
'auth', 'login', 'session', 'token', 'user', 'profile',
'me', 'account', 'verify', 'validate', 'check'
];
return authKeywords.some(keyword =>
url.toLowerCase().includes(keyword)
);
}
// Main function to run the profile
window.profileAuthNavigation = async function() {
// Reset profile with all metrics
Object.assign(authProfile, {
startTime: null,
operations: [],
navigationEvents: [],
fetchCount: 0,
xhrCount: 0,
domUpdates: 0,
authChecks: 0,
totalTime: 0,
maxFrameTime: 0,
longTasks: [],
memorySamples: [],
maxMemory: 0,
currentNavigation: null,
navigationStart: null
});
// Track navigation events
if (window.performance) {
const navObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.entryType === 'navigation') {
authProfile.navigationEvents.push({
type: 'navigation',
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
domComplete: entry.domComplete,
domContentLoaded: entry.domContentLoadedEventEnd,
load: entry.loadEventEnd
});
}
});
});
try {
navObserver.observe({ entryTypes: ['navigation'] });
} catch (e) {
console.warn('Navigation timing not supported:', e);
}
}
console.log('Starting authentication navigation profile...');
console.log('1. Navigate to a few pages while logged in');
console.log('2. Run getAuthProfile() to see the results');
console.log('3. Run resetAuthProfile() to start over');
// Add global accessor
window.getAuthProfile = function() {
const now = performance.now();
const duration = authProfile.startTime ? (now - authProfile.startTime) / 1000 : 0;
console.log('%c\n=== AUTHENTICATION PROFILE RESULTS ===', 'font-weight:bold;font-size:1.2em');
// Summary
console.log('\n%c--- PERFORMANCE SUMMARY ---', 'font-weight:bold');
console.log(`Total Monitoring Time: ${duration.toFixed(2)}s`);
console.log(`Authentication Checks: ${authProfile.authChecks}`);
console.log(`Fetch API Calls: ${authProfile.fetchCount}`);
console.log(`XHR Requests: ${authProfile.xhrCount}`);
console.log(`DOM Updates: ${authProfile.domUpdates}`);
console.log(`Max Frame Time: ${authProfile.maxFrameTime.toFixed(2)}ms`);
// Memory usage
if (authProfile.memorySamples.length > 0) {
const lastMem = authProfile.memorySamples[authProfile.memorySamples.length - 1];
console.log(`\n%c--- MEMORY USAGE ---`, 'font-weight:bold');
console.log(`Max Memory Used: ${(authProfile.maxMemory / 1024 / 1024).toFixed(2)} MB`);
console.log(`Current Memory: ${(lastMem.memory / 1024 / 1024).toFixed(2)} MB`);
}
// Long tasks
if (authProfile.longTasks.length > 0) {
console.log(`\n%c--- LONG TASKS (${authProfile.longTasks.length} > 50ms) ---`, 'font-weight:bold');
authProfile.longTasks
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
.forEach((task, i) => {
console.log(`#${i + 1} ${task.name}: ${task.duration.toFixed(2)}ms`);
});
}
// Slow operations
if (authProfile.operations.length > 0) {
console.log('\n%c--- SLOW OPERATIONS ---', 'font-weight:bold');
const sortedOps = [...authProfile.operations].sort((a, b) => b.duration - a.duration);
sortedOps.slice(0, 10).forEach((op, i) => {
const memUsage = op.memory ? ` | +${(op.memory / 1024).toFixed(2)}KB` : '';
console.log(`#${i + 1} [${op.type.toUpperCase()}] ${op.url || 'unknown'}: ${op.duration.toFixed(2)}ms${memUsage}`);
});
}
return authProfile;
};
window.resetAuthProfile = function() {
Object.assign(authProfile, {
startTime: null,
operations: [],
fetchCount: 0,
xhrCount: 0,
domUpdates: 0,
authChecks: 0,
totalTime: 0
});
console.log('Authentication profile has been reset');
};
};
console.log('Authentication profiler loaded. Run profileAuthNavigation() to start.');
})();

241
upload.py
View File

@ -5,69 +5,83 @@ from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from pathlib import Path
import json
import requests
from datetime import datetime
from convert_to_opus import convert_to_opus
from models import UploadLog, UserQuota, User, PublicStream
from sqlalchemy import select, or_
from database import get_db
from models import UploadLog, UserQuota, User
from sqlalchemy import select
from sqlalchemy.orm import Session
limiter = Limiter(key_func=get_remote_address)
router = APIRouter()
# # Not needed for SlowAPI ≥0.1.5
DATA_ROOT = Path("./data")
@limiter.limit("5/minute")
@router.post("/upload")
async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)):
from log import log_violation
import time
# Generate a unique request ID for this upload
request_id = str(int(time.time()))
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Starting upload of {file.filename}")
try:
user_dir = DATA_ROOT / uid
user_dir.mkdir(parents=True, exist_ok=True)
raw_path = user_dir / ("raw." + file.filename.split(".")[-1])
import uuid
unique_name = str(uuid.uuid4()) + ".opus"
# Save temp upload FIRST
with open(raw_path, "wb") as f:
f.write(await file.read())
# Block music/singing via Ollama prompt
import requests
try:
with open(raw_path, "rb") as f:
audio = f.read()
res = requests.post("http://localhost:11434/api/generate", json={
"model": "whisper",
"prompt": "Does this audio contain music or singing? Answer yes or no only.",
"audio": audio
}, timeout=10)
resp = res.json().get("response", "").lower()
if "yes" in resp:
raw_path.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="Upload rejected: music or singing detected")
except Exception as ollama_err:
# fallback: allow, log if needed
pass
processed_path = user_dir / unique_name
# Block unconfirmed users (use ORM)
# First, verify the user exists and is confirmed
user = db.exec(select(User).where((User.username == uid) | (User.email == uid))).first()
# If result is a Row or tuple, extract the User object
if user is not None and not isinstance(user, User) and hasattr(user, "__getitem__"):
user = user[0]
from log import log_violation
log_violation("UPLOAD", request.client.host, uid, f"DEBUG: Incoming uid={uid}, user found={user}, confirmed={getattr(user, 'confirmed', None)}")
log_violation("UPLOAD", request.client.host, uid, f"DEBUG: After unpack, user={user}, type={type(user)}, confirmed={getattr(user, 'confirmed', None)}")
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] User check - found: {user is not None}, confirmed: {getattr(user, 'confirmed', False) if user else 'N/A'}")
if not user or not hasattr(user, "confirmed") or not user.confirmed:
raw_path.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="Account not confirmed")
# DB-based quota check
quota = db.get(UserQuota, uid)
if quota and quota.storage_bytes >= 100 * 1024 * 1024:
raw_path.unlink(missing_ok=True)
# Check quota before doing any file operations
quota = db.get(UserQuota, uid) or UserQuota(uid=uid, storage_bytes=0)
if quota.storage_bytes >= 100 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Quota exceeded")
# Create user directory if it doesn't exist
user_dir = DATA_ROOT / uid
user_dir.mkdir(parents=True, exist_ok=True)
# Generate a unique filename for the processed file first
import uuid
unique_name = f"{uuid.uuid4()}.opus"
raw_ext = file.filename.split(".")[-1].lower()
raw_path = user_dir / ("raw." + raw_ext)
processed_path = user_dir / unique_name
# Clean up any existing raw files first (except the one we're about to create)
for old_file in user_dir.glob('raw.*'):
try:
if old_file != raw_path: # Don't delete the file we're about to create
old_file.unlink(missing_ok=True)
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Cleaned up old file: {old_file}")
except Exception as e:
log_violation("UPLOAD_ERROR", request.client.host, uid, f"[{request_id}] Failed to clean up {old_file}: {e}")
# Save the uploaded file temporarily
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Saving temporary file to {raw_path}")
try:
with open(raw_path, "wb") as f:
content = await file.read()
if not content:
raise ValueError("Uploaded file is empty")
f.write(content)
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Successfully wrote {len(content)} bytes to {raw_path}")
except Exception as e:
log_violation("UPLOAD_ERROR", request.client.host, uid, f"[{request_id}] Failed to save {raw_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save uploaded file: {e}")
# Ollama music/singing check is disabled for this release
log_violation("UPLOAD", request.client.host, uid, f"[{request_id}] Ollama music/singing check is disabled")
try:
convert_to_opus(str(raw_path), str(processed_path))
@ -78,31 +92,96 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
original_size = raw_path.stat().st_size
raw_path.unlink(missing_ok=True) # cleanup
# First, verify the file was created and has content
if not processed_path.exists() or processed_path.stat().st_size == 0:
raise HTTPException(status_code=500, detail="Failed to process audio file")
# Concatenate all .opus files in random order to stream.opus for public playback
# This is now done after the file is in its final location with log ID
from concat_opus import concat_opus_files
try:
concat_opus_files(user_dir, user_dir / "stream.opus")
except Exception as e:
# fallback: just use the latest processed file if concat fails
import shutil
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
db.add(UploadLog(
uid=uid,
ip=request.client.host,
filename=file.filename,
size_bytes=original_size
))
# Store updated quota
def update_stream_opus():
try:
concat_opus_files(user_dir, user_dir / "stream.opus")
except Exception as e:
# fallback: just use the latest processed file if concat fails
import shutil
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
log_violation("STREAM_UPDATE", request.client.host, uid,
f"[fallback] Updated stream.opus with {processed_path}")
# We'll call this after the file is in its final location
# Get the final file size
size = processed_path.stat().st_size
quota = db.get(UserQuota, uid)
if not quota:
quota = UserQuota(uid=uid)
db.add(quota)
quota.storage_bytes += size
db.commit()
# Start a transaction
try:
# Create a log entry with the original filename
log = UploadLog(
uid=uid,
ip=request.client.host,
filename=file.filename, # Store original filename
processed_filename=unique_name, # Store the processed filename
size_bytes=size
)
db.add(log)
db.flush() # Get the log ID without committing
# Rename the processed file to include the log ID for better tracking
processed_with_id = user_dir / f"{log.id}_{unique_name}"
if processed_path.exists():
# First check if there's already a file with the same UUID but different prefix
for existing_file in user_dir.glob(f"*_{unique_name}"):
if existing_file != processed_path:
log_violation("CLEANUP", request.client.host, uid,
f"[UPLOAD] Removing duplicate file: {existing_file}")
existing_file.unlink(missing_ok=True)
# Now do the rename
if processed_path != processed_with_id:
if processed_with_id.exists():
processed_with_id.unlink(missing_ok=True)
processed_path.rename(processed_with_id)
processed_path = processed_with_id
# Only clean up raw.* files, not previously uploaded opus files
for old_temp_file in user_dir.glob('raw.*'):
try:
old_temp_file.unlink(missing_ok=True)
log_violation("CLEANUP", request.client.host, uid, f"[{request_id}] Cleaned up temp file: {old_temp_file}")
except Exception as e:
log_violation("CLEANUP_ERROR", request.client.host, uid, f"[{request_id}] Failed to clean up {old_temp_file}: {e}")
# Get or create quota
quota = db.query(UserQuota).filter(UserQuota.uid == uid).first()
if not quota:
quota = UserQuota(uid=uid, storage_bytes=0)
db.add(quota)
# Update quota with the new file size
quota.storage_bytes = sum(
f.stat().st_size
for f in user_dir.glob('*.opus')
if f.name != 'stream.opus' and f != processed_path
) + size
# Update public streams
update_public_streams(uid, quota.storage_bytes, db)
# Commit the transaction
db.commit()
# Now that the transaction is committed and files are in their final location,
# update the stream.opus file to include all files
update_stream_opus()
except Exception as e:
db.rollback()
# Clean up the processed file if something went wrong
if processed_path.exists():
processed_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
return {
"filename": file.filename,
@ -123,3 +202,35 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
except Exception:
pass
return {"detail": f"Server error: {type(e).__name__}: {str(e)}"}
def update_public_streams(uid: str, storage_bytes: int, db: Session):
"""Update the public streams list in the database with the latest user upload info"""
try:
# Get the user's info
user = db.query(User).filter(User.username == uid).first()
if not user:
print(f"[WARNING] User {uid} not found when updating public streams")
return
# Try to get existing public stream or create new one
public_stream = db.query(PublicStream).filter(PublicStream.uid == uid).first()
if not public_stream:
public_stream = PublicStream(uid=uid)
db.add(public_stream)
# Update the public stream info
public_stream.username = user.username
public_stream.display_name = user.display_name or user.username
public_stream.storage_bytes = storage_bytes
public_stream.last_updated = datetime.utcnow()
# Don't commit here - let the caller handle the transaction
db.flush()
except Exception as e:
# Just log the error and let the caller handle the rollback
print(f"[ERROR] Error updating public streams: {e}")
import traceback
traceback.print_exc()
raise # Re-raise to let the caller handle the error