Merge pull request #40 from christianlouis/fa-logos

Update to version 0.4.2-dev with Font Awesome integration and enhanced provider icons
This commit is contained in:
Christian Krakau-Louis
2025-04-03 23:27:20 +02:00
committed by GitHub
37 changed files with 2205 additions and 1357 deletions
+1
View File
@@ -4,6 +4,7 @@ DATABASE_URL=sqlite:///./app/database.db
REDIS_URL=redis://redis:6379/0
EXTERNAL_HOSTNAME=docunova.example.com
GOTENBERG_URL=http://gotenberg:3000
ALLOW_FILE_DELETE=true # Allow deletion of file records
# **Authentication**
AUTH_ENABLED=true
+1
View File
@@ -19,6 +19,7 @@ COPY --from=builder /usr/local/bin /usr/local/bin
COPY ./app /app/app
COPY ./frontend /app/frontend
COPY ./VERSION /app/VERSION
COPY ./LICENSE /app/LICENSE
# Set Python path explicitly
ENV PYTHONPATH=/app
+2 -13
View File
@@ -1,4 +1,4 @@
Apache License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@@ -175,18 +175,7 @@
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2025 Christian Krakau-Louis <christian@docunova.org>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1 +1 @@
0.4.1-dev
0.4.3-dev
+3
View File
@@ -0,0 +1,3 @@
"""
Document processor application package.
"""
-940
View File
@@ -1,940 +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
# 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)}"
)
+27
View File
@@ -0,0 +1,27 @@
"""
API Router module that combines all API endpoints
"""
from fastapi import APIRouter
import logging
# Import all the individual routers
from app.api.user import router as user_router
from app.api.files import router as files_router
from app.api.process import router as process_router
from app.api.diagnostic import router as diagnostic_router
from app.api.onedrive import router as onedrive_router
from app.api.dropbox import router as dropbox_router
# Set up logging
logger = logging.getLogger(__name__)
# Create the main router that includes all the others
router = APIRouter()
# Include all the routers
router.include_router(user_router)
router.include_router(files_router)
router.include_router(process_router)
router.include_router(diagnostic_router)
router.include_router(onedrive_router)
router.include_router(dropbox_router)
+36
View File
@@ -0,0 +1,36 @@
"""
Common utilities for API routes
"""
import logging
import os
from sqlalchemy.orm import Session
from fastapi import Depends
from app.database import SessionLocal
from app.config import settings
# Set up logging
logger = logging.getLogger(__name__)
def get_db():
"""Database dependency injection for routes"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def resolve_file_path(file_path: str, subfolder: str = None) -> str:
"""
Resolves a file path to an absolute path.
If the path is not absolute, it will be joined with the workdir path.
Optionally, can include a subfolder like 'processed'.
Returns the absolute file path.
"""
if not os.path.isabs(file_path):
if subfolder:
file_path = os.path.join(settings.workdir, subfolder, file_path)
else:
file_path = os.path.join(settings.workdir, file_path)
return file_path
+48
View File
@@ -0,0 +1,48 @@
"""
Diagnostic API endpoints
"""
from fastapi import APIRouter, Request, Depends
import logging
from app.auth import require_login, get_current_user
from app.config import settings
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
@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"
}
+308
View File
@@ -0,0 +1,308 @@
"""
Dropbox API endpoints
"""
from fastapi import APIRouter, Request, HTTPException, status, Form
import logging
import os
import requests
from app.auth import require_login
from app.config import settings
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
@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)}"
)
+143
View File
@@ -0,0 +1,143 @@
"""
File-related API endpoints
"""
from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File
from sqlalchemy.orm import Session
import logging
import os
import uuid
from app.auth import require_login
from app.models import FileRecord
from app.config import settings
from app.api.common import get_db
from app.tasks.process_document import process_document
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
@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)}"
)
@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."""
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
logger.info(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
}
+364
View File
@@ -0,0 +1,364 @@
"""
OneDrive API endpoints
"""
from fastapi import APIRouter, Request, HTTPException, status, Form
import logging
import os
import requests
from app.auth import require_login
from app.config import settings
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
@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)
}
+140
View File
@@ -0,0 +1,140 @@
"""
Document processing API endpoints
"""
from fastapi import APIRouter, HTTPException
import logging
import os
from app.auth import require_login
from app.config import settings
from app.api.common import resolve_file_path
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()
@router.post("/process/")
@require_login
def process(file_path: str):
"""API Endpoint to start document processing."""
file_path = resolve_file_path(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_endpoint(file_path: str):
"""Send a document to Dropbox."""
file_path = resolve_file_path(file_path, 'processed')
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_endpoint(file_path: str):
"""Send a document to Paperless-ngx."""
file_path = resolve_file_path(file_path, 'processed')
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_endpoint(file_path: str):
"""Send a document to NextCloud."""
file_path = resolve_file_path(file_path, 'processed')
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_endpoint(file_path: str):
"""Send a document to Google Drive."""
file_path = resolve_file_path(file_path, 'processed')
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."""
file_path = resolve_file_path(file_path, 'processed')
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."""
file_path = resolve_file_path(file_path, 'processed')
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
}
+42
View File
@@ -0,0 +1,42 @@
"""
User-related API endpoints
"""
from fastapi import APIRouter, Request, HTTPException
from hashlib import md5
import logging
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
async def whoami_handler(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"
# 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
# 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}
+3
View File
@@ -118,6 +118,9 @@ class Settings(BaseSettings):
uptime_kuma_url: Optional[str] = None
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
# Feature flags
allow_file_delete: bool = True # Default to allowing file deletion from database
# Get version from file or environment
@property
def version(self) -> str:
+8 -217
View File
@@ -1,219 +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
"""
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():
# If you have a real favicon in `frontend/static/favicon.ico`:
favicon_path = Path(__file__).parent.parent / "frontend" / "static" / "favicon.ico"
return str(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
+7 -9
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 - we'll keep both available for compatibility
from app.frontend import router as frontend_router_original
from app.api import router as api_router_original
# 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
@@ -67,8 +67,7 @@ async def startup_event():
else:
logging.info("Application started with valid configuration")
logging.info("Router organization note: Using route handlers from main app directory for now")
logging.info("In the future, we'll transition fully to the frontend/ and api/ submodules")
logging.info("Router organization: Using refactored API routers from app/api/ directory")
# Custom 404 - we can still return the Jinja2 template, or the old static file:
@app.exception_handler(404)
@@ -95,10 +94,9 @@ async def custom_500_handler(request: Request, exc: Exception):
def test_500():
raise RuntimeError("Testing forced 500 error!")
# Include the routers - for now we're using the original routers
# Later we can switch to the organized router structure
app.include_router(frontend_router_original)
# Include the routers
app.include_router(frontend_router)
app.include_router(auth_router)
app.include_router(api_router_original, prefix="/api")
app.include_router(api_router, prefix="/api")
@@ -34,6 +34,30 @@ def get_pdf_page_count(file_path):
logger.error(f"Error getting PDF page count: {e}")
return None
def check_page_rotation(result, filename):
"""
Checks if pages in the document are rotated and logs the rotation information.
Args:
result: The AnalyzeResult from Azure Document Intelligence API
filename: The name of the file being processed
"""
logger.error(f"Checking rotation for document: {filename}")
if not hasattr(result, 'pages') or not result.pages:
logger.error(f"No page information available for rotation check: {filename}")
return
for i, page in enumerate(result.pages):
if hasattr(page, 'angle'):
rotation_angle = page.angle
if rotation_angle != 0:
logger.error(f"Page {i+1} is rotated by {rotation_angle} degrees")
else:
logger.error(f"Page {i+1} has no rotation (0 degrees)")
else:
logger.error(f"Page {i+1} rotation information not available")
@celery.task(base=BaseTaskWithRetry)
def process_with_azure_document_intelligence(filename: str):
"""
@@ -81,6 +105,9 @@ def process_with_azure_document_intelligence(filename: str):
result: AnalyzeResult = poller.result()
operation_id = poller.details["operation_id"]
# Check and log page rotation information
check_page_rotation(result, filename)
# Retrieve the processed searchable PDF
response = document_intelligence_client.get_analyze_result_pdf(
model_id=result.model_id, result_id=operation_id
+29 -15
View File
@@ -154,13 +154,28 @@ def mask_sensitive_value(value):
return str(value)
def get_provider_status():
"""Returns status information for all configured providers"""
"""
Returns status information for all configured providers
Font Awesome icons used:
- fa-brands fa-dropbox: Dropbox icon
- fa-solid fa-envelope: Email icon
- fa-solid fa-server: FTP Server icon
- fa-brands fa-google-drive: Google Drive icon
- fa-solid fa-cloud: NextCloud icon
- fa-brands fa-microsoft: Microsoft/OneDrive icon
- fa-solid fa-file-lines: Document/Paperless icon
- fa-brands fa-aws: AWS/S3 icon
- fa-solid fa-lock: SFTP icon (secure)
- fa-solid fa-heart-pulse: Uptime/health monitoring
- fa-solid fa-globe: WebDAV/web icon
"""
providers = {}
# Add Dropbox configuration - alphabetically ordered providers
providers["Dropbox"] = {
"name": "Dropbox",
"icon": "dropbox",
"icon": "fa-brands fa-dropbox",
"configured": bool(getattr(settings, 'dropbox_app_key', None) and
getattr(settings, 'dropbox_app_secret', None) and
getattr(settings, 'dropbox_refresh_token', None)),
@@ -177,7 +192,7 @@ def get_provider_status():
# Add Email configuration
providers["Email"] = {
"name": "Email",
"icon": "mail",
"icon": "fa-solid fa-envelope",
"configured": bool(getattr(settings, 'email_host', None) and
getattr(settings, 'email_default_recipient', None)),
"enabled": True,
@@ -196,7 +211,7 @@ def get_provider_status():
# Add FTP configuration to providers
providers["FTP Storage"] = {
"name": "FTP Storage",
"icon": "ftp",
"icon": "fa-solid fa-server",
"configured": bool(getattr(settings, 'ftp_host', None) and
getattr(settings, 'ftp_username', None) and
getattr(settings, 'ftp_password', None)),
@@ -216,7 +231,7 @@ def get_provider_status():
# Check Google Drive configuration
providers["Google Drive"] = {
"name": "Google Drive",
"icon": "google",
"icon": "fa-brands fa-google-drive",
"configured": bool(getattr(settings, 'google_drive_credentials_json', None) and
getattr(settings, 'google_drive_folder_id', None)),
"enabled": True,
@@ -231,14 +246,13 @@ def get_provider_status():
# Check NextCloud configuration
nextcloud_url = getattr(settings, 'nextcloud_upload_url', 'Not set')
# Extract base URL from WebDAV URL (remove the /remote.php part and everything after it)
if nextcloud_url != 'Not set' and '/remote.php' in nextcloud_url:
nextcloud_base_url = nextcloud_url
if nextcloud_url != 'Not set' and nextcloud_url is not None and '/remote.php' in nextcloud_url:
nextcloud_base_url = nextcloud_url.split('/remote.php')[0]
else:
nextcloud_base_url = nextcloud_url
providers["NextCloud"] = {
"name": "NextCloud",
"icon": "cloud",
"icon": "fa-solid fa-cloud",
"configured": bool(getattr(settings, 'nextcloud_upload_url', None) and
getattr(settings, 'nextcloud_username', None) and
getattr(settings, 'nextcloud_password', None)),
@@ -256,7 +270,7 @@ def get_provider_status():
# Check OneDrive configuration
providers["OneDrive"] = {
"name": "OneDrive",
"icon": "microsoft",
"icon": "fa-brands fa-microsoft",
"configured": bool(getattr(settings, 'onedrive_client_id', None) and
getattr(settings, 'onedrive_client_secret', None) and
getattr(settings, 'onedrive_refresh_token', None)),
@@ -274,7 +288,7 @@ def get_provider_status():
# Check Paperless configuration
providers["Paperless-ngx"] = {
"name": "Paperless-ngx",
"icon": "file-text",
"icon": "fa-solid fa-file-lines",
"configured": bool(getattr(settings, 'paperless_host', None) and
getattr(settings, 'paperless_ngx_api_token', None)),
"enabled": True,
@@ -288,7 +302,7 @@ def get_provider_status():
# Check S3 configuration
providers["S3 Storage"] = {
"name": "S3 Storage",
"icon": "database",
"icon": "fa-brands fa-aws",
"configured": bool(getattr(settings, 's3_bucket_name', None) and
getattr(settings, 'aws_access_key_id', None) and
getattr(settings, 'aws_secret_access_key', None)),
@@ -308,7 +322,7 @@ def get_provider_status():
# Check SFTP configuration
providers["SFTP Storage"] = {
"name": "SFTP Storage",
"icon": "server",
"icon": "fa-solid fa-lock",
"configured": bool(getattr(settings, 'sftp_host', None) and
getattr(settings, 'sftp_username', None) and
(getattr(settings, 'sftp_password', None) or
@@ -329,7 +343,7 @@ def get_provider_status():
# Add Uptime Kuma configuration
providers["Uptime Kuma"] = {
"name": "Uptime Kuma",
"icon": "activity",
"icon": "fa-solid fa-heart-pulse",
"configured": bool(getattr(settings, 'uptime_kuma_url', None)),
"enabled": True,
"description": "Server monitoring and status page",
@@ -342,7 +356,7 @@ def get_provider_status():
# Check WebDAV configuration
providers["WebDAV"] = {
"name": "WebDAV",
"icon": "globe",
"icon": "fa-solid fa-globe",
"configured": bool(getattr(settings, 'webdav_url', None) and
getattr(settings, 'webdav_username', None) and
getattr(settings, 'webdav_password', None)),
+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)
+43
View File
@@ -0,0 +1,43 @@
"""
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))
# Customize Jinja2Templates to include app_version in all templates
original_template_response = templates.TemplateResponse
def template_response_with_version(*args, **kwargs):
"""Wrapper for TemplateResponse to include version in all templates"""
# If context dict is provided, add version to it
if len(args) >= 2 and isinstance(args[1], dict):
args[1].setdefault("version", settings.version)
elif "context" in kwargs and isinstance(kwargs["context"], dict):
kwargs["context"].setdefault("version", settings.version)
return original_template_response(*args, **kwargs)
templates.TemplateResponse = template_response_with_version
# 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)
})
+76
View File
@@ -0,0 +1,76 @@
"""
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)
@router.get("/license", include_in_schema=False)
async def serve_license(request: Request):
"""Serve the license page."""
# Try multiple possible locations for the license file
possible_locations = [
Path(__file__).parent.parent.parent / "LICENSE", # Repository root
Path("/app/LICENSE"), # Docker container path
Path.home() / "LICENSE", # Home directory (fallback)
]
license_text = None
# Try to read from any of the possible locations
for path in possible_locations:
try:
with open(path, "r") as f:
license_text = f.read()
break # File found and read, exit loop
except (FileNotFoundError, PermissionError):
continue # Try next location
# If license text is still None, use embedded text
if license_text is None:
license_text = """
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
This software is licensed under the Apache License 2.0.
The full license text could not be located on this system.
Please visit http://www.apache.org/licenses/LICENSE-2.0 for the complete license text.
"""
return templates.TemplateResponse(
"license.html",
{
"request": request,
"license_text": license_text
}
)
+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
+12
View File
@@ -0,0 +1,12 @@
/*
* Self-hosted Font Awesome loader - loads only what we need
*/
(function() {
// Create link element for CSS
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/static/fontawesome/css/all.min.css';
document.head.appendChild(link);
console.log('Font Awesome loaded locally');
})();
+63 -22
View File
@@ -7,6 +7,16 @@
<!-- 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">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
{% endblock %}
{% block head_extra %}{% endblock %}
</head>
@@ -30,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>
@@ -62,12 +104,11 @@
<!-- Footer -->
<footer class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
&copy; 2025 DocuNova. All rights reserved.
DocuNova 2025 - Licensed under <a href="/license" class="text-blue-500 hover:underline">Apache License 2.0</a> -
<span class="text-xs">Version {{ app_version|default(version, true) }}</span>
</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 %}
+230 -36
View File
@@ -2,45 +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" />
<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;
align-items: center;
}
.delete-btn:hover {
background-color: #fed7d7;
}
.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);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.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>
{% 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>
<!-- Add JavaScript for handling DELETE requests -->
<script>
// Modal functionality
const modal = document.getElementById('deleteModal');
const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete');
let currentFileId = null;
function showDeleteModal(fileId) {
currentFileId = fileId;
modal.style.display = 'flex';
}
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';
}
});
function deleteFile(fileId) {
fetch(`/api/files/${fileId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.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 %}
{% block scripts %}
<!-- Include Grid.js JS -->
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<script>
new gridjs.Grid({
columns: [
{ id: 'id', name: 'ID' },
{ id: 'original_filename', name: 'Original Filename' },
{ id: 'file_size', name: 'File Size' },
{ id: 'mime_type', name: 'Mime Type' },
{ id: 'created_at', name: 'Created At' }
],
server: {
url: '/api/files',
then: data => data.map(file => [
file.id,
file.original_filename || "",
file.file_size,
file.mime_type,
file.created_at || ""
])
},
search: true,
sort: true,
pagination: {
limit: 10
}
}).render(document.getElementById("gridjs-wrapper"));
</script>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}DocuNova - License{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">License Information</h1>
<div class="bg-white shadow-md rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Apache License 2.0</h2>
<div class="prose">
<pre class="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded border overflow-auto max-h-96 mb-4">{{ license_text }}</pre>
</div>
<p class="mt-4 text-gray-600">
DocuNova is distributed under the Apache License 2.0, which is a permissive
open-source software license that allows you to use, modify, distribute, and
contribute to the project.
</p>
</div>
</div>
{% endblock %}
+15 -59
View File
@@ -1,6 +1,10 @@
{% extends "base.html" %}
{% block title %}System Status{% endblock %}
{% block head %}
<!-- Font Awesome is loaded in base.html -->
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
@@ -23,36 +27,8 @@
<div class="px-4 py-5 sm:px-6 flex justify-between items-center">
<div class="flex items-center">
<span class="inline-flex items-center justify-center h-10 w-10 rounded-md {{ 'bg-green-500' if provider.configured else 'bg-gray-400' }} text-white mr-3">
<!-- Use appropriate icon based on provider -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{% if provider.icon == "mail" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
{% elif provider.icon == "cloud" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
{% elif provider.icon == "dropbox" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
{% elif provider.icon == "file-text" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
{% elif provider.icon == "server" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
{% elif provider.icon == "database" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
{% elif provider.icon == "hard-drive" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h18M3 6h18M3 18h18" />
{% elif provider.icon == "globe" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
{% elif provider.icon == "google" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
{% elif provider.icon == "microsoft" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
{% elif provider.icon == "activity" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
{% elif provider.icon == "ftp" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l-6-6 6-6M2 10h16m4 0l-6 6m0-12l6 6" />
{% else %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
{% endif %}
</svg>
<!-- Use Font Awesome icon -->
<i class="{{ provider.icon }} fa-lg"></i>
</span>
<h3 class="text-lg leading-6 font-medium text-gray-900">{{ name }}</h3>
</div>
@@ -126,10 +102,7 @@
Test Connection
</button>
<a href="/dropbox-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
Manage
</a>
{% else %}
@@ -145,10 +118,7 @@
Test Connection
</button>
<a href="/onedrive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
Manage
</a>
{% else %}
@@ -158,18 +128,14 @@
{% endif %}
{% elif provider.configured and name == "Paperless-ngx" %}
<a href="{{ provider.details.host }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
Open
</a>
{% elif provider.configured and name == "NextCloud" %}
{% if provider.details and provider.details.url %}
{% set nextcloud_base_url = provider.details.url.split('/remote.php')[0] %}
<a href="{{ nextcloud_base_url }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
Open
</a>
{% endif %}
@@ -223,9 +189,7 @@
<div class="absolute top-0 right-0 pt-4 pr-4">
<button type="button" id="closeDetailsModal" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">Close</span>
<svg class="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="M6 18L18 6M6 6l12 12" />
</svg>
<i class="fa-solid fa-xmark h-6 w-6"></i>
</button>
</div>
<div class="mt-3">
@@ -270,20 +234,12 @@ document.addEventListener('DOMContentLoaded', function() {
modalTitle.textContent = title;
modalMessage.textContent = message;
// Set the appropriate icon
// Set the appropriate icon using Font Awesome
if (status === 'success') {
modalIcon.innerHTML = `
<svg class="h-6 w-6 text-green-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
`;
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4';
} else {
modalIcon.innerHTML = `
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
`;
modalIcon.innerHTML = '<i class="fa-solid fa-xmark text-red-600 fa-2x"></i>';
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4';
}
@@ -406,7 +362,7 @@ document.addEventListener('DOMContentLoaded', function() {
const provider = this.getAttribute('data-provider');
const originalText = this.textContent;
this.innerHTML = '<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-indigo-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Testing...';
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.disabled = true;
let endpoint = '';
+1
View File
@@ -0,0 +1 @@
{{ version }}