Compare commits
5 Commits
master
...
prune-fake
| Author | SHA1 | Date | |
|---|---|---|---|
| 6afa4619ef | |||
| 57bb0f5b9e | |||
| 8de4378ad9 | |||
| c375b9ee3d | |||
| 983d10ea97 |
@@ -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
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
ko_fi: nullmedium
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
220
app.py
@@ -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
327
cleanup_inactive_accounts.py
Executable 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
152
cleanup_unverified_accounts.py
Executable 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()
|
||||||
@@ -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
9
docker-cron
Normal 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
|
||||||
@@ -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')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -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
110
recaptcha_helper.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'))
|
|
||||||
@@ -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""
|
|
||||||
|
|
||||||
# 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"})"
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -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 ==="
|
||||||
|
|||||||
@@ -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></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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
167
wiki_links.py
167
wiki_links.py
@@ -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
|
|
||||||
Reference in New Issue
Block a user