Prune unverified accounts

This commit is contained in:
2025-11-22 10:44:50 +01:00
parent 87471e033e
commit 983d10ea97
5 changed files with 100 additions and 74 deletions

View File

@@ -39,8 +39,16 @@ docker-compose up
# Debug mode with hot-reload
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
### Flask-Migrate Commands
@@ -187,6 +195,7 @@ When modifying billing/invoice features:
- Two-factor authentication (2FA) using TOTP
- Session-based authentication with "Remember Me" option
- Email verification for new accounts (configurable)
- Automatic cleanup of unverified accounts after 24 hours
### Mobile UI Features
- Progressive Web App (PWA) manifest for installability

View File

@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
python3-dev \
postgresql-client \
cron \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -35,6 +36,12 @@ RUN pip install gunicorn==21.2.0
# Copy the rest of the application
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
RUN mkdir -p /app/instance && chmod 777 /app/instance

58
app.py
View File

@@ -102,14 +102,14 @@ def force_http_scheme():
if app.debug or os.environ.get('FLASK_ENV') == 'debug':
from flask import url_for as original_url_for
import functools
@functools.wraps(original_url_for)
def url_for_http(*args, **kwargs):
# Force _scheme to http if _external is True
if kwargs.get('_external'):
kwargs['_scheme'] = 'http'
return original_url_for(*args, **kwargs)
app.jinja_env.globals['url_for'] = url_for_http
# Configure Flask-Mail
@@ -365,7 +365,7 @@ def robots_txt():
def sitemap_xml():
"""Generate XML sitemap for search engines"""
pages = []
# Static pages accessible without login
static_pages = [
{'loc': '/', 'priority': '1.0', 'changefreq': 'daily'},
@@ -373,7 +373,7 @@ def sitemap_xml():
{'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'},
{'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'},
]
for page in static_pages:
pages.append({
'loc': request.host_url[:-1] + page['loc'],
@@ -381,10 +381,10 @@ def sitemap_xml():
'priority': page['priority'],
'changefreq': page['changefreq']
})
sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages:
sitemap_xml += ' <url>\n'
sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
@@ -392,9 +392,9 @@ def sitemap_xml():
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
sitemap_xml += ' </url>\n'
sitemap_xml += '</urlset>'
return Response(sitemap_xml, mimetype='application/xml')
@app.route('/site.webmanifest')
@@ -986,11 +986,11 @@ def forgot_password():
"""Handle forgot password requests"""
if request.method == 'POST':
username_or_email = request.form.get('username_or_email', '').strip()
if not username_or_email:
flash('Please enter your username or email address.', 'error')
return render_template('forgot_password.html', title='Forgot Password')
# Try to find user by username or email
user = User.query.filter(
db.or_(
@@ -998,11 +998,11 @@ def forgot_password():
User.email == username_or_email
)
).first()
if user and user.email:
# Generate reset token
token = user.generate_password_reset_token()
# Send reset email
reset_url = url_for('reset_password', token=token, _external=True)
msg = Message(
@@ -1023,7 +1023,7 @@ If you did not request a password reset, please ignore this email.
Best regards,
The {g.branding.app_name if g.branding else "TimeTrack"} Team
'''
try:
mail.send(msg)
logger.info(f"Password reset email sent to user {user.username}")
@@ -1031,11 +1031,11 @@ The {g.branding.app_name if g.branding else "TimeTrack"} Team
logger.error(f"Failed to send password reset email: {str(e)}")
flash('Failed to send reset email. Please contact support.', 'error')
return render_template('forgot_password.html', title='Forgot Password')
# 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')
return redirect(url_for('login'))
return render_template('forgot_password.html', title='Forgot Password')
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
@@ -1043,42 +1043,42 @@ def reset_password(token):
"""Handle password reset with token"""
# Find user by reset token
user = User.query.filter_by(password_reset_token=token).first()
if not user or not user.verify_password_reset_token(token):
flash('Invalid or expired reset link.', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
# Validate input
error = None
if not password:
error = 'Password is required'
elif password != confirm_password:
error = 'Passwords do not match'
# Validate password strength
if not error:
validator = PasswordValidator()
is_valid, password_errors = validator.validate(password)
if not is_valid:
error = password_errors[0]
if error:
flash(error, 'error')
return render_template('reset_password.html', token=token, title='Reset Password')
# Update password
user.set_password(password)
user.clear_password_reset_token()
db.session.commit()
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')
return redirect(url_for('login'))
return render_template('reset_password.html', token=token, title='Reset Password')
@app.route('/dashboard')
@@ -2931,14 +2931,14 @@ def render_markdown():
try:
data = request.get_json()
content = data.get('content', '')
if not content:
return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'})
# Parse frontmatter and extract body
from frontmatter_utils import parse_frontmatter
metadata, body = parse_frontmatter(content)
# Render markdown to HTML
try:
import markdown
@@ -2947,13 +2947,13 @@ def render_markdown():
except ImportError:
# Fallback if markdown not installed
html = f'<pre>{body}</pre>'
return jsonify({'html': html})
except Exception as e:
logger.error(f"Error rendering markdown: {str(e)}")
return jsonify({'html': '<p class="error">Error rendering markdown</p>'})
if __name__ == '__main__':
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)

View File

@@ -73,6 +73,16 @@ if [ -f "migrations_old/run_postgres_migrations.py" ]; then
echo "Found old migration system. Consider removing after confirming Flask-Migrate is working."
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
echo ""
echo "=== Starting Application ==="

View File

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