Compare commits

..

3 commits

2 changed files with 121 additions and 23 deletions

View file

@ -21,6 +21,7 @@
- up- and download in CLI possible - up- and download in CLI possible
- rate-limits - rate-limits
- optional auth token for paste creation - optional auth token for paste creation
- logging
**Ideas**: **Ideas**:
- integrated retention/purge function - integrated retention/purge function
@ -133,7 +134,8 @@ Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Beare
## Installation ## Installation
**Use with reverse-proxy and HTTPS!** > [!IMPORTANT]
> **Production Deployment:** Use a reverse-proxy (nginx, caddy) with TLS/HTTPS! Rate-limiting and logging features require the `X-Real-IP` header from a reverse proxy to function correctly. Less critical for local or trusted environments.
### Docker ### Docker
@ -168,6 +170,8 @@ podman run --replace -d --restart=unless-stopped \
| `RATE_LIMIT` | Rate limit for uploads (format: "requests/timeframe") | `50/hour` | No | | `RATE_LIMIT` | Rate limit for uploads (format: "requests/timeframe") | `50/hour` | No |
| `URL_PATH_LENGTH` | Length of generated URL paths (number of characters) | `6` | No | | `URL_PATH_LENGTH` | Length of generated URL paths (number of characters) | `6` | No |
| `UPLOAD_TOKENS` | Comma-separated list of Bearer tokens for upload authentication (if set, uploads require valid token) | _(disabled)_ | No | | `UPLOAD_TOKENS` | Comma-separated list of Bearer tokens for upload authentication (if set, uploads require valid token) | _(disabled)_ | No |
| `LOGGING_ENABLED` | Enable structured JSON logging to file and stdout | `false` | No |
| `LOG_LEVEL` | Logging level (INFO, WARNING, ERROR) | `INFO` | No |
Create a secure token with: `openssl rand -base64 32`. Create a secure token with: `openssl rand -base64 32`.

120
main.py
View file

@ -10,6 +10,9 @@ import os
from pathlib import Path from pathlib import Path
import hashlib import hashlib
from typing import Optional from typing import Optional
import json
from datetime import datetime
import sys
BASEURL = os.getenv('BASEURL', 'http://127.0.0.1:8000') BASEURL = os.getenv('BASEURL', 'http://127.0.0.1:8000')
@ -18,13 +21,80 @@ MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '50'))
RATE_LIMIT = os.getenv('RATE_LIMIT', '50/hour') RATE_LIMIT = os.getenv('RATE_LIMIT', '50/hour')
URL_PATH_LENGTH = int(os.getenv('URL_PATH_LENGTH', '6')) URL_PATH_LENGTH = int(os.getenv('URL_PATH_LENGTH', '6'))
UPLOAD_TOKENS = [t.strip() for t in os.getenv('UPLOAD_TOKENS', '').split(',') if t.strip()] if os.getenv('UPLOAD_TOKENS') else [] UPLOAD_TOKENS = [t.strip() for t in os.getenv('UPLOAD_TOKENS', '').split(',') if t.strip()] if os.getenv('UPLOAD_TOKENS') else []
LOGGING_ENABLED = os.getenv('LOGGING_ENABLED', 'false').lower() in ['true', '1', 'yes']
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
LOG_FILE = 'logs/linedump.log'
limiter = Limiter(key_func=get_remote_address) # Create logs directory and log file if logging is enabled
if LOGGING_ENABLED:
Path('logs').mkdir(exist_ok=True)
Path(LOG_FILE).touch(exist_ok=True)
def log(level: str, event: str, **kwargs):
"""Simple structured logging function"""
# Skip if logging is disabled
if not LOGGING_ENABLED:
return
# Skip logs based on level
if LOG_LEVEL == 'ERROR' and level in ['INFO', 'WARNING']:
return
if LOG_LEVEL == 'WARNING' and level == 'INFO':
return
log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": level,
"event": event,
**kwargs
}
log_line = json.dumps(log_entry)
# Write to file
try:
with open(LOG_FILE, 'a') as f:
f.write(log_line + '\n')
except:
pass # Fail silently if logging fails
# Write to stdout
print(log_line, file=sys.stdout)
def get_real_ip(request: Request) -> str:
"""Get real client IP for rate limiting and logging (supports reverse proxy)"""
# Check X-Real-IP header first (set by reverse proxy)
x_real_ip = request.headers.get("X-Real-IP")
if x_real_ip:
return x_real_ip.strip()
# Fallback to direct connection IP
return request.client.host
limiter = Limiter(key_func=get_real_ip)
app = FastAPI(title="linedump", version="1.0.0") app = FastAPI(title="linedump", version="1.0.0")
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.exception_handler(RateLimitExceeded)
async def log_rate_limit(request: Request, exc: RateLimitExceeded):
"""Custom handler to log rate limit violations"""
log("WARNING", "rate_limit_exceeded",
client_ip=get_real_ip(request),
user_agent=request.headers.get("User-Agent", "unknown"),
endpoint=request.url.path)
return await _rate_limit_exceeded_handler(request, exc)
# Log startup
log("INFO", "application_started",
base_url=BASEURL,
max_file_size_mb=MAX_FILE_SIZE_MB,
auth_enabled=bool(UPLOAD_TOKENS))
UPLOAD_DIR = Path("uploads") UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True)
@ -36,12 +106,6 @@ def generate_random_path(length: int = None) -> str:
alphabet = string.ascii_letters + string.digits alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length)) return ''.join(secrets.choice(alphabet) for _ in range(length))
def get_client_ip(request: Request) -> str:
x_real_ip = request.headers.get("X-Real-IP")
if x_real_ip:
return x_real_ip.strip()
return request.client.host
def validate_upload_token(request: Request) -> bool: def validate_upload_token(request: Request) -> bool:
"""Validate upload token if authentication is enabled""" """Validate upload token if authentication is enabled"""
@ -51,6 +115,10 @@ def validate_upload_token(request: Request) -> bool:
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
log("WARNING", "auth_failed",
client_ip=get_real_ip(request),
user_agent=request.headers.get("User-Agent", "unknown"),
reason="missing_bearer")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="Unauthorized", detail="Unauthorized",
@ -61,6 +129,10 @@ def validate_upload_token(request: Request) -> bool:
# Use constant-time comparison to prevent timing attacks # Use constant-time comparison to prevent timing attacks
if not any(secrets.compare_digest(token, valid_token) for valid_token in UPLOAD_TOKENS): if not any(secrets.compare_digest(token, valid_token) for valid_token in UPLOAD_TOKENS):
log("WARNING", "auth_failed",
client_ip=get_real_ip(request),
user_agent=request.headers.get("User-Agent", "unknown"),
reason="invalid_token")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="Unauthorized", detail="Unauthorized",
@ -90,13 +162,24 @@ def validate_content(content: str) -> bool:
@limiter.limit(RATE_LIMIT) @limiter.limit(RATE_LIMIT)
async def upload_text(request: Request, authorized: bool = Depends(validate_upload_token)): async def upload_text(request: Request, authorized: bool = Depends(validate_upload_token)):
client_ip = get_real_ip(request)
user_agent = request.headers.get("User-Agent", "unknown")
body = await request.body() body = await request.body()
content = body.decode('utf-8', errors='ignore') content = body.decode('utf-8', errors='ignore')
if not validate_content(content): if not validate_content(content):
log("WARNING", "upload_failed",
client_ip=client_ip,
user_agent=user_agent,
reason="invalid_content",
size_bytes=len(content))
raise HTTPException(status_code=400, detail="Invalid content") raise HTTPException(status_code=400, detail="Invalid content")
if not content.strip(): if not content.strip():
log("WARNING", "upload_failed",
client_ip=client_ip,
user_agent=user_agent,
reason="empty_content")
raise HTTPException(status_code=400, detail="Empty content") raise HTTPException(status_code=400, detail="Empty content")
random_path = generate_random_path() random_path = generate_random_path()
@ -109,17 +192,24 @@ async def upload_text(request: Request, authorized: bool = Depends(validate_uplo
with open(file_path, 'w', encoding='utf-8') as f: with open(file_path, 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
log("INFO", "paste_created",
paste_id=random_path,
client_ip=client_ip,
user_agent=user_agent,
size_bytes=len(content))
return f"{BASEURL}/{random_path}\n" return f"{BASEURL}/{random_path}\n"
except Exception as e: except Exception as e:
# Log the actual error for debugging log("ERROR", "upload_failed",
import traceback paste_id=random_path,
print(f"Error saving file: {e}") client_ip=client_ip,
print(traceback.format_exc()) user_agent=user_agent,
error=str(e))
raise HTTPException(status_code=500, detail="Failed to save file") raise HTTPException(status_code=500, detail="Failed to save file")
@app.get("/{file_path}", response_class=PlainTextResponse) @app.get("/{file_path}", response_class=PlainTextResponse)
async def get_file(file_path: str): async def get_file(file_path: str, request: Request):
if not file_path.isalnum(): if not file_path.isalnum():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
@ -131,8 +221,12 @@ async def get_file(file_path: str):
try: try:
with open(file_location, 'r', encoding='utf-8') as f: with open(file_location, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
return content return content
except Exception: except Exception as e:
log("ERROR", "download_failed",
paste_id=file_path,
error=str(e))
raise HTTPException(status_code=500, detail="Failed to read file") raise HTTPException(status_code=500, detail="Failed to read file")
@app.get("/", response_class=PlainTextResponse) @app.get("/", response_class=PlainTextResponse)