Spaces:
Runtime error
Runtime error
Upload 10 files
Browse files- Dockerfile +33 -0
- admin_logic.py +216 -0
- app.py +136 -0
- static/dummy +0 -0
- templates/admin_dashboard.html +43 -0
- templates/admin_files.html +45 -0
- templates/admin_layout.html +113 -0
- templates/admin_login.html +55 -0
- templates/admin_logs.html +21 -0
- templates/index.html +173 -0
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Create directories that the application might need to write to or expect to exist.
|
| 6 |
+
# These are created by root during the build.
|
| 7 |
+
RUN mkdir -p /app/templates \
|
| 8 |
+
/app/static \
|
| 9 |
+
/app/uploaded_files
|
| 10 |
+
|
| 11 |
+
# Copy application code
|
| 12 |
+
COPY ./app.py /app/
|
| 13 |
+
COPY ./admin_logic.py /app/ # Added: Copy the new admin logic file
|
| 14 |
+
COPY ./templates/ /app/templates/
|
| 15 |
+
# If you had local static files, you'd copy them here:
|
| 16 |
+
# COPY ./static/ /app/static/
|
| 17 |
+
|
| 18 |
+
# Grant write permissions to the 'uploaded_files' directory for the user running the app.
|
| 19 |
+
# Hugging Face Spaces (and many other container platforms) often run containers
|
| 20 |
+
# as a non-root user (e.g., UID 1000).
|
| 21 |
+
# We give ownership to user 1000 and group 1000.
|
| 22 |
+
# You could also use `chmod -R 777 /app/uploaded_files` but chown is more specific.
|
| 23 |
+
RUN chown -R 1000:1000 /app/uploaded_files \
|
| 24 |
+
&& chmod -R u+w /app/uploaded_files # Ensure the owner (user 1000) has write permissions \
|
| 25 |
+
&& chown 1000:1000 /app # Also ensure the app directory and log file can be written by user 1000
|
| 26 |
+
|
| 27 |
+
# Install dependencies
|
| 28 |
+
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" aiofiles Jinja2 python-multipart
|
| 29 |
+
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
# The log file app.log will be created in /app/ which should be writable by user 1000
|
| 33 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
admin_logic.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import datetime as dt
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
|
| 8 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
| 9 |
+
from fastapi.templating import Jinja2Templates
|
| 10 |
+
import aiofiles # For async file operations if needed in admin, though mostly sync for listing/deleting
|
| 11 |
+
|
| 12 |
+
# --- Admin Configuration ---
|
| 13 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 14 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 15 |
+
FILES_DIR = BASE_DIR / "uploaded_files"
|
| 16 |
+
LOG_FILE = BASE_DIR / "app.log"
|
| 17 |
+
|
| 18 |
+
# Ensure directories exist (though app.py also does this)
|
| 19 |
+
FILES_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 22 |
+
|
| 23 |
+
# INSECURE: Hardcoded credentials. Use environment variables in production!
|
| 24 |
+
ADMIN_EMAIL = "admin@example.com"
|
| 25 |
+
ADMIN_PASSWORD = "adminpassword" # Store hashed passwords in a real application
|
| 26 |
+
|
| 27 |
+
ADMIN_SESSION_COOKIE_NAME = "admin_session_id"
|
| 28 |
+
# This should be a very random, long, and secret string in a real app
|
| 29 |
+
ADMIN_SESSION_SECRET_VALUE = "my_super_secret_admin_session_value_for_demo"
|
| 30 |
+
|
| 31 |
+
admin_router = APIRouter(
|
| 32 |
+
prefix="/admin",
|
| 33 |
+
tags=["Admin"]
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# --- Admin Authentication Dependencies ---
|
| 37 |
+
async def get_current_admin(request: Request):
|
| 38 |
+
"""
|
| 39 |
+
Dependency to check if the admin is logged in.
|
| 40 |
+
Redirects to login page if not authenticated.
|
| 41 |
+
"""
|
| 42 |
+
session_cookie = request.cookies.get(ADMIN_SESSION_COOKIE_NAME)
|
| 43 |
+
if session_cookie == ADMIN_SESSION_SECRET_VALUE:
|
| 44 |
+
return {"email": ADMIN_EMAIL} # Return a dummy admin object
|
| 45 |
+
|
| 46 |
+
# If trying to access a protected page without being logged in, redirect to login
|
| 47 |
+
# Allow access to login page itself without this check.
|
| 48 |
+
if request.url.path != "/admin/login" and request.url.path != "/admin/auth":
|
| 49 |
+
return RedirectResponse(url="/admin/login", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 50 |
+
# If on login/auth page, don't raise/redirect, let the route handler proceed
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def verify_admin_auth(request: Request):
|
| 55 |
+
"""
|
| 56 |
+
Stricter dependency for protected routes. Raises HTTPException if not authenticated.
|
| 57 |
+
Used for API-like endpoints or where a redirect isn't the primary action.
|
| 58 |
+
"""
|
| 59 |
+
admin = await get_current_admin(request)
|
| 60 |
+
if not admin and request.url.path not in ["/admin/login", "/admin/auth"]: # Check if it's already a redirect response
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 63 |
+
detail="Not authenticated",
|
| 64 |
+
headers={"WWW-Authenticate": "Bearer"}, # Though we are using cookies
|
| 65 |
+
)
|
| 66 |
+
if isinstance(admin, RedirectResponse): # if get_current_admin decided to redirect
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
| 69 |
+
detail="Not authenticated, redirecting.",
|
| 70 |
+
headers={"Location": "/admin/login"},
|
| 71 |
+
)
|
| 72 |
+
return admin
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# --- Helper Functions for Admin Panel ---
|
| 76 |
+
def get_file_info(file_path: Path):
|
| 77 |
+
try:
|
| 78 |
+
if file_path.is_file():
|
| 79 |
+
stat_info = file_path.stat()
|
| 80 |
+
return {
|
| 81 |
+
"name": file_path.name,
|
| 82 |
+
"size_bytes": stat_info.st_size,
|
| 83 |
+
"size_human": format_bytes(stat_info.st_size),
|
| 84 |
+
"modified_at": dt.datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
| 85 |
+
"url": f"/download/{file_path.name}" # User-facing download URL
|
| 86 |
+
}
|
| 87 |
+
except Exception:
|
| 88 |
+
return None
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
def format_bytes(bytes_val, decimals=2):
|
| 92 |
+
if bytes_val == 0: return '0 Bytes'
|
| 93 |
+
k = 1024
|
| 94 |
+
dm = decimals if decimals >= 0 else 0
|
| 95 |
+
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
| 96 |
+
i = 0
|
| 97 |
+
if bytes_val > 0:
|
| 98 |
+
i = int(dt.math.floor(dt.math.log(bytes_val) / dt.math.log(k)))
|
| 99 |
+
return f"{bytes_val / (k**i):.{dm}f} {sizes[i]}"
|
| 100 |
+
|
| 101 |
+
def get_log_lines(num_lines: int = 200) -> List[str]:
|
| 102 |
+
if not LOG_FILE.exists():
|
| 103 |
+
return ["Log file not found or is empty."]
|
| 104 |
+
try:
|
| 105 |
+
with open(LOG_FILE, "r", encoding="utf-8") as f:
|
| 106 |
+
lines = f.readlines()
|
| 107 |
+
# Display latest lines at the top for easier reading
|
| 108 |
+
return list(reversed(lines[-num_lines:]))
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return [f"Error reading log file: {str(e)}"]
|
| 111 |
+
|
| 112 |
+
# --- Admin Routes ---
|
| 113 |
+
@admin_router.get("/login", response_class=HTMLResponse)
|
| 114 |
+
async def admin_login_page(request: Request):
|
| 115 |
+
return templates.TemplateResponse("admin_login.html", {"request": request})
|
| 116 |
+
|
| 117 |
+
@admin_router.post("/auth")
|
| 118 |
+
async def admin_authenticate(request: Request, email: str = Form(...), password: str = Form(...)):
|
| 119 |
+
error_msg: Optional[str] = None
|
| 120 |
+
if email == ADMIN_EMAIL and password == ADMIN_PASSWORD:
|
| 121 |
+
response = RedirectResponse(url="/admin/dashboard", status_code=status.HTTP_303_SEE_OTHER)
|
| 122 |
+
response.set_cookie(
|
| 123 |
+
key=ADMIN_SESSION_COOKIE_NAME,
|
| 124 |
+
value=ADMIN_SESSION_SECRET_VALUE,
|
| 125 |
+
httponly=True, # Makes it inaccessible to JavaScript
|
| 126 |
+
samesite="lax", # Protects against CSRF to some extent
|
| 127 |
+
# secure=True, # Uncomment if served over HTTPS
|
| 128 |
+
max_age=1800 # 30 minutes
|
| 129 |
+
)
|
| 130 |
+
return response
|
| 131 |
+
else:
|
| 132 |
+
error_msg = "Invalid email or password."
|
| 133 |
+
return templates.TemplateResponse("admin_login.html", {"request": request, "error": error_msg}, status_code=status.HTTP_401_UNAUTHORIZED)
|
| 134 |
+
|
| 135 |
+
@admin_router.get("/logout")
|
| 136 |
+
async def admin_logout(request: Request):
|
| 137 |
+
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
| 138 |
+
response.delete_cookie(ADMIN_SESSION_COOKIE_NAME, httponly=True, samesite="lax") #, secure=True)
|
| 139 |
+
return response
|
| 140 |
+
|
| 141 |
+
@admin_router.get("/dashboard", response_class=HTMLResponse)
|
| 142 |
+
async def admin_dashboard(request: Request, admin_user: dict = Depends(get_current_admin)):
|
| 143 |
+
if isinstance(admin_user, RedirectResponse): return admin_user # Handle redirect from dependency
|
| 144 |
+
if not admin_user: # Should be caught by dependency, but as a fallback
|
| 145 |
+
return RedirectResponse(url="/admin/login", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 146 |
+
|
| 147 |
+
num_files = len([name for name in os.listdir(FILES_DIR) if (FILES_DIR / name).is_file()])
|
| 148 |
+
log_lines_count = 0
|
| 149 |
+
if LOG_FILE.exists():
|
| 150 |
+
with open(LOG_FILE, "r", encoding="utf-8") as f:
|
| 151 |
+
log_lines_count = sum(1 for _ in f)
|
| 152 |
+
|
| 153 |
+
return templates.TemplateResponse("admin_dashboard.html", {
|
| 154 |
+
"request": request,
|
| 155 |
+
"num_files": num_files,
|
| 156 |
+
"log_lines_count": log_lines_count
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
@admin_router.get("/files", response_class=HTMLResponse)
|
| 160 |
+
async def admin_files_page(request: Request, admin_user: dict = Depends(get_current_admin)):
|
| 161 |
+
if isinstance(admin_user, RedirectResponse): return admin_user
|
| 162 |
+
if not admin_user: return RedirectResponse(url="/admin/login", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 163 |
+
|
| 164 |
+
uploaded_file_objects = []
|
| 165 |
+
try:
|
| 166 |
+
for item_name in sorted(os.listdir(FILES_DIR)):
|
| 167 |
+
item_path = FILES_DIR / item_name
|
| 168 |
+
info = get_file_info(item_path)
|
| 169 |
+
if info:
|
| 170 |
+
uploaded_file_objects.append(info)
|
| 171 |
+
except Exception as e:
|
| 172 |
+
# Log this error, maybe show it on the page
|
| 173 |
+
print(f"Error listing files: {e}") # Replace with proper logging
|
| 174 |
+
pass # Or raise HTTPException
|
| 175 |
+
|
| 176 |
+
return templates.TemplateResponse("admin_files.html", {
|
| 177 |
+
"request": request,
|
| 178 |
+
"files": uploaded_file_objects
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
@admin_router.post("/files/delete/{filename}")
|
| 182 |
+
async def admin_delete_file(request: Request, filename: str, admin_user: dict = Depends(verify_admin_auth)):
|
| 183 |
+
if isinstance(admin_user, RedirectResponse): return admin_user # Should not happen with verify_admin_auth
|
| 184 |
+
if not admin_user: return RedirectResponse(url="/admin/login", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 185 |
+
|
| 186 |
+
# Sanitize filename again, though it comes from our listed files
|
| 187 |
+
safe_filename = os.path.basename(filename)
|
| 188 |
+
file_path = FILES_DIR / safe_filename
|
| 189 |
+
|
| 190 |
+
# Extra check to ensure we are deleting within FILES_DIR
|
| 191 |
+
if not file_path.resolve().is_relative_to(FILES_DIR.resolve()):
|
| 192 |
+
raise HTTPException(status_code=400, detail="Invalid file path for deletion.")
|
| 193 |
+
|
| 194 |
+
if file_path.is_file():
|
| 195 |
+
try:
|
| 196 |
+
file_path.unlink()
|
| 197 |
+
# Optionally, add a success message to be displayed (e.g., via query params or session flash if implemented)
|
| 198 |
+
# For now, just redirect.
|
| 199 |
+
return RedirectResponse(url="/admin/files", status_code=status.HTTP_303_SEE_OTHER)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
# Log this error
|
| 202 |
+
raise HTTPException(status_code=500, detail=f"Could not delete file: {str(e)}")
|
| 203 |
+
else:
|
| 204 |
+
raise HTTPException(status_code=404, detail="File not found for deletion.")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@admin_router.get("/logs", response_class=HTMLResponse)
|
| 208 |
+
async def admin_logs_page(request: Request, admin_user: dict = Depends(get_current_admin)):
|
| 209 |
+
if isinstance(admin_user, RedirectResponse): return admin_user
|
| 210 |
+
if not admin_user: return RedirectResponse(url="/admin/login", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 211 |
+
|
| 212 |
+
logs = get_log_lines(num_lines=500) # Show last 500 lines
|
| 213 |
+
return templates.TemplateResponse("admin_logs.html", {
|
| 214 |
+
"request": request,
|
| 215 |
+
"log_entries": logs
|
| 216 |
+
})
|
app.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import aiofiles # For async file operations, good practice with FastAPI
|
| 4 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Request
|
| 5 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import logging # Added for logging
|
| 10 |
+
from logging.handlers import RotatingFileHandler # Added for logging
|
| 11 |
+
|
| 12 |
+
# --- Configuration ---
|
| 13 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 14 |
+
FILES_DIR = BASE_DIR / "uploaded_files"
|
| 15 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 16 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 17 |
+
LOG_FILE = BASE_DIR / "app.log" # Added for logging
|
| 18 |
+
|
| 19 |
+
# Create directories if they don't exist
|
| 20 |
+
FILES_DIR.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
# --- Logging Configuration (New) ---
|
| 25 |
+
logger = logging.getLogger("file_app")
|
| 26 |
+
logger.setLevel(logging.INFO)
|
| 27 |
+
# Prevent duplicate logs if uvicorn also logs
|
| 28 |
+
logger.propagate = False
|
| 29 |
+
|
| 30 |
+
# File handler
|
| 31 |
+
# Rotate log file if it exceeds 5MB, keep 2 backup files
|
| 32 |
+
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=1024*1024*5, backupCount=2, encoding='utf-8')
|
| 33 |
+
file_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s')
|
| 34 |
+
file_handler.setFormatter(file_formatter)
|
| 35 |
+
logger.addHandler(file_handler)
|
| 36 |
+
|
| 37 |
+
# Console handler (optional, as uvicorn also logs to console)
|
| 38 |
+
# console_handler = logging.StreamHandler()
|
| 39 |
+
# console_handler.setFormatter(file_formatter)
|
| 40 |
+
# logger.addHandler(console_handler)
|
| 41 |
+
|
| 42 |
+
logger.info("Application starting up...")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
app = FastAPI(title="File Uploader/Downloader")
|
| 46 |
+
|
| 47 |
+
# Mount static files
|
| 48 |
+
# app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 49 |
+
|
| 50 |
+
# Setup Jinja2 templates
|
| 51 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 52 |
+
|
| 53 |
+
# --- Import and Mount Admin Router (New) ---
|
| 54 |
+
from admin_logic import admin_router # Make sure admin_logic.py is in the same directory
|
| 55 |
+
app.include_router(admin_router)
|
| 56 |
+
logger.info("Admin panel router included.")
|
| 57 |
+
|
| 58 |
+
# --- Helper Functions ---
|
| 59 |
+
def sanitize_filename(filename: str) -> str:
|
| 60 |
+
"""Basic filename sanitization."""
|
| 61 |
+
return os.path.basename(filename).strip()
|
| 62 |
+
|
| 63 |
+
# --- Routes (Original functionality with added logging) ---
|
| 64 |
+
@app.get("/", response_class=HTMLResponse)
|
| 65 |
+
async def read_root(request: Request):
|
| 66 |
+
"""Serves the main HTML page."""
|
| 67 |
+
logger.info(f"Root page accessed by {request.client.host}")
|
| 68 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 69 |
+
|
| 70 |
+
@app.post("/upload/")
|
| 71 |
+
async def upload_file(request: Request, file: UploadFile = File(...)):
|
| 72 |
+
"""Handles file uploads."""
|
| 73 |
+
original_filename = file.filename
|
| 74 |
+
logger.info(f"Upload attempt for '{original_filename}' from {request.client.host}")
|
| 75 |
+
|
| 76 |
+
if not original_filename:
|
| 77 |
+
logger.warning(f"Upload failed: No filename provided by {request.client.host}")
|
| 78 |
+
raise HTTPException(status_code=400, detail="No filename provided.")
|
| 79 |
+
|
| 80 |
+
filename = sanitize_filename(original_filename)
|
| 81 |
+
if not filename: # After sanitization, if it's empty
|
| 82 |
+
logger.warning(f"Upload failed: Invalid filename '{original_filename}' after sanitization by {request.client.host}")
|
| 83 |
+
raise HTTPException(status_code=400, detail="Invalid filename after sanitization.")
|
| 84 |
+
|
| 85 |
+
file_path = FILES_DIR / filename
|
| 86 |
+
|
| 87 |
+
if not file_path.resolve().is_relative_to(FILES_DIR.resolve()):
|
| 88 |
+
logger.error(f"Upload security violation: Attempted directory traversal for '{filename}' by {request.client.host}")
|
| 89 |
+
raise HTTPException(status_code=400, detail="Invalid file path (attempted directory traversal).")
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
async with aiofiles.open(file_path, "wb") as buffer:
|
| 93 |
+
while content := await file.read(1024 * 1024): # Read 1MB chunks
|
| 94 |
+
await buffer.write(content)
|
| 95 |
+
logger.info(f"File '{filename}' uploaded successfully by {request.client.host}. Size: {file.size} bytes.")
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Could not save file '{filename}' from {request.client.host}: {str(e)}")
|
| 98 |
+
if file_path.exists():
|
| 99 |
+
try:
|
| 100 |
+
file_path.unlink()
|
| 101 |
+
logger.info(f"Cleaned up partially uploaded file '{filename}'.")
|
| 102 |
+
except Exception as unlink_e:
|
| 103 |
+
logger.error(f"Error cleaning up partially uploaded file '{filename}': {unlink_e}")
|
| 104 |
+
raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
|
| 105 |
+
|
| 106 |
+
return JSONResponse(
|
| 107 |
+
content={
|
| 108 |
+
"message": "File uploaded successfully",
|
| 109 |
+
"filename": filename,
|
| 110 |
+
"download_url": f"/download/{filename}"
|
| 111 |
+
}
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
@app.get("/download/{filename}")
|
| 115 |
+
async def download_file(request: Request, filename: str):
|
| 116 |
+
"""Handles file downloads."""
|
| 117 |
+
clean_filename = sanitize_filename(filename)
|
| 118 |
+
logger.info(f"Download attempt for '{clean_filename}' by {request.client.host}")
|
| 119 |
+
|
| 120 |
+
if not clean_filename:
|
| 121 |
+
logger.warning(f"Download failed: Invalid filename '{filename}' by {request.client.host}")
|
| 122 |
+
raise HTTPException(status_code=400, detail="Invalid filename.")
|
| 123 |
+
|
| 124 |
+
file_path = FILES_DIR / clean_filename
|
| 125 |
+
|
| 126 |
+
if not file_path.resolve().is_relative_to(FILES_DIR.resolve()) or not file_path.is_file():
|
| 127 |
+
logger.warning(f"Download failed: File not found or access denied for '{clean_filename}' by {request.client.host}")
|
| 128 |
+
raise HTTPException(status_code=404, detail="File not found or access denied.")
|
| 129 |
+
|
| 130 |
+
logger.info(f"File '{clean_filename}' downloaded by {request.client.host}")
|
| 131 |
+
return FileResponse(path=file_path, filename=clean_filename, media_type='application/octet-stream')
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
import uvicorn
|
| 135 |
+
logger.info("Starting Uvicorn server for local development...")
|
| 136 |
+
uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=True) # Added reload for easier dev
|
static/dummy
ADDED
|
File without changes
|
templates/admin_dashboard.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin_layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Dashboard{% endblock %}
|
| 4 |
+
{% block page_title %}Dashboard{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block content %}
|
| 7 |
+
<div class="row">
|
| 8 |
+
<div class="col-md-6">
|
| 9 |
+
<div class="card text-white bg-primary mb-3">
|
| 10 |
+
<div class="card-header">Uploaded Files</div>
|
| 11 |
+
<div class="card-body">
|
| 12 |
+
<h5 class="card-title">{{ num_files }}</h5>
|
| 13 |
+
<p class="card-text">Total files currently stored.</p>
|
| 14 |
+
<a href="{{ url_for('admin_files_page') }}" class="btn btn-light">Manage Files <i class="bi bi-arrow-right-short"></i></a>
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
<div class="col-md-6">
|
| 19 |
+
<div class="card text-dark bg-warning mb-3">
|
| 20 |
+
<div class="card-header">Application Logs</div>
|
| 21 |
+
<div class="card-body">
|
| 22 |
+
<h5 class="card-title">{{ log_lines_count }}</h5>
|
| 23 |
+
<p class="card-text">Total lines in the current log file.</p>
|
| 24 |
+
<a href="{{ url_for('admin_logs_page') }}" class="btn btn-dark">View Logs <i class="bi bi-arrow-right-short"></i></a>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="mt-4">
|
| 31 |
+
<h4>Quick Actions</h4>
|
| 32 |
+
<ul>
|
| 33 |
+
<li><a href="{{ url_for('read_root') }}" target="_blank">View User Upload Page</a></li>
|
| 34 |
+
<!-- Add more quick actions if needed -->
|
| 35 |
+
</ul>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="mt-4">
|
| 39 |
+
<h4>System Information (Example)</h4>
|
| 40 |
+
<p>This is a basic admin dashboard. You can expand it with more system information, statistics, or quick management tools.</p>
|
| 41 |
+
<p><strong>Admin Email:</strong> {{ request.user.email if request.user else "N/A" }} (Note: This is illustrative from dummy user)</p>
|
| 42 |
+
</div>
|
| 43 |
+
{% endblock %}
|
templates/admin_files.html
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin_layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Uploaded Files{% endblock %}
|
| 4 |
+
{% block page_title %}Uploaded Files{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block content %}
|
| 7 |
+
<div class="table-responsive">
|
| 8 |
+
<table class="table table-striped table-sm">
|
| 9 |
+
<thead>
|
| 10 |
+
<tr>
|
| 11 |
+
<th scope="col">#</th>
|
| 12 |
+
<th scope="col">Filename</th>
|
| 13 |
+
<th scope="col">Size</th>
|
| 14 |
+
<th scope="col">Last Modified (UTC)</th>
|
| 15 |
+
<th scope="col">Download URL</th>
|
| 16 |
+
<th scope="col">Actions</th>
|
| 17 |
+
</tr>
|
| 18 |
+
</thead>
|
| 19 |
+
<tbody>
|
| 20 |
+
{% if files %}
|
| 21 |
+
{% for file in files %}
|
| 22 |
+
<tr>
|
| 23 |
+
<td>{{ loop.index }}</td>
|
| 24 |
+
<td>{{ file.name }}</td>
|
| 25 |
+
<td>{{ file.size_human }}</td>
|
| 26 |
+
<td>{{ file.modified_at }}</td>
|
| 27 |
+
<td><a href="{{ file.url }}" target="_blank" title="User download link">Link</a></td>
|
| 28 |
+
<td>
|
| 29 |
+
<form method="post" action="{{ url_for('admin_delete_file', filename=file.name) }}" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete {{ file.name }}?');">
|
| 30 |
+
<button type="submit" class="btn btn-danger btn-sm">
|
| 31 |
+
<i class="bi bi-trash-fill"></i> Delete
|
| 32 |
+
</button>
|
| 33 |
+
</form>
|
| 34 |
+
</td>
|
| 35 |
+
</tr>
|
| 36 |
+
{% endfor %}
|
| 37 |
+
{% else %}
|
| 38 |
+
<tr>
|
| 39 |
+
<td colspan="6" class="text-center">No files uploaded yet.</td>
|
| 40 |
+
</tr>
|
| 41 |
+
{% endif %}
|
| 42 |
+
</tbody>
|
| 43 |
+
</table>
|
| 44 |
+
</div>
|
| 45 |
+
{% endblock %}
|
templates/admin_layout.html
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Admin Panel - {% block title %}Dashboard{% endblock %}</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
font-size: .875rem;
|
| 12 |
+
}
|
| 13 |
+
.sidebar {
|
| 14 |
+
position: fixed;
|
| 15 |
+
top: 0;
|
| 16 |
+
bottom: 0;
|
| 17 |
+
left: 0;
|
| 18 |
+
z-index: 100;
|
| 19 |
+
padding: 48px 0 0;
|
| 20 |
+
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
| 21 |
+
}
|
| 22 |
+
.sidebar-sticky {
|
| 23 |
+
position: relative;
|
| 24 |
+
top: 0;
|
| 25 |
+
height: calc(100vh - 48px);
|
| 26 |
+
padding-top: .5rem;
|
| 27 |
+
overflow-x: hidden;
|
| 28 |
+
overflow-y: auto;
|
| 29 |
+
}
|
| 30 |
+
.nav-link {
|
| 31 |
+
font-weight: 500;
|
| 32 |
+
color: #333;
|
| 33 |
+
}
|
| 34 |
+
.nav-link .bi {
|
| 35 |
+
margin-right: 4px;
|
| 36 |
+
color: #727272;
|
| 37 |
+
}
|
| 38 |
+
.nav-link.active {
|
| 39 |
+
color: #007bff;
|
| 40 |
+
}
|
| 41 |
+
.nav-link:hover .bi,
|
| 42 |
+
.nav-link.active .bi {
|
| 43 |
+
color: inherit;
|
| 44 |
+
}
|
| 45 |
+
.navbar-brand {
|
| 46 |
+
padding-top: .75rem;
|
| 47 |
+
padding-bottom: .75rem;
|
| 48 |
+
font-size: 1rem;
|
| 49 |
+
background-color: rgba(0, 0, 0, .25);
|
| 50 |
+
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
|
| 51 |
+
}
|
| 52 |
+
.navbar .navbar-toggler {
|
| 53 |
+
top: .25rem;
|
| 54 |
+
right: 1rem;
|
| 55 |
+
}
|
| 56 |
+
</style>
|
| 57 |
+
</head>
|
| 58 |
+
<body>
|
| 59 |
+
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
|
| 60 |
+
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="{{ url_for('admin_dashboard') }}">Admin Panel</a>
|
| 61 |
+
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
|
| 62 |
+
<span class="navbar-toggler-icon"></span>
|
| 63 |
+
</button>
|
| 64 |
+
<!-- Optional: Search bar or other elements -->
|
| 65 |
+
<div class="navbar-nav">
|
| 66 |
+
<div class="nav-item text-nowrap">
|
| 67 |
+
<a class="nav-link px-3" href="{{ url_for('admin_logout') }}">Sign out</a>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</header>
|
| 71 |
+
|
| 72 |
+
<div class="container-fluid">
|
| 73 |
+
<div class="row">
|
| 74 |
+
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
| 75 |
+
<div class="position-sticky pt-3">
|
| 76 |
+
<ul class="nav flex-column">
|
| 77 |
+
<li class="nav-item">
|
| 78 |
+
<a class="nav-link {% if request.url.path == url_for('admin_dashboard') %}active{% endif %}" aria-current="page" href="{{ url_for('admin_dashboard') }}">
|
| 79 |
+
<i class="bi bi-house-door-fill"></i> Dashboard
|
| 80 |
+
</a>
|
| 81 |
+
</li>
|
| 82 |
+
<li class="nav-item">
|
| 83 |
+
<a class="nav-link {% if request.url.path == url_for('admin_files_page') %}active{% endif %}" href="{{ url_for('admin_files_page') }}">
|
| 84 |
+
<i class="bi bi-file-earmark-arrow-up-fill"></i> Uploaded Files
|
| 85 |
+
</a>
|
| 86 |
+
</li>
|
| 87 |
+
<li class="nav-item">
|
| 88 |
+
<a class="nav-link {% if request.url.path == url_for('admin_logs_page') %}active{% endif %}" href="{{ url_for('admin_logs_page') }}">
|
| 89 |
+
<i class="bi bi-card-list"></i> Application Logs
|
| 90 |
+
</a>
|
| 91 |
+
</li>
|
| 92 |
+
<li class="nav-item mt-auto mb-2">
|
| 93 |
+
<a class="nav-link" href="{{ url_for('read_root') }}" target="_blank">
|
| 94 |
+
<i class="bi bi-box-arrow-up-right"></i> View Main Site
|
| 95 |
+
</a>
|
| 96 |
+
</li>
|
| 97 |
+
</ul>
|
| 98 |
+
</div>
|
| 99 |
+
</nav>
|
| 100 |
+
|
| 101 |
+
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
| 102 |
+
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
| 103 |
+
<h1 class="h2">{% block page_title %}{% endblock %}</h1>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{% block content %}{% endblock %}
|
| 107 |
+
</main>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
| 112 |
+
</body>
|
| 113 |
+
</html>
|
templates/admin_login.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Admin Login</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body {
|
| 10 |
+
display: flex;
|
| 11 |
+
align-items: center;
|
| 12 |
+
justify-content: center;
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
background-color: #f5f5f5;
|
| 15 |
+
}
|
| 16 |
+
.form-signin {
|
| 17 |
+
width: 100%;
|
| 18 |
+
max-width: 330px;
|
| 19 |
+
padding: 15px;
|
| 20 |
+
margin: auto;
|
| 21 |
+
}
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<main class="form-signin text-center">
|
| 26 |
+
<form method="post" action="{{ url_for('admin_authenticate') }}">
|
| 27 |
+
<!-- <img class="mb-4" src="/docs/5.3/assets/brand/bootstrap-logo.svg" alt="" width="72" height="57"> -->
|
| 28 |
+
<h1 class="h3 mb-3 fw-normal">Admin Panel Login</h1>
|
| 29 |
+
|
| 30 |
+
{% if error %}
|
| 31 |
+
<div class="alert alert-danger" role="alert">
|
| 32 |
+
{{ error }}
|
| 33 |
+
</div>
|
| 34 |
+
{% endif %}
|
| 35 |
+
|
| 36 |
+
<div class="form-floating mb-2">
|
| 37 |
+
<input type="email" class="form-control" id="email" name="email" placeholder="name@example.com" required value="admin@example.com">
|
| 38 |
+
<label for="email">Email address</label>
|
| 39 |
+
</div>
|
| 40 |
+
<div class="form-floating">
|
| 41 |
+
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required value="adminpassword">
|
| 42 |
+
<label for="password">Password</label>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<!-- <div class="checkbox mb-3">
|
| 46 |
+
<label>
|
| 47 |
+
<input type="checkbox" value="remember-me"> Remember me
|
| 48 |
+
</label>
|
| 49 |
+
</div> -->
|
| 50 |
+
<button class="w-100 btn btn-lg btn-primary mt-3" type="submit">Sign in</button>
|
| 51 |
+
<p class="mt-5 mb-3 text-muted">© Your App Name {% now 'local', '%Y' %}</p>
|
| 52 |
+
</form>
|
| 53 |
+
</main>
|
| 54 |
+
</body>
|
| 55 |
+
</html>
|
templates/admin_logs.html
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin_layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Application Logs{% endblock %}
|
| 4 |
+
{% block page_title %}Application Logs{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block content %}
|
| 7 |
+
<div class="mb-3">
|
| 8 |
+
<p>Showing the last {{ log_entries|length }} log entries (newest first). The full log is in <code>app.log</code>.</p>
|
| 9 |
+
<a href="{{ url_for('admin_logs_page') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-clockwise"></i> Refresh Logs</a>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="log-container bg-light p-3 rounded border" style="max-height: 600px; overflow-y: auto; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; word-break: break-all;">
|
| 13 |
+
{% if log_entries %}
|
| 14 |
+
{% for entry in log_entries %}
|
| 15 |
+
<div>{{ entry.strip() }}</div>
|
| 16 |
+
{% endfor %}
|
| 17 |
+
{% else %}
|
| 18 |
+
<div>No log entries found or log file is empty.</div>
|
| 19 |
+
{% endif %}
|
| 20 |
+
</div>
|
| 21 |
+
{% endblock %}
|
templates/index.html
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>File Uploader</title>
|
| 7 |
+
<!-- Bootstrap CSS -->
|
| 8 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
padding-top: 40px;
|
| 12 |
+
padding-bottom: 40px;
|
| 13 |
+
background-color: #f5f5f5;
|
| 14 |
+
}
|
| 15 |
+
.container {
|
| 16 |
+
max-width: 600px;
|
| 17 |
+
}
|
| 18 |
+
.upload-form {
|
| 19 |
+
background-color: #fff;
|
| 20 |
+
padding: 30px;
|
| 21 |
+
border-radius: 8px;
|
| 22 |
+
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
| 23 |
+
}
|
| 24 |
+
.progress-container {
|
| 25 |
+
margin-top: 20px;
|
| 26 |
+
display: none; /* Hidden by default */
|
| 27 |
+
}
|
| 28 |
+
#uploadInfo {
|
| 29 |
+
margin-top: 10px;
|
| 30 |
+
font-size: 0.9em;
|
| 31 |
+
}
|
| 32 |
+
#downloadLinkContainer {
|
| 33 |
+
margin-top: 20px;
|
| 34 |
+
display: none; /* Hidden by default */
|
| 35 |
+
}
|
| 36 |
+
.loader {
|
| 37 |
+
border: 5px solid #f3f3f3; /* Light grey */
|
| 38 |
+
border-top: 5px solid #3498db; /* Blue */
|
| 39 |
+
border-radius: 50%;
|
| 40 |
+
width: 30px;
|
| 41 |
+
height: 30px;
|
| 42 |
+
animation: spin 1s linear infinite;
|
| 43 |
+
display: none; /* Hidden by default */
|
| 44 |
+
margin: 10px auto;
|
| 45 |
+
}
|
| 46 |
+
@keyframes spin {
|
| 47 |
+
0% { transform: rotate(0deg); }
|
| 48 |
+
100% { transform: rotate(360deg); }
|
| 49 |
+
}
|
| 50 |
+
</style>
|
| 51 |
+
</head>
|
| 52 |
+
<body>
|
| 53 |
+
<div class="container">
|
| 54 |
+
<div class="upload-form">
|
| 55 |
+
<h2 class="text-center mb-4">Upload a File</h2>
|
| 56 |
+
<form id="uploadForm" enctype="multipart/form-data">
|
| 57 |
+
<div class="mb-3">
|
| 58 |
+
<label for="fileInput" class="form-label">Choose file</label>
|
| 59 |
+
<input class="form-control" type="file" id="fileInput" name="file" required>
|
| 60 |
+
</div>
|
| 61 |
+
<button type="submit" class="btn btn-primary w-100">Upload</button>
|
| 62 |
+
</form>
|
| 63 |
+
|
| 64 |
+
<div class="loader" id="loader"></div>
|
| 65 |
+
|
| 66 |
+
<div class="progress-container" id="progressContainer">
|
| 67 |
+
<div class="progress">
|
| 68 |
+
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
| 69 |
+
</div>
|
| 70 |
+
<div id="uploadInfo" class="text-muted"></div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div id="downloadLinkContainer" class="alert alert-success" role="alert">
|
| 74 |
+
<strong>Success!</strong> File uploaded. <br>
|
| 75 |
+
Download link: <a href="#" id="downloadLink" target="_blank"></a>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div id="errorContainer" class="alert alert-danger mt-3" role="alert" style="display: none;">
|
| 79 |
+
<strong>Error:</strong> <span id="errorMessage"></span>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- Bootstrap JS Bundle (Popper.js included) -->
|
| 85 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
| 86 |
+
<script>
|
| 87 |
+
const uploadForm = document.getElementById('uploadForm');
|
| 88 |
+
const fileInput = document.getElementById('fileInput');
|
| 89 |
+
const loader = document.getElementById('loader');
|
| 90 |
+
const progressContainer = document.getElementById('progressContainer');
|
| 91 |
+
const progressBar = document.getElementById('progressBar');
|
| 92 |
+
const uploadInfo = document.getElementById('uploadInfo');
|
| 93 |
+
const downloadLinkContainer = document.getElementById('downloadLinkContainer');
|
| 94 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 95 |
+
const errorContainer = document.getElementById('errorContainer');
|
| 96 |
+
const errorMessage = document.getElementById('errorMessage');
|
| 97 |
+
let startTime;
|
| 98 |
+
uploadForm.addEventListener('submit', async function(event) {
|
| 99 |
+
event.preventDefault();
|
| 100 |
+
const file = fileInput.files[0];
|
| 101 |
+
if (!file) {
|
| 102 |
+
alert('Please select a file to upload.');
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
const formData = new FormData();
|
| 106 |
+
formData.append('file', file);
|
| 107 |
+
// Reset UI
|
| 108 |
+
loader.style.display = 'block';
|
| 109 |
+
progressContainer.style.display = 'block';
|
| 110 |
+
progressBar.style.width = '0%';
|
| 111 |
+
progressBar.textContent = '0%';
|
| 112 |
+
uploadInfo.textContent = 'Starting upload...';
|
| 113 |
+
downloadLinkContainer.style.display = 'none';
|
| 114 |
+
errorContainer.style.display = 'none';
|
| 115 |
+
startTime = Date.now();
|
| 116 |
+
const xhr = new XMLHttpRequest();
|
| 117 |
+
xhr.open('POST', '/upload/', true);
|
| 118 |
+
xhr.upload.onprogress = function(event) {
|
| 119 |
+
if (event.lengthComputable) {
|
| 120 |
+
const percentComplete = Math.round((event.loaded / event.total) * 100);
|
| 121 |
+
progressBar.style.width = percentComplete + '%';
|
| 122 |
+
progressBar.textContent = percentComplete + '%';
|
| 123 |
+
const elapsedTime = (Date.now() - startTime) / 1000; // seconds
|
| 124 |
+
const speed = event.loaded / elapsedTime; // bytes per second
|
| 125 |
+
const speedMbps = (speed * 8 / 1000000).toFixed(2); // Mbps
|
| 126 |
+
uploadInfo.textContent = `Uploaded ${formatBytes(event.loaded)} of ${formatBytes(event.total)} (${percentComplete}%) at ${speedMbps} Mbps`;
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
xhr.onload = function() {
|
| 130 |
+
loader.style.display = 'none';
|
| 131 |
+
if (xhr.status === 200) {
|
| 132 |
+
const response = JSON.parse(xhr.responseText);
|
| 133 |
+
uploadInfo.textContent = 'Upload complete!';
|
| 134 |
+
progressBar.classList.remove('progress-bar-animated');
|
| 135 |
+
progressBar.classList.add('bg-success');
|
| 136 |
+
downloadLink.href = response.download_url;
|
| 137 |
+
// The filename in the link text should be the one returned by the server (sanitized)
|
| 138 |
+
downloadLink.textContent = response.filename;
|
| 139 |
+
downloadLinkContainer.style.display = 'block';
|
| 140 |
+
} else {
|
| 141 |
+
progressBar.classList.remove('progress-bar-animated');
|
| 142 |
+
progressBar.classList.add('bg-danger');
|
| 143 |
+
try {
|
| 144 |
+
const errorResponse = JSON.parse(xhr.responseText);
|
| 145 |
+
errorMessage.textContent = errorResponse.detail || `Server error: ${xhr.status}`;
|
| 146 |
+
} catch (e) {
|
| 147 |
+
errorMessage.textContent = `Server error: ${xhr.status} - ${xhr.statusText}`;
|
| 148 |
+
}
|
| 149 |
+
errorContainer.style.display = 'block';
|
| 150 |
+
uploadInfo.textContent = 'Upload failed.';
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
xhr.onerror = function() {
|
| 154 |
+
loader.style.display = 'none';
|
| 155 |
+
progressBar.classList.remove('progress-bar-animated');
|
| 156 |
+
progressBar.classList.add('bg-danger');
|
| 157 |
+
errorMessage.textContent = 'Network error or server unavailable.';
|
| 158 |
+
errorContainer.style.display = 'block';
|
| 159 |
+
uploadInfo.textContent = 'Upload failed.';
|
| 160 |
+
};
|
| 161 |
+
xhr.send(formData);
|
| 162 |
+
});
|
| 163 |
+
function formatBytes(bytes, decimals = 2) {
|
| 164 |
+
if (bytes === 0) return '0 Bytes';
|
| 165 |
+
const k = 1024;
|
| 166 |
+
const dm = decimals < 0 ? 0 : decimals;
|
| 167 |
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
| 168 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 169 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
| 170 |
+
}
|
| 171 |
+
</script>
|
| 172 |
+
</body>
|
| 173 |
+
</html>
|