Fix security issues.
This commit is contained in:
@@ -299,8 +299,9 @@ class Note(db.Model):
|
|||||||
@property
|
@property
|
||||||
def file_url(self):
|
def file_url(self):
|
||||||
"""Get the URL to access the uploaded file"""
|
"""Get the URL to access the uploaded file"""
|
||||||
if self.file_path:
|
if self.file_path and self.id:
|
||||||
return f'/uploads/notes/{self.file_path}'
|
from flask import url_for
|
||||||
|
return url_for('notes_api.serve_note_file', note_id=self.id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from sqlalchemy import and_, or_
|
|||||||
|
|
||||||
# Local application imports
|
# Local application imports
|
||||||
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
|
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
|
||||||
Task, db)
|
Task, UserPreferences, db)
|
||||||
from routes.auth import company_required, login_required
|
from routes.auth import company_required, login_required
|
||||||
|
from security_utils import sanitize_folder_path, validate_folder_access
|
||||||
|
|
||||||
# Create blueprint
|
# Create blueprint
|
||||||
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
|
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
|
||||||
@@ -30,6 +31,16 @@ def notes_list():
|
|||||||
visibility_filter = request.args.get('visibility', '')
|
visibility_filter = request.args.get('visibility', '')
|
||||||
search_query = request.args.get('search', '')
|
search_query = request.args.get('search', '')
|
||||||
|
|
||||||
|
# Sanitize folder filter if provided
|
||||||
|
if folder_filter:
|
||||||
|
try:
|
||||||
|
folder_filter = sanitize_folder_path(folder_filter)
|
||||||
|
# Validate folder exists
|
||||||
|
if not validate_folder_access(folder_filter, g.user.company_id, db.session):
|
||||||
|
folder_filter = '' # Reset to root if invalid
|
||||||
|
except ValueError:
|
||||||
|
folder_filter = '' # Reset to root if invalid
|
||||||
|
|
||||||
# Base query - only non-archived notes for the user's company
|
# Base query - only non-archived notes for the user's company
|
||||||
query = Note.query.filter_by(
|
query = Note.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
@@ -197,6 +208,41 @@ def create_note():
|
|||||||
task_id = request.form.get('task_id')
|
task_id = request.form.get('task_id')
|
||||||
is_pinned = request.form.get('is_pinned') == '1'
|
is_pinned = request.form.get('is_pinned') == '1'
|
||||||
|
|
||||||
|
# Sanitize and validate folder if provided
|
||||||
|
if folder:
|
||||||
|
try:
|
||||||
|
folder = sanitize_folder_path(folder)
|
||||||
|
# Ensure folder exists or create it
|
||||||
|
if not validate_folder_access(folder, g.user.company_id, db.session):
|
||||||
|
# Create folder hierarchy 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 = NoteFolder.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
path=current_path
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
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)
|
||||||
|
db.session.flush() # Ensure folder is created before continuing
|
||||||
|
except ValueError as e:
|
||||||
|
flash(f'Invalid folder path: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('notes.create_note'))
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
if not title:
|
if not title:
|
||||||
flash('Title is required', 'error')
|
flash('Title is required', 'error')
|
||||||
@@ -366,6 +412,18 @@ def edit_note(slug):
|
|||||||
task_id = request.form.get('task_id')
|
task_id = request.form.get('task_id')
|
||||||
is_pinned = request.form.get('is_pinned') == '1'
|
is_pinned = request.form.get('is_pinned') == '1'
|
||||||
|
|
||||||
|
# Sanitize and validate folder if provided
|
||||||
|
if folder:
|
||||||
|
try:
|
||||||
|
folder = sanitize_folder_path(folder)
|
||||||
|
# Validate folder exists
|
||||||
|
if not validate_folder_access(folder, g.user.company_id, db.session):
|
||||||
|
flash('Invalid folder selected', 'error')
|
||||||
|
return redirect(url_for('notes.edit_note', slug=slug))
|
||||||
|
except ValueError as e:
|
||||||
|
flash(f'Invalid folder path: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('notes.edit_note', slug=slug))
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
if not title:
|
if not title:
|
||||||
flash('Title is required', 'error')
|
flash('Title is required', 'error')
|
||||||
@@ -496,3 +554,29 @@ def notes_folders():
|
|||||||
folder_tree=folder_tree,
|
folder_tree=folder_tree,
|
||||||
folder_counts=folder_counts,
|
folder_counts=folder_counts,
|
||||||
title='Manage Folders')
|
title='Manage Folders')
|
||||||
|
|
||||||
|
|
||||||
|
@notes_bp.route('/preferences', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def update_note_preferences():
|
||||||
|
"""Update note-related user preferences"""
|
||||||
|
note_preview_font = request.form.get('note_preview_font', 'system')
|
||||||
|
|
||||||
|
# Get or create user preferences
|
||||||
|
preferences = UserPreferences.query.filter_by(user_id=g.user.id).first()
|
||||||
|
if not preferences:
|
||||||
|
preferences = UserPreferences(user_id=g.user.id)
|
||||||
|
db.session.add(preferences)
|
||||||
|
|
||||||
|
# Update preferences
|
||||||
|
preferences.note_preview_font = note_preview_font
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Return JSON response for AJAX calls
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': True, 'font': note_preview_font})
|
||||||
|
|
||||||
|
# Otherwise redirect back
|
||||||
|
return redirect(request.referrer or url_for('notes.notes_list'))
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
from flask import Blueprint, abort, g, jsonify, request
|
from flask import Blueprint, abort, g, jsonify, request, url_for, current_app, send_file
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
# Local application imports
|
# Local application imports
|
||||||
from models import Note, NoteFolder, NoteLink, NoteVisibility, db
|
from models import Note, NoteFolder, NoteLink, NoteVisibility, db
|
||||||
from routes.auth import company_required, login_required
|
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
|
# Create blueprint
|
||||||
notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes')
|
notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes')
|
||||||
@@ -20,6 +23,13 @@ def api_folder_details():
|
|||||||
"""Get folder details including note count"""
|
"""Get folder details including note count"""
|
||||||
folder_path = request.args.get('folder', '')
|
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
|
# Get note count for this folder
|
||||||
note_count = Note.query.filter_by(
|
note_count = Note.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
@@ -62,15 +72,30 @@ def api_create_folder():
|
|||||||
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
|
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
|
||||||
|
|
||||||
# Validate folder name
|
# Validate folder name
|
||||||
if '/' in folder_name:
|
if '/' in folder_name or '..' in folder_name:
|
||||||
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
|
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400
|
||||||
|
|
||||||
# Build full path
|
# 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:
|
if parent_path:
|
||||||
full_path = f"{parent_path}/{folder_name}"
|
full_path = f"{parent_path}/{folder_name}"
|
||||||
else:
|
else:
|
||||||
full_path = folder_name
|
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
|
# Check if folder already exists
|
||||||
existing = NoteFolder.query.filter_by(
|
existing = NoteFolder.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
@@ -118,9 +143,15 @@ def api_rename_folder():
|
|||||||
if not old_path or not new_name:
|
if not old_path or not new_name:
|
||||||
return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400
|
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
|
# Validate new name
|
||||||
if '/' in new_name:
|
if '/' in new_name or '..' in new_name:
|
||||||
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
|
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400
|
||||||
|
|
||||||
# Find the folder
|
# Find the folder
|
||||||
folder = NoteFolder.query.filter_by(
|
folder = NoteFolder.query.filter_by(
|
||||||
@@ -136,6 +167,12 @@ def api_rename_folder():
|
|||||||
path_parts[-1] = new_name
|
path_parts[-1] = new_name
|
||||||
new_path = '/'.join(path_parts)
|
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
|
# Check if new path already exists
|
||||||
existing = NoteFolder.query.filter_by(
|
existing = NoteFolder.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
@@ -194,6 +231,12 @@ def api_delete_folder():
|
|||||||
if not folder_path:
|
if not folder_path:
|
||||||
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
|
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
|
# Check if folder has notes
|
||||||
note_count = Note.query.filter_by(
|
note_count = Note.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
@@ -663,10 +706,7 @@ def clean_filename_for_title(filename):
|
|||||||
def upload_note():
|
def upload_note():
|
||||||
"""Upload a file (markdown or image) and create a note from it"""
|
"""Upload a file (markdown or image) and create a note from it"""
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from flask import current_app, url_for
|
|
||||||
|
|
||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
return jsonify({'success': False, 'error': 'No file provided'}), 400
|
return jsonify({'success': False, 'error': 'No file provided'}), 400
|
||||||
@@ -675,32 +715,38 @@ def upload_note():
|
|||||||
if file.filename == '':
|
if file.filename == '':
|
||||||
return jsonify({'success': False, 'error': 'No file selected'}), 400
|
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
|
# Check if file type is allowed
|
||||||
if not Note.allowed_file(file.filename):
|
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
|
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
|
# Get file info
|
||||||
original_filename = secure_filename(file.filename)
|
|
||||||
file_type = Note.get_file_type_from_filename(original_filename)
|
file_type = Note.get_file_type_from_filename(original_filename)
|
||||||
|
|
||||||
# Create upload directory in persistent volume
|
# Generate secure file path using UUID
|
||||||
upload_base = os.path.join('/data', 'uploads', 'notes')
|
try:
|
||||||
if file_type == 'image':
|
relative_path = generate_secure_file_path(file_type, original_filename)
|
||||||
upload_dir = os.path.join(upload_base, 'images')
|
except ValueError as e:
|
||||||
elif file_type == 'markdown':
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
upload_dir = os.path.join(upload_base, 'markdown')
|
|
||||||
elif file_type == 'text':
|
|
||||||
upload_dir = os.path.join(upload_base, 'text')
|
|
||||||
else:
|
|
||||||
upload_dir = os.path.join(upload_base, 'documents')
|
|
||||||
|
|
||||||
|
# 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)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
# Generate unique filename
|
# Ensure the path is safe
|
||||||
timestamp = int(time.time())
|
try:
|
||||||
filename = f"{g.user.company_id}_{g.user.id}_{timestamp}_{original_filename}"
|
file_path = ensure_safe_file_path(upload_base, relative_path)
|
||||||
file_path = os.path.join(upload_dir, filename)
|
except ValueError:
|
||||||
relative_path = os.path.join(file_type + 's' if file_type != 'markdown' else file_type, filename)
|
return jsonify({'success': False, 'error': 'Invalid file path'}), 400
|
||||||
|
|
||||||
# Save the file
|
# Save the file
|
||||||
file.save(file_path)
|
file.save(file_path)
|
||||||
@@ -708,6 +754,9 @@ def upload_note():
|
|||||||
# Get file size
|
# Get file size
|
||||||
file_size = os.path.getsize(file_path)
|
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
|
# Create note content based on file type
|
||||||
if file_type == 'markdown' or file_type == 'text':
|
if file_type == 'markdown' or file_type == 'text':
|
||||||
# Read text content
|
# Read text content
|
||||||
@@ -723,7 +772,8 @@ def upload_note():
|
|||||||
elif file_type == 'image':
|
elif file_type == 'image':
|
||||||
# For images, create a simple markdown content with the image
|
# For images, create a simple markdown content with the image
|
||||||
title = request.form.get('title') or clean_filename_for_title(original_filename)
|
title = request.form.get('title') or clean_filename_for_title(original_filename)
|
||||||
content = f""
|
# Content will be updated after note is created with its ID
|
||||||
|
content = f""
|
||||||
|
|
||||||
# Get image dimensions
|
# Get image dimensions
|
||||||
try:
|
try:
|
||||||
@@ -734,7 +784,8 @@ def upload_note():
|
|||||||
else:
|
else:
|
||||||
# For other documents
|
# For other documents
|
||||||
title = request.form.get('title') or clean_filename_for_title(original_filename)
|
title = request.form.get('title') or clean_filename_for_title(original_filename)
|
||||||
content = f"[Download {original_filename}](/uploads/notes/{relative_path})"
|
# Content will be updated after note is created with its ID
|
||||||
|
content = f"[Download {original_filename}](PLACEHOLDER)"
|
||||||
image_width = image_height = None
|
image_width = image_height = None
|
||||||
|
|
||||||
# Create the note
|
# Create the note
|
||||||
@@ -749,7 +800,7 @@ def upload_note():
|
|||||||
file_type=file_type,
|
file_type=file_type,
|
||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
mime_type=file.content_type
|
mime_type=mime_type
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_type == 'image' and image_width:
|
if file_type == 'image' and image_width:
|
||||||
@@ -759,9 +810,11 @@ def upload_note():
|
|||||||
# Set folder if provided
|
# Set folder if provided
|
||||||
folder = request.form.get('folder')
|
folder = request.form.get('folder')
|
||||||
if folder:
|
if folder:
|
||||||
note.folder = folder
|
try:
|
||||||
|
folder = sanitize_folder_path(folder)
|
||||||
# Ensure folder exists in database
|
# 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('/')
|
folder_parts = folder.split('/')
|
||||||
current_path = ''
|
current_path = ''
|
||||||
for i, part in enumerate(folder_parts):
|
for i, part in enumerate(folder_parts):
|
||||||
@@ -770,14 +823,12 @@ def upload_note():
|
|||||||
else:
|
else:
|
||||||
current_path = current_path + '/' + part
|
current_path = current_path + '/' + part
|
||||||
|
|
||||||
# Check if folder exists
|
|
||||||
existing_folder = NoteFolder.query.filter_by(
|
existing_folder = NoteFolder.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
path=current_path
|
path=current_path
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_folder:
|
if not existing_folder:
|
||||||
# Create folder
|
|
||||||
parent_path = '/'.join(folder_parts[:i]) if i > 0 else None
|
parent_path = '/'.join(folder_parts[:i]) if i > 0 else None
|
||||||
new_folder = NoteFolder(
|
new_folder = NoteFolder(
|
||||||
name=part,
|
name=part,
|
||||||
@@ -787,6 +838,10 @@ def upload_note():
|
|||||||
created_by_id=g.user.id
|
created_by_id=g.user.id
|
||||||
)
|
)
|
||||||
db.session.add(new_folder)
|
db.session.add(new_folder)
|
||||||
|
note.folder = folder
|
||||||
|
except ValueError:
|
||||||
|
# Skip invalid folder
|
||||||
|
pass
|
||||||
|
|
||||||
# Set tags if provided
|
# Set tags if provided
|
||||||
tags = request.form.get('tags')
|
tags = request.form.get('tags')
|
||||||
@@ -800,6 +855,14 @@ def upload_note():
|
|||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Update content with proper file URLs now that we have the note ID
|
||||||
|
if file_type == 'image':
|
||||||
|
note.content = f"})"
|
||||||
|
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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'note': {
|
'note': {
|
||||||
@@ -807,7 +870,7 @@ def upload_note():
|
|||||||
'title': note.title,
|
'title': note.title,
|
||||||
'slug': note.slug,
|
'slug': note.slug,
|
||||||
'file_type': note.file_type,
|
'file_type': note.file_type,
|
||||||
'file_url': note.file_url,
|
'file_url': url_for('notes_api.serve_note_file', note_id=note.id),
|
||||||
'url': url_for('notes.view_note', slug=note.slug)
|
'url': url_for('notes.view_note', slug=note.slug)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -888,13 +951,14 @@ def bulk_delete_notes():
|
|||||||
|
|
||||||
# Delete files if they exist
|
# Delete files if they exist
|
||||||
import os
|
import os
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
for note in notes:
|
for note in notes:
|
||||||
if note.file_path:
|
if note.file_path:
|
||||||
file_path = os.path.join(current_app.root_path, note.file_path.lstrip('/'))
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
try:
|
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)
|
os.remove(file_path)
|
||||||
except:
|
except:
|
||||||
pass # Continue even if file deletion fails
|
pass # Continue even if file deletion fails
|
||||||
@@ -913,3 +977,47 @@ def bulk_delete_notes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@notes_api_bp.route('/file/<int:note_id>')
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from flask import (Blueprint, Response, abort, flash, g, redirect, request,
|
|||||||
from frontmatter_utils import parse_frontmatter
|
from frontmatter_utils import parse_frontmatter
|
||||||
from models import Note, db
|
from models import Note, db
|
||||||
from routes.auth import company_required, login_required
|
from routes.auth import company_required, login_required
|
||||||
|
from security_utils import sanitize_folder_path, validate_folder_access
|
||||||
|
|
||||||
# Create blueprint
|
# Create blueprint
|
||||||
notes_download_bp = Blueprint('notes_download', __name__)
|
notes_download_bp = Blueprint('notes_download', __name__)
|
||||||
@@ -30,8 +31,11 @@ def download_note(slug, format):
|
|||||||
if not note.can_user_view(g.user):
|
if not note.can_user_view(g.user):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
# Prepare filename
|
# Prepare filename - extra sanitization
|
||||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||||
|
# Ensure filename isn't too long
|
||||||
|
if len(safe_filename) > 100:
|
||||||
|
safe_filename = safe_filename[:100]
|
||||||
timestamp = datetime.now().strftime('%Y%m%d')
|
timestamp = datetime.now().strftime('%Y%m%d')
|
||||||
|
|
||||||
if format == 'md':
|
if format == 'md':
|
||||||
@@ -142,6 +146,8 @@ def download_notes_bulk():
|
|||||||
if note and note.can_user_view(g.user):
|
if note and note.can_user_view(g.user):
|
||||||
# Get content based on format
|
# Get content based on format
|
||||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||||
|
if len(safe_filename) > 100:
|
||||||
|
safe_filename = safe_filename[:100]
|
||||||
|
|
||||||
if format == 'md':
|
if format == 'md':
|
||||||
content = note.content
|
content = note.content
|
||||||
@@ -195,8 +201,15 @@ def download_notes_bulk():
|
|||||||
@company_required
|
@company_required
|
||||||
def download_folder(folder_path, format):
|
def download_folder(folder_path, format):
|
||||||
"""Download all notes in a folder as a zip file"""
|
"""Download all notes in a folder as a zip file"""
|
||||||
# Decode folder path (replace URL encoding)
|
# Decode and sanitize folder path
|
||||||
folder_path = unquote(folder_path)
|
try:
|
||||||
|
folder_path = sanitize_folder_path(unquote(folder_path))
|
||||||
|
except ValueError:
|
||||||
|
abort(400, "Invalid folder path")
|
||||||
|
|
||||||
|
# Validate folder exists and user has access
|
||||||
|
if not validate_folder_access(folder_path, g.user.company_id, db.session):
|
||||||
|
abort(404, "Folder not found")
|
||||||
|
|
||||||
# Get all notes in this folder
|
# Get all notes in this folder
|
||||||
notes = Note.query.filter_by(
|
notes = Note.query.filter_by(
|
||||||
@@ -220,6 +233,8 @@ def download_folder(folder_path, format):
|
|||||||
for note in viewable_notes:
|
for note in viewable_notes:
|
||||||
# Get content based on format
|
# Get content based on format
|
||||||
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
||||||
|
if len(safe_filename) > 100:
|
||||||
|
safe_filename = safe_filename[:100]
|
||||||
|
|
||||||
if format == 'md':
|
if format == 'md':
|
||||||
content = note.content
|
content = note.content
|
||||||
|
|||||||
260
security_utils.py
Normal file
260
security_utils.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Security utilities for path sanitization and file handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_folder_path(path):
|
||||||
|
"""
|
||||||
|
Sanitize folder path to prevent traversal attacks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The folder path to sanitize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized path string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path contains forbidden patterns or characters
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Remove any leading/trailing slashes and whitespace
|
||||||
|
path = path.strip().strip('/')
|
||||||
|
|
||||||
|
# Reject paths containing dangerous patterns
|
||||||
|
dangerous_patterns = [
|
||||||
|
'..', # Parent directory traversal
|
||||||
|
'./', # Current directory reference
|
||||||
|
'\\', # Windows path separator
|
||||||
|
'\0', # Null byte
|
||||||
|
'~', # Home directory reference
|
||||||
|
'\x00', # Alternative null byte
|
||||||
|
'%2e%2e', # URL encoded ..
|
||||||
|
'%252e%252e', # Double URL encoded ..
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check both original and lowercase version
|
||||||
|
path_lower = path.lower()
|
||||||
|
for pattern in dangerous_patterns:
|
||||||
|
if pattern in path or pattern in path_lower:
|
||||||
|
raise ValueError(f"Invalid path: contains forbidden pattern '{pattern}'")
|
||||||
|
|
||||||
|
# Only allow alphanumeric, spaces, hyphens, underscores, and forward slashes
|
||||||
|
if not re.match(r'^[a-zA-Z0-9\s\-_/]+$', path):
|
||||||
|
raise ValueError("Invalid path: contains forbidden characters")
|
||||||
|
|
||||||
|
# Normalize path (remove double slashes, etc.)
|
||||||
|
path_parts = [p for p in path.split('/') if p]
|
||||||
|
|
||||||
|
# Additional check: ensure no part is '..' or '.' or empty
|
||||||
|
for part in path_parts:
|
||||||
|
if part in ('.', '..', '') or part.strip() == '':
|
||||||
|
raise ValueError("Invalid path: contains directory traversal")
|
||||||
|
|
||||||
|
# Check each part doesn't exceed reasonable length
|
||||||
|
if len(part) > 100:
|
||||||
|
raise ValueError("Invalid path: folder name too long")
|
||||||
|
|
||||||
|
# Check total depth
|
||||||
|
if len(path_parts) > 10:
|
||||||
|
raise ValueError("Invalid path: folder depth exceeds maximum allowed")
|
||||||
|
|
||||||
|
normalized = '/'.join(path_parts)
|
||||||
|
|
||||||
|
# Final length check
|
||||||
|
if len(normalized) > 500:
|
||||||
|
raise ValueError("Invalid path: total path length exceeds maximum allowed")
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secure_file_path(file_type, original_filename):
|
||||||
|
"""
|
||||||
|
Generate secure file path using UUID to prevent predictable paths.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_type: Type of file (image, markdown, text, document)
|
||||||
|
original_filename: Original uploaded filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Secure relative path for file storage
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If file type is not allowed
|
||||||
|
"""
|
||||||
|
if not original_filename:
|
||||||
|
raise ValueError("Filename is required")
|
||||||
|
|
||||||
|
# Extract and validate extension
|
||||||
|
_, ext = os.path.splitext(original_filename)
|
||||||
|
ext = ext.lower()
|
||||||
|
|
||||||
|
# Whitelist allowed extensions by type
|
||||||
|
allowed_extensions = {
|
||||||
|
'markdown': {'.md', '.markdown', '.mdown', '.mkd'},
|
||||||
|
'image': {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'},
|
||||||
|
'text': {'.txt'},
|
||||||
|
'document': {'.pdf', '.doc', '.docx'}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify file type matches extension
|
||||||
|
type_extensions = allowed_extensions.get(file_type, set())
|
||||||
|
if ext not in type_extensions:
|
||||||
|
all_allowed = set()
|
||||||
|
for exts in allowed_extensions.values():
|
||||||
|
all_allowed.update(exts)
|
||||||
|
if ext not in all_allowed:
|
||||||
|
raise ValueError(f"File extension '{ext}' is not allowed")
|
||||||
|
# Find correct file type based on extension
|
||||||
|
for ftype, exts in allowed_extensions.items():
|
||||||
|
if ext in exts:
|
||||||
|
file_type = ftype
|
||||||
|
break
|
||||||
|
|
||||||
|
# Generate UUID for filename
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create secure filename
|
||||||
|
secure_name = f"{file_id}{ext}"
|
||||||
|
|
||||||
|
# Return path with type subdirectory
|
||||||
|
return f"{file_type}/{secure_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_folder_access(folder_path, company_id, db_session):
|
||||||
|
"""
|
||||||
|
Validate folder exists and belongs to company.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to validate
|
||||||
|
company_id: Company ID to check against
|
||||||
|
db_session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if folder is valid and accessible, False otherwise
|
||||||
|
"""
|
||||||
|
if not folder_path:
|
||||||
|
return True # Root folder is always valid
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Sanitize the path first
|
||||||
|
folder_path = sanitize_folder_path(folder_path)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from models import NoteFolder
|
||||||
|
|
||||||
|
# Check if folder exists in database
|
||||||
|
folder = db_session.query(NoteFolder).filter_by(
|
||||||
|
path=folder_path,
|
||||||
|
company_id=company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return folder is not None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_safe_file_path(base_path, file_path):
|
||||||
|
"""
|
||||||
|
Ensure a file path is within the safe base directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_path: The safe base directory
|
||||||
|
file_path: The file path to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute safe path
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path would escape the base directory
|
||||||
|
"""
|
||||||
|
# Get absolute paths
|
||||||
|
base_abs = os.path.abspath(base_path)
|
||||||
|
|
||||||
|
# Join paths and resolve
|
||||||
|
full_path = os.path.join(base_abs, file_path)
|
||||||
|
full_abs = os.path.abspath(full_path)
|
||||||
|
|
||||||
|
# Ensure the resolved path is within the base
|
||||||
|
if not full_abs.startswith(base_abs + os.sep) and full_abs != base_abs:
|
||||||
|
raise ValueError("Path traversal detected")
|
||||||
|
|
||||||
|
return full_abs
|
||||||
|
|
||||||
|
|
||||||
|
def validate_filename(filename):
|
||||||
|
"""
|
||||||
|
Validate and secure a filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The filename to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Secure filename
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If filename is invalid
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
raise ValueError("Filename is required")
|
||||||
|
|
||||||
|
# Use werkzeug's secure_filename
|
||||||
|
secured = secure_filename(filename)
|
||||||
|
|
||||||
|
if not secured or secured == '':
|
||||||
|
raise ValueError("Invalid filename")
|
||||||
|
|
||||||
|
# Additional checks
|
||||||
|
if len(secured) > 255:
|
||||||
|
raise ValueError("Filename too long")
|
||||||
|
|
||||||
|
# Ensure it has an extension
|
||||||
|
if '.' not in secured:
|
||||||
|
raise ValueError("Filename must have an extension")
|
||||||
|
|
||||||
|
return secured
|
||||||
|
|
||||||
|
|
||||||
|
def get_safe_mime_type(filename):
|
||||||
|
"""
|
||||||
|
Get MIME type for a filename, defaulting to safe types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The filename to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Safe MIME type string
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
|
mime_types = {
|
||||||
|
# Markdown
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.markdown': 'text/markdown',
|
||||||
|
'.mdown': 'text/markdown',
|
||||||
|
'.mkd': 'text/markdown',
|
||||||
|
|
||||||
|
# Images
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
|
||||||
|
# Text
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
}
|
||||||
|
|
||||||
|
return mime_types.get(ext, 'application/octet-stream')
|
||||||
@@ -2,100 +2,157 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<!-- Page Header -->
|
<!-- Compact Unified Header -->
|
||||||
<div class="page-header">
|
<div class="note-header-compact">
|
||||||
<div class="header-content">
|
<!-- Title Bar -->
|
||||||
<div class="header-left">
|
<div class="header-title-bar">
|
||||||
<h1 class="page-title">{{ note.title }}</h1>
|
<button class="btn-icon" onclick="window.location.href='{{ url_for('notes.notes_list') }}'">
|
||||||
<div class="page-meta">
|
<i class="ti ti-arrow-left"></i>
|
||||||
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
|
|
||||||
{% if note.visibility.value == 'Private' %}<i class="ti ti-lock"></i>{% elif note.visibility.value == 'Team' %}<i class="ti ti-users"></i>{% else %}<i class="ti ti-building"></i>{% endif %}
|
|
||||||
{{ note.visibility.value }}
|
|
||||||
</span>
|
|
||||||
{% if note.is_pinned %}
|
|
||||||
<span class="pin-badge">
|
|
||||||
<span class="icon"><i class="ti ti-pin"></i></span>
|
|
||||||
Pinned
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="meta-divider">•</span>
|
|
||||||
<span class="author">
|
|
||||||
<span class="icon"><i class="ti ti-user"></i></span>
|
|
||||||
{{ note.created_by.username }}
|
|
||||||
</span>
|
|
||||||
<span class="meta-divider">•</span>
|
|
||||||
<span class="date">
|
|
||||||
<span class="icon"><i class="ti ti-calendar"></i></span>
|
|
||||||
Created {{ note.created_at|format_date }}
|
|
||||||
</span>
|
|
||||||
{% if note.updated_at > note.created_at %}
|
|
||||||
<span class="meta-divider">•</span>
|
|
||||||
<span class="date">
|
|
||||||
<span class="icon"><i class="ti ti-refresh"></i></span>
|
|
||||||
Updated {{ note.updated_at|format_date }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if note.folder %}
|
|
||||||
<span class="meta-divider">•</span>
|
|
||||||
<span class="folder">
|
|
||||||
<span class="icon"><i class="ti ti-folder"></i></span>
|
|
||||||
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="folder-link">
|
|
||||||
{{ note.folder }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="downloadDropdown" data-toggle="dropdown">
|
|
||||||
<span class="icon"><i class="ti ti-download"></i></span>
|
|
||||||
Download
|
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
|
||||||
|
<h1 class="note-title">{{ note.title }}</h1>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<!-- Context-Specific Primary Actions -->
|
||||||
|
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
|
||||||
|
<!-- PDF Actions -->
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="btn-icon" onclick="pdfZoomOut()" title="Zoom Out">
|
||||||
|
<i class="ti ti-zoom-out"></i>
|
||||||
|
</button>
|
||||||
|
<span class="zoom-level" id="zoom-level">100%</span>
|
||||||
|
<button class="btn-icon" onclick="pdfZoomIn()" title="Zoom In">
|
||||||
|
<i class="ti ti-zoom-in"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a href="{{ note.file_url }}" class="btn btn-primary btn-sm" download>
|
||||||
|
<i class="ti ti-download"></i>
|
||||||
|
<span class="btn-text">Download PDF</span>
|
||||||
|
</a>
|
||||||
|
{% elif note.is_image %}
|
||||||
|
<!-- Image Actions -->
|
||||||
|
<button class="btn-icon" onclick="toggleFullscreen()" title="Fullscreen">
|
||||||
|
<i class="ti ti-maximize"></i>
|
||||||
|
</button>
|
||||||
|
<a href="{{ note.file_url }}" class="btn btn-primary btn-sm" download>
|
||||||
|
<i class="ti ti-download"></i>
|
||||||
|
<span class="btn-text">Download</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<!-- Markdown/Text Actions -->
|
||||||
|
{% if note.can_user_edit(g.user) %}
|
||||||
|
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="ti ti-pencil"></i>
|
||||||
|
<span class="btn-text">Edit</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="ti ti-brain"></i>
|
||||||
|
<span class="btn-text">Mind Map</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Common Actions -->
|
||||||
|
{% if note.can_user_edit(g.user) %}
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="showShareModal()">
|
||||||
|
<i class="ti ti-share"></i>
|
||||||
|
<span class="btn-text">Share</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- More Actions Dropdown -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn-icon" data-toggle="dropdown" title="More actions">
|
||||||
|
<i class="ti ti-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-right">
|
||||||
|
{% if not (note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf')) %}
|
||||||
|
<!-- Download options for non-PDF -->
|
||||||
|
<h6 class="dropdown-header">Download as</h6>
|
||||||
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='md') }}">
|
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='md') }}">
|
||||||
<span class="icon"><i class="ti ti-file-text"></i></span>
|
<i class="ti ti-file-text"></i> Markdown
|
||||||
Markdown (.md)
|
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='html') }}">
|
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='html') }}">
|
||||||
<span class="icon"><i class="ti ti-world"></i></span>
|
<i class="ti ti-world"></i> HTML
|
||||||
HTML (.html)
|
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='txt') }}">
|
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='txt') }}">
|
||||||
<span class="icon"><i class="ti ti-file"></i></span>
|
<i class="ti ti-file"></i> Plain Text
|
||||||
Plain Text (.txt)
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<div class="dropdown-divider"></div>
|
||||||
</div>
|
{% endif %}
|
||||||
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary">
|
|
||||||
<span class="icon"><i class="ti ti-brain"></i></span>
|
{% if note.is_pinned %}
|
||||||
Mind Map
|
<a class="dropdown-item" href="#">
|
||||||
|
<i class="ti ti-pin-filled"></i> Pinned
|
||||||
</a>
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="dropdown-item" href="#">
|
||||||
|
<i class="ti ti-pin"></i> Pin Note
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="dropdown-item" onclick="window.print()">
|
||||||
|
<i class="ti ti-printer"></i> Print
|
||||||
|
</a>
|
||||||
|
|
||||||
{% if note.can_user_edit(g.user) %}
|
{% if note.can_user_edit(g.user) %}
|
||||||
<button type="button" class="btn btn-secondary" onclick="showShareModal()">
|
<div class="dropdown-divider"></div>
|
||||||
<span class="icon"><i class="ti ti-link"></i></span>
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
<a href="{{ url_for('notes.edit_note', slug=note.slug) }}" class="btn btn-primary">
|
|
||||||
<span class="icon"><i class="ti ti-pencil"></i></span>
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}"
|
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}"
|
||||||
style="display: inline;"
|
style="display: inline;"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
onsubmit="return confirm('Are you sure you want to delete this note?')">
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="dropdown-item text-danger">
|
||||||
<span class="icon"><i class="ti ti-trash"></i></span>
|
<i class="ti ti-trash"></i> Delete Note
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">
|
|
||||||
<span class="icon"><i class="ti ti-arrow-left"></i></span>
|
|
||||||
Back to Notes
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata Bar -->
|
||||||
|
<div class="header-meta-bar">
|
||||||
|
{% if note.folder %}
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="ti ti-folder"></i>
|
||||||
|
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}">{{ note.folder }}</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if note.tags %}
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="ti ti-tag"></i>
|
||||||
|
{% for tag in note.get_tags_list() %}
|
||||||
|
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-link">{{ tag }}</a>{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="ti ti-user"></i> {{ note.created_by.username }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="ti ti-clock"></i>
|
||||||
|
{% if note.updated_at > note.created_at %}
|
||||||
|
Updated {{ note.updated_at|format_date }}
|
||||||
|
{% else %}
|
||||||
|
Created {{ note.created_at|format_date }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}">
|
||||||
|
{% if note.visibility.value == 'Private' %}
|
||||||
|
<i class="ti ti-lock"></i>
|
||||||
|
{% elif note.visibility.value == 'Team' %}
|
||||||
|
<i class="ti ti-users"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="ti ti-building"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ note.visibility.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Note Metadata Card -->
|
<!-- Note Metadata Card -->
|
||||||
{% if note.project or note.task or note.tags %}
|
{% if note.project or note.task or note.tags %}
|
||||||
@@ -153,24 +210,15 @@
|
|||||||
<!-- Note Content -->
|
<!-- Note Content -->
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
|
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %}
|
||||||
<!-- PDF Preview -->
|
<!-- PDF Preview (toolbar moved to unified header) -->
|
||||||
<div class="pdf-preview-container">
|
<div class="pdf-preview-container">
|
||||||
<div class="pdf-toolbar">
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="pdfZoomIn()">
|
|
||||||
<i class="ti ti-zoom-in"></i> Zoom In
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="pdfZoomOut()">
|
|
||||||
<i class="ti ti-zoom-out"></i> Zoom Out
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="pdfZoomReset()">
|
|
||||||
<i class="ti ti-zoom-reset"></i> Reset
|
|
||||||
</button>
|
|
||||||
<a href="{{ note.file_url }}" class="btn btn-sm btn-primary" download>
|
|
||||||
<i class="ti ti-download"></i> Download PDF
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<iframe id="pdf-viewer" src="{{ note.file_url }}" class="pdf-viewer"></iframe>
|
<iframe id="pdf-viewer" src="{{ note.file_url }}" class="pdf-viewer"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif note.is_image %}
|
||||||
|
<!-- Image Preview -->
|
||||||
|
<div class="image-preview-container">
|
||||||
|
<img src="{{ note.file_url }}" alt="{{ note.title }}" class="note-image" id="note-image">
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Regular Content -->
|
<!-- Regular Content -->
|
||||||
<div class="markdown-content">
|
<div class="markdown-content">
|
||||||
@@ -311,14 +359,197 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page Header - Time Tracking style */
|
/* Compact Unified Header */
|
||||||
.page-header {
|
.note-header-compact {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #495057;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-level {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item i {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item a:hover {
|
||||||
|
color: #495057;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-link {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-link:hover {
|
||||||
|
color: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Updated button styles for compact header */
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary.btn-sm {
|
||||||
|
background: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
}
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
.btn-primary.btn-sm:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
border-color: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.btn-sm {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.btn-sm:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-title-bar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide button text on mobile */
|
||||||
|
.btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm i {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
padding: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
@@ -745,6 +976,32 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
|
{% if g.user.preferences and g.user.preferences.note_preview_font and g.user.preferences.note_preview_font != 'system' %}
|
||||||
|
{% set font = g.user.preferences.note_preview_font %}
|
||||||
|
{% if font == 'sans-serif' %}
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
{% elif font == 'serif' %}
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
{% elif font == 'monospace' %}
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
{% elif font == 'georgia' %}
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
{% elif font == 'palatino' %}
|
||||||
|
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
|
||||||
|
{% elif font == 'garamond' %}
|
||||||
|
font-family: Garamond, serif;
|
||||||
|
{% elif font == 'bookman' %}
|
||||||
|
font-family: "Bookman Old Style", serif;
|
||||||
|
{% elif font == 'comic-sans' %}
|
||||||
|
font-family: "Comic Sans MS", cursive;
|
||||||
|
{% elif font == 'trebuchet' %}
|
||||||
|
font-family: "Trebuchet MS", sans-serif;
|
||||||
|
{% elif font == 'arial-black' %}
|
||||||
|
font-family: "Arial Black", sans-serif;
|
||||||
|
{% elif font == 'impact' %}
|
||||||
|
font-family: Impact, sans-serif;
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
}
|
}
|
||||||
|
|
||||||
.linked-note-meta {
|
.linked-note-meta {
|
||||||
@@ -1013,6 +1270,27 @@
|
|||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image preview styles */
|
||||||
|
.image-preview-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-image {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-image:fullscreen {
|
||||||
|
cursor: zoom-out;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 2rem;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive PDF viewer */
|
/* Responsive PDF viewer */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pdf-viewer {
|
.pdf-viewer {
|
||||||
@@ -1048,13 +1326,37 @@ function pdfZoomReset() {
|
|||||||
|
|
||||||
function updatePdfZoom() {
|
function updatePdfZoom() {
|
||||||
const viewer = document.getElementById('pdf-viewer');
|
const viewer = document.getElementById('pdf-viewer');
|
||||||
|
const zoomLevel = document.getElementById('zoom-level');
|
||||||
if (viewer) {
|
if (viewer) {
|
||||||
viewer.style.transform = `scale(${pdfZoom})`;
|
viewer.style.transform = `scale(${pdfZoom})`;
|
||||||
viewer.style.transformOrigin = 'top center';
|
viewer.style.transformOrigin = 'top center';
|
||||||
}
|
}
|
||||||
|
if (zoomLevel) {
|
||||||
|
zoomLevel.textContent = Math.round(pdfZoom * 100) + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image viewer functions
|
||||||
|
function toggleFullscreen() {
|
||||||
|
const image = document.getElementById('note-image');
|
||||||
|
if (image) {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
image.requestFullscreen().catch(err => {
|
||||||
|
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize zoom level display for PDFs
|
||||||
|
const zoomLevel = document.getElementById('zoom-level');
|
||||||
|
if (zoomLevel) {
|
||||||
|
zoomLevel.textContent = '100%';
|
||||||
|
}
|
||||||
|
|
||||||
// Download dropdown functionality
|
// Download dropdown functionality
|
||||||
const downloadBtn = document.getElementById('downloadDropdown');
|
const downloadBtn = document.getElementById('downloadDropdown');
|
||||||
const downloadMenu = downloadBtn.nextElementSibling;
|
const downloadMenu = downloadBtn.nextElementSibling;
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
<span class="icon"><i class="ti ti-settings"></i></span>
|
<span class="icon"><i class="ti ti-settings"></i></span>
|
||||||
Manage Folders
|
Manage Folders
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-secondary" id="preferences-btn">
|
||||||
|
<span class="icon"><i class="ti ti-adjustments"></i></span>
|
||||||
|
Preferences
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +403,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Preferences Modal -->
|
||||||
|
<div id="preferences-modal" class="move-modal">
|
||||||
|
<div class="move-modal-content" style="max-width: 500px;">
|
||||||
|
<div class="move-modal-header">
|
||||||
|
<h3><i class="ti ti-adjustments"></i> Note Preferences</h3>
|
||||||
|
<button type="button" class="close-btn" onclick="closePreferencesModal()">
|
||||||
|
<i class="ti ti-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="note-preferences-form" method="POST" action="{{ url_for('notes.update_note_preferences') }}">
|
||||||
|
<div class="move-modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note_preview_font" class="form-label">Preview Font</label>
|
||||||
|
<select id="note_preview_font" name="note_preview_font" class="form-control">
|
||||||
|
<option value="system" {% if not g.user.preferences or g.user.preferences.note_preview_font == 'system' %}selected{% endif %}>System Default</option>
|
||||||
|
<option value="sans-serif" {% if g.user.preferences and g.user.preferences.note_preview_font == 'sans-serif' %}selected{% endif %}>Sans-serif (Arial, Helvetica)</option>
|
||||||
|
<option value="serif" {% if g.user.preferences and g.user.preferences.note_preview_font == 'serif' %}selected{% endif %}>Serif (Times, Georgia)</option>
|
||||||
|
<option value="monospace" {% if g.user.preferences and g.user.preferences.note_preview_font == 'monospace' %}selected{% endif %}>Monospace (Courier, Consolas)</option>
|
||||||
|
<option value="georgia" {% if g.user.preferences and g.user.preferences.note_preview_font == 'georgia' %}selected{% endif %}>Georgia</option>
|
||||||
|
<option value="palatino" {% if g.user.preferences and g.user.preferences.note_preview_font == 'palatino' %}selected{% endif %}>Palatino</option>
|
||||||
|
<option value="garamond" {% if g.user.preferences and g.user.preferences.note_preview_font == 'garamond' %}selected{% endif %}>Garamond</option>
|
||||||
|
<option value="bookman" {% if g.user.preferences and g.user.preferences.note_preview_font == 'bookman' %}selected{% endif %}>Bookman</option>
|
||||||
|
<option value="comic-sans" {% if g.user.preferences and g.user.preferences.note_preview_font == 'comic-sans' %}selected{% endif %}>Comic Sans MS</option>
|
||||||
|
<option value="trebuchet" {% if g.user.preferences and g.user.preferences.note_preview_font == 'trebuchet' %}selected{% endif %}>Trebuchet MS</option>
|
||||||
|
<option value="arial-black" {% if g.user.preferences and g.user.preferences.note_preview_font == 'arial-black' %}selected{% endif %}>Arial Black</option>
|
||||||
|
<option value="impact" {% if g.user.preferences and g.user.preferences.note_preview_font == 'impact' %}selected{% endif %}>Impact</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Choose the font family for note previews in the list view</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-section mt-4">
|
||||||
|
<label class="form-label">Preview</label>
|
||||||
|
<div class="preview-box" id="fontPreview">
|
||||||
|
<p class="mb-2">This is how your note previews will look with the selected font.</p>
|
||||||
|
<p class="mb-0 text-muted">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="move-modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closePreferencesModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="ti ti-check"></i> Save Preferences
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Container */
|
/* Container */
|
||||||
.notes-container {
|
.notes-container {
|
||||||
@@ -1516,6 +1568,30 @@ td.checkbox-column {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preferences Modal Styles */
|
||||||
|
.preview-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section .form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bulk-actions-bar {
|
.bulk-actions-bar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2147,7 +2223,87 @@ window.addEventListener('click', function(e) {
|
|||||||
if (e.target === moveModal) {
|
if (e.target === moveModal) {
|
||||||
closeMoveModal();
|
closeMoveModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preferencesModal = document.getElementById('preferences-modal');
|
||||||
|
if (e.target === preferencesModal) {
|
||||||
|
closePreferencesModal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Font families mapping
|
||||||
|
const fontFamilies = {
|
||||||
|
'system': 'inherit',
|
||||||
|
'sans-serif': 'Arial, Helvetica, sans-serif',
|
||||||
|
'serif': '"Times New Roman", Times, serif',
|
||||||
|
'monospace': '"Courier New", Courier, monospace',
|
||||||
|
'georgia': 'Georgia, serif',
|
||||||
|
'palatino': '"Palatino Linotype", "Book Antiqua", Palatino, serif',
|
||||||
|
'garamond': 'Garamond, serif',
|
||||||
|
'bookman': '"Bookman Old Style", serif',
|
||||||
|
'comic-sans': '"Comic Sans MS", cursive',
|
||||||
|
'trebuchet': '"Trebuchet MS", sans-serif',
|
||||||
|
'arial-black': '"Arial Black", sans-serif',
|
||||||
|
'impact': 'Impact, sans-serif'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle font preview in modal
|
||||||
|
document.getElementById('note_preview_font').addEventListener('change', function() {
|
||||||
|
const selectedFont = this.value;
|
||||||
|
const previewBox = document.getElementById('fontPreview');
|
||||||
|
previewBox.style.fontFamily = fontFamilies[selectedFont] || 'inherit';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle preferences form submission
|
||||||
|
document.getElementById('note-preferences-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Apply font immediately without page reload
|
||||||
|
const font = data.font;
|
||||||
|
|
||||||
|
// Update all note previews
|
||||||
|
document.querySelectorAll('.note-preview').forEach(preview => {
|
||||||
|
preview.style.fontFamily = fontFamilies[font] || 'inherit';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closePreferencesModal();
|
||||||
|
|
||||||
|
// Show success toast or feedback
|
||||||
|
// You could add a toast notification here
|
||||||
|
} else {
|
||||||
|
alert('Error saving preferences: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving preferences: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preferences modal functions
|
||||||
|
document.getElementById('preferences-btn').addEventListener('click', function() {
|
||||||
|
document.getElementById('preferences-modal').classList.add('active');
|
||||||
|
// Initialize preview font
|
||||||
|
const currentFont = document.getElementById('note_preview_font').value;
|
||||||
|
const previewBox = document.getElementById('fontPreview');
|
||||||
|
previewBox.style.fontFamily = fontFamilies[currentFont] || 'inherit';
|
||||||
|
});
|
||||||
|
|
||||||
|
function closePreferencesModal() {
|
||||||
|
document.getElementById('preferences-modal').classList.remove('active');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user