5 Commits

Author SHA1 Message Date
6afa4619ef Add reCAPTCHA env variables to docker-compose.yml 2025-11-22 12:10:24 +01:00
57bb0f5b9e Add reCAPTCHA feature 2025-11-22 11:53:28 +01:00
8de4378ad9 Add missing cron scripts. 2025-11-22 11:12:40 +01:00
c375b9ee3d Add cron file 2025-11-22 10:52:58 +01:00
983d10ea97 Prune unverified accounts 2025-11-22 10:44:50 +01:00
29 changed files with 925 additions and 3928 deletions

View File

@@ -24,4 +24,10 @@ MAIL_PORT=587
MAIL_USE_TLS=true MAIL_USE_TLS=true
MAIL_USERNAME=your-email@example.com MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-password MAIL_PASSWORD=your-password
MAIL_DEFAULT_SENDER=TimeTrack <noreply@timetrack.com> MAIL_DEFAULT_SENDER=TimeTrack <noreply@timetrack.com>
# reCAPTCHA Configuration (Google reCAPTCHA v2)
# Get your keys at: https://www.google.com/recaptcha/admin
RECAPTCHA_SITE_KEY=your-site-key-here
RECAPTCHA_SECRET_KEY=your-secret-key-here
RECAPTCHA_ENABLED=true

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
ko_fi: nullmedium

View File

@@ -39,8 +39,16 @@ docker-compose up
# Debug mode with hot-reload # Debug mode with hot-reload
docker-compose -f docker-compose.debug.yml up docker-compose -f docker-compose.debug.yml up
# Manual cleanup of unverified accounts
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py
# Dry run to see what would be deleted
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py --dry-run
``` ```
**Note:** Unverified accounts are automatically cleaned up every hour via cron job in the Docker container.
## Database Operations ## Database Operations
### Flask-Migrate Commands ### Flask-Migrate Commands
@@ -187,6 +195,7 @@ When modifying billing/invoice features:
- Two-factor authentication (2FA) using TOTP - Two-factor authentication (2FA) using TOTP
- Session-based authentication with "Remember Me" option - Session-based authentication with "Remember Me" option
- Email verification for new accounts (configurable) - Email verification for new accounts (configurable)
- Automatic cleanup of unverified accounts after 24 hours
### Mobile UI Features ### Mobile UI Features
- Progressive Web App (PWA) manifest for installability - Progressive Web App (PWA) manifest for installability

View File

@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
python3-dev \ python3-dev \
postgresql-client \ postgresql-client \
cron \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -35,6 +36,12 @@ RUN pip install gunicorn==21.2.0
# Copy the rest of the application # Copy the rest of the application
COPY . . COPY . .
# Setup cron job for cleanup
COPY docker-cron /etc/cron.d/cleanup-cron
RUN chmod 0644 /etc/cron.d/cleanup-cron && \
crontab /etc/cron.d/cleanup-cron && \
touch /var/log/cron.log
# Create the SQLite database directory with proper permissions # Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance RUN mkdir -p /app/instance && chmod 777 /app/instance

220
app.py
View File

@@ -19,6 +19,7 @@ from flask_mail import Mail, Message
from dotenv import load_dotenv from dotenv import load_dotenv
from password_utils import PasswordValidator from password_utils import PasswordValidator
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from recaptcha_helper import recaptcha
# Import blueprints # Import blueprints
from routes.notes import notes_bp from routes.notes import notes_bp
@@ -102,14 +103,14 @@ def force_http_scheme():
if app.debug or os.environ.get('FLASK_ENV') == 'debug': if app.debug or os.environ.get('FLASK_ENV') == 'debug':
from flask import url_for as original_url_for from flask import url_for as original_url_for
import functools import functools
@functools.wraps(original_url_for) @functools.wraps(original_url_for)
def url_for_http(*args, **kwargs): def url_for_http(*args, **kwargs):
# Force _scheme to http if _external is True # Force _scheme to http if _external is True
if kwargs.get('_external'): if kwargs.get('_external'):
kwargs['_scheme'] = 'http' kwargs['_scheme'] = 'http'
return original_url_for(*args, **kwargs) return original_url_for(*args, **kwargs)
app.jinja_env.globals['url_for'] = url_for_http app.jinja_env.globals['url_for'] = url_for_http
# Configure Flask-Mail # Configure Flask-Mail
@@ -129,6 +130,9 @@ logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}")
mail = Mail(app) mail = Mail(app)
# Initialize reCAPTCHA
recaptcha.init_app(app)
# Initialize the database with the app # Initialize the database with the app
db.init_app(app) db.init_app(app)
@@ -365,7 +369,7 @@ def robots_txt():
def sitemap_xml(): def sitemap_xml():
"""Generate XML sitemap for search engines""" """Generate XML sitemap for search engines"""
pages = [] pages = []
# Static pages accessible without login # Static pages accessible without login
static_pages = [ static_pages = [
{'loc': '/', 'priority': '1.0', 'changefreq': 'daily'}, {'loc': '/', 'priority': '1.0', 'changefreq': 'daily'},
@@ -373,7 +377,7 @@ def sitemap_xml():
{'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'}, {'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'},
{'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'}, {'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'},
] ]
for page in static_pages: for page in static_pages:
pages.append({ pages.append({
'loc': request.host_url[:-1] + page['loc'], 'loc': request.host_url[:-1] + page['loc'],
@@ -381,10 +385,10 @@ def sitemap_xml():
'priority': page['priority'], 'priority': page['priority'],
'changefreq': page['changefreq'] 'changefreq': page['changefreq']
}) })
sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n' sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages: for page in pages:
sitemap_xml += ' <url>\n' sitemap_xml += ' <url>\n'
sitemap_xml += f' <loc>{page["loc"]}</loc>\n' sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
@@ -392,9 +396,9 @@ def sitemap_xml():
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n' sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
sitemap_xml += f' <priority>{page["priority"]}</priority>\n' sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
sitemap_xml += ' </url>\n' sitemap_xml += ' </url>\n'
sitemap_xml += '</urlset>' sitemap_xml += '</urlset>'
return Response(sitemap_xml, mimetype='application/xml') return Response(sitemap_xml, mimetype='application/xml')
@app.route('/site.webmanifest') @app.route('/site.webmanifest')
@@ -438,39 +442,6 @@ def favicon_16():
def apple_touch_icon(): def apple_touch_icon():
return send_from_directory(app.static_folder, 'apple-touch-icon.png', mimetype='image/png') return send_from_directory(app.static_folder, 'apple-touch-icon.png', mimetype='image/png')
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
"""Serve uploaded files from the data directory"""
import os
from werkzeug.security import safe_join
# Ensure the request is from a logged-in user
if not g.user:
abort(403)
# Construct safe path to the uploaded file
upload_dir = '/data/uploads'
file_path = safe_join(upload_dir, filename)
if file_path is None or not os.path.exists(file_path):
abort(404)
# For notes, check if user has permission to view
if filename.startswith('notes/'):
# Extract the note from the database to check permissions
# This is a simplified check - in production you might want to store
# file ownership in a separate table for faster lookups
from models import Note
note = Note.query.filter_by(
file_path=filename.replace('notes/', ''),
company_id=g.user.company_id
).first()
if note and not note.can_user_view(g.user):
abort(403)
return send_from_directory(upload_dir, filename)
@app.route('/') @app.route('/')
def home(): def home():
if g.user: if g.user:
@@ -685,6 +656,13 @@ def register():
if not is_valid: if not is_valid:
error = password_errors[0] # Show first error error = password_errors[0] # Show first error
# Verify reCAPTCHA
if not error:
recaptcha_response = request.form.get('g-recaptcha-response')
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
if not is_valid:
error = recaptcha_error
# Find company by code or create new one if no code provided # Find company by code or create new one if no code provided
company = None company = None
if company_code: if company_code:
@@ -762,31 +740,47 @@ def register():
# Make first user in company an admin with full privileges # Make first user in company an admin with full privileges
if is_first_user_in_company: if is_first_user_in_company:
new_user.role = Role.ADMIN new_user.role = Role.ADMIN
new_user.is_verified = True # Auto-verify first user in company # Removed auto-verification - all users must verify email
elif not email_verification_required: elif not email_verification_required:
# If email verification is disabled, auto-verify new users # If email verification is disabled, auto-verify new users
new_user.is_verified = True new_user.is_verified = True
# Generate verification token (even if not needed, for consistency) # Generate verification token
token = new_user.generate_verification_token() token = new_user.generate_verification_token()
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
if is_first_user_in_company: if not email_verification_required:
# First user in company gets admin privileges and is auto-verified
logger.info(f"First user account created in company {company.name}: {username} with admin privileges")
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. You can now log in.', 'success')
elif not email_verification_required:
# Email verification is disabled, user can log in immediately # Email verification is disabled, user can log in immediately
logger.info(f"User account created with auto-verification in company {company.name}: {username}") logger.info(f"User account created with auto-verification in company {company.name}: {username}")
flash('Registration successful! You can now log in.', 'success') flash('Registration successful! You can now log in.', 'success')
else: else:
# Send verification email for regular users when verification is required # Send verification email for all users (including first user)
verification_url = url_for('verify_email', token=token, _external=True) verification_url = url_for('verify_email', token=token, _external=True)
msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email]) msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email])
msg.body = f'''Hello {username},
# Special message for first user in company
if is_first_user_in_company:
msg.body = f'''Hello {username},
Thank you for registering with {g.branding.app_name}. You are the first user in {company.name} and have been granted administrator privileges.
To complete your registration and access your account, please click on the link below:
{verification_url}
This link will expire in 24 hours.
If you did not register for {g.branding.app_name}, please ignore this email.
Best regards,
The {g.branding.app_name} Team
'''
logger.info(f"First user account created in company {company.name}: {username} with admin privileges - verification email sent")
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. Please check your email to verify your account.', 'success')
else:
msg.body = f'''Hello {username},
Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below: Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below:
@@ -799,9 +793,10 @@ If you did not register for {g.branding.app_name}, please ignore this email.
Best regards, Best regards,
The {g.branding.app_name} Team The {g.branding.app_name} Team
''' '''
logger.info(f"User account created in company {company.name}: {username} - verification email sent")
flash('Registration initiated! Please check your email to verify your account.', 'success')
mail.send(msg) mail.send(msg)
logger.info(f"Verification email sent to {email}")
flash('Registration initiated! Please check your email to verify your account.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
except Exception as e: except Exception as e:
@@ -848,6 +843,13 @@ def register_freelancer():
if not is_valid: if not is_valid:
error = password_errors[0] # Show first error error = password_errors[0] # Show first error
# Verify reCAPTCHA
if not error:
recaptcha_response = request.form.get('g-recaptcha-response')
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
if not is_valid:
error = recaptcha_error
# Check for existing users globally (freelancers get unique usernames/emails) # Check for existing users globally (freelancers get unique usernames/emails)
if not error: if not error:
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
@@ -884,6 +886,9 @@ def register_freelancer():
db.session.add(personal_company) db.session.add(personal_company)
db.session.flush() # Get company ID db.session.flush() # Get company ID
# Check if email verification is required
email_verification_required = get_system_setting('email_verification_required', 'true') == 'true'
# Create freelancer user # Create freelancer user
new_user = User( new_user = User(
username=username, username=username,
@@ -892,15 +897,42 @@ def register_freelancer():
account_type=AccountType.FREELANCER, account_type=AccountType.FREELANCER,
business_name=business_name if business_name else None, business_name=business_name if business_name else None,
role=Role.ADMIN, # Freelancers are admins of their personal company role=Role.ADMIN, # Freelancers are admins of their personal company
is_verified=True # Auto-verify freelancers is_verified=not email_verification_required # Only auto-verify if email verification is disabled
) )
new_user.set_password(password) new_user.set_password(password)
# Generate verification token
token = new_user.generate_verification_token()
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
logger.info(f"Freelancer account created: {username} with personal company: {company_name}") if not email_verification_required:
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success') # Email verification is disabled, user can log in immediately
logger.info(f"Freelancer account created with auto-verification: {username} with personal company: {company_name}")
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success')
else:
# Send verification email
verification_url = url_for('verify_email', token=token, _external=True)
msg = Message(f'Verify your {g.branding.app_name} freelancer account', recipients=[email])
msg.body = f'''Hello {username},
Thank you for registering as a freelancer with {g.branding.app_name}. Your personal workspace "{company_name}" has been created.
To complete your registration and access your account, please click on the link below:
{verification_url}
This link will expire in 24 hours.
If you did not register for {g.branding.app_name}, please ignore this email.
Best regards,
The {g.branding.app_name} Team
'''
mail.send(msg)
logger.info(f"Freelancer account created: {username} with personal company: {company_name} - verification email sent")
flash(f'Welcome {username}! Your freelancer workspace has been created. Please check your email to verify your account.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -1019,11 +1051,11 @@ def forgot_password():
"""Handle forgot password requests""" """Handle forgot password requests"""
if request.method == 'POST': if request.method == 'POST':
username_or_email = request.form.get('username_or_email', '').strip() username_or_email = request.form.get('username_or_email', '').strip()
if not username_or_email: if not username_or_email:
flash('Please enter your username or email address.', 'error') flash('Please enter your username or email address.', 'error')
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
# Try to find user by username or email # Try to find user by username or email
user = User.query.filter( user = User.query.filter(
db.or_( db.or_(
@@ -1031,11 +1063,11 @@ def forgot_password():
User.email == username_or_email User.email == username_or_email
) )
).first() ).first()
if user and user.email: if user and user.email:
# Generate reset token # Generate reset token
token = user.generate_password_reset_token() token = user.generate_password_reset_token()
# Send reset email # Send reset email
reset_url = url_for('reset_password', token=token, _external=True) reset_url = url_for('reset_password', token=token, _external=True)
msg = Message( msg = Message(
@@ -1056,7 +1088,7 @@ If you did not request a password reset, please ignore this email.
Best regards, Best regards,
The {g.branding.app_name if g.branding else "TimeTrack"} Team The {g.branding.app_name if g.branding else "TimeTrack"} Team
''' '''
try: try:
mail.send(msg) mail.send(msg)
logger.info(f"Password reset email sent to user {user.username}") logger.info(f"Password reset email sent to user {user.username}")
@@ -1064,11 +1096,11 @@ The {g.branding.app_name if g.branding else "TimeTrack"} Team
logger.error(f"Failed to send password reset email: {str(e)}") logger.error(f"Failed to send password reset email: {str(e)}")
flash('Failed to send reset email. Please contact support.', 'error') flash('Failed to send reset email. Please contact support.', 'error')
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
# Always show success message to prevent user enumeration # Always show success message to prevent user enumeration
flash('If an account exists with that username or email address, we have sent a password reset link.', 'success') flash('If an account exists with that username or email address, we have sent a password reset link.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
@app.route('/reset_password/<token>', methods=['GET', 'POST']) @app.route('/reset_password/<token>', methods=['GET', 'POST'])
@@ -1076,42 +1108,42 @@ def reset_password(token):
"""Handle password reset with token""" """Handle password reset with token"""
# Find user by reset token # Find user by reset token
user = User.query.filter_by(password_reset_token=token).first() user = User.query.filter_by(password_reset_token=token).first()
if not user or not user.verify_password_reset_token(token): if not user or not user.verify_password_reset_token(token):
flash('Invalid or expired reset link.', 'error') flash('Invalid or expired reset link.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': if request.method == 'POST':
password = request.form.get('password') password = request.form.get('password')
confirm_password = request.form.get('confirm_password') confirm_password = request.form.get('confirm_password')
# Validate input # Validate input
error = None error = None
if not password: if not password:
error = 'Password is required' error = 'Password is required'
elif password != confirm_password: elif password != confirm_password:
error = 'Passwords do not match' error = 'Passwords do not match'
# Validate password strength # Validate password strength
if not error: if not error:
validator = PasswordValidator() validator = PasswordValidator()
is_valid, password_errors = validator.validate(password) is_valid, password_errors = validator.validate(password)
if not is_valid: if not is_valid:
error = password_errors[0] error = password_errors[0]
if error: if error:
flash(error, 'error') flash(error, 'error')
return render_template('reset_password.html', token=token, title='Reset Password') return render_template('reset_password.html', token=token, title='Reset Password')
# Update password # Update password
user.set_password(password) user.set_password(password)
user.clear_password_reset_token() user.clear_password_reset_token()
db.session.commit() db.session.commit()
logger.info(f"Password reset successful for user {user.username}") logger.info(f"Password reset successful for user {user.username}")
flash('Your password has been reset successfully. Please log in with your new password.', 'success') flash('Your password has been reset successfully. Please log in with your new password.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
return render_template('reset_password.html', token=token, title='Reset Password') return render_template('reset_password.html', token=token, title='Reset Password')
@app.route('/dashboard') @app.route('/dashboard')
@@ -1171,31 +1203,6 @@ def profile():
return render_template('profile.html', title='My Profile', user=user) return render_template('profile.html', title='My Profile', user=user)
@app.route('/update-note-preferences', methods=['POST'])
@login_required
def update_note_preferences():
"""Update user's note preferences"""
user = User.query.get(session['user_id'])
# Get or create user preferences
if not user.preferences:
preferences = UserPreferences(user_id=user.id)
db.session.add(preferences)
else:
preferences = user.preferences
# Update font preference
note_preview_font = request.form.get('note_preview_font', 'system')
if note_preview_font in ['system', 'sans-serif', 'serif', 'monospace', 'georgia',
'palatino', 'garamond', 'bookman', 'comic-sans',
'trebuchet', 'arial-black', 'impact']:
preferences.note_preview_font = note_preview_font
db.session.commit()
flash('Note preferences updated successfully!', 'success')
return redirect(url_for('profile'))
@app.route('/update-avatar', methods=['POST']) @app.route('/update-avatar', methods=['POST'])
@login_required @login_required
def update_avatar(): def update_avatar():
@@ -1291,7 +1298,7 @@ def upload_avatar():
unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}" unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}"
# Create user avatar directory if it doesn't exist # Create user avatar directory if it doesn't exist
avatar_dir = os.path.join('/data', 'uploads', 'avatars') avatar_dir = os.path.join(app.static_folder, 'uploads', 'avatars')
os.makedirs(avatar_dir, exist_ok=True) os.makedirs(avatar_dir, exist_ok=True)
# Save the file # Save the file
@@ -1299,9 +1306,8 @@ def upload_avatar():
file.save(file_path) file.save(file_path)
# Delete old avatar file if it exists and is a local upload # Delete old avatar file if it exists and is a local upload
if user.avatar_url and user.avatar_url.startswith('/uploads/avatars/'): if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'):
old_filename = user.avatar_url.split('/')[-1] old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/'))
old_file_path = os.path.join(avatar_dir, old_filename)
if os.path.exists(old_file_path): if os.path.exists(old_file_path):
try: try:
os.remove(old_file_path) os.remove(old_file_path)
@@ -1309,7 +1315,7 @@ def upload_avatar():
logger.warning(f"Failed to delete old avatar: {e}") logger.warning(f"Failed to delete old avatar: {e}")
# Update user's avatar URL # Update user's avatar URL
user.avatar_url = f"/uploads/avatars/{unique_filename}" user.avatar_url = f"/static/uploads/avatars/{unique_filename}"
db.session.commit() db.session.commit()
flash('Avatar uploaded successfully!', 'success') flash('Avatar uploaded successfully!', 'success')
@@ -2990,14 +2996,14 @@ def render_markdown():
try: try:
data = request.get_json() data = request.get_json()
content = data.get('content', '') content = data.get('content', '')
if not content: if not content:
return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'}) return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'})
# Parse frontmatter and extract body # Parse frontmatter and extract body
from frontmatter_utils import parse_frontmatter from frontmatter_utils import parse_frontmatter
metadata, body = parse_frontmatter(content) metadata, body = parse_frontmatter(content)
# Render markdown to HTML # Render markdown to HTML
try: try:
import markdown import markdown
@@ -3006,13 +3012,13 @@ def render_markdown():
except ImportError: except ImportError:
# Fallback if markdown not installed # Fallback if markdown not installed
html = f'<pre>{body}</pre>' html = f'<pre>{body}</pre>'
return jsonify({'html': html}) return jsonify({'html': html})
except Exception as e: except Exception as e:
logger.error(f"Error rendering markdown: {str(e)}") logger.error(f"Error rendering markdown: {str(e)}")
return jsonify({'html': '<p class="error">Error rendering markdown</p>'}) return jsonify({'html': '<p class="error">Error rendering markdown</p>'})
if __name__ == '__main__': if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000)) port = int(os.environ.get('PORT', 5000))
app.run(debug=True, host='0.0.0.0', port=port) app.run(debug=True, host='0.0.0.0', port=port)

327
cleanup_inactive_accounts.py Executable file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python
"""
Cleanup verified but inactive user accounts.
Identifies and removes bot-created or unused accounts that:
- Are verified (is_verified = True)
- Have never logged in (no SystemEvent login records)
- Have never created time entries
- Are older than a specified number of days
This script can be run manually or scheduled via cron.
"""
import os
import sys
from datetime import datetime, timedelta
from sqlalchemy import and_, or_
# Add the application path to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db
from models import User, Company, SystemEvent, TimeEntry
from models.enums import Role
def find_inactive_verified_accounts(min_age_days=30, dry_run=False):
"""
Find verified user accounts that have never been used.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
dry_run (bool): If True, only show what would be deleted without actually deleting.
Returns:
tuple: (accounts_to_delete, details_list)
"""
with app.app_context():
cutoff_time = datetime.utcnow() - timedelta(days=min_age_days)
# Find verified accounts older than cutoff
verified_old_users = User.query.filter(
and_(
User.is_verified == True,
User.created_at < cutoff_time
)
).all()
inactive_accounts = []
for user in verified_old_users:
# Check if user has any time entries
time_entry_count = TimeEntry.query.filter_by(user_id=user.id).count()
# Check if user has any login events
login_event_count = SystemEvent.query.filter(
and_(
SystemEvent.user_id == user.id,
SystemEvent.event_type == 'user_login'
)
).count()
# If no time entries and no logins, this is an inactive account
if time_entry_count == 0 and login_event_count == 0:
account_age_days = (datetime.utcnow() - user.created_at).days
# Get company info
company = Company.query.get(user.company_id) if user.company_id else None
company_name = company.name if company else "Unknown"
# Check if user is the only admin in their company
is_only_admin = False
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
other_admins = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id,
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
)
).count()
is_only_admin = (other_admins == 0)
inactive_accounts.append({
'user': user,
'company_name': company_name,
'age_days': account_age_days,
'is_only_admin': is_only_admin,
'company': company
})
return inactive_accounts
def cleanup_inactive_accounts(min_age_days=30, dry_run=False, skip_admins=True):
"""
Delete inactive verified accounts.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
dry_run (bool): If True, only show what would be deleted without actually deleting
skip_admins (bool): If True, skip accounts that are the only admin in their company
Returns:
int: Number of accounts deleted (or would be deleted in dry run mode)
"""
with app.app_context():
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run)
deleted_count = 0
skipped_count = 0
companies_deleted = 0
print(f"\nFound {len(inactive_accounts)} inactive verified account(s) older than {min_age_days} days")
print("=" * 80)
for account_info in inactive_accounts:
user = account_info['user']
company_name = account_info['company_name']
age_days = account_info['age_days']
is_only_admin = account_info['is_only_admin']
company = account_info['company']
# Skip if this is the only admin and skip_admins is True
if is_only_admin and skip_admins:
print(f"\nSKIPPED (only admin): {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
skipped_count += 1
continue
if dry_run:
print(f"\nWOULD DELETE: {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
# Check if company would be deleted too
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
if other_users == 0:
print(f" -> Would also delete empty company: {company_name}")
deleted_count += 1
else:
print(f"\nDELETING: {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
# Log the deletion as a system event
try:
SystemEvent.log_event(
event_type='user_deleted',
event_category='system',
description=f'Inactive verified user {user.username} deleted (no activity for {age_days} days)',
user_id=None, # No user context for system cleanup
company_id=user.company_id
)
except Exception as e:
print(f" Warning: Could not log deletion event: {e}")
# Check if company should be deleted
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
# Delete the user
db.session.delete(user)
deleted_count += 1
# If no other users, delete the company too
if other_users == 0:
print(f" -> Also deleting empty company: {company_name}")
db.session.delete(company)
companies_deleted += 1
else:
# Delete the user even if company doesn't exist
db.session.delete(user)
deleted_count += 1
if not dry_run and deleted_count > 0:
db.session.commit()
print("\n" + "=" * 80)
print(f"Successfully deleted {deleted_count} inactive account(s)")
if companies_deleted > 0:
print(f"Successfully deleted {companies_deleted} empty company/companies")
if skipped_count > 0:
print(f"Skipped {skipped_count} account(s) (only admin in company)")
elif dry_run:
print("\n" + "=" * 80)
print(f"DRY RUN: Would delete {deleted_count} inactive account(s)")
if skipped_count > 0:
print(f"Would skip {skipped_count} account(s) (only admin in company)")
else:
print("\n" + "=" * 80)
print("No inactive accounts found to delete")
if skipped_count > 0:
print(f"Skipped {skipped_count} account(s) (only admin in company)")
return deleted_count
def generate_report(min_age_days=30):
"""
Generate a detailed report of inactive accounts without deleting anything.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
"""
with app.app_context():
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run=True)
print(f"\n{'='*80}")
print(f"INACTIVE VERIFIED ACCOUNTS REPORT")
print(f"Accounts older than {min_age_days} days with no activity")
print(f"Generated: {datetime.utcnow()}")
print(f"{'='*80}\n")
if not inactive_accounts:
print("No inactive verified accounts found.")
return
# Group by company
by_company = {}
for account_info in inactive_accounts:
company_name = account_info['company_name']
if company_name not in by_company:
by_company[company_name] = []
by_company[company_name].append(account_info)
# Print summary
print(f"Total inactive accounts: {len(inactive_accounts)}")
print(f"Companies affected: {len(by_company)}\n")
# Print details by company
for company_name, accounts in sorted(by_company.items()):
print(f"\nCompany: {company_name}")
print("-" * 80)
for account_info in accounts:
user = account_info['user']
age_days = account_info['age_days']
is_only_admin = account_info['is_only_admin']
admin_warning = " [ONLY ADMIN]" if is_only_admin else ""
print(f"{user.username}{admin_warning}")
print(f" Email: {user.email or 'N/A'}")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
print()
def main():
"""Main function to handle command line arguments"""
import argparse
parser = argparse.ArgumentParser(
description='Cleanup verified but inactive user accounts (no logins, no time entries)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting'
)
parser.add_argument(
'--min-age',
type=int,
default=30,
help='Minimum account age in days (default: 30)'
)
parser.add_argument(
'--include-admins',
action='store_true',
help='Include accounts that are the only admin in their company (default: skip them)'
)
parser.add_argument(
'--report-only',
action='store_true',
help='Generate a detailed report only, do not delete anything'
)
parser.add_argument(
'--quiet',
action='store_true',
help='Suppress output except for errors'
)
args = parser.parse_args()
if not args.quiet:
print(f"Starting cleanup of inactive verified accounts at {datetime.utcnow()}")
print("-" * 80)
try:
if args.report_only:
generate_report(min_age_days=args.min_age)
else:
deleted_count = cleanup_inactive_accounts(
min_age_days=args.min_age,
dry_run=args.dry_run,
skip_admins=not args.include_admins
)
if not args.quiet:
print("-" * 80)
print(f"Cleanup completed at {datetime.utcnow()}")
# Exit with 0 for success
sys.exit(0)
except Exception as e:
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

152
cleanup_unverified_accounts.py Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python
"""
Cleanup unverified user accounts older than 24 hours.
This script can be run manually or scheduled via cron.
"""
import os
import sys
from datetime import datetime, timedelta
from sqlalchemy import and_
# Add the application path to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db
from models import User, Company, SystemEvent
from models.enums import Role
def cleanup_unverified_accounts(dry_run=False):
"""
Delete unverified user accounts that are older than 24 hours.
Args:
dry_run (bool): If True, only show what would be deleted without actually deleting.
Returns:
int: Number of accounts deleted (or would be deleted in dry run mode)
"""
with app.app_context():
# Find unverified accounts older than 24 hours
cutoff_time = datetime.utcnow() - timedelta(hours=24)
unverified_users = User.query.filter(
and_(
User.is_verified == False,
User.created_at < cutoff_time
)
).all()
deleted_count = 0
for user in unverified_users:
# Check if this user is the only admin in their company
# We shouldn't delete them if they are, as it would orphan the company
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
other_admins = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id,
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
)
).count()
if other_admins == 0:
print(f"Skipping {user.username} (ID: {user.id}) - only admin in company {user.company_id}")
continue
if dry_run:
print(f"Would delete unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
deleted_count += 1
# Check if company would be deleted too (for dry run)
company = Company.query.get(user.company_id)
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
if other_users == 0:
print(f" Would also delete empty company: {company.name} (ID: {company.id})")
else:
print(f"Deleting unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
# Log the deletion as a system event if SystemEvent exists
try:
SystemEvent.log_event(
event_type='user_deleted',
event_category='system',
description=f'Unverified user {user.username} deleted after 24 hours',
user_id=None, # No user context for system cleanup
company_id=user.company_id
)
except:
# SystemEvent might not exist, continue without logging
pass
# Check if the company should be deleted (if it was created with this user and has no other users)
company = Company.query.get(user.company_id)
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
# Delete the user
db.session.delete(user)
deleted_count += 1
# If no other users, delete the company too
if other_users == 0:
print(f" Also deleting empty company: {company.name} (ID: {company.id})")
db.session.delete(company)
else:
# Delete the user even if company doesn't exist
db.session.delete(user)
deleted_count += 1
if not dry_run and deleted_count > 0:
db.session.commit()
print(f"\nSuccessfully deleted {deleted_count} unverified account(s)")
elif dry_run:
print(f"\nDry run: Would delete {deleted_count} unverified account(s)")
else:
print("No unverified accounts older than 24 hours found")
return deleted_count
def main():
"""Main function to handle command line arguments"""
import argparse
parser = argparse.ArgumentParser(description='Cleanup unverified user accounts older than 24 hours')
parser.add_argument('--dry-run', action='store_true',
help='Show what would be deleted without actually deleting')
parser.add_argument('--quiet', action='store_true',
help='Suppress output except for errors')
args = parser.parse_args()
if not args.quiet:
print(f"Starting cleanup of unverified accounts at {datetime.utcnow()}")
print("-" * 60)
try:
deleted_count = cleanup_unverified_accounts(dry_run=args.dry_run)
if not args.quiet:
print("-" * 60)
print(f"Cleanup completed at {datetime.utcnow()}")
# Exit with 0 for success
sys.exit(0)
except Exception as e:
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -43,6 +43,9 @@ services:
- MAIL_USERNAME=${MAIL_USERNAME} - MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD} - MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER} - MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER}
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

9
docker-cron Normal file
View File

@@ -0,0 +1,9 @@
# Cron job for cleaning up unverified accounts
# Run every hour at minute 0
0 * * * * cd /app && /usr/local/bin/python /app/cleanup_unverified_accounts.py --quiet >> /var/log/cron.log 2>&1
# Cron job for cleaning up inactive verified accounts (no logins, no time entries)
# Run monthly on the 1st at 2:00 AM, cleaning accounts older than 90 days
0 2 1 * * cd /app && /usr/local/bin/python /app/cleanup_inactive_accounts.py --min-age 90 --quiet >> /var/log/cron.log 2>&1
# Empty line required at the end

View File

@@ -1,45 +0,0 @@
"""Add file-based note support fields
Revision ID: 275ef106dc91
Revises: 85d490db548b
Create Date: 2025-07-18 15:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '275ef106dc91'
down_revision = '85d490db548b'
branch_labels = None
depends_on = None
def upgrade():
# Add file-based note support columns to note table
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_file_based', sa.Boolean(), nullable=True, default=False))
batch_op.add_column(sa.Column('file_path', sa.String(length=500), nullable=True))
batch_op.add_column(sa.Column('file_type', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('original_filename', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('file_size', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('mime_type', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('image_width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('image_height', sa.Integer(), nullable=True))
# Set default value for existing records
op.execute("UPDATE note SET is_file_based = FALSE WHERE is_file_based IS NULL")
def downgrade():
# Remove file-based note support columns from note table
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('image_height')
batch_op.drop_column('image_width')
batch_op.drop_column('mime_type')
batch_op.drop_column('file_size')
batch_op.drop_column('original_filename')
batch_op.drop_column('file_type')
batch_op.drop_column('file_path')
batch_op.drop_column('is_file_based')

View File

@@ -1,31 +0,0 @@
"""Add note preview font preference
Revision ID: 4a5b2c7d9e3f
Revises: 275ef106dc91
Create Date: 2024-07-18 13:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4a5b2c7d9e3f'
down_revision = '275ef106dc91'
branch_labels = None
depends_on = None
def upgrade():
# Add note_preview_font column to user_preferences table
with op.batch_alter_table('user_preferences', schema=None) as batch_op:
batch_op.add_column(sa.Column('note_preview_font', sa.String(length=50), nullable=True))
# Set default value for existing rows
op.execute("UPDATE user_preferences SET note_preview_font = 'system' WHERE note_preview_font IS NULL")
def downgrade():
# Remove note_preview_font column from user_preferences table
with op.batch_alter_table('user_preferences', schema=None) as batch_op:
batch_op.drop_column('note_preview_font')

View File

@@ -52,18 +52,6 @@ class Note(db.Model):
# Pin important notes # Pin important notes
is_pinned = db.Column(db.Boolean, default=False) is_pinned = db.Column(db.Boolean, default=False)
# File-based note support
is_file_based = db.Column(db.Boolean, default=False) # True if note was created from uploaded file
file_path = db.Column(db.String(500), nullable=True) # Path to uploaded file
file_type = db.Column(db.String(20), nullable=True) # 'markdown', 'image', etc.
original_filename = db.Column(db.String(255), nullable=True) # Original uploaded filename
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
mime_type = db.Column(db.String(100), nullable=True) # MIME type of file
# For images
image_width = db.Column(db.Integer, nullable=True)
image_height = db.Column(db.Integer, nullable=True)
# Soft delete # Soft delete
is_archived = db.Column(db.Boolean, default=False) is_archived = db.Column(db.Boolean, default=False)
archived_at = db.Column(db.DateTime, nullable=True) archived_at = db.Column(db.DateTime, nullable=True)
@@ -174,18 +162,12 @@ class Note(db.Model):
return text return text
def render_html(self): def render_html(self):
"""Render markdown content to HTML with Wiki-style link support""" """Render markdown content to HTML"""
try: try:
import markdown import markdown
from frontmatter_utils import parse_frontmatter from frontmatter_utils import parse_frontmatter
from wiki_links import process_wiki_links
# Extract body content without frontmatter # Extract body content without frontmatter
_, body = parse_frontmatter(self.content) _, body = parse_frontmatter(self.content)
# Process Wiki-style links before markdown rendering
body = process_wiki_links(body, current_note_id=self.id)
# Use extensions for better markdown support # Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc']) html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html return html
@@ -280,78 +262,6 @@ class Note(db.Model):
"""Check if this note has any active share links""" """Check if this note has any active share links"""
return any(s.is_valid() for s in self.shares) return any(s.is_valid() for s in self.shares)
@property
def is_image(self):
"""Check if this is an image note"""
return self.file_type == 'image'
@property
def is_markdown_file(self):
"""Check if this is a markdown file note"""
return self.file_type == 'markdown'
@property
def is_pdf(self):
"""Check if this is a PDF note"""
return self.file_type == 'document' and self.original_filename and self.original_filename.lower().endswith('.pdf')
@property
def file_url(self):
"""Get the URL to access the uploaded file"""
if self.file_path and self.id:
from flask import url_for
return url_for('notes_api.serve_note_file', note_id=self.id)
return None
@property
def thumbnail_url(self):
"""Get thumbnail URL for image notes"""
if self.is_image and self.file_path:
# Could implement thumbnail generation later
return self.file_url
return None
@staticmethod
def allowed_file(filename):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {
'markdown': {'md', 'markdown', 'mdown', 'mkd'},
'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'},
'text': {'txt'},
'document': {'pdf', 'doc', 'docx'}
}
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
# Check all allowed extensions
for file_type, extensions in ALLOWED_EXTENSIONS.items():
if ext in extensions:
return True
return False
@staticmethod
def get_file_type_from_filename(filename):
"""Determine file type from extension"""
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
if ext in {'md', 'markdown', 'mdown', 'mkd'}:
return 'markdown'
elif ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}:
return 'image'
elif ext == 'txt':
return 'text'
elif ext in {'pdf', 'doc', 'docx'}:
return 'document'
else:
return 'other'
class NoteLink(db.Model): class NoteLink(db.Model):
"""Links between notes for creating relationships""" """Links between notes for creating relationships"""

View File

@@ -1,92 +0,0 @@
"""
Note attachment model for storing uploaded files associated with notes.
"""
from datetime import datetime
from . import db
class NoteAttachment(db.Model):
"""Model for files attached to notes (images, documents, etc.)"""
__tablename__ = 'note_attachment'
id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('note.id'), nullable=False)
# File information
filename = db.Column(db.String(255), nullable=False) # Stored filename
original_filename = db.Column(db.String(255), nullable=False) # Original upload name
file_path = db.Column(db.String(500), nullable=False) # Relative path from uploads dir
file_size = db.Column(db.Integer) # Size in bytes
mime_type = db.Column(db.String(100))
# File type for easier filtering
file_type = db.Column(db.String(20)) # 'image', 'document', 'archive', etc.
# Metadata
uploaded_at = db.Column(db.DateTime, default=datetime.now)
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# For images: dimensions
image_width = db.Column(db.Integer)
image_height = db.Column(db.Integer)
# Soft delete
is_deleted = db.Column(db.Boolean, default=False)
deleted_at = db.Column(db.DateTime)
# Relationships
note = db.relationship('Note', backref='attachments')
uploaded_by = db.relationship('User', backref='uploaded_attachments')
def __repr__(self):
return f'<NoteAttachment {self.original_filename} for Note {self.note_id}>'
@property
def is_image(self):
"""Check if attachment is an image"""
return self.file_type == 'image'
@property
def url(self):
"""Get the URL to access this attachment"""
return f'/uploads/notes/{self.file_path}'
def get_file_extension(self):
"""Get file extension"""
return self.original_filename.rsplit('.', 1)[1].lower() if '.' in self.original_filename else ''
@staticmethod
def allowed_file(filename, file_type='any'):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {
'image': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'},
'document': {'pdf', 'doc', 'docx', 'txt', 'md', 'csv', 'xls', 'xlsx'},
'archive': {'zip', 'tar', 'gz', '7z'},
'any': {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'pdf', 'doc',
'docx', 'txt', 'md', 'csv', 'xls', 'xlsx', 'zip', 'tar', 'gz', '7z'}
}
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
allowed = ALLOWED_EXTENSIONS.get(file_type, ALLOWED_EXTENSIONS['any'])
return ext in allowed
@staticmethod
def get_file_type(filename):
"""Determine file type from extension"""
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
if ext in {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}:
return 'image'
elif ext in {'pdf', 'doc', 'docx', 'txt', 'md', 'csv', 'xls', 'xlsx'}:
return 'document'
elif ext in {'zip', 'tar', 'gz', '7z'}:
return 'archive'
else:
return 'other'

View File

@@ -183,9 +183,6 @@ class UserPreferences(db.Model):
time_format = db.Column(db.String(10), default='24h') time_format = db.Column(db.String(10), default='24h')
time_format_24h = db.Column(db.Boolean, default=True) # True for 24h, False for 12h time_format_24h = db.Column(db.Boolean, default=True) # True for 24h, False for 12h
# Note preview preferences
note_preview_font = db.Column(db.String(50), default='system') # system, serif, sans-serif, monospace, etc.
# Time tracking preferences # Time tracking preferences
time_rounding_minutes = db.Column(db.Integer, default=0) # 0, 5, 10, 15, 30, 60 time_rounding_minutes = db.Column(db.Integer, default=0) # 0, 5, 10, 15, 30, 60
round_to_nearest = db.Column(db.Boolean, default=False) # False=round down, True=round to nearest round_to_nearest = db.Column(db.Boolean, default=False) # False=round down, True=round to nearest

110
recaptcha_helper.py Normal file
View File

@@ -0,0 +1,110 @@
"""
reCAPTCHA Helper Module
Provides verification functionality for Google reCAPTCHA v2
"""
import os
import requests
import logging
logger = logging.getLogger(__name__)
class ReCaptcha:
"""Helper class for reCAPTCHA verification"""
def __init__(self, app=None):
self.site_key = None
self.secret_key = None
self.enabled = False
if app is not None:
self.init_app(app)
def init_app(self, app):
"""Initialize reCAPTCHA with Flask app configuration"""
self.site_key = os.getenv('RECAPTCHA_SITE_KEY', '')
self.secret_key = os.getenv('RECAPTCHA_SECRET_KEY', '')
self.enabled = os.getenv('RECAPTCHA_ENABLED', 'true').lower() == 'true'
# Store in app config for template access
app.config['RECAPTCHA_SITE_KEY'] = self.site_key
app.config['RECAPTCHA_ENABLED'] = self.enabled
if self.enabled and (not self.site_key or not self.secret_key):
logger.warning("reCAPTCHA is enabled but keys are not configured properly")
def verify(self, response_token, remote_ip=None):
"""
Verify a reCAPTCHA response token
Args:
response_token (str): The g-recaptcha-response from the form
remote_ip (str, optional): The user's IP address
Returns:
tuple: (success: bool, error_message: str or None)
"""
# If reCAPTCHA is disabled, always return success
if not self.enabled:
return True, None
# Check if we have the required configuration
if not self.secret_key:
logger.error("reCAPTCHA secret key is not configured")
return False, "reCAPTCHA is not properly configured"
# Check if response token was provided
if not response_token:
return False, "Please complete the reCAPTCHA challenge"
# Prepare the verification request
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
payload = {
'secret': self.secret_key,
'response': response_token
}
if remote_ip:
payload['remoteip'] = remote_ip
try:
# Make the verification request
response = requests.post(verify_url, data=payload, timeout=10)
response.raise_for_status()
result = response.json()
if result.get('success'):
logger.info("reCAPTCHA verification successful")
return True, None
else:
error_codes = result.get('error-codes', [])
logger.warning(f"reCAPTCHA verification failed: {error_codes}")
# Map error codes to user-friendly messages
error_messages = {
'missing-input-secret': 'reCAPTCHA configuration error',
'invalid-input-secret': 'reCAPTCHA configuration error',
'missing-input-response': 'Please complete the reCAPTCHA challenge',
'invalid-input-response': 'reCAPTCHA verification failed. Please try again.',
'bad-request': 'reCAPTCHA verification failed. Please try again.',
'timeout-or-duplicate': 'reCAPTCHA expired. Please try again.'
}
# Get the first error message or use a default
error_code = error_codes[0] if error_codes else 'unknown'
error_message = error_messages.get(error_code, 'reCAPTCHA verification failed. Please try again.')
return False, error_message
except requests.RequestException as e:
logger.error(f"reCAPTCHA verification request failed: {str(e)}")
return False, "Unable to verify reCAPTCHA. Please try again later."
except Exception as e:
logger.error(f"Unexpected error during reCAPTCHA verification: {str(e)}")
return False, "An error occurred during verification. Please try again."
# Create a singleton instance
recaptcha = ReCaptcha()

View File

@@ -17,4 +17,4 @@ Flask-Migrate==3.1.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
markdown==3.4.4 markdown==3.4.4
PyYAML==6.0.1 PyYAML==6.0.1
Pillow==10.3.0 requests==2.31.0

View File

@@ -8,9 +8,8 @@ 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, UserPreferences, db) Task, 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')
@@ -31,16 +30,6 @@ 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,
@@ -208,41 +197,6 @@ 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')
@@ -412,18 +366,6 @@ 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')
@@ -553,30 +495,4 @@ def notes_folders():
folders=folders, folders=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'))

View File

@@ -2,15 +2,12 @@
from datetime import datetime, timezone from datetime import datetime, timezone
# Third-party imports # Third-party imports
from flask import Blueprint, abort, g, jsonify, request, url_for, current_app, send_file from flask import Blueprint, abort, g, jsonify, request
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')
@@ -23,13 +20,6 @@ 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,
@@ -72,30 +62,15 @@ 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 or '..' in folder_name: if '/' in folder_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400 return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Sanitize parent path if provided # Build full path
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,
@@ -143,15 +118,9 @@ 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 or '..' in new_name: if '/' in new_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain / or ..'}), 400 return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Find the folder # Find the folder
folder = NoteFolder.query.filter_by( folder = NoteFolder.query.filter_by(
@@ -167,12 +136,6 @@ 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,
@@ -231,12 +194,6 @@ 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,
@@ -600,424 +557,4 @@ def update_note_share(share_id):
}) })
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('/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/<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
)

View File

@@ -14,7 +14,6 @@ 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__)
@@ -31,11 +30,8 @@ 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 - extra sanitization # Prepare filename
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':
@@ -146,8 +142,6 @@ 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
@@ -201,15 +195,8 @@ 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 and sanitize folder path # Decode folder path (replace URL encoding)
try: folder_path = unquote(folder_path)
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(
@@ -233,8 +220,6 @@ 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

View File

@@ -1,260 +0,0 @@
"""
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')

View File

@@ -73,6 +73,16 @@ if [ -f "migrations_old/run_postgres_migrations.py" ]; then
echo "Found old migration system. Consider removing after confirming Flask-Migrate is working." echo "Found old migration system. Consider removing after confirming Flask-Migrate is working."
fi fi
# Start cron service for scheduled tasks
echo ""
echo "=== Starting Cron Service ==="
service cron start
if [ $? -eq 0 ]; then
echo "✅ Cron service started for scheduled cleanup tasks"
else
echo "⚠️ Failed to start cron service, cleanup tasks won't run automatically"
fi
# Start the Flask application with gunicorn # Start the Flask application with gunicorn
echo "" echo ""
echo "=== Starting Application ===" echo "=== Starting Application ==="

View File

@@ -19,10 +19,6 @@
</p> </p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn btn-secondary" id="help-toggle">
<span class="icon"><i class="ti ti-help"></i></span>
Help
</button>
<button type="button" class="btn btn-secondary" id="settings-toggle"> <button type="button" class="btn btn-secondary" id="settings-toggle">
<span class="icon"><i class="ti ti-settings"></i></span> <span class="icon"><i class="ti ti-settings"></i></span>
Settings Settings
@@ -136,59 +132,6 @@
</div> </div>
</div> </div>
<!-- Help Panel -->
<div class="help-panel" id="help-panel" style="display: none;">
<div class="help-card">
<h3 class="help-title">
<span class="icon"><i class="ti ti-help"></i></span>
Markdown & Wiki Syntax Help
</h3>
<div class="help-content">
<div class="help-section">
<h4>Wiki-style Links</h4>
<ul class="help-list">
<li><code>[[Note Title]]</code> - Create a link to another note</li>
<li><code>![[Note Title]]</code> - Embed another note's content</li>
</ul>
<p class="help-note">Wiki links work with note titles or slugs. If a note isn't found, it will show as a broken link.</p>
</div>
<div class="help-section">
<h4>Basic Markdown</h4>
<ul class="help-list">
<li><code># Heading 1</code> / <code>## Heading 2</code> / <code>### Heading 3</code></li>
<li><code>**bold**</code> / <code>*italic*</code> / <code>`code`</code></li>
<li><code>[Link text](url)</code> - Regular links</li>
<li><code>![Alt text](image-url)</code> - Images</li>
<li><code>- Item</code> - Bullet lists</li>
<li><code>1. Item</code> - Numbered lists</li>
<li><code>- [ ] Task</code> - Checklists</li>
<li><code>> Quote</code> - Blockquotes</li>
</ul>
</div>
<div class="help-section">
<h4>Advanced Markdown</h4>
<ul class="help-list">
<li><code>```language<br>code block<br>```</code> - Code blocks with syntax highlighting</li>
<li><code>| Header | Header |<br>|--------|--------|<br>| Cell | Cell |</code> - Tables</li>
<li><code>---</code> - Horizontal rule</li>
</ul>
</div>
<div class="help-section">
<h4>Tips</h4>
<ul class="help-list">
<li>Use frontmatter (YAML at the top) to set metadata</li>
<li>Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save quickly</li>
<li>The preview updates in real-time as you type</li>
<li>Wiki embeds show a preview of the linked note</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Editor Form --> <!-- Editor Form -->
<form method="POST" id="note-form"> <form method="POST" id="note-form">
<input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required> <input type="hidden" id="title" name="title" value="{{ note.title if note else '' }}" required>
@@ -279,33 +222,12 @@
<i class="ti ti-minus"></i> <i class="ti ti-minus"></i>
</button> </button>
</div> </div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn" onclick="insertMarkdown('[[', ']]')" title="Wiki Link - Link to another note">
<i class="ti ti-file-symlink"></i>
</button>
<button type="button" class="toolbar-btn" onclick="insertMarkdown('![[', ']]')" title="Wiki Embed - Embed another note">
<i class="ti ti-file-import"></i>
</button>
</div>
</div> </div>
<!-- Ace Editor Container --> <!-- Ace Editor Container -->
<div id="ace-editor" class="ace-editor-container">{{ note.content if note else '# New Note\n\nStart writing here...' }}</div> <div id="ace-editor" class="ace-editor-container">{{ note.content if note else '# New Note\n\nStart writing here...' }}</div>
<textarea id="content" name="content" style="display: none;" required>{{ note.content if note else '# New Note\n\nStart writing here...' }}</textarea> <textarea id="content" name="content" style="display: none;" required>{{ note.content if note else '# New Note\n\nStart writing here...' }}</textarea>
<!-- Wiki Autocomplete Popup -->
<div id="wiki-autocomplete" class="wiki-autocomplete" style="display: none;">
<div class="autocomplete-search">
<input type="text" id="autocomplete-query" class="autocomplete-input" placeholder="Search notes...">
</div>
<div id="autocomplete-results" class="autocomplete-results">
<!-- Results will be populated here -->
</div>
</div>
<!-- Editor Footer --> <!-- Editor Footer -->
<div class="editor-footer"> <div class="editor-footer">
<div class="editor-stats"> <div class="editor-stats">
@@ -679,196 +601,6 @@
margin-top: 2rem; margin-top: 2rem;
} }
/* Help Panel */
.help-panel {
margin-bottom: 2rem;
animation: slideDown 0.3s ease-out;
}
.help-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.help-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.help-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.help-section {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
}
.help-section h4 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1rem;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
}
.help-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
}
.help-list li:last-child {
border-bottom: none;
}
.help-list code {
background: #fff;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.875rem;
color: #e83e8c;
border: 1px solid #dee2e6;
}
.help-note {
margin: 1rem 0 0 0;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
}
kbd {
background-color: #f7f7f7;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(0,0,0,0.2);
display: inline-block;
font-size: 0.85em;
font-family: monospace;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* Wiki Autocomplete */
.wiki-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 300px;
max-height: 250px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.autocomplete-search {
padding: 0.375rem;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.autocomplete-input {
width: 100%;
padding: 0.375rem 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.8125rem;
outline: none;
}
.autocomplete-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.15rem rgba(0, 123, 255, 0.25);
}
.autocomplete-results {
flex: 1;
overflow-y: auto;
max-height: 180px;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s ease;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: #f8f9fa;
}
.autocomplete-item.selected {
background: #e7f3ff;
}
.autocomplete-item-title {
font-weight: 500;
color: #333;
font-size: 0.875rem;
margin-bottom: 0.125rem;
}
.autocomplete-item-meta {
display: flex;
gap: 0.75rem;
font-size: 0.6875rem;
color: #6c757d;
}
.autocomplete-item-folder,
.autocomplete-item-visibility {
display: flex;
align-items: center;
gap: 0.125rem;
}
.autocomplete-item-folder i,
.autocomplete-item-visibility i {
font-size: 0.75rem;
}
.autocomplete-item-preview {
display: none; /* Hide preview to save space */
}
.autocomplete-empty {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
.autocomplete-loading {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.8125rem;
}
/* Animations */ /* Animations */
@keyframes slideDown { @keyframes slideDown {
from { from {
@@ -1220,8 +952,7 @@ function initializeAceEditor() {
useSoftTabs: true, useSoftTabs: true,
wrap: true, wrap: true,
showInvisibles: false, showInvisibles: false,
scrollPastEnd: 0.5, scrollPastEnd: 0.5
behavioursEnabled: true // Keep auto-closing brackets enabled
}); });
// Set initial content from hidden textarea // Set initial content from hidden textarea
@@ -1326,37 +1057,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Settings toggle // Settings toggle
const settingsBtn = document.getElementById('settings-toggle'); const settingsBtn = document.getElementById('settings-toggle');
const settingsPanel = document.getElementById('settings-panel'); const settingsPanel = document.getElementById('settings-panel');
const helpBtn = document.getElementById('help-toggle');
const helpPanel = document.getElementById('help-panel');
settingsBtn.addEventListener('click', function() { settingsBtn.addEventListener('click', function() {
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) { if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
settingsPanel.style.display = 'block'; settingsPanel.style.display = 'block';
this.classList.add('active'); this.classList.add('active');
// Close help panel if open
helpPanel.style.display = 'none';
helpBtn.classList.remove('active');
} else { } else {
settingsPanel.style.display = 'none'; settingsPanel.style.display = 'none';
this.classList.remove('active'); this.classList.remove('active');
} }
}); });
// Help toggle
helpBtn.addEventListener('click', function() {
if (helpPanel.style.display === 'none' || !helpPanel.style.display) {
helpPanel.style.display = 'block';
this.classList.add('active');
// Close settings panel if open
settingsPanel.style.display = 'none';
settingsBtn.classList.remove('active');
} else {
helpPanel.style.display = 'none';
this.classList.remove('active');
}
});
// Preview toggle // Preview toggle
const previewToggle = document.getElementById('preview-toggle'); const previewToggle = document.getElementById('preview-toggle');
const previewPanel = document.getElementById('preview-panel'); const previewPanel = document.getElementById('preview-panel');
@@ -1419,285 +1130,6 @@ document.addEventListener('DOMContentLoaded', function() {
syncContentAndUpdatePreview(false); syncContentAndUpdatePreview(false);
syncSettingsToHiddenFields(); // Sync to hidden fields syncSettingsToHiddenFields(); // Sync to hidden fields
}); });
// Wiki Autocomplete functionality
let autocompleteActive = false;
let autocompleteType = ''; // 'link' or 'embed'
let autocompleteStartPos = null;
let selectedIndex = -1;
let autocompleteResults = [];
const autocompletePopup = document.getElementById('wiki-autocomplete');
const autocompleteInput = document.getElementById('autocomplete-query');
const autocompleteResultsDiv = document.getElementById('autocomplete-results');
// Listen for [[ or ![[ triggers in ACE editor
aceEditor.commands.on("afterExec", function(e) {
if (e.command.name === "insertstring") {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
// Check for [[ or ![[ triggers
if (textBeforeCursor.endsWith('[[')) {
// Regular link
autocompleteType = 'link';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
} else if (textBeforeCursor.endsWith('![[')) {
// Embed
autocompleteType = 'embed';
autocompleteStartPos = {row: cursor.row, column: cursor.column};
// Check if ACE auto-closed the brackets
const textAfterCursor = line.substring(cursor.column);
if (textAfterCursor.startsWith(']]')) {
// Move cursor back inside the brackets
aceEditor.moveCursorToPosition({row: cursor.row, column: cursor.column});
}
showAutocomplete();
}
}
});
function showAutocomplete() {
autocompleteActive = true;
selectedIndex = -1;
// Get cursor position in the editor
const cursorPixelPos = aceEditor.renderer.textToScreenCoordinates(
autocompleteStartPos.row,
autocompleteStartPos.column
);
// Get editor container position
const editorContainer = aceEditor.container;
const editorRect = editorContainer.getBoundingClientRect();
// Calculate position relative to the editor container
// The popup should appear right below the cursor
const lineHeight = aceEditor.renderer.lineHeight;
const left = cursorPixelPos.pageX - editorRect.left - window.scrollX;
const top = cursorPixelPos.pageY - editorRect.top + lineHeight + 5 - window.scrollY;
// Make sure popup doesn't go off the right edge
const maxLeft = editorRect.width - 300; // 300px is popup width
autocompletePopup.style.left = Math.min(left, maxLeft) + 'px';
autocompletePopup.style.top = top + 'px';
autocompletePopup.style.display = 'block';
// Focus input and load initial results
autocompleteInput.value = '';
autocompleteInput.focus();
searchNotes('');
}
function hideAutocomplete() {
autocompleteActive = false;
autocompletePopup.style.display = 'none';
aceEditor.focus();
}
function searchNotes(query) {
// Show loading state
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-loading">Loading...</div>';
// Fetch notes from API
fetch(`/api/notes/autocomplete?q=${encodeURIComponent(query)}&limit=20`)
.then(response => response.json())
.then(data => {
if (data.success) {
autocompleteResults = data.results;
renderResults(data.results);
}
})
.catch(error => {
console.error('Error fetching notes:', error);
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">Error loading notes</div>';
});
}
function renderResults(results) {
if (results.length === 0) {
autocompleteResultsDiv.innerHTML = '<div class="autocomplete-empty">No notes found</div>';
return;
}
let html = '';
results.forEach((note, index) => {
const visibilityIcon = note.visibility === 'Private' ? 'lock' :
note.visibility === 'Team' ? 'users' : 'building';
html += `
<div class="autocomplete-item ${index === selectedIndex ? 'selected' : ''}"
data-index="${index}">
<div class="autocomplete-item-title">${escapeHtml(note.title)}</div>
<div class="autocomplete-item-meta">
${note.folder ? `
<div class="autocomplete-item-folder">
<i class="ti ti-folder"></i>
${escapeHtml(note.folder)}
</div>
` : ''}
<div class="autocomplete-item-visibility">
<i class="ti ti-${visibilityIcon}"></i>
${note.visibility}
</div>
</div>
${note.preview ? `
<div class="autocomplete-item-preview">${escapeHtml(note.preview)}</div>
` : ''}
</div>
`;
});
autocompleteResultsDiv.innerHTML = html;
// Add click handlers
document.querySelectorAll('.autocomplete-item').forEach(item => {
item.addEventListener('click', function() {
const index = parseInt(this.dataset.index);
selectNote(autocompleteResults[index]);
});
});
}
function selectNote(note) {
if (!note) return;
// Get current cursor position
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if there are already closing brackets after cursor
const textAfterCursor = line.substring(cursor.column);
const hasClosingBrackets = textAfterCursor.startsWith(']]');
// Insert only the title if closing brackets already exist
const insertText = hasClosingBrackets ? note.title : note.title + ']]';
// Insert the text
aceEditor.session.insert(cursor, insertText);
// If we didn't add closing brackets, move cursor past existing ones
if (hasClosingBrackets) {
const newPos = {
row: cursor.row,
column: cursor.column + note.title.length + 2 // +2 for ]]
};
aceEditor.moveCursorToPosition(newPos);
}
// Hide autocomplete
hideAutocomplete();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle input changes
let searchTimeout;
autocompleteInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchNotes(e.target.value);
}, 200);
});
// Handle keyboard navigation
autocompleteInput.addEventListener('keydown', function(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < autocompleteResults.length - 1) {
selectedIndex++;
updateSelection();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) {
selectedIndex--;
updateSelection();
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < autocompleteResults.length) {
selectNote(autocompleteResults[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideAutocomplete();
}
});
function updateSelection() {
document.querySelectorAll('.autocomplete-item').forEach((item, index) => {
if (index === selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
// Close autocomplete when clicking outside
document.addEventListener('click', function(e) {
if (autocompleteActive && !autocompletePopup.contains(e.target)) {
hideAutocomplete();
}
});
// Handle closing brackets and escape
aceEditor.on('change', function(e) {
if (!autocompleteActive) return;
if (e.action === 'insert') {
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
// Check if user typed ]] to close the link
if (e.lines[0].includes(']')) {
const textBeforeCursor = line.substring(0, cursor.column);
if (textBeforeCursor.endsWith(']]')) {
hideAutocomplete();
}
}
} else if (e.action === 'remove') {
// Check if user deleted the opening [[
const cursor = aceEditor.getCursorPosition();
const line = aceEditor.session.getLine(cursor.row);
const textBeforeCursor = line.substring(0, cursor.column);
if (!textBeforeCursor.includes('[[') && !textBeforeCursor.includes('![[')) {
hideAutocomplete();
}
}
});
// Handle escape key in editor
aceEditor.commands.addCommand({
name: 'closeAutocomplete',
bindKey: {win: 'Escape', mac: 'Escape'},
exec: function() {
if (autocompleteActive) {
hideAutocomplete();
return true;
}
return false;
}
});
}); });
</script> </script>

View File

@@ -2,155 +2,98 @@
{% block content %} {% block content %}
<div class="page-container"> <div class="page-container">
<!-- Compact Unified Header --> <!-- Page Header -->
<div class="note-header-compact"> <div class="page-header">
<!-- Title Bar --> <div class="header-content">
<div class="header-title-bar"> <div class="header-left">
<button class="btn-icon" onclick="window.location.href='{{ url_for('notes.notes_list') }}'"> <h1 class="page-title">{{ note.title }}</h1>
<i class="ti ti-arrow-left"></i> <div class="page-meta">
</button> <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 %}
<h1 class="note-title">{{ note.title }}</h1> {{ note.visibility.value }}
</span>
<div class="header-actions"> {% if note.is_pinned %}
<!-- Context-Specific Primary Actions --> <span class="pin-badge">
{% if note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf') %} <span class="icon"><i class="ti ti-pin"></i></span>
<!-- PDF Actions --> Pinned
<div class="zoom-controls"> </span>
<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 %} {% endif %}
<a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary btn-sm"> <span class="meta-divider"></span>
<i class="ti ti-brain"></i> <span class="author">
<span class="btn-text">Mind Map</span> <span class="icon"><i class="ti ti-user"></i></span>
</a> {{ note.created_by.username }}
{% endif %} </span>
<span class="meta-divider"></span>
<!-- Common Actions --> <span class="date">
{% if note.can_user_edit(g.user) %} <span class="icon"><i class="ti ti-calendar"></i></span>
<button class="btn btn-secondary btn-sm" onclick="showShareModal()"> Created {{ note.created_at|format_date }}
<i class="ti ti-share"></i> </span>
<span class="btn-text">Share</span> {% if note.updated_at > note.created_at %}
</button> <span class="meta-divider"></span>
{% endif %} <span class="date">
<span class="icon"><i class="ti ti-refresh"></i></span>
<!-- More Actions Dropdown --> Updated {{ note.updated_at|format_date }}
<div class="dropdown"> </span>
<button class="btn-icon" data-toggle="dropdown" title="More actions"> {% endif %}
<i class="ti ti-dots-vertical"></i> {% if note.folder %}
</button> <span class="meta-divider"></span>
<div class="dropdown-menu dropdown-menu-right"> <span class="folder">
{% if not (note.is_file_based and note.file_type == 'document' and note.original_filename.endswith('.pdf')) %} <span class="icon"><i class="ti ti-folder"></i></span>
<!-- Download options for non-PDF --> <a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="folder-link">
<h6 class="dropdown-header">Download as</h6> {{ note.folder }}
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='md') }}">
<i class="ti ti-file-text"></i> Markdown
</a> </a>
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='html') }}"> </span>
<i class="ti ti-world"></i> HTML {% endif %}
</a>
<a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='txt') }}">
<i class="ti ti-file"></i> Plain Text
</a>
<div class="dropdown-divider"></div>
{% endif %}
{% if note.is_pinned %}
<a class="dropdown-item" href="#">
<i class="ti ti-pin-filled"></i> Pinned
</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) %}
<div class="dropdown-divider"></div>
<form method="POST" action="{{ url_for('notes.delete_note', slug=note.slug) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this note?')">
<button type="submit" class="dropdown-item text-danger">
<i class="ti ti-trash"></i> Delete Note
</button>
</form>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> <div class="header-actions">
<div class="dropdown">
<!-- Metadata Bar --> <button class="btn btn-secondary dropdown-toggle" type="button" id="downloadDropdown" data-toggle="dropdown">
<div class="header-meta-bar"> <span class="icon"><i class="ti ti-download"></i></span>
{% if note.folder %} Download
<span class="meta-item"> </button>
<i class="ti ti-folder"></i> <div class="dropdown-menu">
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}">{{ note.folder }}</a> <a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='md') }}">
</span> <span class="icon"><i class="ti ti-file-text"></i></span>
{% endif %} Markdown (.md)
</a>
{% if note.tags %} <a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='html') }}">
<span class="meta-item"> <span class="icon"><i class="ti ti-world"></i></span>
<i class="ti ti-tag"></i> HTML (.html)
{% for tag in note.get_tags_list() %} </a>
<a href="{{ url_for('notes.notes_list', tag=tag) }}" class="tag-link">{{ tag }}</a>{% if not loop.last %}, {% endif %} <a class="dropdown-item" href="{{ url_for('notes_download.download_note', slug=note.slug, format='txt') }}">
{% endfor %} <span class="icon"><i class="ti ti-file"></i></span>
</span> Plain Text (.txt)
{% endif %} </a>
</div>
<span class="meta-item"> </div>
<i class="ti ti-user"></i> {{ note.created_by.username }} <a href="{{ url_for('notes.view_note_mindmap', slug=note.slug) }}" class="btn btn-secondary">
</span> <span class="icon"><i class="ti ti-brain"></i></span>
Mind Map
<span class="meta-item"> </a>
<i class="ti ti-clock"></i> {% if note.can_user_edit(g.user) %}
{% if note.updated_at > note.created_at %} <button type="button" class="btn btn-secondary" onclick="showShareModal()">
Updated {{ note.updated_at|format_date }} <span class="icon"><i class="ti ti-link"></i></span>
{% else %} Share
Created {{ note.created_at|format_date }} </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) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this note?')">
<button type="submit" class="btn btn-danger">
<span class="icon"><i class="ti ti-trash"></i></span>
Delete
</button>
</form>
{% endif %} {% endif %}
</span> <a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">
<span class="icon"><i class="ti ti-arrow-left"></i></span>
<span class="visibility-badge visibility-{{ note.visibility.value.lower() }}"> Back to Notes
{% if note.visibility.value == 'Private' %} </a>
<i class="ti ti-lock"></i> </div>
{% 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>
</div> </div>
@@ -209,22 +152,9 @@
<!-- 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') %}
<!-- PDF Preview (toolbar moved to unified header) -->
<div class="pdf-preview-container">
<iframe id="pdf-viewer" src="{{ note.file_url }}" class="pdf-viewer"></iframe>
</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 %}
<!-- Regular Content -->
<div class="markdown-content"> <div class="markdown-content">
{{ note.render_html()|safe }} {{ note.render_html()|safe }}
</div> </div>
{% endif %}
</div> </div>
<!-- Linked Notes Section --> <!-- Linked Notes Section -->
@@ -359,197 +289,14 @@
margin: 0 auto; margin: 0 auto;
} }
/* Compact Unified Header */ /* Page Header - Time Tracking style */
.note-header-compact { .page-header {
background: white; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px; border-radius: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 2rem;
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 {
@@ -976,32 +723,6 @@
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 {
@@ -1136,227 +857,10 @@
justify-content: center; justify-content: center;
} }
} }
/* Wiki-style Links */
.wiki-link {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px dotted var(--primary-color);
transition: all 0.2s ease;
}
.wiki-link:hover {
border-bottom-style: solid;
text-decoration: none;
}
.wiki-link-broken {
color: #dc3545;
text-decoration: line-through;
cursor: not-allowed;
opacity: 0.7;
}
/* Wiki-style Embeds */
.wiki-embed {
margin: 1.5rem 0;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
background: #f8f9fa;
}
.wiki-embed-header {
padding: 0.75rem 1rem;
background: white;
border-bottom: 1px solid #dee2e6;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wiki-embed-title {
font-weight: 600;
color: #333;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wiki-embed-title:hover {
color: var(--primary-color);
}
.wiki-embed-content {
padding: 1rem;
background: white;
max-height: 400px;
overflow-y: auto;
}
.wiki-embed-content .markdown-content {
margin: 0;
}
.wiki-embed-error {
margin: 1rem 0;
padding: 0.75rem 1rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
color: #721c24;
font-style: italic;
}
/* Nested embeds have reduced padding */
.wiki-embed .wiki-embed {
margin: 1rem 0;
}
.wiki-embed .wiki-embed .wiki-embed-content {
max-height: 200px;
}
/* Font families based on user preferences */
{% if g.user.preferences and g.user.preferences.note_preview_font %}
{% set font = g.user.preferences.note_preview_font %}
{% if font == 'sans-serif' %}
.markdown-content { font-family: Arial, Helvetica, sans-serif; }
{% elif font == 'serif' %}
.markdown-content { font-family: "Times New Roman", Times, serif; }
{% elif font == 'monospace' %}
.markdown-content { font-family: "Courier New", Courier, monospace; }
{% elif font == 'georgia' %}
.markdown-content { font-family: Georgia, serif; }
{% elif font == 'palatino' %}
.markdown-content { font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; }
{% elif font == 'garamond' %}
.markdown-content { font-family: Garamond, serif; }
{% elif font == 'bookman' %}
.markdown-content { font-family: "Bookman Old Style", serif; }
{% elif font == 'comic-sans' %}
.markdown-content { font-family: "Comic Sans MS", cursive; }
{% elif font == 'trebuchet' %}
.markdown-content { font-family: "Trebuchet MS", sans-serif; }
{% elif font == 'arial-black' %}
.markdown-content { font-family: "Arial Black", sans-serif; }
{% elif font == 'impact' %}
.markdown-content { font-family: Impact, sans-serif; }
{% endif %}
{% endif %}
/* PDF Preview Styles */
.pdf-preview-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
}
.pdf-toolbar {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 8px 8px 0 0;
}
.pdf-viewer {
width: 100%;
height: 800px;
border: none;
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 */
@media (max-width: 768px) {
.pdf-viewer {
height: 600px;
}
.pdf-toolbar {
flex-wrap: wrap;
}
}
</style> </style>
<script> <script>
// PDF viewer controls
let pdfZoom = 1.0;
function pdfZoomIn() {
pdfZoom += 0.1;
updatePdfZoom();
}
function pdfZoomOut() {
if (pdfZoom > 0.5) {
pdfZoom -= 0.1;
updatePdfZoom();
}
}
function pdfZoomReset() {
pdfZoom = 1.0;
updatePdfZoom();
}
function updatePdfZoom() {
const viewer = document.getElementById('pdf-viewer');
const zoomLevel = document.getElementById('zoom-level');
if (viewer) {
viewer.style.transform = `scale(${pdfZoom})`;
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;

File diff suppressed because it is too large Load Diff

View File

@@ -301,57 +301,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Note Preferences Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-notes"></i></span>
Note Preferences
</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('update_note_preferences') }}" class="modern-form">
<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>
<span class="form-hint">Choose your preferred font for note content</span>
</div>
<div class="font-preview-section">
<label class="form-label">Preview</label>
<div id="font-preview" class="font-preview">
<h3>Sample Heading</h3>
<p>This is how your notes will appear with the selected font. The quick brown fox jumps over the lazy dog.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<ul>
<li>First item in a list</li>
<li>Second item with <strong>bold text</strong></li>
<li>Third item with <em>italic text</em></li>
</ul>
</div>
</div>
<button type="submit" class="btn btn-primary">
<span class="icon"><i class="ti ti-check"></i></span>
Save Preferences
</button>
</form>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -956,57 +905,6 @@
.card:nth-child(3) { .card:nth-child(3) {
animation-delay: 0.2s; animation-delay: 0.2s;
} }
.card:nth-child(4) {
animation-delay: 0.3s;
}
/* Font Preview Section */
.font-preview-section {
margin-top: 1.5rem;
}
.font-preview {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
background: #f8f9fa;
max-height: 300px;
overflow-y: auto;
}
.font-preview h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.font-preview p {
margin-bottom: 1rem;
line-height: 1.6;
}
.font-preview ul {
margin-left: 1.5rem;
}
.font-preview li {
margin-bottom: 0.5rem;
}
/* Font families for preview */
.font-system { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.font-sans-serif { font-family: Arial, Helvetica, sans-serif; }
.font-serif { font-family: "Times New Roman", Times, serif; }
.font-monospace { font-family: "Courier New", Courier, monospace; }
.font-georgia { font-family: Georgia, serif; }
.font-palatino { font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; }
.font-garamond { font-family: Garamond, serif; }
.font-bookman { font-family: "Bookman Old Style", serif; }
.font-comic-sans { font-family: "Comic Sans MS", cursive; }
.font-trebuchet { font-family: "Trebuchet MS", sans-serif; }
.font-arial-black { font-family: "Arial Black", sans-serif; }
.font-impact { font-family: Impact, sans-serif; }
</style> </style>
<script> <script>
@@ -1134,33 +1032,6 @@ function isValidUrl(string) {
} }
} }
// Font preview update
document.addEventListener('DOMContentLoaded', function() {
const fontSelect = document.getElementById('note_preview_font');
const fontPreview = document.getElementById('font-preview');
if (fontSelect && fontPreview) {
fontSelect.addEventListener('change', function() {
updateFontPreview(this.value);
});
// Set initial font
updateFontPreview(fontSelect.value);
}
function updateFontPreview(fontValue) {
// Remove all font classes
fontPreview.className = 'font-preview';
// Add the selected font class
if (fontValue !== 'system') {
fontPreview.classList.add('font-' + fontValue);
} else {
fontPreview.classList.add('font-system');
}
}
});
function resetAvatar() { function resetAvatar() {
if (confirm('Reset to your default avatar? This will remove any custom avatar.')) { if (confirm('Reset to your default avatar? This will remove any custom avatar.')) {
const form = document.createElement('form'); const form = document.createElement('form');

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
{% if config.RECAPTCHA_ENABLED %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
<style> <style>
.registration-type { .registration-type {
display: flex; display: flex;
@@ -201,7 +204,13 @@
I agree to the Terms of Service and Privacy Policy I agree to the Terms of Service and Privacy Policy
</label> </label>
</div> </div>
{% if config.RECAPTCHA_ENABLED %}
<div class="form-group" style="margin-top: 1.5rem;">
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
</div>
{% endif %}
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">Create Account</button> <button type="submit" class="btn btn-primary">Create Account</button>
</div> </div>

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
{% if config.RECAPTCHA_ENABLED %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
</head> </head>
<body class="auth-page"> <body class="auth-page">
<div class="auth-container"> <div class="auth-container">
@@ -78,7 +81,13 @@
I agree to the Terms of Service and Privacy Policy I agree to the Terms of Service and Privacy Policy
</label> </label>
</div> </div>
{% if config.RECAPTCHA_ENABLED %}
<div class="form-group" style="margin-top: 1.5rem;">
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
</div>
{% endif %}
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">Create My Workspace</button> <button type="submit" class="btn btn-primary">Create My Workspace</button>
</div> </div>

View File

@@ -8,72 +8,72 @@ from models import db
class BaseRepository: class BaseRepository:
"""Base repository with common database operations""" """Base repository with common database operations"""
def __init__(self, model): def __init__(self, model):
self.model = model self.model = model
def get_by_id(self, id): def get_by_id(self, id):
"""Get entity by ID""" """Get entity by ID"""
return self.model.query.get(id) return self.model.query.get(id)
def get_by_company(self, company_id=None): def get_by_company(self, company_id=None):
"""Get all entities for a company""" """Get all entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
return self.model.query.filter_by(company_id=company_id).all() return self.model.query.filter_by(company_id=company_id).all()
def get_by_company_ordered(self, company_id=None, order_by=None): def get_by_company_ordered(self, company_id=None, order_by=None):
"""Get all entities for a company with ordering""" """Get all entities for a company with ordering"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
query = self.model.query.filter_by(company_id=company_id) query = self.model.query.filter_by(company_id=company_id)
if order_by is not None: if order_by is not None:
query = query.order_by(order_by) query = query.order_by(order_by)
return query.all() return query.all()
def exists_by_name_in_company(self, name, company_id=None, exclude_id=None): def exists_by_name_in_company(self, name, company_id=None, exclude_id=None):
"""Check if entity with name exists in company""" """Check if entity with name exists in company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
query = self.model.query.filter_by(name=name, company_id=company_id) query = self.model.query.filter_by(name=name, company_id=company_id)
if exclude_id is not None: if exclude_id is not None:
query = query.filter(self.model.id != exclude_id) query = query.filter(self.model.id != exclude_id)
return query.first() is not None return query.first() is not None
def create(self, **kwargs): def create(self, **kwargs):
"""Create new entity""" """Create new entity"""
entity = self.model(**kwargs) entity = self.model(**kwargs)
db.session.add(entity) db.session.add(entity)
return entity return entity
def update(self, entity, **kwargs): def update(self, entity, **kwargs):
"""Update entity with given attributes""" """Update entity with given attributes"""
for key, value in kwargs.items(): for key, value in kwargs.items():
if hasattr(entity, key): if hasattr(entity, key):
setattr(entity, key, value) setattr(entity, key, value)
return entity return entity
def delete(self, entity): def delete(self, entity):
"""Delete entity""" """Delete entity"""
db.session.delete(entity) db.session.delete(entity)
def save(self): def save(self):
"""Commit changes to database""" """Commit changes to database"""
db.session.commit() db.session.commit()
def rollback(self): def rollback(self):
"""Rollback database changes""" """Rollback database changes"""
db.session.rollback() db.session.rollback()
@@ -81,42 +81,42 @@ class BaseRepository:
class CompanyScopedRepository(BaseRepository): class CompanyScopedRepository(BaseRepository):
"""Repository for entities scoped to a company""" """Repository for entities scoped to a company"""
def get_by_id_and_company(self, id, company_id=None): def get_by_id_and_company(self, id, company_id=None):
"""Get entity by ID, ensuring it belongs to the company""" """Get entity by ID, ensuring it belongs to the company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return None return None
return self.model.query.filter_by(id=id, company_id=company_id).first() return self.model.query.filter_by(id=id, company_id=company_id).first()
def get_active_by_company(self, company_id=None): def get_active_by_company(self, company_id=None):
"""Get active entities for a company""" """Get active entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
# Assumes model has is_active field # Assumes model has is_active field
if hasattr(self.model, 'is_active'): if hasattr(self.model, 'is_active'):
return self.model.query.filter_by( return self.model.query.filter_by(
company_id=company_id, company_id=company_id,
is_active=True is_active=True
).all() ).all()
return self.get_by_company(company_id) return self.get_by_company(company_id)
def count_by_company(self, company_id=None): def count_by_company(self, company_id=None):
"""Count entities for a company""" """Count entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return 0 return 0
return self.model.query.filter_by(company_id=company_id).count() return self.model.query.filter_by(company_id=company_id).count()
@@ -124,18 +124,18 @@ class CompanyScopedRepository(BaseRepository):
class UserRepository(CompanyScopedRepository): class UserRepository(CompanyScopedRepository):
"""Repository for User operations""" """Repository for User operations"""
def __init__(self): def __init__(self):
from models import User from models import User
super().__init__(User) super().__init__(User)
def get_by_username_and_company(self, username, company_id): def get_by_username_and_company(self, username, company_id):
"""Get user by username within a company""" """Get user by username within a company"""
return self.model.query.filter_by( return self.model.query.filter_by(
username=username, username=username,
company_id=company_id company_id=company_id
).first() ).first()
def get_by_email(self, email): def get_by_email(self, email):
"""Get user by email (globally unique)""" """Get user by email (globally unique)"""
return self.model.query.filter_by(email=email).first() return self.model.query.filter_by(email=email).first()
@@ -143,19 +143,19 @@ class UserRepository(CompanyScopedRepository):
class TeamRepository(CompanyScopedRepository): class TeamRepository(CompanyScopedRepository):
"""Repository for Team operations""" """Repository for Team operations"""
def __init__(self): def __init__(self):
from models import Team from models import Team
super().__init__(Team) super().__init__(Team)
def get_with_member_count(self, company_id=None): def get_with_member_count(self, company_id=None):
"""Get teams with member count""" """Get teams with member count"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
# This would need a more complex query with joins # This would need a more complex query with joins
teams = self.get_by_company(company_id) teams = self.get_by_company(company_id)
for team in teams: for team in teams:
@@ -165,27 +165,27 @@ class TeamRepository(CompanyScopedRepository):
class ProjectRepository(CompanyScopedRepository): class ProjectRepository(CompanyScopedRepository):
"""Repository for Project operations""" """Repository for Project operations"""
def __init__(self): def __init__(self):
from models import Project from models import Project
super().__init__(Project) super().__init__(Project)
def get_by_code_and_company(self, code, company_id): def get_by_code_and_company(self, code, company_id):
"""Get project by code within a company""" """Get project by code within a company"""
return self.model.query.filter_by( return self.model.query.filter_by(
code=code, code=code,
company_id=company_id company_id=company_id
).first() ).first()
def get_accessible_by_user(self, user): def get_accessible_by_user(self, user):
"""Get projects accessible by a user""" """Get projects accessible by a user"""
if not user: if not user:
return [] return []
# Admin/Supervisor can see all company projects # Admin/Supervisor can see all company projects
if user.role.value in ['Administrator', 'Supervisor', 'System Administrator']: if user.role.value in ['Administrator', 'Supervisor', 'System Administrator']:
return self.get_by_company(user.company_id) return self.get_by_company(user.company_id)
# Team members see team projects + unassigned projects # Team members see team projects + unassigned projects
from models import Project from models import Project
return Project.query.filter( return Project.query.filter(
@@ -194,4 +194,4 @@ class ProjectRepository(CompanyScopedRepository):
Project.team_id == user.team_id, Project.team_id == user.team_id,
Project.team_id.is_(None) Project.team_id.is_(None)
) )
).all() ).all()

View File

@@ -1,167 +0,0 @@
"""
Wiki-style link processing for Notes feature.
Supports [[note-title]] for links and ![[note-title]] for embedding.
"""
import re
from flask import g, url_for
from models import Note, NoteVisibility
from sqlalchemy import or_, and_
def process_wiki_links(content, current_note_id=None):
"""
Process wiki-style links in content.
- [[note-title]] becomes a link to the note
- ![[note-title]] embeds the note content
Args:
content: Markdown content with potential wiki links
current_note_id: ID of the current note (to avoid circular embeds)
Returns:
Processed content with wiki links replaced
"""
# Process embeds first (![[...]]) to avoid link processing inside embedded content
content = process_wiki_embeds(content, current_note_id)
# Then process regular links ([[...]])
content = process_wiki_regular_links(content)
return content
def process_wiki_regular_links(content):
"""Convert [[note-title]] to HTML links"""
def replace_link(match):
link_text = match.group(1).strip()
# Try to find the note by title or slug
note = find_note_by_reference(link_text)
if note:
# Create a link to the note
url = url_for('notes.view_note', slug=note.slug)
return f'<a href="{url}" class="wiki-link" title="View: {note.title}">{link_text}</a>'
else:
# Note not found - create a broken link indicator
return f'<span class="wiki-link-broken" title="Note not found: {link_text}">{link_text}</span>'
# Pattern to match [[text]] but not ![[text]]
pattern = r'(?<!\!)\[\[([^\]]+)\]\]'
return re.sub(pattern, replace_link, content)
def process_wiki_embeds(content, current_note_id=None):
"""Convert ![[note-title]] to embedded content"""
# Keep track of embedded notes to prevent infinite recursion
embedded_notes = set()
if current_note_id:
embedded_notes.add(current_note_id)
def replace_embed(match):
embed_ref = match.group(1).strip()
# Try to find the note by title or slug
note = find_note_by_reference(embed_ref)
if note:
# Check if we've already embedded this note (circular reference)
if note.id in embedded_notes:
return f'<div class="wiki-embed-error">Circular reference detected: {embed_ref}</div>'
# Check if user can view the note
if not note.can_user_view(g.user):
return f'<div class="wiki-embed-error">Access denied: {embed_ref}</div>'
# Add to embedded set
embedded_notes.add(note.id)
# Get the note content without frontmatter
from frontmatter_utils import parse_frontmatter
_, body = parse_frontmatter(note.content)
# Process any wiki links in the embedded content (but not embeds to avoid deep recursion)
embedded_content = process_wiki_regular_links(body)
# Render the embedded content
import markdown
html_content = markdown.markdown(embedded_content, extensions=['extra', 'codehilite', 'toc'])
# Create an embedded note block
embed_html = f'''
<div class="wiki-embed" data-note-id="{note.id}">
<div class="wiki-embed-header">
<a href="{url_for('notes.view_note', slug=note.slug)}" class="wiki-embed-title">
<i class="ti ti-file-text"></i> {note.title}
</a>
</div>
<div class="wiki-embed-content">
{html_content}
</div>
</div>'''
return embed_html
else:
# Note not found
return f'<div class="wiki-embed-error">Note not found: {embed_ref}</div>'
# Pattern to match ![[text]]
pattern = r'\!\[\[([^\]]+)\]\]'
return re.sub(pattern, replace_embed, content)
def find_note_by_reference(reference):
"""
Find a note by title or slug reference.
First tries exact slug match, then exact title match, then case-insensitive title match.
Args:
reference: The note reference (title or slug)
Returns:
Note object or None
"""
if not g.user or not g.user.company_id:
return None
# Build base query for notes the user can see
base_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
)
)
# Try exact slug match first
note = base_query.filter_by(slug=reference).first()
if note:
return note
# Try exact title match
note = base_query.filter_by(title=reference).first()
if note:
return note
# Try case-insensitive title match
note = base_query.filter(Note.title.ilike(reference)).first()
if note:
return note
# Try slug-ified version of the reference
import re
slugified = re.sub(r'[^\w\s-]', '', reference.lower())
slugified = re.sub(r'[-\s]+', '-', slugified).strip('-')
if slugified != reference:
note = base_query.filter_by(slug=slugified).first()
if note:
return note
return None