feat: add frontend views and file management functionality, enhance user response structure, and update configuration guide

This commit is contained in:
Christian Krakau-Louis
2025-04-03 21:49:15 +02:00
parent 2cafda11dd
commit d23a48b1c0
19 changed files with 892 additions and 1406 deletions
+3
View File
@@ -0,0 +1,3 @@
"""
Document processor application package.
"""
-986
View File
@@ -1,986 +0,0 @@
# app/api.py
from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File, Form
from hashlib import md5
from sqlalchemy.orm import Session
from typing import List
import os
import requests
import logging
from app.auth import require_login, get_current_user
from app.database import SessionLocal
from app.models import FileRecord
from app.config import settings
from app.tasks.process_document import process_document
from app.tasks.upload_to_dropbox import upload_to_dropbox
from app.tasks.upload_to_paperless import upload_to_paperless
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
from app.tasks.upload_to_google_drive import upload_to_google_drive
from app.tasks.upload_to_onedrive import upload_to_onedrive
from app.tasks.send_to_all import send_to_all_destinations
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/whoami")
async def whoami(request: Request):
"""
Returns user info if logged in, else 401.
"""
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401, detail="Not logged in")
email = user.get("email")
if not email:
raise HTTPException(status_code=400, detail="User has no email in session")
# Generate Gravatar URL from email
email_hash = md5(email.strip().lower().encode()).hexdigest()
gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
return {
"email": email,
"picture": gravatar_url
}
@router.get("/files")
@require_login
def list_files_api(request: Request, db: Session = Depends(get_db)):
"""
Returns a JSON list of all FileRecord entries.
Protected by `@require_login`, so only logged-in sessions can access.
Example response:
[
{
"id": 123,
"filehash": "abc123...",
"original_filename": "example.pdf",
"local_filename": "/workdir/tmp/<uuid>.pdf",
"file_size": 1048576,
"mime_type": "application/pdf",
"created_at": "2025-05-01T12:34:56.789000"
},
...
]
"""
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Return a simple list of dicts
result = []
for f in files:
result.append({
"id": f.id,
"filehash": f.filehash,
"original_filename": f.original_filename,
"local_filename": f.local_filename,
"file_size": f.file_size,
"mime_type": f.mime_type,
"created_at": f.created_at.isoformat() if f.created_at else None
})
return result
@router.delete("/files/{file_id}")
@require_login
def delete_file_record(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Delete a file record from the database.
This only removes the database entry, not the actual file.
"""
# Check if file deletion is allowed
if not settings.allow_file_delete:
raise HTTPException(
status_code=403,
detail="File deletion is disabled in the configuration"
)
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(
status_code=404,
detail=f"File record with ID {file_id} not found"
)
# Log the deletion
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
# Delete the record
db.delete(file_record)
db.commit()
return {
"status": "success",
"message": f"File record {file_id} deleted successfully"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.exception(f"Error deleting file record {file_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting file record: {str(e)}"
)
# API endpoints
@router.get("/diagnostic/settings")
@require_login
async def diagnostic_settings(request: Request, current_user: dict = Depends(get_current_user)):
"""
API endpoint to dump settings to the log and view basic config information
This endpoint doesn't expose sensitive information like passwords or tokens
"""
from app.utils.config_validator import dump_all_settings, get_settings_for_display
# Dump full settings to log for admin to see
dump_all_settings()
# Return safe subset of settings for API response
safe_settings = {
"workdir": settings.workdir,
"external_hostname": settings.external_hostname,
"configured_services": {
"email": bool(getattr(settings, 'email_host', None)),
"s3": bool(getattr(settings, 's3_bucket_name', None)),
"dropbox": bool(getattr(settings, 'dropbox_refresh_token', None)),
"nextcloud": bool(getattr(settings, 'nextcloud_upload_url', None)),
"sftp": bool(getattr(settings, 'sftp_host', None)),
"paperless": bool(getattr(settings, 'paperless_host', None)),
"google_drive": bool(getattr(settings, 'google_drive_credentials_json', None)),
"uptime_kuma": bool(getattr(settings, 'uptime_kuma_url', None)),
"auth": bool(getattr(settings, 'authentik_config_url', None)),
},
"imap_enabled": bool(getattr(settings, 'imap1_host', None) or getattr(settings, 'imap2_host', None)),
}
return {
"status": "success",
"settings": safe_settings,
"message": "Full settings have been dumped to application logs"
}
@router.post("/process/")
@require_login
def process(file_path: str):
"""API Endpoint to start document processing."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = process_document.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_dropbox/")
@require_login
def send_to_dropbox(file_path: str):
"""Send a document to Dropbox."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_dropbox.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_paperless/")
@require_login
def send_to_paperless(file_path: str):
"""Send a document to Paperless-ngx."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_paperless.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_nextcloud/")
@require_login
def send_to_nextcloud(file_path: str):
"""Send a document to NextCloud."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_nextcloud.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_google_drive/")
@require_login
def send_to_google_drive(file_path: str):
"""Send a document to Google Drive."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_google_drive.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_onedrive/")
@require_login
def send_to_onedrive_endpoint(file_path: str):
"""Send a document to OneDrive."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_onedrive.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_all_destinations/")
@require_login
def send_to_all_destinations_endpoint(file_path: str):
"""Call the aggregator task that sends this file to all configured destinations."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = send_to_all_destinations.delay(file_path)
return {"task_id": task.id, "status": "queued", "file_path": file_path}
@router.post("/processall")
@require_login
def process_all_pdfs_in_workdir():
"""Finds all .pdf files in <workdir> and enqueues them for processing."""
target_dir = settings.workdir
if not os.path.exists(target_dir):
raise HTTPException(
status_code=400, detail=f"Directory {target_dir} does not exist."
)
pdf_files = []
for filename in os.listdir(target_dir):
if filename.lower().endswith(".pdf"):
pdf_files.append(filename)
if not pdf_files:
return {"message": "No PDF files found in that directory."}
task_ids = []
for pdf in pdf_files:
file_path = os.path.join(target_dir, pdf)
task = process_document.delay(file_path)
task_ids.append(task.id)
return {
"message": f"Enqueued {len(pdf_files)} PDFs to upload_to_s3",
"pdf_files": pdf_files,
"task_ids": task_ids
}
@router.post("/ui-upload")
@require_login
async def ui_upload(request: Request, file: UploadFile = File(...)):
"""Endpoint to accept a user-uploaded file and enqueue it for processing."""
import uuid
import os.path
workdir = settings.workdir
# Extract just the filename without any path components to prevent path traversal
safe_filename = os.path.basename(file.filename)
# Generate a unique filename with UUID to prevent overwriting and filename conflicts
unique_id = str(uuid.uuid4())
# Keep the original extension if present
if "." in safe_filename:
file_extension = safe_filename.rsplit(".", 1)[1]
target_filename = f"{unique_id}.{file_extension}"
else:
target_filename = unique_id
# Store both the safe original name and the unique name
target_path = os.path.join(workdir, target_filename)
try:
with open(target_path, "wb") as f:
content = await file.read()
f.write(content)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save file: {e}"
)
# Log the mapping between original and safe filename
print(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
task = process_document.delay(target_path)
return {
"task_id": task.id,
"status": "queued",
"original_filename": safe_filename,
"stored_filename": target_filename
}
@router.post("/onedrive/exchange-token")
@require_login
async def exchange_onedrive_token(
request: Request,
client_id: str = Form(...),
client_secret: str = Form(...),
redirect_uri: str = Form(...),
code: str = Form(...),
tenant_id: str = Form(...)
):
"""
Exchange an authorization code for a refresh token.
This is done on the server to avoid exposing client secret in the browser.
"""
try:
logger.info(f"Starting OneDrive token exchange process with tenant_id: {tenant_id}")
# Prepare the token request
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
logger.info(f"Using token URL: {token_url}")
payload = {
'client_id': client_id,
'scope': 'https://graph.microsoft.com/.default offline_access',
'code': code,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
'client_secret': client_secret
}
# Log request details (excluding secret)
safe_payload = payload.copy()
safe_payload['client_secret'] = '[REDACTED]'
safe_payload['code'] = f"{code[:5]}...{code[-5:]}" if len(code) > 10 else '[REDACTED]'
logger.info(f"Token exchange request payload: {safe_payload}")
# Make the token request
logger.info("Sending POST request to Microsoft for token exchange")
response = requests.post(token_url, data=payload)
# Check if the request was successful
logger.info(f"Token exchange response status: {response.status_code}")
if response.status_code != 200:
# Log the error response for debugging
try:
error_json = response.json()
logger.error(f"Token exchange failed with status {response.status_code}: {error_json}")
error_detail = error_json
except Exception as json_err:
logger.error(f"Failed to parse error response as JSON: {str(json_err)}")
logger.error(f"Raw response content: {response.content[:500]}") # Limit log size
error_detail = {"error": "Unknown error", "raw_content_snippet": str(response.content[:100])}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Token exchange failed: {error_detail}"
)
# Return the token response
token_data = response.json()
# Validate the token response
if "refresh_token" not in token_data:
logger.error(f"Microsoft returned success but no refresh token found in response: {token_data.keys()}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Microsoft OAuth server returned success but no refresh token was included"
)
# Calculate token length for logging
refresh_token_length = len(token_data.get("refresh_token", ""))
access_token_length = len(token_data.get("access_token", ""))
logger.info(f"Successfully exchanged authorization code for OneDrive tokens. "
f"Refresh token length: {refresh_token_length}, "
f"Access token length: {access_token_length}")
# Return just what's needed by the frontend
return {
"refresh_token": token_data["refresh_token"],
"expires_in": token_data.get("expires_in", 3600)
}
except HTTPException:
# Re-raise HTTP exceptions as they already have appropriate status codes
raise
except Exception as e:
logger.exception(f"Unexpected error during OneDrive token exchange: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to exchange token: {str(e)}"
)
@router.get("/onedrive/test-token")
@require_login
async def test_onedrive_token(request: Request):
"""
Test if the configured OneDrive refresh token is valid.
Provides detailed error information if token is invalid.
"""
try:
from app.tasks.upload_to_onedrive import get_onedrive_token
logger.info("Testing OneDrive token validity")
if not settings.onedrive_refresh_token:
logger.warning("No OneDrive refresh token configured")
return {
"status": "error",
"message": "No OneDrive refresh token is configured"
}
# Check if client ID and client secret are configured
if not settings.onedrive_client_id or not settings.onedrive_client_secret:
logger.warning("OneDrive client ID or client secret is missing")
return {
"status": "error",
"message": "OneDrive client ID or client secret is missing",
"missing_config": True
}
# Try to get an access token using the configured refresh token
try:
access_token = get_onedrive_token()
# If we got here, token is valid
logger.info("OneDrive token is valid")
return {
"status": "success",
"message": "OneDrive token is valid",
}
except Exception as e:
error_msg = str(e)
logger.error(f"OneDrive token test failed: {error_msg}")
# Determine if this is an invalid_grant error (expired token)
is_expired = "invalid_grant" in error_msg.lower()
return {
"status": "error",
"message": f"Token validation failed: {error_msg}",
"is_expired": is_expired,
"needs_reauth": True
}
except Exception as e:
logger.exception("Unexpected error testing OneDrive token")
return {
"status": "error",
"message": f"Unexpected error: {str(e)}"
}
@router.post("/onedrive/save-settings")
@require_login
async def save_onedrive_settings(
request: Request,
client_id: str = Form(None),
client_secret: str = Form(None),
refresh_token: str = Form(...),
tenant_id: str = Form("common"),
folder_path: str = Form(None)
):
"""
Save OneDrive settings to the .env file
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.error(f".env file not found at {env_path}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not find .env file to update"
)
logger.info(f"Updating OneDrive settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Define settings to update
onedrive_settings = {
"ONEDRIVE_REFRESH_TOKEN": refresh_token,
}
# Only update these if provided
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if line.startswith(f"{key}=") or line.startswith(f"# {key}="):
if line.startswith("# "): # Uncomment if commented out
line = line[2:]
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(line)
# Add any settings that weren't updated (they weren't in the file)
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
# Update the settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
if client_id:
settings.onedrive_client_id = client_id
if client_secret:
settings.onedrive_client_secret = client_secret
if tenant_id:
settings.onedrive_tenant_id = tenant_id
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Successfully updated OneDrive settings")
return {
"status": "success",
"message": "OneDrive settings have been saved"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save OneDrive settings: {str(e)}"
)
@router.post("/onedrive/update-settings")
@require_login
async def update_onedrive_settings(
request: Request,
client_id: str = Form(None),
client_secret: str = Form(None),
refresh_token: str = Form(...),
tenant_id: str = Form("common"),
folder_path: str = Form(None)
):
"""
Update OneDrive settings in memory (without modifying .env file)
"""
try:
logger.info("Updating OneDrive settings in memory")
# Update settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory")
if client_id:
settings.onedrive_client_id = client_id
logger.info("Updated ONEDRIVE_CLIENT_ID in memory")
if client_secret:
settings.onedrive_client_secret = client_secret
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory")
if tenant_id:
settings.onedrive_tenant_id = tenant_id
logger.info("Updated ONEDRIVE_TENANT_ID in memory")
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory")
# Test the token to make sure it works
try:
from app.tasks.upload_to_onedrive import get_onedrive_token
access_token = get_onedrive_token()
logger.info("Successfully tested OneDrive token")
except Exception as e:
logger.error(f"Token test failed after updating settings: {str(e)}")
return {
"status": "warning",
"message": "Settings updated but token test failed: " + str(e)
}
return {
"status": "success",
"message": "OneDrive settings have been updated in memory"
}
except Exception as e:
logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update OneDrive settings: {str(e)}"
)
@router.get("/onedrive/get-full-config")
@require_login
async def get_onedrive_full_config(request: Request):
"""
Get the full OneDrive configuration for sharing with worker nodes
"""
try:
# Create a configuration object with all OneDrive settings
config = {
"client_id": settings.onedrive_client_id or "",
"client_secret": settings.onedrive_client_secret or "",
"tenant_id": settings.onedrive_tenant_id or "common",
"refresh_token": settings.onedrive_refresh_token or "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads"
}
# Generate environment variable format
env_format = "\n".join([
f"ONEDRIVE_CLIENT_ID={config['client_id']}",
f"ONEDRIVE_CLIENT_SECRET={config['client_secret']}",
f"ONEDRIVE_TENANT_ID={config['tenant_id']}",
f"ONEDRIVE_REFRESH_TOKEN={config['refresh_token']}",
f"ONEDRIVE_FOLDER_PATH={config['folder_path']}"
])
return {
"status": "success",
"config": config,
"env_format": env_format
}
except Exception as e:
logger.exception("Error getting OneDrive configuration")
return {
"status": "error",
"message": str(e)
}
@router.post("/dropbox/exchange-token")
@require_login
async def exchange_dropbox_token(
request: Request,
client_id: str = Form(...),
client_secret: str = Form(...),
redirect_uri: str = Form(...),
code: str = Form(...),
folder_path: str = Form(None)
):
"""
Exchange an authorization code for a refresh token from Dropbox.
This is done on the server to avoid exposing client secret in the browser.
"""
try:
logger.info("Starting Dropbox token exchange process")
# Prepare the token request
token_url = "https://api.dropboxapi.com/oauth2/token"
payload = {
'client_id': client_id,
'client_secret': client_secret,
'code': code,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code'
}
# Log request details (excluding secret)
safe_payload = payload.copy()
safe_payload['client_secret'] = '[REDACTED]'
safe_payload['code'] = f"{code[:5]}...{code[-5:]}" if len(code) > 10 else '[REDACTED]'
logger.info(f"Token exchange request payload: {safe_payload}")
# Make the token request
logger.info("Sending POST request to Dropbox for token exchange")
response = requests.post(token_url, data=payload)
# Check if the request was successful
logger.info(f"Token exchange response status: {response.status_code}")
if response.status_code != 200:
# Log the error response for debugging
try:
error_json = response.json()
logger.error(f"Token exchange failed with status {response.status_code}: {error_json}")
error_detail = error_json
except Exception as json_err:
logger.error(f"Failed to parse error response as JSON: {str(json_err)}")
logger.error(f"Raw response content: {response.content[:500]}") # Limit log size
error_detail = {"error": "Unknown error", "raw_content_snippet": str(response.content[:100])}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Token exchange failed: {error_detail}"
)
# Return the token response
token_data = response.json()
# Validate the token response
if "refresh_token" not in token_data:
logger.error(f"Dropbox returned success but no refresh token found in response: {token_data.keys()}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Dropbox OAuth server returned success but no refresh token was included"
)
# Calculate token length for logging
refresh_token_length = len(token_data.get("refresh_token", ""))
access_token_length = len(token_data.get("access_token", ""))
logger.info(f"Successfully exchanged authorization code for Dropbox tokens. "
f"Refresh token length: {refresh_token_length}, "
f"Access token length: {access_token_length}")
# Return just what's needed by the frontend
return {
"refresh_token": token_data["refresh_token"],
"access_token": token_data["access_token"],
"expires_in": token_data.get("expires_in", 14400)
}
except HTTPException:
# Re-raise HTTP exceptions as they already have appropriate status codes
raise
except Exception as e:
logger.exception(f"Unexpected error during Dropbox token exchange: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to exchange token: {str(e)}"
)
@router.post("/dropbox/update-settings")
@require_login
async def update_dropbox_settings(
request: Request,
app_key: str = Form(None),
app_secret: str = Form(None),
refresh_token: str = Form(...),
folder_path: str = Form(None)
):
"""
Update Dropbox settings in memory
"""
try:
logger.info("Updating Dropbox settings in memory")
# Update settings in memory
if refresh_token:
settings.dropbox_refresh_token = refresh_token
logger.info("Updated DROPBOX_REFRESH_TOKEN in memory")
if app_key:
settings.dropbox_app_key = app_key
logger.info("Updated DROPBOX_APP_KEY in memory")
if app_secret:
settings.dropbox_app_secret = app_secret
logger.info("Updated DROPBOX_APP_SECRET in memory")
if folder_path:
settings.dropbox_folder = folder_path
logger.info("Updated DROPBOX_FOLDER in memory")
# Test token validity would be here, but we'll skip it for now
return {
"status": "success",
"message": "Dropbox settings have been updated in memory"
}
except Exception as e:
logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update Dropbox settings: {str(e)}"
)
@router.get("/dropbox/test-token")
@require_login
async def test_dropbox_token(request: Request):
"""
Test if the configured Dropbox refresh token is valid.
"""
try:
from app.tasks.upload_to_dropbox import get_dropbox_client
logger.info("Testing Dropbox token validity")
if not settings.dropbox_refresh_token:
logger.warning("No Dropbox refresh token configured")
return {
"status": "error",
"message": "No Dropbox refresh token is configured"
}
# Check if app key and app secret are configured
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
logger.warning("Dropbox app key or app secret is missing")
return {
"status": "error",
"message": "Dropbox app key or app secret is missing",
"missing_config": True
}
# Try to get a client using the configured refresh token
try:
dbx = get_dropbox_client()
# Test connection by getting account info
account = dbx.users_get_current_account()
logger.info(f"Successfully connected to Dropbox as {account.name.display_name}")
return {
"status": "success",
"message": f"Token is valid! Connected as {account.name.display_name}",
"account": account.name.display_name,
"email": account.email
}
except Exception as e:
error_msg = str(e)
logger.error(f"Dropbox token test failed: {error_msg}")
# Determine if this is an authentication error
is_auth_error = "auth" in error_msg.lower() or "invalid" in error_msg.lower()
return {
"status": "error",
"message": f"Token validation failed: {error_msg}",
"is_auth_error": is_auth_error,
"needs_reauth": is_auth_error
}
except Exception as e:
logger.exception("Unexpected error testing Dropbox token")
return {
"status": "error",
"message": f"Unexpected error: {str(e)}"
}
@router.post("/dropbox/save-settings")
@require_login
async def save_dropbox_settings(
request: Request,
app_key: str = Form(None),
app_secret: str = Form(None),
refresh_token: str = Form(...),
folder_path: str = Form(None)
):
"""
Save Dropbox settings to the .env file
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.error(f".env file not found at {env_path}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not find .env file to update"
)
logger.info(f"Updating Dropbox settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Define settings to update
dropbox_settings = {
"DROPBOX_REFRESH_TOKEN": refresh_token,
}
# Only update these if provided
if app_key:
dropbox_settings["DROPBOX_APP_KEY"] = app_key
if app_secret:
dropbox_settings["DROPBOX_APP_SECRET"] = app_secret
if folder_path:
dropbox_settings["DROPBOX_FOLDER"] = folder_path
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
line = line.rstrip()
is_updated = False
for key, value in dropbox_settings.items():
if line.startswith(f"{key}=") or line.startswith(f"# {key}="):
if line.startswith("# "): # Uncomment if commented out
line = line[2:]
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(line)
# Add any settings that weren't updated (they weren't in the file)
for key, value in dropbox_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
# Update the settings in memory
if refresh_token:
settings.dropbox_refresh_token = refresh_token
if app_key:
settings.dropbox_app_key = app_key
if app_secret:
settings.dropbox_app_secret = app_secret
if folder_path:
settings.dropbox_folder = folder_path
logger.info("Successfully updated Dropbox settings")
return {
"status": "success",
"message": "Dropbox settings have been saved"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save Dropbox settings: {str(e)}"
)
+15 -6
View File
@@ -10,8 +10,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/whoami")
async def whoami(request: Request):
async def whoami_handler(request: Request):
"""
Returns user info if logged in, else 401.
"""
@@ -26,8 +25,18 @@ async def whoami(request: Request):
# Generate Gravatar URL from email
email_hash = md5(email.strip().lower().encode()).hexdigest()
gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
# Add the gravatar URL to the user object instead of creating a new response
user_response = user.copy() # Create a copy to avoid modifying the session
user_response["picture"] = gravatar_url
return user_response
return {
"email": email,
"picture": gravatar_url
}
# Register the same handler under two different paths
@router.get("/whoami")
async def whoami(request: Request):
return await whoami_handler(request)
@router.get("/auth/whoami")
async def auth_whoami(request: Request):
return await whoami_handler(request)
+1 -1
View File
@@ -71,4 +71,4 @@ if AUTH_ENABLED:
async def private_page(request: Request):
"""A protected endpoint that requires login."""
user = request.session.get("user") # e.g. {"email": "...", ...}
return {"message": f"This is a protected page. Hello {user['email']}!"}
return {"message": "This is a protected page.", "user": user}
+8 -221
View File
@@ -1,223 +1,10 @@
# app/frontend.py
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.templating import Jinja2Templates
from pathlib import Path
from sqlalchemy.orm import Session
from datetime import datetime
import logging
from fastapi.responses import FileResponse # Add this import
"""
Frontend routes for the application.
This module is now a re-export of the modularized view routers.
"""
# Import and re-export the router from the views package
from app.views import router
from app.auth import require_login
from app.database import SessionLocal
from app.config import settings
router = APIRouter()
# Set up Jinja2 templates
templates_dir = Path(__file__).parent.parent / "frontend" / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Set up logging
logger = logging.getLogger(__name__)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/files")
@require_login
def files_page(request: Request):
"""
Return the 'files.html' template.
The actual file data is fetched via XHR from /api/files in the template.
"""
return templates.TemplateResponse("files.html", {"request": request})
@router.get("/", include_in_schema=False)
async def serve_index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/about", include_in_schema=False)
async def serve_about(request: Request):
return templates.TemplateResponse("about.html", {"request": request})
@router.get("/upload", include_in_schema=False)
@require_login
async def serve_upload(request: Request):
return templates.TemplateResponse("upload.html", {"request": request})
@router.get("/favicon.ico", include_in_schema=False)
def favicon():
# Return the favicon file as a FileResponse instead of the path string
favicon_path = Path(__file__).parent.parent / "frontend" / "static" / "favicon.ico"
if not favicon_path.exists():
# If favicon doesn't exist, return a 404
raise HTTPException(status_code=404, detail="Favicon not found")
return FileResponse(favicon_path)
@router.get("/status")
@require_login
async def status_dashboard(request: Request):
"""
Status dashboard showing all configured integration targets
"""
from app.utils.config_validator import get_provider_status
# Get provider status
providers = get_provider_status()
return templates.TemplateResponse(
"status_dashboard.html",
{
"request": request,
"providers": providers,
"app_version": settings.version, # Add app version to the context
"debug_enabled": getattr(settings, 'debug', False),
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
)
@router.get("/env")
@require_login
async def env_debug(request: Request):
"""
Debug endpoint to view environment variables and settings
Uses actual debug setting from config
"""
# Use the actual debug setting from configuration
debug_enabled = settings.debug
# Get settings data
from app.utils.config_validator import get_settings_for_display
settings_data = get_settings_for_display(show_values=debug_enabled)
return templates.TemplateResponse(
"env_debug.html",
{
"request": request,
"settings": settings_data,
"debug_enabled": debug_enabled,
"app_version": settings.version
}
)
@router.get("/onedrive-setup")
@require_login
async def onedrive_setup_page(request: Request):
"""
Setup page for the OneDrive integration.
Shows configuration status and setup instructions.
"""
# Check OneDrive configuration
is_configured = bool(settings.onedrive_client_id and
settings.onedrive_client_secret and
settings.onedrive_refresh_token)
# Get configuration values to display status (hide sensitive values)
return templates.TemplateResponse(
"onedrive.html",
{
"request": request,
"is_configured": is_configured,
"client_id": bool(settings.onedrive_client_id),
"client_id_value": settings.onedrive_client_id or "", # Pass the actual value for the form
"client_secret": bool(settings.onedrive_client_secret),
"client_secret_value": settings.onedrive_client_secret if settings.onedrive_client_secret else "",
"tenant_id": settings.onedrive_tenant_id,
"refresh_token": bool(settings.onedrive_refresh_token),
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads" # Default folder path
}
)
@router.get("/onedrive-callback")
@require_login
async def onedrive_callback(request: Request, code: str = None, error: str = None):
"""
Callback endpoint for OneDrive OAuth flow.
Now automatically exchanges the code for a token and saves it to the configuration.
"""
if error:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": error}
)
if not code:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": "No authorization code received from Microsoft"}
)
# Display the processing page with automatic token exchange
return templates.TemplateResponse(
"onedrive_callback.html",
{
"request": request,
"code": code,
"client_id_value": settings.onedrive_client_id or "",
"client_secret_value": settings.onedrive_client_secret or "",
"tenant_id": settings.onedrive_tenant_id or "common"
}
)
@router.get("/dropbox-setup")
@require_login
async def dropbox_setup_page(request: Request):
"""
Setup page for the Dropbox integration.
Shows configuration status and setup instructions.
"""
# Check Dropbox configuration
is_configured = bool(settings.dropbox_app_key and
settings.dropbox_app_secret and
settings.dropbox_refresh_token)
return templates.TemplateResponse(
"dropbox.html",
{
"request": request,
"is_configured": is_configured,
"app_key_value": settings.dropbox_app_key or "",
"app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "",
"refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "",
"folder_path": settings.dropbox_folder or "/Documents/Uploads" # Default folder path
}
)
@router.get("/dropbox-callback")
@require_login
async def dropbox_callback(request: Request, code: str = None, error: str = None):
"""
Callback endpoint for Dropbox OAuth flow.
Automatically exchanges the code for a token and saves it to the configuration.
"""
if error:
return templates.TemplateResponse(
"dropbox_callback_error.html",
{"request": request, "error": error}
)
if not code:
return templates.TemplateResponse(
"dropbox_callback_error.html",
{"request": request, "error": "No authorization code received from Dropbox"}
)
# Display the processing page with automatic token exchange
# Note: We provide empty strings for app_key_value and app_secret_value
# to prevent overriding what's in sessionStorage
return templates.TemplateResponse(
"dropbox_callback.html",
{
"request": request,
"code": code,
"app_key_value": "", # The callback will prioritize sessionStorage values
"app_secret_value": "", # The callback will prioritize sessionStorage values
"folder_path": "" # The callback will prioritize sessionStorage values
}
)
# Keep the original router name for compatibility
# This allows existing imports in main.py to continue working
+5 -5
View File
@@ -15,9 +15,9 @@ from app.database import init_db
from app.config import settings
from app.utils.config_validator import check_all_configs
# Import both the traditional and new routers
from app.frontend import router as frontend_router_original
from app.api import router as api_router # Updated import for the refactored API router
# Import the routers - now using views directly instead of frontend
from app.views import router as frontend_router
from app.api import router as api_router
from app.auth import router as auth_router
# Load configuration from .env for the session key
@@ -95,8 +95,8 @@ def test_500():
raise RuntimeError("Testing forced 500 error!")
# Include the routers
app.include_router(frontend_router_original)
app.include_router(frontend_router)
app.include_router(auth_router)
app.include_router(api_router, prefix="/api") # Use the refactored API router
app.include_router(api_router, prefix="/api")
+22
View File
@@ -0,0 +1,22 @@
"""
Frontend views/pages for the application.
This module combines all the individual view routers into a single router.
"""
from fastapi import APIRouter
# Import routers from view modules
from app.views.general import router as general_router
from app.views.files import router as files_router
from app.views.status import router as status_router
from app.views.dropbox import router as dropbox_router
from app.views.onedrive import router as onedrive_router
# Create a combined router
router = APIRouter()
# Include all view routers
router.include_router(general_router)
router.include_router(files_router)
router.include_router(status_router)
router.include_router(dropbox_router)
router.include_router(onedrive_router)
+29
View File
@@ -0,0 +1,29 @@
"""
Base setup for views, containing shared functionality and imports.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.templating import Jinja2Templates
from pathlib import Path
from sqlalchemy.orm import Session
import logging
from app.auth import require_login
from app.database import SessionLocal
from app.config import settings
# Set up Jinja2 templates
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Set up logging
logger = logging.getLogger(__name__)
def get_db():
"""
Dependency to get a database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+65
View File
@@ -0,0 +1,65 @@
"""
Dropbox integration views for setup and OAuth callback.
"""
from fastapi import Request
from app.views.base import APIRouter, templates, require_login, settings
router = APIRouter()
@router.get("/dropbox-setup")
@require_login
async def dropbox_setup_page(request: Request):
"""
Setup page for the Dropbox integration.
Shows configuration status and setup instructions.
"""
# Check Dropbox configuration
is_configured = bool(settings.dropbox_app_key and
settings.dropbox_app_secret and
settings.dropbox_refresh_token)
return templates.TemplateResponse(
"dropbox.html",
{
"request": request,
"is_configured": is_configured,
"app_key_value": settings.dropbox_app_key or "",
"app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "",
"refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "",
"folder_path": settings.dropbox_folder or "/Documents/Uploads" # Default folder path
}
)
@router.get("/dropbox-callback")
@require_login
async def dropbox_callback(request: Request, code: str = None, error: str = None):
"""
Callback endpoint for Dropbox OAuth flow.
Automatically exchanges the code for a token and saves it to the configuration.
"""
if error:
return templates.TemplateResponse(
"dropbox_callback_error.html",
{"request": request, "error": error}
)
if not code:
return templates.TemplateResponse(
"dropbox_callback_error.html",
{"request": request, "error": "No authorization code received from Dropbox"}
)
# Display the processing page with automatic token exchange
# Note: We provide empty strings for app_key_value and app_secret_value
# to prevent overriding what's in sessionStorage
return templates.TemplateResponse(
"dropbox_callback.html",
{
"request": request,
"code": code,
"app_key_value": "", # The callback will prioritize sessionStorage values
"app_secret_value": "", # The callback will prioritize sessionStorage values
"folder_path": "" # The callback will prioritize sessionStorage values
}
)
+39
View File
@@ -0,0 +1,39 @@
"""
File management views for displaying and managing files.
"""
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from app.views.base import APIRouter, templates, require_login, get_db, logger
router = APIRouter()
@router.get("/files")
@require_login
def files_page(request: Request, db: Session = Depends(get_db)):
"""
Return the 'files.html' template with files from the database
"""
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
# Fetch all files from the database
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Debug output
logger.info(f"Retrieved {len(files)} files from database")
return templates.TemplateResponse("files.html", {
"request": request,
"files": files
})
except Exception as e:
# Log any errors
logger.error(f"Error retrieving files: {str(e)}")
# Return error message to template
return templates.TemplateResponse("files.html", {
"request": request,
"files": [],
"error": str(e)
})
+35
View File
@@ -0,0 +1,35 @@
"""
General routes for the application homepage and basic pages.
"""
from fastapi import Request, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
from app.views.base import APIRouter, templates, require_login
router = APIRouter()
@router.get("/", include_in_schema=False)
async def serve_index(request: Request):
"""Serve the index/home page."""
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/about", include_in_schema=False)
async def serve_about(request: Request):
"""Serve the about page."""
return templates.TemplateResponse("about.html", {"request": request})
@router.get("/upload", include_in_schema=False)
@require_login
async def serve_upload(request: Request):
"""Serve the upload page."""
return templates.TemplateResponse("upload.html", {"request": request})
@router.get("/favicon.ico", include_in_schema=False)
def favicon():
"""Serve the favicon."""
favicon_path = Path(__file__).parent.parent.parent / "frontend" / "static" / "favicon.ico"
if not favicon_path.exists():
# If favicon doesn't exist, return a 404
raise HTTPException(status_code=404, detail="Favicon not found")
return FileResponse(favicon_path)
+68
View File
@@ -0,0 +1,68 @@
"""
OneDrive integration views for setup and OAuth callback.
"""
from fastapi import Request
from app.views.base import APIRouter, templates, require_login, settings
router = APIRouter()
@router.get("/onedrive-setup")
@require_login
async def onedrive_setup_page(request: Request):
"""
Setup page for the OneDrive integration.
Shows configuration status and setup instructions.
"""
# Check OneDrive configuration
is_configured = bool(settings.onedrive_client_id and
settings.onedrive_client_secret and
settings.onedrive_refresh_token)
# Get configuration values to display status (hide sensitive values)
return templates.TemplateResponse(
"onedrive.html",
{
"request": request,
"is_configured": is_configured,
"client_id": bool(settings.onedrive_client_id),
"client_id_value": settings.onedrive_client_id or "", # Pass the actual value for the form
"client_secret": bool(settings.onedrive_client_secret),
"client_secret_value": settings.onedrive_client_secret if settings.onedrive_client_secret else "",
"tenant_id": settings.onedrive_tenant_id,
"refresh_token": bool(settings.onedrive_refresh_token),
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads" # Default folder path
}
)
@router.get("/onedrive-callback")
@require_login
async def onedrive_callback(request: Request, code: str = None, error: str = None):
"""
Callback endpoint for OneDrive OAuth flow.
Now automatically exchanges the code for a token and saves it to the configuration.
"""
if error:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": error}
)
if not code:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": "No authorization code received from Microsoft"}
)
# Display the processing page with automatic token exchange
return templates.TemplateResponse(
"onedrive_callback.html",
{
"request": request,
"code": code,
"client_id_value": settings.onedrive_client_id or "",
"client_secret_value": settings.onedrive_client_secret or "",
"tenant_id": settings.onedrive_tenant_id or "common"
}
)
+55
View File
@@ -0,0 +1,55 @@
"""
Status and configuration views for the application.
"""
from fastapi import Request
from datetime import datetime
from app.views.base import APIRouter, templates, require_login, settings
router = APIRouter()
@router.get("/status")
@require_login
async def status_dashboard(request: Request):
"""
Status dashboard showing all configured integration targets
"""
from app.utils.config_validator import get_provider_status
# Get provider status
providers = get_provider_status()
return templates.TemplateResponse(
"status_dashboard.html",
{
"request": request,
"providers": providers,
"app_version": settings.version,
"debug_enabled": getattr(settings, 'debug', False),
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
)
@router.get("/env")
@require_login
async def env_debug(request: Request):
"""
Debug endpoint to view environment variables and settings
Uses actual debug setting from config
"""
# Use the actual debug setting from configuration
debug_enabled = settings.debug
# Get settings data
from app.utils.config_validator import get_settings_for_display
settings_data = get_settings_for_display(show_values=debug_enabled)
return templates.TemplateResponse(
"env_debug.html",
{
"request": request,
"settings": settings_data,
"debug_enabled": debug_enabled,
"app_version": settings.version
}
)
+2
View File
@@ -15,6 +15,7 @@ Configuration is primarily done through environment variables specified in a `.e
| `WORKDIR` | Working directory for the application. | `/workdir` |
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docunova.example.com` |
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
### IMAP Configuration
@@ -184,6 +185,7 @@ REDIS_URL=redis://redis:6379/0
WORKDIR=/workdir
GOTENBERG_URL=http://gotenberg:3000
EXTERNAL_HOSTNAME=docunova.example.com
ALLOW_FILE_DELETE=true
# IMAP settings
IMAP1_HOST=mail.example.com
+47 -3
View File
@@ -4,7 +4,7 @@ This guide explains how to set up the Dropbox integration for DocuNova.
## Required Configuration Parameters
| **Variable** | **Description** |
| **Variable** | **Description** |
|-------------------------|--------------------------------------------------|
| `DROPBOX_APP_KEY` | Dropbox API app key |
| `DROPBOX_APP_SECRET` | Dropbox API app secret |
@@ -13,7 +13,29 @@ This guide explains how to set up the Dropbox integration for DocuNova.
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
## Step-by-Step Setup Instructions
## Setup Methods
You can set up Dropbox integration in two ways:
1. **Using the Built-in Setup Wizard (Recommended)**: An interactive setup experience available at `/dropbox-setup` in the web interface
2. **Manual Setup**: Following the step-by-step instructions in this document
## Using the Setup Wizard
The easiest way to set up Dropbox integration is to use the built-in setup wizard:
1. Navigate to the `/dropbox-setup` page in your DocuNova instance
2. Follow the on-screen instructions to create a Dropbox app
3. Enter your App Key and App Secret in the wizard
4. Optionally specify a custom folder path for uploads
5. Click "Start Authentication Flow" to begin the authorization process
6. Complete the Dropbox authentication process
7. The system will automatically exchange the authorization code for a refresh token
8. Copy the generated environment variables for your worker nodes
The wizard handles all the token exchange steps and provides you with the exact configuration needed for your environment.
## Step-by-Step Manual Setup Instructions
### 1. Create a Dropbox App
@@ -73,4 +95,26 @@ For a complete list of configuration options, see the [Configuration Guide](Conf
1. Set `DROPBOX_APP_KEY`, `DROPBOX_APP_SECRET`, and `DROPBOX_REFRESH_TOKEN` with your values
2. Set `DROPBOX_FOLDER` to the path where files should be uploaded (e.g., `/Documents/Uploads`)
The system will use the refresh token to automatically generate short-lived access tokens when needed, so you shouldn't need to worry about token expiration.
## Token Management
The system will use the refresh token to automatically generate short-lived access tokens when needed. Refresh tokens typically don't expire unless revoked.
### Testing Your Token
You can test if your token is working correctly:
1. Navigate to the `/dropbox-setup` page in your DocuNova instance
2. Click the "Test Token" button to verify your credentials
3. If the test fails, click "Refresh Token" to obtain a new refresh token
## Troubleshooting
If you encounter issues with Dropbox integration:
1. **Authentication Errors**: Make sure your App Key and App Secret are correct
2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token
3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations
4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow
5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again
For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md).
+68 -41
View File
@@ -1,46 +1,73 @@
// frontend/static/js/common.js
(async function checkAuth() {
try {
const resp = await fetch("/api/whoami");
if (resp.ok) {
const data = await resp.json();
const authSection = document.getElementById("authSection");
if (!authSection) return;
authSection.innerHTML = '';
const img = document.createElement('img');
img.src = data.picture;
img.alt = 'User Avatar';
img.className = 'inline-block h-8 w-8 rounded-full mr-2';
const textNode = document.createTextNode('Logged in as ');
const strong = document.createElement('strong');
strong.textContent = data.email;
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800';
logoutLink.textContent = 'Logout';
authSection.appendChild(img);
authSection.appendChild(textNode);
authSection.appendChild(strong);
authSection.appendChild(logoutLink);
} else {
const authSection = document.getElementById("authSection");
if (authSection) {
authSection.innerHTML =
`<a href="/login" class="text-blue-600">Login</a>`;
}
}
} catch (error) {
// Fallback if whoami endpoint fails
const authSection = document.getElementById("authSection");
// Check authentication status and update the auth section
(async function() {
console.log('Checking authentication status...');
try {
const response = await fetch('/api/auth/whoami');
const data = await response.json();
const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection");
// If we have an email, user is authenticated (the whoami endpoint would have thrown 401 otherwise)
if (data.email) {
// Get the display name (prefer name, fall back to preferred_username, then email)
const displayName = data.name || data.preferred_username || data.email;
// User is logged in
let authHTML = `
<div class="flex items-center">
<img src="${data.picture}" alt="Avatar" class="w-8 h-8 rounded-full mr-2" />
<span>${displayName}</span>
<a href="/logout" class="ml-3 text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
`;
if (authSection) {
authSection.innerHTML =
`<a href="/login" class="text-blue-600">Login</a>`;
authSection.innerHTML = authHTML;
}
if (mobileAuthSection) {
mobileAuthSection.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-center">
<img src="${data.picture}" alt="Avatar" class="w-6 h-6 rounded-full mr-2" />
<span>${displayName}</span>
</div>
<a href="/logout" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
</div>
`;
}
} else {
// User is not logged in (this shouldn't happen with current setup, but keeping as fallback)
if (authSection) {
authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
}
if (mobileAuthSection) {
mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
}
}
})();
} catch (error) {
console.error('Authentication check failed:', error);
// Fallback if whoami endpoint fails
const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection");
if (authSection) {
authSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
}
if (mobileAuthSection) {
mobileAuthSection.innerHTML = `<a href="/login" class="text-blue-600">Login</a>`;
}
}
})();
// Other common functionality can be added here
+53 -21
View File
@@ -7,6 +7,8 @@
<!-- Tailwind CSS or other global CSS references -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="/static/styles.css" />
<!-- Alpine.js moved to head for earlier loading -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{% block head_css %}
<!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
@@ -38,26 +40,58 @@
</a>
</div>
<!-- Menu Items -->
<div class="hidden md:flex space-x-4 items-center">
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<!-- Menu Items - using x-data for mobile menu toggle -->
<div x-data="{ mobileMenuOpen: false }">
<div class="hidden md:flex space-x-4 items-center">
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<!-- Dynamic Auth Section -->
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
</div>
<!-- Dynamic Auth Section -->
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Mobile menu button -->
<div class="md:hidden">
<button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
:aria-expanded="mobileMenuOpen"
>
<span class="sr-only">Open main menu</span>
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- Mobile menu, show/hide based on menu state -->
<div
x-show="mobileMenuOpen"
x-transition:enter="transition ease-out duration-100 transform"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75 transform"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="md:hidden absolute top-16 inset-x-0 bg-white shadow-md z-50"
>
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<a href="/" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
<a href="/upload" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
<a href="/status" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Status</a>
<a href="/about" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a>
<!-- Mobile Auth Section -->
<div id="mobileAuthSection" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<!-- Will be populated by JS -->
</div>
</div>
</div>
</div>
</div>
</nav>
@@ -74,8 +108,6 @@
</div>
</footer>
<!-- Alpine.js for dropdown functionality -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- Common JS (shared) -->
<script src="/static/js/common.js"></script>
+185
View File
@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}File Records{% endblock %}
{% block head_extra %}
<!-- Include Grid.js CSS -->
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<script src="/static/js/common.js"></script>
<!-- Make sure Alpine.js is properly initialized -->
<script>
console.log('Before Alpine init on /files page');
document.addEventListener('alpine:init', () => {
console.log('Alpine.js initialized in files view');
});
console.log('After Alpine init listener registration');
</script>
<style>
.delete-btn {
color: #e53e3e;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: flex;
justify-content: center;
align-items: center;
}
.delete-btn:hover {
background-color: #fed7d7;
}
.confirm-delete-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.confirm-delete-content {
background-color: white;
padding: 1.5rem;
border-radius: 0.5rem;
max-width: 500px;
width: 90%;
}
.hidden {
display: none;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-6">File Records</h2>
<!-- Grid.js will render the table in this container -->
<div id="gridjs-wrapper"></div>
<!-- Confirmation Modal (temporarily disabled) -->
<div id="confirmDeleteModal" class="confirm-delete-modal hidden">
<div class="confirm-delete-content">
<h3 class="text-lg font-medium text-gray-900 mb-4">Confirm Deletion</h3>
<p class="text-sm text-gray-500 mb-4">
Are you sure you want to delete this file record? This action cannot be undone.
</p>
<p class="text-sm text-gray-700 mb-4">
<strong>File:</strong> <span id="deleteFileName"></span>
</p>
<div class="flex justify-end space-x-3">
<button id="cancelDelete" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
Cancel
</button>
<button id="confirmDelete" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Delete
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- Include Grid.js JS -->
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<script>
// File deletion handling
let fileToDelete = null;
const confirmDeleteModal = document.getElementById('confirmDeleteModal');
const deleteFileName = document.getElementById('deleteFileName');
const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete');
// Close modal
function closeDeleteModal() {
confirmDeleteModal.classList.add('hidden');
fileToDelete = null;
}
// Delete file directly without confirmation
async function deleteFile(fileId) {
try {
const response = await fetch(`/api/files/${fileId}`, {
method: 'DELETE'
});
if (response.ok) {
// Success - reload the grid
grid.forceRender();
} else {
// Show error
const error = await response.json();
alert(`Error: ${error.detail || 'Failed to delete file'}`);
}
} catch (err) {
console.error('Delete error:', err);
alert('An error occurred while deleting the file');
}
}
// Show delete confirmation modal (temporarily bypassed)
function showDeleteModal(fileId, fileName) {
// Bypass modal and delete immediately
deleteFile(fileId);
// The original code is commented out:
// fileToDelete = { id: fileId, name: fileName };
// deleteFileName.textContent = fileName || `ID: ${fileId}`;
// confirmDeleteModal.classList.remove('hidden');
}
// Handle delete confirmation
confirmDelete.addEventListener('click', async function() {
if (!fileToDelete) return;
deleteFile(fileToDelete.id);
closeDeleteModal();
});
// Cancel delete
cancelDelete.addEventListener('click', closeDeleteModal);
confirmDeleteModal.addEventListener('click', function(e) {
if (e.target === confirmDeleteModal) closeDeleteModal();
});
// Initialize the grid
const grid = new gridjs.Grid({
columns: [
{ id: 'id', name: 'ID' },
{ id: 'original_filename', name: 'Original Filename' },
{ id: 'file_size', name: 'File Size', formatter: (size) => `${(size / 1024).toFixed(2)} KB` },
{ id: 'mime_type', name: 'Mime Type' },
{ id: 'created_at', name: 'Created At' },
{
id: 'actions',
name: 'Actions',
formatter: (_, row) => {
return gridjs.h('button', {
className: 'delete-btn',
onClick: () => showDeleteModal(row.cells[0].data, row.cells[1].data)
}, [
gridjs.h('i', { className: 'fas fa-trash' }),
]);
}
}
],
server: {
url: '/api/files',
then: data => data.map(file => [
file.id,
file.original_filename || "",
file.file_size,
file.mime_type,
file.created_at || "",
"" // This cell will be rendered by the formatter
])
},
search: true,
sort: true,
pagination: {
limit: 10
}
}).render(document.getElementById("gridjs-wrapper"));
</script>
{% endblock %}
+192 -122
View File
@@ -2,169 +2,239 @@
{% block title %}File Records{% endblock %}
{% block head_extra %}
<!-- Include Grid.js CSS -->
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/static/js/common.js"></script>
<!-- Add TableSorter library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/theme.bootstrap_4.min.css">
<style>
.file-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.file-table th,
.file-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.file-table th {
background-color: #f7fafc;
font-weight: 600;
cursor: pointer;
}
.file-table th:hover {
background-color: #edf2f7;
}
.file-table tbody tr:hover {
background-color: #f7fafc;
}
.delete-btn {
color: #e53e3e;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
display: flex;
justify-content: center;
align-items: center;
}
.delete-btn:hover {
background-color: #fed7d7;
}
.confirm-delete-modal {
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
/* TableSorter specific styles */
.tablesorter-header-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
z-index: 1000;
justify-content: center;
z-index: 100;
align-items: center;
}
.confirm-delete-content {
.modal-content {
background-color: white;
padding: 1.5rem;
border-radius: 0.5rem;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.hidden {
display: none;
.modal-title {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1rem;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
gap: 0.75rem;
}
.modal-btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
.modal-btn-cancel {
background-color: #e2e8f0;
}
.modal-btn-cancel:hover {
background-color: #cbd5e0;
}
.modal-btn-delete {
background-color: #e53e3e;
color: white;
}
.modal-btn-delete:hover {
background-color: #c53030;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="container mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-6">File Records</h2>
<!-- Grid.js will render the table in this container -->
<div id="gridjs-wrapper"></div>
<!-- Confirmation Modal -->
<div id="confirmDeleteModal" class="confirm-delete-modal hidden">
<div class="confirm-delete-content">
<h3 class="text-lg font-medium text-gray-900 mb-4">Confirm Deletion</h3>
<p class="text-sm text-gray-500 mb-4">
Are you sure you want to delete this file record? This action cannot be undone.
</p>
<p class="text-sm text-gray-700 mb-4">
<strong>File:</strong> <span id="deleteFileName"></span>
</p>
<div class="flex justify-end space-x-3">
<button id="cancelDelete" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
Cancel
</button>
<button id="confirmDelete" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Delete
</button>
{% if error %}
<div class="error-message">
<p><strong>Error:</strong> {{ error }}</p>
<p class="mt-2">This might be due to a configuration or import issue. Please check the server logs.</p>
</div>
{% endif %}
<!-- File table -->
<div class="overflow-x-auto">
<table class="file-table" id="fileTable">
<thead>
<tr>
<th>ID</th>
<th>Original Filename</th>
<th>File Size</th>
<th>Mime Type</th>
<th>Created At</th>
<th class="sorter-false">Actions</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<td>{{ file.id }}</td>
<td>{{ file.original_filename }}</td>
<td data-sort-value="{{ file.file_size }}">{{ (file.file_size / 1024) | round(2) }} KB</td>
<td>{{ file.mime_type }}</td>
<td>{{ file.created_at }}</td>
<td>
<button onclick="showDeleteModal('{{ file.id }}')" class="delete-btn">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-4">No files found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Delete confirmation modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-title">Confirm Deletion</div>
<p>Are you sure you want to delete this file?</p>
<div class="modal-buttons">
<button id="cancelDelete" class="modal-btn modal-btn-cancel">Cancel</button>
<button id="confirmDelete" class="modal-btn modal-btn-delete">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- Include Grid.js JS -->
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<!-- Add JavaScript for handling DELETE requests -->
<script>
// File deletion handling
let fileToDelete = null;
const confirmDeleteModal = document.getElementById('confirmDeleteModal');
const deleteFileName = document.getElementById('deleteFileName');
// Modal functionality
const modal = document.getElementById('deleteModal');
const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete');
// Close modal
function closeDeleteModal() {
confirmDeleteModal.classList.add('hidden');
fileToDelete = null;
let currentFileId = null;
function showDeleteModal(fileId) {
currentFileId = fileId;
modal.style.display = 'flex';
}
// Show delete confirmation modal
function showDeleteModal(fileId, fileName) {
fileToDelete = { id: fileId, name: fileName };
deleteFileName.textContent = fileName || `ID: ${fileId}`;
confirmDeleteModal.classList.remove('hidden');
}
// Handle delete confirmation
confirmDelete.addEventListener('click', async function() {
if (!fileToDelete) return;
try {
const response = await fetch(`/api/files/${fileToDelete.id}`, {
method: 'DELETE'
});
if (response.ok) {
// Success - reload the grid
grid.forceRender();
closeDeleteModal();
} else {
// Show error
const error = await response.json();
alert(`Error: ${error.detail || 'Failed to delete file'}`);
closeDeleteModal();
}
} catch (err) {
console.error('Delete error:', err);
alert('An error occurred while deleting the file');
closeDeleteModal();
cancelDelete.addEventListener('click', () => {
modal.style.display = 'none';
});
confirmDelete.addEventListener('click', () => {
deleteFile(currentFileId);
modal.style.display = 'none';
});
// Close modal if clicking outside of it
window.addEventListener('click', (event) => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
// Cancel delete
cancelDelete.addEventListener('click', closeDeleteModal);
confirmDeleteModal.addEventListener('click', function(e) {
if (e.target === confirmDeleteModal) closeDeleteModal();
});
// Initialize the grid
const grid = new gridjs.Grid({
columns: [
{ id: 'id', name: 'ID' },
{ id: 'original_filename', name: 'Original Filename' },
{ id: 'file_size', name: 'File Size', formatter: (size) => `${(size / 1024).toFixed(2)} KB` },
{ id: 'mime_type', name: 'Mime Type' },
{ id: 'created_at', name: 'Created At' },
{
id: 'actions',
name: 'Actions',
formatter: (_, row) => {
return gridjs.h('button', {
className: 'delete-btn',
onClick: () => showDeleteModal(row.cells[0].data, row.cells[1].data)
}, [
gridjs.h('i', { className: 'fas fa-trash' }),
]);
}
function deleteFile(fileId) {
fetch(`/api/files/${fileId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
],
server: {
url: '/api/files',
then: data => data.map(file => [
file.id,
file.original_filename || "",
file.file_size,
file.mime_type,
file.created_at || "",
"" // This cell will be rendered by the formatter
])
},
search: true,
sort: true,
pagination: {
limit: 10
}
}).render(document.getElementById("gridjs-wrapper"));
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete file');
}
// Reload the page to show updated file list
window.location.reload();
})
.catch(error => {
console.error('Error:', error);
alert(`Error deleting file: ${error.message}`);
});
}
// Initialize TableSorter
$(document).ready(function() {
$("#fileTable").tablesorter({
theme: 'bootstrap',
widthFixed: true,
headerTemplate: '{content} {icon}',
widgets: ['zebra', 'stickyHeaders'],
sortList: [[0, 0]], // Default sort on the first column ascending
headers: {
5: { sorter: false } // Disable sorting on the Actions column
}
});
});
</script>
</div>
{% endblock %}