Spaces:
Sleeping
Sleeping
| import os | |
| import shutil | |
| import gdown | |
| from flask import Blueprint, render_template, request, jsonify, current_app, send_from_directory, send_file, url_for, redirect, session | |
| from flask_login import login_required, current_user | |
| from database import get_db_connection | |
| from datetime import datetime | |
| import threading | |
| import re | |
| import json | |
| # Allow OAuth over HTTP for local testing | |
| os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' | |
| from gdrive_service import get_drive_service, create_flow, list_drive_files, download_file_to_stream, get_file_metadata | |
| drive_bp = Blueprint('drive', __name__) | |
| DRIVE_SYNC_FOLDER = 'drive_sync' | |
| def extract_drive_id(url): | |
| # Extracts Drive ID (File or Folder) - simplified regex for ~25+ chars | |
| match = re.search(r'[-\w]{25,}', url) | |
| return match.group(0) if match else None | |
| def get_sync_folder_path(source_name=None): | |
| base = os.path.join(current_app.config['OUTPUT_FOLDER'], DRIVE_SYNC_FOLDER) | |
| if not os.path.exists(base): | |
| os.makedirs(base) | |
| if source_name: | |
| path = os.path.join(base, source_name) | |
| if not os.path.exists(path): | |
| os.makedirs(path) | |
| return path | |
| return base | |
| def drive_manager(): | |
| conn = get_db_connection() | |
| sources = conn.execute('SELECT * FROM drive_sources WHERE user_id = ? ORDER BY created_at DESC', (current_user.id,)).fetchall() | |
| # Get last 4 opened PDFs | |
| recent_pdfs = conn.execute(''' | |
| SELECT file_id, filename, opened_at | |
| FROM pdf_access_history | |
| WHERE user_id = ? | |
| ORDER BY opened_at DESC | |
| LIMIT 4 | |
| ''', (current_user.id,)).fetchall() | |
| conn.close() | |
| # Check Drive API Status | |
| drive_connected = bool(current_user.google_token) | |
| return render_template('drive_manager.html', | |
| sources=[dict(s) for s in sources], | |
| drive_connected=drive_connected, | |
| recent_pdfs=[dict(p) for p in recent_pdfs]) | |
| def connect_drive(): | |
| try: | |
| redirect_uri = 'http://localhost' | |
| flow = create_flow(redirect_uri) | |
| authorization_url, state = flow.authorization_url( | |
| access_type='offline', | |
| include_granted_scopes='true') | |
| session['oauth_state'] = state | |
| return render_template('drive_connect_manual.html', auth_url=authorization_url) | |
| except FileNotFoundError: | |
| return "client_secret.json not found. Please upload it to the app root via Settings.", 404 | |
| except Exception as e: | |
| return f"Error creating flow: {e}", 500 | |
| def manual_callback(): | |
| state = session.get('oauth_state') | |
| full_url = request.form.get('full_url') | |
| if not full_url: return "URL is required", 400 | |
| try: | |
| redirect_uri = 'http://localhost' | |
| flow = create_flow(redirect_uri) | |
| flow.fetch_token(authorization_response=full_url) | |
| credentials = flow.credentials | |
| token_json = credentials.to_json() | |
| conn = get_db_connection() | |
| conn.execute('UPDATE users SET google_token = ? WHERE id = ?', (token_json, current_user.id)) | |
| conn.commit() | |
| conn.close() | |
| current_user.google_token = token_json | |
| return redirect(url_for('drive.drive_manager')) | |
| except Exception as e: | |
| return f"Auth failed: {e}<br><br>Make sure you copied the full URL correctly.", 500 | |
| def oauth2callback(): | |
| state = session.get('oauth_state') | |
| if not state: return "Invalid state", 400 | |
| try: | |
| redirect_uri = url_for('drive.oauth2callback', _external=True) | |
| flow = create_flow(redirect_uri) | |
| flow.fetch_token(authorization_response=request.url) | |
| credentials = flow.credentials | |
| token_json = credentials.to_json() | |
| conn = get_db_connection() | |
| conn.execute('UPDATE users SET google_token = ? WHERE id = ?', (token_json, current_user.id)) | |
| conn.commit() | |
| conn.close() | |
| current_user.google_token = token_json | |
| return redirect(url_for('drive.drive_manager')) | |
| except Exception as e: | |
| return f"Auth failed: {e}", 500 | |
| def add_source(): | |
| name = request.form.get('name') | |
| url = request.form.get('url') | |
| if not name or not url: return jsonify({'error': 'Name and URL required'}), 400 | |
| conn = get_db_connection() | |
| try: | |
| source_type = 'file' | |
| if '/folders/' in url or 'drive/folders' in url: source_type = 'folder' | |
| local_path = name.strip().replace(' ', '_') | |
| conn.execute('INSERT INTO drive_sources (name, url, local_path, user_id, source_type) VALUES (?, ?, ?, ?, ?)', | |
| (name, url, local_path, current_user.id, source_type)) | |
| conn.commit() | |
| return jsonify({'success': True}) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| finally: | |
| conn.close() | |
| def delete_source(id): | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (id,)).fetchone() | |
| if not source or source['user_id'] != current_user.id: | |
| conn.close() | |
| return jsonify({'error': 'Unauthorized'}), 403 | |
| conn.execute('DELETE FROM drive_sources WHERE id = ?', (id,)) | |
| conn.commit() | |
| conn.close() | |
| try: | |
| path = get_sync_folder_path(source['local_path']) | |
| if os.path.exists(path): shutil.rmtree(path) | |
| except Exception as e: | |
| print(f"Error deleting folder: {e}") | |
| return jsonify({'success': True}) | |
| def sync_task(source_id, user_id, app_config): | |
| import sqlite3 | |
| conn = sqlite3.connect('database.db') | |
| conn.row_factory = sqlite3.Row | |
| try: | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| if not source: return | |
| output_base = os.path.join(app_config['OUTPUT_FOLDER'], DRIVE_SYNC_FOLDER, source['local_path']) | |
| if not os.path.exists(output_base): os.makedirs(output_base) | |
| print(f"Syncing Drive: {source['name']} to {output_base}") | |
| try: | |
| gdown.download_folder(url=source['url'], output=output_base, quiet=False, use_cookies=False) | |
| conn.execute('UPDATE drive_sources SET last_synced = CURRENT_TIMESTAMP WHERE id = ?', (source_id,)) | |
| conn.commit() | |
| print("Sync complete.") | |
| except Exception as e: | |
| print(f"GDown Error: {e}") | |
| except Exception as e: | |
| print(f"Sync Task Error: {e}") | |
| finally: | |
| conn.close() | |
| def sync_source(id): | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: return jsonify({'error': 'Unauthorized'}), 403 | |
| thread = threading.Thread(target=sync_task, args=(id, current_user.id, current_app.config.copy())) | |
| thread.start() | |
| return jsonify({'success': True, 'message': 'Sync started in background'}) | |
| def browse_drive(source_id, subpath=''): | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: return "Unauthorized", 403 | |
| # === API Upgrade Logic === | |
| if current_user.google_token and not subpath: | |
| drive_id = extract_drive_id(source['url']) | |
| if drive_id: | |
| return redirect(url_for('drive.browse_drive_api', folder_id=drive_id, title=source['name'])) | |
| # ========================= | |
| base_path = get_sync_folder_path(source['local_path']) | |
| current_path = os.path.join(base_path, subpath) | |
| if not os.path.exists(current_path): | |
| if source['source_type'] == 'file': pass | |
| else: return "Path not found (Not synced yet). Click Sync Now in Manager.", 404 | |
| # Count total files to determine if lazy loading is needed | |
| file_count = 0 | |
| if os.path.exists(current_path): | |
| try: | |
| file_count = sum(1 for _ in os.scandir(current_path)) | |
| except: | |
| pass | |
| breadcrumbs = [] | |
| if subpath: | |
| parts = subpath.split('/') | |
| built = '' | |
| for part in parts: | |
| built = os.path.join(built, part).strip('/') | |
| breadcrumbs.append({'name': part, 'path': built}) | |
| # Use lazy loading for folders with more than 50 items | |
| if file_count > 50: | |
| return render_template('drive_browser.html', | |
| source=source, | |
| items=[], | |
| breadcrumbs=breadcrumbs, | |
| current_subpath=subpath, | |
| lazy_load=True, | |
| is_local=True) | |
| # Small folder - load all items inline | |
| items = [] | |
| if os.path.exists(current_path): | |
| try: | |
| for entry in os.scandir(current_path): | |
| is_dir = entry.is_dir() | |
| file_type = 'file' | |
| name_lower = entry.name.lower() | |
| if is_dir: file_type = 'folder' | |
| elif name_lower.endswith('.pdf'): file_type = 'pdf' | |
| elif name_lower.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')): file_type = 'image' | |
| elif name_lower.endswith(('.ppt', '.pptx', '.odp')): file_type = 'slides' | |
| elif name_lower.endswith(('.xls', '.xlsx', '.ods', '.csv')): file_type = 'sheet' | |
| elif name_lower.endswith(('.doc', '.docx', '.odt', '.txt', '.rtf')): file_type = 'doc' | |
| elif name_lower.endswith(('.mp4', '.mov', '.avi', '.mkv', '.webm')): file_type = 'video' | |
| items.append({ | |
| 'name': entry.name, | |
| 'type': file_type, | |
| 'path': os.path.join(subpath, entry.name).strip('/') | |
| }) | |
| except Exception as e: return f"Error listing files: {e}", 500 | |
| items.sort(key=lambda x: (x['type'] != 'folder', x['name'].lower())) | |
| if not items and source['source_type'] == 'file': | |
| items.append({'name': 'Tap to Download & View', 'type': 'pdf', 'path': 'document.pdf'}) | |
| return render_template('drive_browser.html', source=source, items=items, breadcrumbs=breadcrumbs, current_subpath=subpath) | |
| def view_drive_file(source_id, filepath): | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: return "Unauthorized", 403 | |
| base_path = get_sync_folder_path(source['local_path']) | |
| full_path = os.path.join(base_path, filepath) | |
| if not os.path.exists(full_path) and source['source_type'] == 'file': | |
| try: | |
| if not os.path.exists(base_path): os.makedirs(base_path) | |
| gdown.download(url=source['url'], output=full_path, quiet=False, fuzzy=True) | |
| except Exception as e: return f"Error downloading file: {e}", 500 | |
| if not os.path.exists(full_path): return "File not found.", 404 | |
| name_lower = full_path.lower() | |
| if name_lower.endswith('.pdf'): | |
| file_url = url_for('drive.serve_drive_file', source_id=source_id, filepath=os.path.basename(full_path)) | |
| return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=os.path.basename(full_path)) | |
| if name_lower.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')): | |
| file_url = url_for('drive.serve_drive_file', source_id=source_id, filepath=os.path.basename(full_path)) | |
| return render_template('image_viewer.html', image_url=file_url, image_title=os.path.basename(full_path)) | |
| return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path)) | |
| def serve_drive_file(source_id, filepath): | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: return "Unauthorized", 403 | |
| base_path = get_sync_folder_path(source['local_path']) | |
| return send_from_directory(base_path, filepath) | |
| def api_list_files(folder_id='root'): | |
| service = get_drive_service(current_user) | |
| if not service: return jsonify({'error': 'Not connected'}), 401 | |
| files, next_token = list_drive_files(service, folder_id) | |
| file_list = [] | |
| for f in files: | |
| is_folder = f['mimeType'] == 'application/vnd.google-apps.folder' | |
| icon = 'folder-fill text-warning' if is_folder else 'file-earmark-text text-secondary' | |
| if f['mimeType'] == 'application/pdf': icon = 'file-earmark-pdf-fill text-danger' | |
| elif 'image' in f['mimeType']: icon = 'file-earmark-image-fill text-info' | |
| file_list.append({ | |
| 'id': f['id'], | |
| 'name': f['name'], | |
| 'type': 'folder' if is_folder else 'file', | |
| 'mimeType': f['mimeType'], | |
| 'icon': icon, | |
| 'size': f.get('size') | |
| }) | |
| return jsonify({'files': file_list, 'next_token': next_token}) | |
| def browse_drive_api(folder_id): | |
| service = get_drive_service(current_user) | |
| if not service: return redirect(url_for('drive.drive_manager')) | |
| title = request.args.get('title', 'My Drive') | |
| # Build breadcrumbs from query param (JSON encoded list) | |
| breadcrumbs_param = request.args.get('breadcrumbs', '[]') | |
| try: | |
| breadcrumbs = json.loads(breadcrumbs_param) | |
| except: | |
| breadcrumbs = [] | |
| # For initial page load, just render the template (files loaded via AJAX) | |
| return render_template('drive_browser.html', | |
| source={'id': 'api', 'name': title}, | |
| items=[], # Empty - loaded via AJAX | |
| breadcrumbs=breadcrumbs, | |
| is_api=True, | |
| folder_id=folder_id, | |
| lazy_load=True) | |
| def api_get_files(folder_id): | |
| """Paginated JSON API for lazy loading files.""" | |
| service = get_drive_service(current_user) | |
| if not service: return jsonify({'error': 'Not connected'}), 401 | |
| page_token = request.args.get('page_token') | |
| files, next_token = list_drive_files(service, folder_id, page_token) | |
| items = [] | |
| for f in files: | |
| is_folder = f['mimeType'] == 'application/vnd.google-apps.folder' | |
| mime = f['mimeType'] | |
| f_type = 'folder' if is_folder else 'file' | |
| if mime == 'application/pdf': f_type = 'pdf' | |
| elif 'image' in mime: f_type = 'image' | |
| elif mime in ['application/vnd.google-apps.presentation', 'application/vnd.ms-powerpoint', | |
| 'application/vnd.openxmlformats-officedocument.presentationml.presentation']: | |
| f_type = 'slides' | |
| elif mime in ['application/vnd.google-apps.spreadsheet', 'application/vnd.ms-excel', | |
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']: | |
| f_type = 'sheet' | |
| elif mime in ['application/vnd.google-apps.document', 'application/msword', | |
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']: | |
| f_type = 'doc' | |
| elif 'video' in mime: f_type = 'video' | |
| items.append({ | |
| 'name': f['name'], | |
| 'type': f_type, | |
| 'path': f['id'], | |
| 'size': f.get('size'), | |
| 'modified': f.get('modifiedTime') | |
| }) | |
| return jsonify({ | |
| 'items': items, | |
| 'next_token': next_token, | |
| 'has_more': next_token is not None | |
| }) | |
| def local_get_files(source_id): | |
| """Paginated JSON API for lazy loading local synced files.""" | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: | |
| return jsonify({'error': 'Unauthorized'}), 403 | |
| subpath = request.args.get('path', '') | |
| offset = int(request.args.get('offset', 0)) | |
| limit = int(request.args.get('limit', 50)) | |
| base_path = get_sync_folder_path(source['local_path']) | |
| current_path = os.path.join(base_path, subpath) if subpath else base_path | |
| if not os.path.exists(current_path): | |
| return jsonify({'items': [], 'has_more': False}) | |
| all_items = [] | |
| try: | |
| for entry in os.scandir(current_path): | |
| is_dir = entry.is_dir() | |
| file_type = 'file' | |
| name_lower = entry.name.lower() | |
| if is_dir: file_type = 'folder' | |
| elif name_lower.endswith('.pdf'): file_type = 'pdf' | |
| elif name_lower.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')): file_type = 'image' | |
| elif name_lower.endswith(('.ppt', '.pptx', '.odp')): file_type = 'slides' | |
| elif name_lower.endswith(('.xls', '.xlsx', '.ods', '.csv')): file_type = 'sheet' | |
| elif name_lower.endswith(('.doc', '.docx', '.odt', '.txt', '.rtf')): file_type = 'doc' | |
| elif name_lower.endswith(('.mp4', '.mov', '.avi', '.mkv', '.webm')): file_type = 'video' | |
| stat = entry.stat() | |
| all_items.append({ | |
| 'name': entry.name, | |
| 'type': file_type, | |
| 'path': os.path.join(subpath, entry.name).strip('/'), | |
| 'size': stat.st_size if not is_dir else None, | |
| 'sort_key': (0 if is_dir else 1, entry.name.lower()) | |
| }) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| # Sort: folders first, then alphabetically | |
| all_items.sort(key=lambda x: x['sort_key']) | |
| for item in all_items: | |
| del item['sort_key'] | |
| # Paginate | |
| paginated = all_items[offset:offset + limit] | |
| has_more = offset + limit < len(all_items) | |
| return jsonify({ | |
| 'items': paginated, | |
| 'has_more': has_more, | |
| 'total': len(all_items) | |
| }) | |
| def api_open_file(file_id): | |
| service = get_drive_service(current_user) | |
| if not service: return "Not connected", 401 | |
| try: | |
| meta = get_file_metadata(service, file_id) | |
| if not meta: return "File not found", 404 | |
| filename = meta['name'] | |
| cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache') | |
| if not os.path.exists(cache_dir): os.makedirs(cache_dir) | |
| from werkzeug.utils import secure_filename | |
| safe_name = secure_filename(filename) | |
| file_path = os.path.join(cache_dir, safe_name) | |
| if not os.path.exists(file_path): | |
| with open(file_path, 'wb') as f: | |
| download_file_to_stream(service, file_id, f) | |
| if safe_name.lower().endswith('.pdf'): | |
| # Log PDF access to history | |
| conn = get_db_connection() | |
| conn.execute(''' | |
| INSERT INTO pdf_access_history (user_id, file_id, filename, source_type, opened_at) | |
| VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | |
| ''', (current_user.id, file_id, filename, 'drive_api')) | |
| conn.commit() | |
| conn.close() | |
| file_url = url_for('drive.serve_cache_file', filename=safe_name) | |
| return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=filename) | |
| if safe_name.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')): | |
| return render_template('image_viewer.html', image_url=url_for('drive.serve_cache_file', filename=safe_name), image_title=filename) | |
| if safe_name.lower().endswith(('.ppt', '.pptx', '.odp')): | |
| # Redirect to Google Slides for presentation files | |
| return redirect(f'https://docs.google.com/presentation/d/{file_id}/edit') | |
| if safe_name.lower().endswith(('.xls', '.xlsx', '.ods', '.csv')): | |
| return redirect(f'https://docs.google.com/spreadsheets/d/{file_id}/edit') | |
| if safe_name.lower().endswith(('.doc', '.docx', '.odt')): | |
| return redirect(f'https://docs.google.com/document/d/{file_id}/edit') | |
| # For other files, serve directly | |
| return send_from_directory(cache_dir, safe_name) | |
| except Exception as e: return f"Error opening file: {e}", 500 | |
| def serve_cache_file(filename): | |
| cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache') | |
| return send_from_directory(cache_dir, filename) | |
| def api_download_file(file_id): | |
| """Download a file from Google Drive (caches if not already cached).""" | |
| service = get_drive_service(current_user) | |
| if not service: return "Not connected", 401 | |
| try: | |
| meta = get_file_metadata(service, file_id) | |
| if not meta: return "File not found", 404 | |
| filename = meta['name'] | |
| cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache') | |
| if not os.path.exists(cache_dir): os.makedirs(cache_dir) | |
| from werkzeug.utils import secure_filename | |
| safe_name = secure_filename(filename) | |
| file_path = os.path.join(cache_dir, safe_name) | |
| if not os.path.exists(file_path): | |
| with open(file_path, 'wb') as f: | |
| download_file_to_stream(service, file_id, f) | |
| return send_from_directory(cache_dir, safe_name, as_attachment=False) | |
| except Exception as e: return f"Error downloading file: {e}", 500 | |
| # ==================== PUBLIC ACCESS ROUTES (No Auth Required) ==================== | |
| def generate_api_file_token(file_id, user_id): | |
| """Generate a permanent unique token for a Google Drive API file.""" | |
| import hashlib | |
| secret = current_app.config.get('SECRET_KEY', 'default-secret') | |
| data = f"api:{file_id}:{user_id}:{secret}" | |
| return hashlib.sha256(data.encode()).hexdigest()[:24] | |
| def public_api_download(file_id): | |
| """ | |
| Public endpoint: Download a Google Drive API file without authentication. | |
| Requires a valid token unique to the file. | |
| Usage: /drive/api/public/<file_id>?token=<token> | |
| """ | |
| token = request.args.get('token') | |
| if not token: | |
| return "Missing token parameter", 401 | |
| # Find which user owns this token by checking all users with google_token | |
| conn = get_db_connection() | |
| users = conn.execute('SELECT id, google_token FROM users WHERE google_token IS NOT NULL').fetchall() | |
| conn.close() | |
| valid_user = None | |
| for user in users: | |
| expected_token = generate_api_file_token(file_id, user['id']) | |
| if token == expected_token: | |
| valid_user = user | |
| break | |
| if not valid_user: | |
| return "Invalid or expired token", 403 | |
| # Build drive service for this user | |
| try: | |
| import json | |
| from google.oauth2.credentials import Credentials | |
| from googleapiclient.discovery import build | |
| token_info = json.loads(valid_user['google_token']) | |
| SCOPES = ['https://www.googleapis.com/auth/drive.readonly'] | |
| creds = Credentials.from_authorized_user_info(token_info, SCOPES) | |
| service = build('drive', 'v3', credentials=creds) | |
| meta = get_file_metadata(service, file_id) | |
| if not meta: | |
| return "File not found", 404 | |
| filename = meta['name'] | |
| cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache') | |
| if not os.path.exists(cache_dir): | |
| os.makedirs(cache_dir) | |
| from werkzeug.utils import secure_filename | |
| safe_name = secure_filename(filename) | |
| file_path = os.path.join(cache_dir, safe_name) | |
| if not os.path.exists(file_path): | |
| with open(file_path, 'wb') as f: | |
| download_file_to_stream(service, file_id, f) | |
| # Determine MIME type | |
| import mimetypes | |
| mime_type = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream' | |
| # Check if download mode requested | |
| download_mode = request.args.get('download', '').lower() in ('1', 'true', 'yes') | |
| # Return file with proper headers for both inline viewing and download | |
| response = send_file( | |
| file_path, | |
| mimetype=mime_type, | |
| as_attachment=download_mode, | |
| download_name=filename | |
| ) | |
| # Add headers for better compatibility with PDF viewer apps | |
| if not download_mode: | |
| response.headers['Content-Disposition'] = f'inline; filename="{filename}"' | |
| # Allow cross-origin access for apps | |
| response.headers['Access-Control-Allow-Origin'] = '*' | |
| response.headers['Accept-Ranges'] = 'bytes' | |
| return response | |
| except Exception as e: | |
| return f"Error: {e}", 500 | |
| def get_api_file_token(file_id): | |
| """Get the public access token for a specific Google Drive API file.""" | |
| if not current_user.google_token: | |
| return jsonify({'error': 'Not connected to Google Drive'}), 401 | |
| token = generate_api_file_token(file_id, current_user.id) | |
| public_url = url_for('drive.public_api_download', file_id=file_id, token=token, _external=True) | |
| return jsonify({ | |
| 'token': token, | |
| 'public_url': public_url | |
| }) | |
| def generate_public_token(source_id, user_id): | |
| """Generate a simple token for public access.""" | |
| import hashlib | |
| secret = current_app.config.get('SECRET_KEY', 'default-secret') | |
| data = f"{source_id}:{user_id}:{secret}" | |
| return hashlib.sha256(data.encode()).hexdigest()[:32] | |
| def verify_public_token(source_id, token): | |
| """Verify public access token and return the source if valid.""" | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source: | |
| return None | |
| expected_token = generate_public_token(source_id, source['user_id']) | |
| if token == expected_token: | |
| return dict(source) | |
| return None | |
| def get_public_token(source_id): | |
| """Generate a public access token for a source (owner only).""" | |
| conn = get_db_connection() | |
| source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone() | |
| conn.close() | |
| if not source or source['user_id'] != current_user.id: | |
| return jsonify({'error': 'Unauthorized'}), 403 | |
| token = generate_public_token(source_id, current_user.id) | |
| return jsonify({'token': token}) | |
| def public_list_files(source_id): | |
| """ | |
| Public endpoint: Returns a plain text list of all files in the synced folder. | |
| Perfect for curl/wget scripts. | |
| Usage: curl https://host/drive/public/123/list.txt?token=abc123 | |
| """ | |
| token = request.args.get('token') | |
| if not token: | |
| return "Missing token parameter. Add ?token=YOUR_TOKEN to the URL.", 401 | |
| source = verify_public_token(source_id, token) | |
| if not source: | |
| return "Invalid or expired token.", 403 | |
| base_path = get_sync_folder_path(source['local_path']) | |
| if not os.path.exists(base_path): | |
| return "Source not synced yet.", 404 | |
| subpath = request.args.get('path', '') | |
| current_path = os.path.join(base_path, subpath) if subpath else base_path | |
| if not os.path.exists(current_path): | |
| return "Path not found.", 404 | |
| # Collect all files recursively | |
| files_list = [] | |
| base_url = request.host_url.rstrip('/') | |
| def scan_directory(dir_path, prefix=''): | |
| try: | |
| for entry in sorted(os.scandir(dir_path), key=lambda e: e.name.lower()): | |
| rel_path = os.path.join(prefix, entry.name) if prefix else entry.name | |
| if entry.is_dir(): | |
| files_list.append(f"[DIR] {rel_path}/") | |
| scan_directory(entry.path, rel_path) | |
| else: | |
| # Add download URL for each file | |
| download_url = f"{base_url}/drive/public/{source_id}/download/{rel_path}?token={token}" | |
| size = entry.stat().st_size | |
| size_str = format_file_size(size) | |
| files_list.append(f"{rel_path}\t{size_str}\t{download_url}") | |
| except Exception as e: | |
| files_list.append(f"[ERROR] {str(e)}") | |
| scan_directory(current_path) | |
| # Build response | |
| header = f"# Directory listing for: {source['name']}\n" | |
| header += f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" | |
| header += f"# Format: filename\\tsize\\tdownload_url\n" | |
| header += "#" + "=" * 60 + "\n\n" | |
| content = header + "\n".join(files_list) | |
| from flask import Response | |
| return Response(content, mimetype='text/plain', headers={ | |
| 'Content-Disposition': f'inline; filename="{source["name"]}_files.txt"' | |
| }) | |
| def format_file_size(size_bytes): | |
| """Format bytes to human readable string.""" | |
| for unit in ['B', 'KB', 'MB', 'GB']: | |
| if size_bytes < 1024: | |
| return f"{size_bytes:.1f}{unit}" | |
| size_bytes /= 1024 | |
| return f"{size_bytes:.1f}TB" | |
| def public_download_file(source_id, filepath=None): | |
| """ | |
| Public endpoint: Download a file without authentication. | |
| Usage: | |
| - Single file source: /drive/public/123/download?token=abc123 | |
| - Specific file: /drive/public/123/download/subfolder/file.pdf?token=abc123 | |
| """ | |
| token = request.args.get('token') | |
| if not token: | |
| return "Missing token parameter. Add ?token=YOUR_TOKEN to the URL.", 401 | |
| source = verify_public_token(source_id, token) | |
| if not source: | |
| return "Invalid or expired token.", 403 | |
| base_path = get_sync_folder_path(source['local_path']) | |
| # If no filepath specified, try to find the first/only file | |
| if not filepath: | |
| if source['source_type'] == 'file': | |
| # For file sources, download using gdown if not cached | |
| if not os.path.exists(base_path): | |
| os.makedirs(base_path) | |
| # Find existing file or download | |
| files = [f for f in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, f))] if os.path.exists(base_path) else [] | |
| if files: | |
| filepath = files[0] | |
| else: | |
| # Try to download | |
| try: | |
| output_path = os.path.join(base_path, 'document.pdf') | |
| gdown.download(url=source['url'], output=output_path, quiet=True, fuzzy=True) | |
| filepath = 'document.pdf' | |
| except Exception as e: | |
| return f"Error downloading file: {e}", 500 | |
| else: | |
| return "Filepath required for folder sources. Use /download/path/to/file.pdf", 400 | |
| full_path = os.path.join(base_path, filepath) | |
| # Security: Ensure path doesn't escape base | |
| if not os.path.abspath(full_path).startswith(os.path.abspath(base_path)): | |
| return "Invalid path.", 403 | |
| if not os.path.exists(full_path): | |
| return "File not found.", 404 | |
| if not os.path.isfile(full_path): | |
| return "Not a file.", 400 | |
| # Serve the file | |
| directory = os.path.dirname(full_path) | |
| filename = os.path.basename(full_path) | |
| # Check if inline viewing is requested | |
| inline = request.args.get('inline', '0') == '1' | |
| return send_from_directory( | |
| directory, | |
| filename, | |
| as_attachment=not inline | |
| ) | |
| def public_browse(source_id): | |
| """ | |
| Public endpoint: Browse files in a synced folder (JSON response). | |
| """ | |
| token = request.args.get('token') | |
| if not token: | |
| return jsonify({'error': 'Missing token'}), 401 | |
| source = verify_public_token(source_id, token) | |
| if not source: | |
| return jsonify({'error': 'Invalid token'}), 403 | |
| base_path = get_sync_folder_path(source['local_path']) | |
| subpath = request.args.get('path', '') | |
| current_path = os.path.join(base_path, subpath) if subpath else base_path | |
| if not os.path.exists(current_path): | |
| return jsonify({'error': 'Path not found'}), 404 | |
| items = [] | |
| base_url = request.host_url.rstrip('/') | |
| try: | |
| for entry in sorted(os.scandir(current_path), key=lambda e: (not e.is_dir(), e.name.lower())): | |
| rel_path = os.path.join(subpath, entry.name) if subpath else entry.name | |
| item = { | |
| 'name': entry.name, | |
| 'type': 'folder' if entry.is_dir() else 'file', | |
| 'path': rel_path | |
| } | |
| if entry.is_file(): | |
| item['size'] = entry.stat().st_size | |
| item['download_url'] = f"{base_url}/drive/public/{source_id}/download/{rel_path}?token={token}" | |
| items.append(item) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| return jsonify({ | |
| 'source': source['name'], | |
| 'path': subpath or '/', | |
| 'items': items | |
| }) | |