Compare commits
No commits in common. "ed081b280cab76845a43118d33fc6782e72e7cfc" and "2a7bab59f3cf976cae414eefb9a6aefe45307c1c" have entirely different histories.
ed081b280c
...
2a7bab59f3
2 changed files with 23 additions and 121 deletions
|
|
@ -21,7 +21,6 @@
|
||||||
- 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
|
||||||
|
|
@ -134,8 +133,7 @@ Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Beare
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
> [!IMPORTANT]
|
**Use with reverse-proxy and HTTPS!**
|
||||||
> **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
|
||||||
|
|
||||||
|
|
@ -170,8 +168,6 @@ 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`.
|
||||||
|
|
||||||
|
|
|
||||||
138
main.py
138
main.py
|
|
@ -10,9 +10,6 @@ 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')
|
||||||
|
|
@ -21,80 +18,13 @@ 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'
|
|
||||||
|
|
||||||
# Create logs directory and log file if logging is enabled
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -106,6 +36,12 @@ 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"""
|
||||||
|
|
@ -115,10 +51,6 @@ 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",
|
||||||
|
|
@ -129,10 +61,6 @@ 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",
|
||||||
|
|
@ -161,72 +89,50 @@ def validate_content(content: str) -> bool:
|
||||||
@app.post("/", response_class=PlainTextResponse)
|
@app.post("/", response_class=PlainTextResponse)
|
||||||
@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()
|
||||||
while (UPLOAD_DIR / random_path).exists():
|
while (UPLOAD_DIR / random_path).exists():
|
||||||
random_path = generate_random_path()
|
random_path = generate_random_path()
|
||||||
|
|
||||||
file_path = UPLOAD_DIR / random_path
|
file_path = UPLOAD_DIR / random_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
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("ERROR", "upload_failed",
|
# Log the actual error for debugging
|
||||||
paste_id=random_path,
|
import traceback
|
||||||
client_ip=client_ip,
|
print(f"Error saving file: {e}")
|
||||||
user_agent=user_agent,
|
print(traceback.format_exc())
|
||||||
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, request: Request):
|
async def get_file(file_path: str):
|
||||||
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")
|
||||||
|
|
||||||
file_location = UPLOAD_DIR / file_path
|
file_location = UPLOAD_DIR / file_path
|
||||||
|
|
||||||
if not file_location.exists() or not file_location.is_file():
|
if not file_location.exists() or not file_location.is_file():
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
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 as e:
|
except Exception:
|
||||||
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue