# Standard library imports from datetime import datetime, timezone # Third-party imports from flask import Blueprint, abort, g, jsonify, request, url_for, current_app, send_file from sqlalchemy import and_, or_ # Local application imports from models import Note, NoteFolder, NoteLink, NoteVisibility, db from routes.auth import company_required, login_required from security_utils import (sanitize_folder_path, validate_folder_access, generate_secure_file_path, validate_filename, ensure_safe_file_path, get_safe_mime_type) # Create blueprint notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes') @notes_api_bp.route('/folder-details') @login_required @company_required def api_folder_details(): """Get folder details including note count""" folder_path = request.args.get('folder', '') # Sanitize folder path if provided if folder_path: try: folder_path = sanitize_folder_path(folder_path) except ValueError: return jsonify({'success': False, 'message': 'Invalid folder path'}), 400 # Get note count for this folder note_count = Note.query.filter_by( company_id=g.user.company_id, folder=folder_path, is_archived=False ).count() # Check if folder exists in NoteFolder table folder = NoteFolder.query.filter_by( company_id=g.user.company_id, path=folder_path ).first() return jsonify({ 'success': True, 'folder': { 'path': folder_path, 'name': folder_path.split('/')[-1] if folder_path else 'Root', 'exists': folder is not None, 'note_count': note_count, 'created_at': folder.created_at.isoformat() if folder else None } }) @notes_api_bp.route('/folders', methods=['POST']) @login_required @company_required def api_create_folder(): """Create a new folder""" data = request.get_json() if not data: return jsonify({'success': False, 'message': 'No data provided'}), 400 folder_name = data.get('name', '').strip() parent_path = data.get('parent', '').strip() description = data.get('description', '').strip() if not folder_name: return jsonify({'success': False, 'message': 'Folder name is required'}), 400 # Validate folder name if '/' in folder_name or '..' in folder_name: return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400 # Sanitize parent path if provided if parent_path: try: parent_path = sanitize_folder_path(parent_path) # Validate parent exists if not validate_folder_access(parent_path, g.user.company_id, db.session): return jsonify({'success': False, 'message': 'Parent folder does not exist'}), 404 except ValueError: return jsonify({'success': False, 'message': 'Invalid parent folder path'}), 400 # Build full path and sanitize if parent_path: full_path = f"{parent_path}/{folder_name}" else: full_path = folder_name try: full_path = sanitize_folder_path(full_path) except ValueError as e: return jsonify({'success': False, 'message': str(e)}), 400 # Check if folder already exists existing = NoteFolder.query.filter_by( company_id=g.user.company_id, path=full_path ).first() if existing: return jsonify({'success': False, 'message': 'Folder already exists'}), 400 # Create folder folder = NoteFolder( name=folder_name, path=full_path, parent_path=parent_path if parent_path else None, description=description if description else None, company_id=g.user.company_id, created_by_id=g.user.id ) db.session.add(folder) db.session.commit() return jsonify({ 'success': True, 'message': 'Folder created successfully', 'folder': { 'path': folder.path, 'name': folder_name } }) @notes_api_bp.route('/folders', methods=['PUT']) @login_required @company_required def api_rename_folder(): """Rename a folder""" data = request.get_json() if not data: return jsonify({'success': False, 'message': 'No data provided'}), 400 old_path = data.get('old_path', '').strip() new_name = data.get('new_name', '').strip() if not old_path or not new_name: return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400 # Sanitize old path try: old_path = sanitize_folder_path(old_path) except ValueError: return jsonify({'success': False, 'message': 'Invalid folder path'}), 400 # Validate new name if '/' in new_name or '..' in new_name: return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400 # Find the folder folder = NoteFolder.query.filter_by( company_id=g.user.company_id, path=old_path ).first() if not folder: return jsonify({'success': False, 'message': 'Folder not found'}), 404 # Build new path path_parts = old_path.split('/') path_parts[-1] = new_name new_path = '/'.join(path_parts) # Sanitize new path try: new_path = sanitize_folder_path(new_path) except ValueError as e: return jsonify({'success': False, 'message': str(e)}), 400 # Check if new path already exists existing = NoteFolder.query.filter_by( company_id=g.user.company_id, path=new_path ).first() if existing: return jsonify({'success': False, 'message': 'A folder with this name already exists'}), 400 # Update folder path folder.path = new_path # Update all notes in this folder Note.query.filter_by( company_id=g.user.company_id, folder=old_path ).update({Note.folder: new_path}) # Update all subfolders subfolders = NoteFolder.query.filter( NoteFolder.company_id == g.user.company_id, NoteFolder.path.like(f"{old_path}/%") ).all() for subfolder in subfolders: subfolder.path = subfolder.path.replace(old_path, new_path, 1) # Update all notes in subfolders notes_in_subfolders = Note.query.filter( Note.company_id == g.user.company_id, Note.folder.like(f"{old_path}/%") ).all() for note in notes_in_subfolders: note.folder = note.folder.replace(old_path, new_path, 1) db.session.commit() return jsonify({ 'success': True, 'message': 'Folder renamed successfully', 'folder': { 'old_path': old_path, 'new_path': new_path } }) @notes_api_bp.route('/folders', methods=['DELETE']) @login_required @company_required def api_delete_folder(): """Delete a folder""" folder_path = request.args.get('path', '').strip() if not folder_path: return jsonify({'success': False, 'message': 'Folder path is required'}), 400 # Sanitize folder path try: folder_path = sanitize_folder_path(folder_path) except ValueError: return jsonify({'success': False, 'message': 'Invalid folder path'}), 400 # Check if folder has notes note_count = Note.query.filter_by( company_id=g.user.company_id, folder=folder_path, is_archived=False ).count() if note_count > 0: return jsonify({ 'success': False, 'message': f'Cannot delete folder with {note_count} notes. Please move or delete the notes first.' }), 400 # Find and delete the folder folder = NoteFolder.query.filter_by( company_id=g.user.company_id, path=folder_path ).first() if folder: db.session.delete(folder) db.session.commit() return jsonify({ 'success': True, 'message': 'Folder deleted successfully' }) @notes_api_bp.route('//folder', methods=['PUT']) @login_required @company_required def update_note_folder(slug): """Update a note's folder via drag and drop""" note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404() # Check permissions if not note.can_user_edit(g.user): return jsonify({'success': False, 'message': 'Permission denied'}), 403 data = request.get_json() new_folder = data.get('folder', '').strip() # Update note folder note.folder = new_folder if new_folder else None note.updated_at = datetime.now(timezone.utc) db.session.commit() return jsonify({ 'success': True, 'message': 'Note moved successfully' }) @notes_api_bp.route('//move', methods=['POST']) @login_required @company_required def move_note_to_folder(note_id): """Move a note to a different folder (used by drag and drop)""" note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() if not note: return jsonify({'success': False, 'error': 'Note not found'}), 404 # Check permissions if not note.can_user_edit(g.user): return jsonify({'success': False, 'error': 'Permission denied'}), 403 data = request.get_json() new_folder = data.get('folder', '').strip() # Update note folder note.folder = new_folder if new_folder else None note.updated_at = datetime.now(timezone.utc) db.session.commit() return jsonify({ 'success': True, 'message': 'Note moved successfully', 'folder': note.folder or '' }) @notes_api_bp.route('//tags', methods=['POST']) @login_required @company_required def add_tags_to_note(note_id): """Add tags to a note""" note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() if not note: return jsonify({'success': False, 'message': 'Note not found'}), 404 # Check permissions if not note.can_user_edit(g.user): return jsonify({'success': False, 'message': 'Permission denied'}), 403 data = request.get_json() new_tags = data.get('tags', '').strip() if not new_tags: return jsonify({'success': False, 'message': 'No tags provided'}), 400 # Merge with existing tags existing_tags = note.get_tags_list() new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()] # Combine and deduplicate all_tags = list(set(existing_tags + new_tag_list)) # Update note note.tags = ', '.join(sorted(all_tags)) note.updated_at = datetime.now(timezone.utc) db.session.commit() return jsonify({ 'success': True, 'message': 'Tags added successfully', 'tags': all_tags }) @notes_api_bp.route('//link', methods=['POST']) @login_required @company_required def link_notes(note_id): """Create a link between two notes""" source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() if not source_note: return jsonify({'success': False, 'message': 'Source note not found'}), 404 # Check permissions if not source_note.can_user_edit(g.user): return jsonify({'success': False, 'message': 'Permission denied'}), 403 data = request.get_json() target_note_id = data.get('target_note_id') link_type = data.get('link_type', 'related') if not target_note_id: return jsonify({'success': False, 'message': 'Target note ID is required'}), 400 # Get target note target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first() if not target_note: return jsonify({'success': False, 'message': 'Target note not found'}), 404 # Check if user can view target note if not target_note.can_user_view(g.user): return jsonify({'success': False, 'message': 'You cannot link to a note you cannot view'}), 403 # Check if link already exists existing_link = NoteLink.query.filter_by( source_note_id=source_note.id, target_note_id=target_note.id ).first() if existing_link: return jsonify({'success': False, 'message': 'Link already exists'}), 400 # Create link link = NoteLink( source_note_id=source_note.id, target_note_id=target_note.id, link_type=link_type, created_by_id=g.user.id ) db.session.add(link) db.session.commit() return jsonify({ 'success': True, 'message': 'Notes linked successfully' }) @notes_api_bp.route('//link', methods=['DELETE']) @login_required @company_required def unlink_notes(note_id): """Remove a link between two notes""" source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first() if not source_note: return jsonify({'success': False, 'message': 'Source note not found'}), 404 # Check permissions if not source_note.can_user_edit(g.user): return jsonify({'success': False, 'message': 'Permission denied'}), 403 data = request.get_json() target_note_id = data.get('target_note_id') if not target_note_id: return jsonify({'success': False, 'message': 'Target note ID is required'}), 400 # Find and delete the link (check both directions) link = NoteLink.query.filter( or_( and_( NoteLink.source_note_id == source_note.id, NoteLink.target_note_id == target_note_id ), and_( NoteLink.source_note_id == target_note_id, NoteLink.target_note_id == source_note.id ) ) ).first() if not link: return jsonify({'success': False, 'message': 'Link not found'}), 404 db.session.delete(link) db.session.commit() return jsonify({ 'success': True, 'message': 'Link removed successfully' }) @notes_api_bp.route('//shares', methods=['POST']) @login_required @company_required def create_note_share(slug): """Create a share link for a note""" note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first() if not note: return jsonify({'success': False, 'error': 'Note not found'}), 404 # Check permissions - only editors can create shares if not note.can_user_edit(g.user): return jsonify({'success': False, 'error': 'Permission denied'}), 403 data = request.get_json() try: share = note.create_share_link( expires_in_days=data.get('expires_in_days'), password=data.get('password'), max_views=data.get('max_views') ) db.session.commit() return jsonify({ 'success': True, 'share': share.to_dict() }) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('//shares', methods=['GET']) @login_required @company_required def list_note_shares(slug): """List all share links for a note""" note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first() if not note: return jsonify({'success': False, 'error': 'Note not found'}), 404 # Check permissions if not note.can_user_view(g.user): return jsonify({'success': False, 'error': 'Permission denied'}), 403 # Get all shares (not just active ones) shares = note.get_all_shares() return jsonify({ 'success': True, 'shares': [s.to_dict() for s in shares] }) @notes_api_bp.route('/shares/', methods=['DELETE']) @login_required @company_required def delete_note_share(share_id): """Delete a share link""" from models import NoteShare share = NoteShare.query.get(share_id) if not share: return jsonify({'success': False, 'error': 'Share not found'}), 404 # Check permissions if share.note.company_id != g.user.company_id: return jsonify({'success': False, 'error': 'Permission denied'}), 403 if not share.note.can_user_edit(g.user): return jsonify({'success': False, 'error': 'Permission denied'}), 403 try: db.session.delete(share) db.session.commit() return jsonify({ 'success': True, 'message': 'Share link deleted successfully' }) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('/shares/', methods=['PUT']) @login_required @company_required def update_note_share(share_id): """Update a share link settings""" from models import NoteShare share = NoteShare.query.get(share_id) if not share: return jsonify({'success': False, 'error': 'Share not found'}), 404 # Check permissions if share.note.company_id != g.user.company_id: return jsonify({'success': False, 'error': 'Permission denied'}), 403 if not share.note.can_user_edit(g.user): return jsonify({'success': False, 'error': 'Permission denied'}), 403 data = request.get_json() try: # Update expiration if 'expires_in_days' in data: if data['expires_in_days'] is None: share.expires_at = None else: from datetime import datetime, timedelta share.expires_at = datetime.now() + timedelta(days=data['expires_in_days']) # Update password if 'password' in data: share.set_password(data['password']) # Update view limit if 'max_views' in data: share.max_views = data['max_views'] db.session.commit() return jsonify({ 'success': True, 'share': share.to_dict() }) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('/autocomplete') @login_required @company_required def autocomplete_notes(): """Get notes for autocomplete suggestions""" query = request.args.get('q', '').strip() limit = min(int(request.args.get('limit', 10)), 50) # Max 50 results # Base query - only notes user can see notes_query = Note.query.filter( Note.company_id == g.user.company_id, Note.is_archived == False ).filter( or_( # Private notes created by user and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id), # Team notes from user's team and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)), # Company notes Note.visibility == NoteVisibility.COMPANY ) ) # Apply search filter if query provided if query: search_pattern = f'%{query}%' notes_query = notes_query.filter( or_( Note.title.ilike(search_pattern), Note.slug.ilike(search_pattern) ) ) # Order by relevance (exact matches first) and limit results notes = notes_query.order_by( # Exact title match first (Note.title == query).desc(), # Then exact slug match (Note.slug == query).desc(), # Then by title Note.title ).limit(limit).all() # Format results for autocomplete results = [] for note in notes: results.append({ 'id': note.id, 'title': note.title, 'slug': note.slug, 'folder': note.folder or '', 'tags': note.get_tags_list(), 'visibility': note.visibility.value, 'preview': note.get_preview(100), 'updated_at': note.updated_at.isoformat() if note.updated_at else None }) return jsonify({ 'success': True, 'results': results, 'count': len(results), 'query': query }) def clean_filename_for_title(filename): """Remove common folder-like prefixes from filename to create cleaner titles.""" # Remove file extension name = filename.rsplit('.', 1)[0] # Common prefixes that might be folder names prefixes_to_remove = [ 'Webpages_', 'Documents_', 'Files_', 'Downloads_', 'Images_', 'Photos_', 'Pictures_', 'Uploads_', 'Docs_', 'PDFs_', 'Attachments_', 'Archive_' ] # Remove prefix if found at the beginning for prefix in prefixes_to_remove: if name.startswith(prefix): name = name[len(prefix):] break # Also handle cases where the filename starts with numbers/dates # e.g., "2024-01-15_Document_Name" -> "Document Name" import re name = re.sub(r'^\d{4}[-_]\d{2}[-_]\d{2}[-_]', '', name) name = re.sub(r'^\d+[-_]', '', name) # Replace underscores with spaces for readability name = name.replace('_', ' ') # Clean up multiple spaces name = ' '.join(name.split()) return name if name else filename.rsplit('.', 1)[0] @notes_api_bp.route('/upload', methods=['POST']) @login_required @company_required def upload_note(): """Upload a file (markdown or image) and create a note from it""" import os from PIL import Image if 'file' not in request.files: return jsonify({'success': False, 'error': 'No file provided'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'success': False, 'error': 'No file selected'}), 400 # Validate filename try: original_filename = validate_filename(file.filename) except ValueError as e: return jsonify({'success': False, 'error': str(e)}), 400 # Check if file type is allowed if not Note.allowed_file(original_filename): return jsonify({'success': False, 'error': 'File type not allowed. Supported: markdown (.md), images (.jpg, .png, .gif, .webp, .svg), text (.txt), documents (.pdf, .doc, .docx)'}), 400 # Get file info file_type = Note.get_file_type_from_filename(original_filename) # Generate secure file path using UUID try: relative_path = generate_secure_file_path(file_type, original_filename) except ValueError as e: return jsonify({'success': False, 'error': str(e)}), 400 # Create upload directory in persistent volume upload_base = '/data/uploads/notes' file_path = os.path.join(upload_base, relative_path) upload_dir = os.path.dirname(file_path) # Ensure directory exists os.makedirs(upload_dir, exist_ok=True) # Ensure the path is safe try: file_path = ensure_safe_file_path(upload_base, relative_path) except ValueError: return jsonify({'success': False, 'error': 'Invalid file path'}), 400 # Save the file file.save(file_path) # Get file size file_size = os.path.getsize(file_path) # Get safe MIME type mime_type = get_safe_mime_type(original_filename) # Create note content based on file type if file_type == 'markdown' or file_type == 'text': # Read text content try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except UnicodeDecodeError: # If not UTF-8, try with different encoding with open(file_path, 'r', encoding='latin-1') as f: content = f.read() title = request.form.get('title') or clean_filename_for_title(original_filename) image_width = image_height = None elif file_type == 'image': # For images, create a simple markdown content with the image title = request.form.get('title') or clean_filename_for_title(original_filename) # Content will be updated after note is created with its ID content = f"![{title}](PLACEHOLDER)" # Get image dimensions try: with Image.open(file_path) as img: image_width, image_height = img.size except: image_width = image_height = None else: # For other documents title = request.form.get('title') or clean_filename_for_title(original_filename) # Content will be updated after note is created with its ID content = f"[Download {original_filename}](PLACEHOLDER)" image_width = image_height = None # Create the note note = Note( title=title, content=content, visibility=NoteVisibility.PRIVATE, created_by_id=g.user.id, company_id=g.user.company_id, is_file_based=True, file_path=relative_path, file_type=file_type, original_filename=original_filename, file_size=file_size, mime_type=mime_type ) if file_type == 'image' and image_width: note.image_width = image_width note.image_height = image_height # Set folder if provided folder = request.form.get('folder') if folder: try: folder = sanitize_folder_path(folder) # Validate folder exists if not validate_folder_access(folder, g.user.company_id, db.session): # Create folder if it doesn't exist folder_parts = folder.split('/') current_path = '' for i, part in enumerate(folder_parts): if i == 0: current_path = part else: current_path = current_path + '/' + part existing_folder = NoteFolder.query.filter_by( company_id=g.user.company_id, path=current_path ).first() if not existing_folder: parent_path = '/'.join(folder_parts[:i]) if i > 0 else None new_folder = NoteFolder( name=part, path=current_path, parent_path=parent_path, company_id=g.user.company_id, created_by_id=g.user.id ) db.session.add(new_folder) note.folder = folder except ValueError: # Skip invalid folder pass # Set tags if provided tags = request.form.get('tags') if tags: note.tags = tags # Generate slug note.generate_slug() try: db.session.add(note) db.session.commit() # Update content with proper file URLs now that we have the note ID if file_type == 'image': note.content = f"![{note.title}]({url_for('notes_api.serve_note_file', note_id=note.id)})" elif file_type not in ['markdown', 'text']: note.content = f"[Download {original_filename}]({url_for('notes_api.serve_note_file', note_id=note.id)})" db.session.commit() return jsonify({ 'success': True, 'note': { 'id': note.id, 'title': note.title, 'slug': note.slug, 'file_type': note.file_type, 'file_url': url_for('notes_api.serve_note_file', note_id=note.id), 'url': url_for('notes.view_note', slug=note.slug) } }) except Exception as e: db.session.rollback() # Delete uploaded file on error if os.path.exists(file_path): os.remove(file_path) return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('/bulk-move', methods=['POST']) @login_required @company_required def bulk_move_notes(): """Move multiple notes to a different folder.""" data = request.get_json() if not data or 'note_ids' not in data: return jsonify({'success': False, 'error': 'No notes specified'}), 400 note_ids = data.get('note_ids', []) folder = data.get('folder', '') if not note_ids: return jsonify({'success': False, 'error': 'No notes specified'}), 400 try: # Get all notes and verify ownership notes = Note.query.filter( Note.id.in_(note_ids), Note.company_id == g.user.company_id ).all() if len(notes) != len(note_ids): return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403 # Update folder for all notes for note in notes: note.folder = folder if folder else None db.session.commit() return jsonify({ 'success': True, 'message': f'Moved {len(notes)} note(s) to {"folder: " + folder if folder else "root"}' }) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('/bulk-delete', methods=['POST']) @login_required @company_required def bulk_delete_notes(): """Delete multiple notes.""" data = request.get_json() if not data or 'note_ids' not in data: return jsonify({'success': False, 'error': 'No notes specified'}), 400 note_ids = data.get('note_ids', []) if not note_ids: return jsonify({'success': False, 'error': 'No notes specified'}), 400 try: # Get all notes and verify ownership notes = Note.query.filter( Note.id.in_(note_ids), Note.company_id == g.user.company_id ).all() if len(notes) != len(note_ids): return jsonify({'success': False, 'error': 'Some notes not found or access denied'}), 403 # Delete files if they exist import os for note in notes: if note.file_path: try: # Use the safe base path upload_base = '/data/uploads/notes' file_path = ensure_safe_file_path(upload_base, note.file_path) if os.path.exists(file_path): os.remove(file_path) except: pass # Continue even if file deletion fails # Delete all notes for note in notes: db.session.delete(note) db.session.commit() return jsonify({ 'success': True, 'message': f'Deleted {len(notes)} note(s)' }) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @notes_api_bp.route('/file/') @login_required @company_required def serve_note_file(note_id): """Securely serve uploaded files after validating access permissions""" import os # Get the note and validate access note = Note.query.filter_by( id=note_id, company_id=g.user.company_id ).first_or_404() # Check if user can view the note if not note.can_user_view(g.user): abort(403) # Check if note has a file if not note.file_path: abort(404) # Build safe file path upload_base = '/data/uploads/notes' try: safe_path = ensure_safe_file_path(upload_base, note.file_path) except ValueError: abort(403) # Check if file exists if not os.path.exists(safe_path): abort(404) # Get safe MIME type mime_type = get_safe_mime_type(note.original_filename or 'file') # Send the file return send_file( safe_path, mimetype=mime_type, as_attachment=False, # Display inline for images download_name=note.original_filename )