diff --git a/app.py b/app.py index 7ffb05c..9bb570e 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation, Note, NoteFolder, NoteShare from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data, format_burndown_data @@ -23,6 +23,7 @@ from werkzeug.security import check_password_hash from routes.notes import notes_bp from routes.notes_download import notes_download_bp from routes.notes_api import notes_api_bp +from routes.notes_public import notes_public_bp from routes.tasks import tasks_bp, get_filtered_tasks_for_burndown from routes.tasks_api import tasks_api_bp from routes.sprints import sprints_bp @@ -87,6 +88,7 @@ db.init_app(app) app.register_blueprint(notes_bp) app.register_blueprint(notes_download_bp) app.register_blueprint(notes_api_bp) +app.register_blueprint(notes_public_bp) app.register_blueprint(tasks_bp) app.register_blueprint(tasks_api_bp) app.register_blueprint(sprints_bp) diff --git a/migrations/add_note_sharing.sql b/migrations/add_note_sharing.sql new file mode 100644 index 0000000..5cae4cc --- /dev/null +++ b/migrations/add_note_sharing.sql @@ -0,0 +1,21 @@ +-- Add note_share table for public note sharing functionality +CREATE TABLE IF NOT EXISTS note_share ( + id SERIAL PRIMARY KEY, + note_id INTEGER NOT NULL REFERENCES note(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + expires_at TIMESTAMP, + password_hash VARCHAR(255), + view_count INTEGER DEFAULT 0, + max_views INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL REFERENCES "user"(id), + last_accessed_at TIMESTAMP +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_note_share_token ON note_share(token); +CREATE INDEX IF NOT EXISTS idx_note_share_note_id ON note_share(note_id); +CREATE INDEX IF NOT EXISTS idx_note_share_created_by ON note_share(created_by_id); + +-- Add comment +COMMENT ON TABLE note_share IS 'Public sharing links for notes with optional password protection and view limits'; \ No newline at end of file diff --git a/migrations/run_postgres_migrations.py b/migrations/run_postgres_migrations.py index 235ee90..ff28f71 100755 --- a/migrations/run_postgres_migrations.py +++ b/migrations/run_postgres_migrations.py @@ -17,6 +17,7 @@ MIGRATION_STATE_FILE = '/data/postgres_migrations_state.json' # List of PostgreSQL migrations in order POSTGRES_MIGRATIONS = [ 'postgres_only_migration.py', # Main migration from commit 4214e88 onward + 'add_note_sharing.sql', # Add note sharing functionality ] @@ -49,12 +50,27 @@ def run_migration(migration_file): print(f"\nšŸ”„ Running migration: {migration_file}") try: - # Run the migration script - result = subprocess.run( - [sys.executable, script_path], - capture_output=True, - text=True - ) + # Check if it's a SQL file + if migration_file.endswith('.sql'): + # Run SQL file using psql + import os + db_host = os.environ.get('POSTGRES_HOST', 'db') + db_name = os.environ.get('POSTGRES_DB', 'timetrack') + db_user = os.environ.get('POSTGRES_USER', 'timetrack') + + result = subprocess.run( + ['psql', '-h', db_host, '-U', db_user, '-d', db_name, '-f', script_path], + capture_output=True, + text=True, + env={**os.environ, 'PGPASSWORD': os.environ.get('POSTGRES_PASSWORD', 'timetrack')} + ) + else: + # Run Python migration script + result = subprocess.run( + [sys.executable, script_path], + capture_output=True, + text=True + ) if result.returncode == 0: print(f"āœ… {migration_file} completed successfully") diff --git a/models/__init__.py b/models/__init__.py index 8e49fce..209dbd7 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -27,6 +27,7 @@ from .dashboard import DashboardWidget, WidgetTemplate from .work_config import WorkConfig from .invitation import CompanyInvitation from .note import Note, NoteVisibility, NoteLink, NoteFolder +from .note_share import NoteShare # Make all models available at package level __all__ = [ @@ -47,5 +48,5 @@ __all__ = [ 'DashboardWidget', 'WidgetTemplate', 'WorkConfig', 'CompanyInvitation', - 'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder' + 'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder', 'NoteShare' ] \ No newline at end of file diff --git a/models/note.py b/models/note.py index 876c02c..ac6e9f0 100644 --- a/models/note.py +++ b/models/note.py @@ -5,7 +5,7 @@ Migrated from models_old.py to maintain consistency with the new modular structu import enum import re -from datetime import datetime +from datetime import datetime, timedelta from sqlalchemy import UniqueConstraint @@ -223,6 +223,44 @@ class Note(db.Model): self.tags = metadata['tags'] if 'pinned' in metadata: self.is_pinned = bool(metadata['pinned']) + + def create_share_link(self, expires_in_days=None, password=None, max_views=None, created_by=None): + """Create a public share link for this note""" + from .note_share import NoteShare + from flask import g + + share = NoteShare( + note_id=self.id, + created_by_id=created_by.id if created_by else g.user.id + ) + + # Set expiration + if expires_in_days: + share.expires_at = datetime.now() + timedelta(days=expires_in_days) + + # Set password + if password: + share.set_password(password) + + # Set view limit + if max_views: + share.max_views = max_views + + db.session.add(share) + return share + + def get_active_shares(self): + """Get all active share links for this note""" + return [s for s in self.shares if s.is_valid()] + + def get_all_shares(self): + """Get all share links for this note""" + from models.note_share import NoteShare + return self.shares.order_by(NoteShare.created_at.desc()).all() + + def has_active_shares(self): + """Check if this note has any active share links""" + return any(s.is_valid() for s in self.shares) class NoteLink(db.Model): diff --git a/models/note_share.py b/models/note_share.py new file mode 100644 index 0000000..fb0a260 --- /dev/null +++ b/models/note_share.py @@ -0,0 +1,107 @@ +from datetime import datetime, timedelta +from . import db +import secrets +import string +from werkzeug.security import generate_password_hash, check_password_hash + + +class NoteShare(db.Model): + """Public sharing links for notes""" + __tablename__ = 'note_share' + + id = db.Column(db.Integer, primary_key=True) + note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False) + token = db.Column(db.String(64), unique=True, nullable=False, index=True) + + # Share settings + expires_at = db.Column(db.DateTime, nullable=True) # None means no expiry + password_hash = db.Column(db.String(255), nullable=True) # Optional password protection + view_count = db.Column(db.Integer, default=0) + max_views = db.Column(db.Integer, nullable=True) # Limit number of views + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + last_accessed_at = db.Column(db.DateTime, nullable=True) + + # Relationships + note = db.relationship('Note', backref=db.backref('shares', cascade='all, delete-orphan', lazy='dynamic')) + created_by = db.relationship('User', foreign_keys=[created_by_id]) + + def __init__(self, **kwargs): + super(NoteShare, self).__init__(**kwargs) + if not self.token: + self.token = self.generate_token() + + @staticmethod + def generate_token(): + """Generate a secure random token""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(32)) + + def set_password(self, password): + """Set password protection for the share""" + if password: + self.password_hash = generate_password_hash(password) + else: + self.password_hash = None + + def check_password(self, password): + """Check if the provided password is correct""" + if not self.password_hash: + return True + return check_password_hash(self.password_hash, password) + + def is_valid(self): + """Check if share link is still valid""" + # Check expiration + if self.expires_at and datetime.now() > self.expires_at: + return False + + # Check view count + if self.max_views and self.view_count >= self.max_views: + return False + + return True + + def is_expired(self): + """Check if the share has expired""" + if self.expires_at and datetime.now() > self.expires_at: + return True + return False + + def is_view_limit_reached(self): + """Check if view limit has been reached""" + if self.max_views and self.view_count >= self.max_views: + return True + return False + + def record_access(self): + """Record that the share was accessed""" + self.view_count += 1 + self.last_accessed_at = datetime.now() + + def get_share_url(self, _external=True): + """Get the full URL for this share""" + from flask import url_for + return url_for('notes_public.view_shared_note', + token=self.token, + _external=_external) + + def to_dict(self): + """Convert share to dictionary for API responses""" + return { + 'id': self.id, + 'token': self.token, + 'url': self.get_share_url(), + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'has_password': bool(self.password_hash), + 'max_views': self.max_views, + 'view_count': self.view_count, + 'created_at': self.created_at.isoformat(), + 'created_by': self.created_by.username, + 'last_accessed_at': self.last_accessed_at.isoformat() if self.last_accessed_at else None, + 'is_valid': self.is_valid(), + 'is_expired': self.is_expired(), + 'is_view_limit_reached': self.is_view_limit_reached() + } \ No newline at end of file diff --git a/routes/notes_api.py b/routes/notes_api.py index 28ba09a..7a8657c 100644 --- a/routes/notes_api.py +++ b/routes/notes_api.py @@ -419,4 +419,142 @@ def unlink_notes(note_id): return jsonify({ 'success': True, 'message': 'Link removed successfully' - }) \ No newline at end of file + }) + + +@notes_api_bp.route('//shares', methods=['POST']) +@login_required +@company_required +def create_note_share(slug): + """Create a share link for a note""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first() + + if not note: + return jsonify({'success': False, 'error': 'Note not found'}), 404 + + # Check permissions - only editors can create shares + if not note.can_user_edit(g.user): + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + data = request.get_json() + + try: + share = note.create_share_link( + expires_in_days=data.get('expires_in_days'), + password=data.get('password'), + max_views=data.get('max_views') + ) + + db.session.commit() + + return jsonify({ + 'success': True, + 'share': share.to_dict() + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@notes_api_bp.route('//shares', methods=['GET']) +@login_required +@company_required +def list_note_shares(slug): + """List all share links for a note""" + note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first() + + if not note: + return jsonify({'success': False, 'error': 'Note not found'}), 404 + + # Check permissions + if not note.can_user_view(g.user): + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + # Get all shares (not just active ones) + shares = note.get_all_shares() + + return jsonify({ + 'success': True, + 'shares': [s.to_dict() for s in shares] + }) + + +@notes_api_bp.route('/shares/', methods=['DELETE']) +@login_required +@company_required +def delete_note_share(share_id): + """Delete a share link""" + from models import NoteShare + + share = NoteShare.query.get(share_id) + + if not share: + return jsonify({'success': False, 'error': 'Share not found'}), 404 + + # Check permissions + if share.note.company_id != g.user.company_id: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + if not share.note.can_user_edit(g.user): + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + try: + db.session.delete(share) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Share link deleted successfully' + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@notes_api_bp.route('/shares/', methods=['PUT']) +@login_required +@company_required +def update_note_share(share_id): + """Update a share link settings""" + from models import NoteShare + + share = NoteShare.query.get(share_id) + + if not share: + return jsonify({'success': False, 'error': 'Share not found'}), 404 + + # Check permissions + if share.note.company_id != g.user.company_id: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + if not share.note.can_user_edit(g.user): + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + data = request.get_json() + + try: + # Update expiration + if 'expires_in_days' in data: + if data['expires_in_days'] is None: + share.expires_at = None + else: + from datetime import datetime, timedelta + share.expires_at = datetime.now() + timedelta(days=data['expires_in_days']) + + # Update password + if 'password' in data: + share.set_password(data['password']) + + # Update view limit + if 'max_views' in data: + share.max_views = data['max_views'] + + db.session.commit() + + return jsonify({ + 'success': True, + 'share': share.to_dict() + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 \ No newline at end of file diff --git a/routes/notes_public.py b/routes/notes_public.py new file mode 100644 index 0000000..751d0b2 --- /dev/null +++ b/routes/notes_public.py @@ -0,0 +1,191 @@ +""" +Public routes for viewing shared notes without authentication +""" + +from flask import Blueprint, render_template, abort, request, session, jsonify +from werkzeug.security import check_password_hash +from models import NoteShare, db + +notes_public_bp = Blueprint('notes_public', __name__, url_prefix='/public/notes') + + +@notes_public_bp.route('/') +def view_shared_note(token): + """View a publicly shared note""" + # Find the share + share = NoteShare.query.filter_by(token=token).first() + if not share: + abort(404, "Share link not found") + + # Check if share is valid + if not share.is_valid(): + if share.is_expired(): + abort(410, "This share link has expired") + elif share.is_view_limit_reached(): + abort(410, "This share link has reached its view limit") + else: + abort(404, "This share link is no longer valid") + + # Check password if required + if share.password_hash: + # Check if password was already verified in session + verified_shares = session.get('verified_shares', []) + if share.id not in verified_shares: + # For GET request, show password form + if request.method == 'GET': + return render_template('notes/share_password.html', + token=token, + note_title=share.note.title) + + # Record access + share.record_access() + db.session.commit() + + # Render the note (read-only view) + return render_template('notes/public_view.html', + note=share.note, + share=share) + + +@notes_public_bp.route('//verify', methods=['POST']) +def verify_share_password(token): + """Verify password for a protected share""" + share = NoteShare.query.filter_by(token=token).first() + if not share: + abort(404, "Share link not found") + + if not share.is_valid(): + abort(410, "This share link is no longer valid") + + password = request.form.get('password', '') + + if share.check_password(password): + # Store verification in session + verified_shares = session.get('verified_shares', []) + if share.id not in verified_shares: + verified_shares.append(share.id) + session['verified_shares'] = verified_shares + + # Redirect to the note view + return jsonify({'success': True, 'redirect': f'/public/notes/{token}'}) + else: + return jsonify({'success': False, 'error': 'Invalid password'}), 401 + + +@notes_public_bp.route('//download/') +def download_shared_note(token, format): + """Download a shared note in various formats""" + share = NoteShare.query.filter_by(token=token).first() + if not share: + abort(404, "Share link not found") + + if not share.is_valid(): + abort(410, "This share link is no longer valid") + + # Check password protection + if share.password_hash: + verified_shares = session.get('verified_shares', []) + if share.id not in verified_shares: + abort(403, "Password verification required") + + # Record access + share.record_access() + db.session.commit() + + # Generate download based on format + from flask import Response, send_file + import markdown + import tempfile + import os + from datetime import datetime + + note = share.note + + if format == 'md': + # Markdown download + response = Response(note.content, mimetype='text/markdown') + response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.md"' + return response + + elif format == 'html': + # HTML download + html_content = f""" + + + + {note.title} + + + +

{note.title}

+

Created: {note.created_at.strftime('%B %d, %Y')}

+ {note.render_html()} + +""" + response = Response(html_content, mimetype='text/html') + response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.html"' + return response + + elif format == 'pdf': + # PDF download using weasyprint + try: + import weasyprint + + # Generate HTML first + html_content = f""" + + + + {note.title} + + + +

{note.title}

+

Created: {note.created_at.strftime('%B %d, %Y')}

+ {note.render_html()} + +""" + + # Create temporary file for PDF + temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.pdf', delete=False) + + # Generate PDF + weasyprint.HTML(string=html_content).write_pdf(temp_file.name) + temp_file.close() + + # Send file + response = send_file( + temp_file.name, + mimetype='application/pdf', + as_attachment=True, + download_name=f'{note.slug}.pdf' + ) + + # Clean up temp file after sending + os.unlink(temp_file.name) + + return response + + except ImportError: + # If weasyprint is not installed, return error + abort(500, "PDF generation not available") + + else: + abort(400, "Invalid format") \ No newline at end of file diff --git a/templates/note_view.html b/templates/note_view.html index 9f6d2b1..45e2e45 100644 --- a/templates/note_view.html +++ b/templates/note_view.html @@ -63,6 +63,10 @@ Mind Map {% if note.can_user_edit(g.user) %} + āœļø Edit @@ -962,4 +966,504 @@ window.onclick = function(event) { {% endif %} + + + + + + + {% endblock %} \ No newline at end of file diff --git a/templates/notes/public_view.html b/templates/notes/public_view.html new file mode 100644 index 0000000..26e6d1d --- /dev/null +++ b/templates/notes/public_view.html @@ -0,0 +1,283 @@ + + + + + + {{ note.title }} + + + + + + +
+
+

{{ note.title }}

+
+ Shared by {{ note.created_by.username }} • + Created {{ note.created_at|format_date }} + {% if note.updated_at > note.created_at %} + • Updated {{ note.updated_at|format_date }} + {% endif %} +
+
+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/templates/notes/share_password.html b/templates/notes/share_password.html new file mode 100644 index 0000000..2bb9b96 --- /dev/null +++ b/templates/notes/share_password.html @@ -0,0 +1,194 @@ + + + + + + Password Protected Note - {{ note_title }} + + + + + +
+
šŸ”’
+

Password Protected Note

+

{{ note_title }}

+ +
+
+ +
+ + +
+ + +
+
+ + + + \ No newline at end of file