From af59158ff9ec9bde9e483bbef9916f339efb8bcdcf1b3e15138c67c6f61be0e7 Mon Sep 17 00:00:00 2001 From: Discsearcher Date: Fri, 28 Aug 2026 14:56:11 -0400 Subject: [PATCH] Updated to do local content w/ passwording and encryption --- ENCRYPTION.md | 283 +++++++++++++++++++ PASSWORD_PROTECTION.md | 206 ++++++++++++++ README.md | 161 +++++++---- app.py | 502 +++++++++++++++++++++------------ docker-compose.yml | 21 ++ manage_content.py | 462 ++++++++++++++++++++++++++++++ requirements.txt | 2 +- templates/password_prompt.html | 179 ++++++++++++ templates/view.html | 59 +++- 9 files changed, 1642 insertions(+), 233 deletions(-) create mode 100644 ENCRYPTION.md create mode 100644 PASSWORD_PROTECTION.md create mode 100644 docker-compose.yml create mode 100644 manage_content.py create mode 100644 templates/password_prompt.html diff --git a/ENCRYPTION.md b/ENCRYPTION.md new file mode 100644 index 0000000..bb302f7 --- /dev/null +++ b/ENCRYPTION.md @@ -0,0 +1,283 @@ +# Encrypted Files & Password Protection + +This document describes how to encrypt and password-protect markdown files in markmywords. + +## Overview + +markmywords supports file encryption with automatic password protection. When you encrypt a file: +- It becomes password-protected automatically (same password for both encryption and browser access) +- Users must authenticate in the browser before viewing +- File is decrypted and displayed with full markdown support (code highlighting, tables, etc.) +- File can also be decrypted from CLI for offline access or backups +- Only encrypted files (`.md.enc`) support password protection; regular `.md` files are always public + +## Encryption Details + +- **Algorithm**: Fernet (symmetric encryption based on AES-128) +- **Key Derivation**: PBKDF2 with SHA256, 100,000 iterations +- **Fixed Salt**: `markmywords_salt` (enables consistent key derivation across sessions) +- **File Format**: Encrypted files use `.md.enc` extension + +## Security Features + +- **Bcrypt Hashing**: Passwords are hashed using bcrypt for secure storage +- **Session Management**: Once authenticated in browser, users remain authenticated during their session +- **File Permissions**: Password database stored with restricted permissions (0600) +- **No Plain Text**: Passwords never stored in plain text (only bcrypt hashes) +- **HTTPS Ready**: Works seamlessly with HTTPS/SSL in production +- **Same Password Model**: One password serves both encryption key derivation and browser authentication + +## Configuration + +### Environment Variables + +```bash +# REQUIRED for production - strong secret key for Flask sessions +export FLASK_SECRET_KEY="your-very-secure-random-key" +``` + +These variables should be set in your Docker environment or `.env` file. + +## Quick Start + +### Encrypt a File + +```bash +# Encrypt a markdown file with a password +# Automatically sets password protection - if encrypted, it requires a password +python manage_content.py encrypt content/secret.md "mypassword" + +# This creates: content/secret.md.enc +# The original file is deleted +# The password is hashed and stored for browser access +``` + +### Decrypt a File from CLI + +```bash +# Decrypt and view a file (prints to stdout) +python manage_content.py decrypt content/secret.md.enc "mypassword" + +# Save to a new file +python manage_content.py decrypt content/secret.md.enc "mypassword" > secret_decrypted.md +``` + +### View Encrypted Files in Browser + +1. Navigate to the file in the browser +2. You'll be prompted for the password (same password used for encryption) +3. Enter the password and the file will be decrypted and displayed with full markdown support + +## Using the CLI Tool + +### Encrypt a File + +```bash +python manage_content.py encrypt + +# Example: +python manage_content.py encrypt content/docs/secret-guide.md "secure_password_123" +``` + +**Result**: +- Original file is deleted +- New encrypted file created: `content/docs/secret-guide.md.enc` +- Password hashed and stored in config (required for browser access) +- Decryption key is derived from the password +- Both encryption and browser authentication use the same password +- File is NOT viewable in browser without password + +### Decrypt a File + +```bash +# Print decrypted content to stdout +python manage_content.py decrypt + +# Example: +python manage_content.py decrypt content/docs/secret-guide.md.enc "secure_password_123" + +# Save to a file +python manage_content.py decrypt content/docs/secret-guide.md.enc "secure_password_123" > decrypted.md +``` + +### Decrypt and Display as HTML + +```bash +# Show formatted markdown (requires markdown library) +python manage_content.py view content/docs/secret-guide.md.enc "secure_password_123" +``` + +### List Encrypted Files + +```bash +# List all encrypted files +python manage_content.py list + +# List encrypted files in a directory +python manage_content.py list content/docs/ +``` + +### Check File Status + +```bash +# Check if a file is encrypted and protected +python manage_content.py status content/secret.md.enc +``` + +## Workflow: Encryption Always Requires Password + +When you encrypt a file, password protection is automatic: + +```bash +# Single command encrypts file AND sets password protection +python manage_content.py encrypt content/secret.md "mypassword" + +# When viewing in browser: +# - User sees password prompt (required) +# - User enters "mypassword" +# - Password is validated against stored hash +# - File is decrypted and rendered as HTML +``` + +**Design principle**: If a file is encrypted, it must be password-protected. This ensures encrypted content is never readable without authentication. + +## Optional: Remove Password Requirement + +If you want to make an encrypted file publicly viewable (while staying encrypted on disk): + +```bash +# Unprotect - removes password requirement but keeps encryption +python manage_content.py unprotect content/secret.md.enc + +# Now the file is viewable in browser without password +# But it's still encrypted in the filesystem +# You can re-protect it later by encrypting again with new password +``` + +## Security Considerations + +### Password Requirements +- Minimum 4 characters +- Should be strong and unique for sensitive content +- The same password is used for both encryption and key derivation + +### File Access Control +- Encrypted files require password authentication to view in browser +- Encrypted content is never readable without the password +- Password database is stored with restricted permissions (0600) +- Session-based authentication persists only for the current browser session + +### Key Derivation +- Fixed salt is used for consistency (not random per file) +- This allows decryption on any machine with the password +- PBKDF2 with 100,000 iterations provides strong key derivation +- Should be secure for typical use cases; for extremely sensitive data, consider additional measures + +### Recommendations +- Use HTTPS in production to prevent password interception +- Use strong, unique passwords for sensitive content +- Combine encryption + password protection for critical files +- Regularly backup encrypted content with passwords stored securely +- Consider using environment variables for passwords in automated scripts + +## Programmatic Usage + +### Python API + +```python +from app import encrypt_file_content, decrypt_file_content + +# Encrypt content +password = "mypassword" +original_content = "# My Secret Document\n\nThis is secret." +encrypted = encrypt_file_content(original_content, password) + +# Save encrypted content +with open("secret.md.enc", "w") as f: + f.write(encrypted) + +# Decrypt content +with open("secret.md.enc", "r") as f: + encrypted_content = f.read() +decrypted = decrypt_file_content(encrypted_content, password) +print(decrypted) # "# My Secret Document\n\nThis is secret." +``` + +### Key Derivation Details + +```python +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2 +from cryptography.hazmat.backends import default_backend +import base64 + +def derive_encryption_key(password): + salt = b'markmywords_salt' # Fixed salt + kdf = PBKDF2( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(password.encode())) + return key + +# Same password always produces same key (due to fixed salt) +key1 = derive_encryption_key("mypassword") +key2 = derive_encryption_key("mypassword") +assert key1 == key2 # True +``` + +## Troubleshooting + +### "Failed to decrypt" error +- Verify the password is correct +- Ensure the file is actually encrypted (has .md.enc extension) +- Check that the file hasn't been corrupted + +### Encrypted files not appearing in search +- Encrypted files are excluded from the search index for security +- They only appear when accessed directly with proper authentication +- Once authenticated in browser, they display with full markdown support + +### Browser shows incomplete markdown +- Make sure authentication succeeded (file should have rendered) +- Check browser console for errors +- Verify the password was entered correctly + +### Decryption works CLI but fails in browser +- Browser authentication and decryption share the same password +- Session may have expired; try re-authenticating +- Check that file protection is set up correctly + +## File Management Workflow + +### Recommended Process + +1. **Create and edit** markdown files in `content/` directory +2. **Test** locally: `python manage_content.py view content/file.md` +3. **Encrypt** when ready: `python manage_content.py encrypt content/file.md "password"` + - Automatically sets password protection for browser access +4. **Deploy** via Docker: `docker-compose up -d` +5. **Access** in browser: File requires password to view; all markdown features work after authentication + +### Backup Workflow + +```bash +# Export decrypted version for backup +python manage_content.py decrypt content/secret.md.enc "password" > backup/secret_backup.md + +# Store password securely (not in version control) +echo "password" > backup/secret_password.txt # Secure this file! +chmod 600 backup/secret_password.txt +``` + +## Environment Variables + +```bash +# Enable/disable encryption feature (default: true) +export ENCRYPTION_ENABLED="true" +``` + +All encryption runs with the selected interpreter's cryptography library. Ensure `cryptography>=41.0.7` is installed. diff --git a/PASSWORD_PROTECTION.md b/PASSWORD_PROTECTION.md new file mode 100644 index 0000000..fbb4b4b --- /dev/null +++ b/PASSWORD_PROTECTION.md @@ -0,0 +1,206 @@ +# Password Protection for markmywords + +This document describes the password protection feature for encrypted markdown files in markmywords. + +## Overview + +When you encrypt a file with a password, it is automatically password-protected. Users must authenticate with the password before viewing the content in a browser. + +**Important**: Only encrypted files (`.md.enc`) support password protection. Regular markdown files (`.md`) are always publicly viewable. + +## Security Features + +- **Bcrypt Hashing**: Passwords are hashed using bcrypt, a secure password hashing algorithm +- **Session Management**: Once authenticated, users remain authenticated for that file in their session +- **File Permissions**: Password database is stored with restricted file permissions (0600) +- **No Plain Text**: Passwords are never stored in plain text +- **HTTPS Ready**: Works seamlessly with HTTPS/SSL +- **Encryption Compatible**: Works with encrypted files for defense-in-depth security + +## Configuration + +### Environment Variables + +```bash +# Set a strong secret key for Flask sessions (REQUIRED for production) +export FLASK_SECRET_KEY="your-very-secure-random-key" + +# Optional: Set master admin password for programmatic file operations +export MARKMYWORDS_ADMIN_PASSWORD="your-admin-password" +``` + +## Using the CLI Tool + +The `manage_content.py` script provides encryption and password protection management. + +### Encrypt a File (Automatically Protected) + +```bash +python manage_content.py encrypt + +# Example: +python manage_content.py encrypt content/docs/secret-guide.md "mypassword123" +``` + +This encrypts the file and automatically sets password protection. The same password is used for both encryption and browser authentication. + +### Remove Password Requirement (Keep Encryption) + +```bash +python manage_content.py unprotect + +# Example: +python manage_content.py unprotect content/docs/secret-guide.md.enc + +# File stays encrypted but no password prompt in browser +``` + +### List Protected Files + +```bash +# List all encrypted/protected files +python manage_content.py list + +# List encrypted files in a directory +python manage_content.py list content/docs/ +``` + +### Check Protection Status + +```bash +python manage_content.py status + +# Example: +python manage_content.py status content/docs/secret-guide.md.enc + +# Output shows: Encrypted: Yes, Protected: Yes +``` + +## Encrypted Files with Automatic Protection + +When you encrypt a file, password protection is automatic: + +```bash +# Encrypt a file - automatically sets password protection +python manage_content.py encrypt content/secret.md "mypassword" + +# In browser: +# 1. User navigates to file +# 2. User sees password prompt (same password as encryption) +# 3. User enters password +# 4. File is decrypted and displayed with full markdown support +``` + +The same password is used for both encryption and browser authentication. + +## Using the API + +### Authenticate for an Encrypted File + +```bash +curl -X POST http://localhost:5000/api/auth/docs/secret-guide.md.enc \ + -d "password=mypassword123" + +# Response: +# {"success": true, "redirect": "/view/docs/secret-guide.md.enc"} +``` + +After successful authentication, the password is stored in the session and used to decrypt the file for display. + +## Password Database + +Passwords are stored in `/config/page_passwords.json` with the following structure: + +```json +{ + "content/path/to/file.md": { + "protected": true, + "password_hash": "$2b$12$...", + "created_at": "2024-01-15T10:30:00.000000" + }, + "content/path/to/encrypted.md.enc": { + "encrypted": true, + "protected": true, + "password_hash": "$2b$12$...", + "encrypted_at": "2024-01-15T10:30:00.000000", + "created_at": "2024-01-15T10:30:00.000000" + } +} +``` + +**Important**: This file should be backed up and kept secure. The password hashes cannot be reversed, but the file itself should have restricted access. + +## How It Works + +1. **User Requests File**: User navigates to a protected file +2. **Protection Check**: Application checks if file is password protected +3. **Password Prompt**: If protected and user not authenticated, show password form +4. **Authentication**: User enters password +5. **Verification**: Password is checked against the stored bcrypt hash +6. **Session Storage**: On success, file authentication is stored in user's session +7. **Content Display**: Protected file content is displayed +8. **Session Expiry**: Authentication expires when user's session expires (typically when browser is closed) + +## Use Cases + +- **Sensitive Documentation**: Encrypt internal documentation +- **Private Notes**: Keep personal notes encrypted and password-protected +- **Confidential Information**: Encrypt security guidelines, API keys, or business secrets +- **Draft Content**: Encrypt unfinished or embargoed content with password protection +- **Temporary Public Access**: Use `unprotect` to share encrypted content without password requirement + +## Best Practices + +1. **Strong Passwords**: Use passwords with at least 8 characters including mixed case and numbers +2. **Unique Passwords**: Use different passwords for different files +3. **Regular Backups**: Backup the password database +4. **Secure Secret Key**: Set a strong `FLASK_SECRET_KEY` environment variable +5. **HTTPS**: Always use HTTPS in production +6. **Share Carefully**: Share passwords through secure channels only +7. **Monitor Access**: Check logs for authentication attempts + +## Troubleshooting + +### "Invalid password" error repeatedly + +- Check that the password is exactly correct (case-sensitive) +- Verify the file is actually protected: `python manage_content.py status content/path/to/file.md` + +### Session expires too quickly + +- The session expires based on Flask's session cookie settings +- For persistent authentication longer than the browser session, consider implementing "Remember Me" functionality + +### Lost access to protected file + +- If you lose the password, you must unprotect the file using the CLI: + ```bash + python manage_content.py unprotect content/path/to/file.md + ``` +- Then set a new password if desired + +### Password database corruption + +- If `/config/page_passwords.json` becomes corrupted, delete it and recreate protections: + ```bash + rm /config/page_passwords.json + python manage_content.py protect content/path/to/file.md + ``` + +## Security Considerations + +- **Never** share passwords via email or unencrypted channels +- **Always** use HTTPS in production +- **Regularly** audit which files are protected +- **Securely** delete the password database when no longer needed +- **Use** strong, random passwords generated by a password manager +- **Backup** the password database in a secure location + +## Future Enhancements + +Potential future features: +- Per-user authentication and roles +- IP whitelisting for protected files +- Audit logging of access attempts +- Time-limited access links +- Integration with external authentication systems (LDAP, OAuth, SAML) diff --git a/README.md b/README.md index 31e7e9d..064630e 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,48 @@ # markMyWords -A self-hostable Docker application that automatically pulls markdown repositories at user-defined intervals and displays them as searchable HTML pages. +A self-hostable Docker application for hosting and searching markdown files with optional password protection and encryption. ## Features - 🚀 **Self-hosted**: Run entirely on your own infrastructure - 📦 **Docker-ready**: Simple Docker Compose setup -- 📅 **Scheduled Pulls**: Configure cron-style schedules for each repository - 🔍 **Full-text Search**: Search across all markdown files - 📄 **Markdown Rendering**: Beautiful HTML rendering with syntax highlighting -- ⚡ **Zero Configuration**: Works out of the box with configuration file +- 🔐 **Encryption**: Optional password-based file encryption +- 🛡️ **Access Control**: Password-protect individual files +- ⚡ **Simple Setup**: Just add files to the `content/` directory ## Quick Start -### 1. Clone and Configure +### 1. Setup Directory ```bash cd markmywords -# Create the config directory -mkdir -p config -# Copy the example configuration -cp config/repositories.json.example config/repositories.json - -# Edit the configuration with your repositories -nano config/repositories.json +# Create necessary directories +mkdir -p content config data ``` -### 2. Configure Repositories +### 2. Add Markdown Files -Edit `config/repositories.json` to specify which repositories to pull: +Copy your markdown files to the `content/` directory: -```json -{ - "repo-name": { - "url": "https://github.com/username/repo.git", - "enabled": true, - "schedule": "0 */6 * * *" - } -} +```bash +cp /path/to/your/markdown/files/* ./content/ ``` -**Schedule Format**: Standard cron expression (minute, hour, day of month, month, day of week) +Organize with subdirectories as needed: -Common schedules: -- `0 * * * *` - Every hour -- `0 */6 * * *` - Every 6 hours (default) -- `0 9 * * *` - Daily at 9 AM -- `0 0 * * 0` - Weekly (Sunday at midnight) +``` +content/ +├── README.md +├── docs/ +│ ├── guide.md +│ └── tutorial.md +└── blog/ + ├── post1.md + └── post2.md +``` ### 3. Run with Docker Compose @@ -60,43 +55,109 @@ The application will be available at `http://localhost:5000` ### 4. Access the Application - **Main Page**: http://localhost:5000 - - View all repositories and their markdown files - - Search across all files + - Browse all files organized by directory + - Search across all markdown content -- **View File**: Click on any markdown file to view it as HTML +- **View File**: Click on any markdown file to view as HTML +- **Encrypted Files**: Files with `.md.enc` extension require password authentication +- **Search**: Full-text search across all unencrypted files -- **API Endpoints**: - - `/api/status` - Application status and repository info - - `/api/search?q=` - Search markdown files +### 5. (Optional) Encrypt Files -## Configuration +Use the management script to encrypt sensitive files: -### repositories.json +```bash +# Encrypt a file with a password +# File is automatically encrypted AND password-protected +python manage_content.py encrypt content/secret.md "mypassword" -```json -{ - "repo-name": { - "url": "https://github.com/username/repo.git", - "enabled": true, - "schedule": "0 */6 * * *" - } -} +# In browser: User enters password → file is decrypted and displayed +# Same password used for both encryption and browser access ``` -**Fields**: -- `url`: Git repository URL (HTTPS recommended, SSH with proper key mounting) -- `enabled`: Boolean to enable/disable this repository -- `schedule`: Cron expression for pull schedule +See [ENCRYPTION.md](ENCRYPTION.md) for more details. + +## File Management + +### Adding Content + +Simply add markdown files to `./content/` and they appear automatically: + +```bash +echo "# New Document" > content/new-file.md +``` + +The search index updates automatically when the app starts. + +### Directory Structure + +Files are organized hierarchically in the UI: + +``` +content/ +├── index.md → Shows as "index" in root +├── docs/ +│ ├── guide.md → Shows as "docs > guide" +│ └── images/ → Images in subdirectories +│ └── diagram.png +└── archive/ + └── old.md → Shows as "archive > old" +``` + +### Supported Formats + +- **Markdown**: `.md`, `.markdown` +- **Encrypted Markdown**: `.md.enc` (password-protected, viewed in browser) +- **Images**: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp` (referenced in markdown) +- **Relative Paths**: Images referenced with relative paths work correctly + +## Configuration ### Docker Compose Configuration The `docker-compose.yml` file manages: - Port mapping (default 5000) -- Volume management for configuration and data +- Volume mounts for content and data - Restart policies -- Networking +- Encryption settings -To modify port or other settings, edit `docker-compose.yml`: +To modify settings, edit `docker-compose.yml`: + +```yaml +services: + markmywords: + ports: + - "5000:5000" # Change port here if needed + volumes: + - ./content:/content:rw # Your markdown files + - ./config:/config:rw # Password/protection config + - ./data:/data:rw # Search index + environment: + ENCRYPTION_ENABLED: "true" # Enable/disable encryption +``` + +### Environment Variables + +```bash +# Flask secret key (REQUIRED for production) +export FLASK_SECRET_KEY="your-secure-random-key-here" + +# Admin password for API operations (optional) +export MARKMYWORDS_ADMIN_PASSWORD="admin-password" + +# Enable/disable file encryption feature +export ENCRYPTION_ENABLED="true" +``` + +## API Endpoints + +- `GET /` - Main page with file browser +- `GET /view/` - View markdown file as HTML +- `GET /image/` - Serve images from content +- `GET /api/search?q=` - Full-text search +- `GET /api/status` - Application status +- `POST /api/auth/` - Authenticate for protected file +- `POST /api/protect/` - Protect/unprotect files (admin only) ```yaml ports: diff --git a/app.py b/app.py index 5ba875d..ae1e934 100644 --- a/app.py +++ b/app.py @@ -1,38 +1,125 @@ #!/usr/bin/env python3 """ -markmywords - Self-hosted markdown repository viewer with search +markmywords - Self-hosted markdown viewer with search and encryption """ import os -import subprocess import json from pathlib import Path from datetime import datetime -from flask import Flask, render_template, request, jsonify, send_file -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger +from flask import Flask, render_template, request, jsonify, send_file, session, redirect, url_for +from werkzeug.security import generate_password_hash, check_password_hash import markdown import logging import re from whoosh.index import create_in, open_dir from whoosh.fields import Schema, TEXT, ID from whoosh.qparser import QueryParser +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend +import base64 app = Flask(__name__) +app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') # Configuration -CONFIG_FILE = "/config/repositories.json" -REPOS_DIR = "/data/repos" +CONTENT_DIR = "/content" INDEX_DIR = "/data/search_index" +PASSWORD_FILE = "/config/page_passwords.json" MARKDOWN_EXTENSIONS = ['.md', '.markdown'] +ENCRYPTION_ENABLED = os.environ.get('ENCRYPTION_ENABLED', 'true').lower() == 'true' # Setup logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Ensure directories exist -os.makedirs(REPOS_DIR, exist_ok=True) +os.makedirs(CONTENT_DIR, exist_ok=True) os.makedirs(INDEX_DIR, exist_ok=True) +os.makedirs(os.path.dirname(PASSWORD_FILE), exist_ok=True) + +def load_passwords(): + """Load password database from file.""" + if not os.path.exists(PASSWORD_FILE): + return {} + try: + with open(PASSWORD_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Error loading passwords: {e}") + return {} + +def save_passwords(passwords): + """Save password database to file.""" + try: + os.makedirs(os.path.dirname(PASSWORD_FILE), exist_ok=True) + with open(PASSWORD_FILE, 'w') as f: + json.dump(passwords, f, indent=2) + os.chmod(PASSWORD_FILE, 0o600) # Restrict file permissions + return True + except Exception as e: + logger.error(f"Error saving passwords: {e}") + return False + +def derive_encryption_key(password): + """Derive an encryption key from a password.""" + salt = b'markmywords_salt' # Fixed salt for consistency + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(password.encode())) + return key + +def encrypt_file_content(content, password): + """Encrypt file content using password-derived key.""" + try: + key = derive_encryption_key(password) + f = Fernet(key) + encrypted = f.encrypt(content.encode('utf-8')) + return encrypted.decode('utf-8') + except Exception as e: + logger.error(f"Error encrypting content: {e}") + return None + +def decrypt_file_content(encrypted_content, password): + """Decrypt file content using password-derived key.""" + try: + key = derive_encryption_key(password) + f = Fernet(key) + decrypted = f.decrypt(encrypted_content.encode('utf-8')) + return decrypted.decode('utf-8') + except Exception as e: + logger.error(f"Error decrypting content: {e}") + return None + +def is_encrypted_file(filepath): + """Check if a file is encrypted (has .enc extension).""" + return filepath.endswith('.enc') + +def get_file_key(filepath): + """Generate a consistent key for a file.""" + return filepath + +def is_file_protected(filepath): + """Check if a file is password protected.""" + passwords = load_passwords() + file_key = get_file_key(filepath) + return passwords.get(file_key, {}).get('protected', False) + +def is_authenticated(filepath): + """Check if user is authenticated for a protected file.""" + if not is_file_protected(filepath): + return True # Not protected, so allowed + + file_key = get_file_key(filepath) + authenticated_files = session.get('authenticated_files', {}) + return authenticated_files.get(file_key, False) # Initialize search index def create_search_index(): @@ -55,9 +142,9 @@ def is_hidden_path(file_path): # Check all parts of the path including the filename return any(part.startswith('.') for part in path_obj.parts) -def rewrite_image_paths(md_content, repo, filepath): +def rewrite_image_paths(md_content, filepath): """Rewrite relative image paths to be served by Flask.""" - # Get the directory of the current file (relative to repo root) + # Get the directory of the current file file_dir = os.path.dirname(filepath) # Pattern to match markdown image syntax: ![alt](path) @@ -71,7 +158,7 @@ def rewrite_image_paths(md_content, repo, filepath): # Resolve relative paths if img_path.startswith('/'): - # Absolute path from repo root + # Absolute path from content root resolved_path = img_path.lstrip('/') else: # Relative path - resolve it relative to the file's directory @@ -82,7 +169,7 @@ def rewrite_image_paths(md_content, repo, filepath): # Ensure forward slashes for URL resolved_path = resolved_path.replace(os.sep, '/') - image_url = f"/image/{repo}/{resolved_path}" + image_url = f"/image/{resolved_path}" return f"![{alt_text}]({image_url})" @@ -90,26 +177,37 @@ def rewrite_image_paths(md_content, repo, filepath): md_content = re.sub(r'!\[([^\]]*)\]\(([^\)]+)\)', replace_image_path, md_content) return md_content -def build_directory_tree(repo_path): +def build_directory_tree(): """Build a hierarchical tree of markdown files organized by directory.""" tree = {} - if not os.path.exists(repo_path): - logger.warning(f"Repo path does not exist: {repo_path}") + if not os.path.exists(CONTENT_DIR): + logger.warning(f"Content directory does not exist: {CONTENT_DIR}") return tree file_count = 0 - for md_file in Path(repo_path).rglob('*'): - if md_file.suffix.lower() not in MARKDOWN_EXTENSIONS: + # Include both .md and .md.enc files + for md_file in Path(CONTENT_DIR).rglob('*'): + is_md = md_file.suffix.lower() in MARKDOWN_EXTENSIONS + is_enc = md_file.suffix == '.enc' and md_file.stem.endswith('.md') + + if not (is_md or is_enc): continue if is_hidden_path(md_file): logger.debug(f"Skipping hidden path: {md_file}") continue file_count += 1 - rel_path = md_file.relative_to(repo_path) + rel_path = md_file.relative_to(CONTENT_DIR) parts = rel_path.parts[:-1] # All parts except filename - filename = md_file.stem # Name without extension + + # For encrypted files, show without .enc extension + if is_enc: + filename = md_file.stem # Removes .enc, keeping the .md + if filename.endswith('.md'): + filename = filename[:-3] # Remove .md to show clean name + else: + filename = md_file.stem # Name without extension logger.debug(f"Adding to tree: {rel_path} (name: {filename})") @@ -125,168 +223,73 @@ def build_directory_tree(repo_path): current['_files'] = [] current['_files'].append({ 'name': filename, - 'path': str(rel_path) + 'path': str(rel_path), + 'encrypted': is_enc }) - logger.info(f"Built tree for {repo_path}: found {file_count} markdown files") + logger.info(f"Built tree for {CONTENT_DIR}: found {file_count} markdown files") return tree -def load_repositories(): - """Load repository configuration from file.""" - if not os.path.exists(CONFIG_FILE): - logger.warning(f"Config file not found: {CONFIG_FILE}") - return {} - - try: - with open(CONFIG_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logger.error(f"Error loading config: {e}") - return {} - -def git_pull_repo(repo_name, repo_path): - """Perform a git pull on the specified repository.""" - try: - logger.info(f"Pulling repository: {repo_name}") - result = subprocess.run( - ["git", "pull"], - cwd=repo_path, - capture_output=True, - text=True, - timeout=300 - ) - logger.info(f"Pull result for {repo_name}: {result.stdout}") - if result.returncode != 0: - logger.error(f"Pull error for {repo_name}: {result.stderr}") - return result.returncode == 0 - except subprocess.TimeoutExpired: - logger.error(f"Git pull timeout for {repo_name}") - return False - except Exception as e: - logger.error(f"Error pulling {repo_name}: {e}") - return False - -def clone_or_pull(repo_name, repo_url): - """Clone repository if it doesn't exist, otherwise pull.""" - repo_path = os.path.join(REPOS_DIR, repo_name) - - if not os.path.exists(repo_path): - try: - logger.info(f"Cloning repository: {repo_name} from {repo_url}") - subprocess.run( - ["git", "clone", repo_url, repo_path], - capture_output=True, - text=True, - timeout=300 - ) - logger.info(f"Successfully cloned {repo_name}") - except Exception as e: - logger.error(f"Error cloning {repo_name}: {e}") - return False - else: - return git_pull_repo(repo_name, repo_path) - - return True - def update_search_index(): """Update the search index with all markdown files.""" try: writer = ix.writer() - for repo_dir in Path(REPOS_DIR).iterdir(): - if not repo_dir.is_dir(): + for md_file in Path(CONTENT_DIR).rglob('*'): + if md_file.suffix.lower() not in MARKDOWN_EXTENSIONS and not md_file.suffix == '.enc': + continue + if is_hidden_path(md_file): continue - for md_file in repo_dir.rglob('*'): - if md_file.suffix.lower() in MARKDOWN_EXTENSIONS and not is_hidden_path(md_file): - try: - content = md_file.read_text(encoding='utf-8', errors='ignore') - # Extract title from filename or first heading - title = md_file.stem - rel_path = str(md_file.relative_to(REPOS_DIR)) - - writer.add_document( - path=rel_path, - title=title, - content=content - ) - except Exception as e: - logger.error(f"Error indexing {md_file}: {e}") + try: + # Handle encrypted files + rel_path = str(md_file.relative_to(CONTENT_DIR)) + title = md_file.stem + + if is_encrypted_file(rel_path): + # Skip encrypted files in search index (they need authentication) + logger.debug(f"Skipping encrypted file from search index: {rel_path}") + continue + + content = md_file.read_text(encoding='utf-8', errors='ignore') + writer.add_document( + path=rel_path, + title=title, + content=content + ) + except Exception as e: + logger.error(f"Error indexing {md_file}: {e}") writer.commit() logger.info("Search index updated successfully") except Exception as e: logger.error(f"Error updating search index: {e}") -def scheduled_pull(): - """Perform scheduled pulls of all repositories.""" - logger.info("Starting scheduled repository pull") - repos = load_repositories() - - for repo_name, repo_config in repos.items(): - if not repo_config.get('enabled', True): - continue - - clone_or_pull(repo_name, repo_config['url']) - - # Update search index after pulling - update_search_index() - logger.info("Scheduled pull completed") - -def setup_scheduler(): - """Setup the background scheduler for periodic pulls.""" - scheduler = BackgroundScheduler() - repos = load_repositories() - - for repo_name, repo_config in repos.items(): - if repo_config.get('enabled', True): - cron_schedule = repo_config.get('schedule', '0 */6 * * *') # Default: every 6 hours - try: - scheduler.add_job( - scheduled_pull, - CronTrigger.from_crontab(cron_schedule), - id=f"pull_{repo_name}", - name=f"Pull {repo_name}" - ) - logger.info(f"Scheduled pull for {repo_name}: {cron_schedule}") - except Exception as e: - logger.error(f"Error scheduling {repo_name}: {e}") - - scheduler.start() - return scheduler - # Routes @app.route('/') def index(): - """Display the main page with list of repositories and search.""" - repos = load_repositories() - repo_list = [] + """Display the main page with file navigation and search.""" + file_tree = build_directory_tree() - for repo_name in repos.keys(): - repo_path = os.path.join(REPOS_DIR, repo_name) - file_tree = build_directory_tree(repo_path) - - repo_list.append({ - 'name': repo_name, - 'tree': file_tree - }) - - return render_template('index.html', repositories=repo_list) + return render_template('index.html', repositories=[{ + 'name': 'Content', + 'tree': file_tree + }]) -@app.route('/image//') -def serve_image(repo, filepath): - """Serve images from repository directories.""" +@app.route('/image/') +def serve_image(filepath): + """Serve images from content directory.""" # Security: prevent directory traversal if '..' in filepath or filepath.startswith('/'): return "Invalid path", 400 - file_path = os.path.join(REPOS_DIR, repo, filepath) + file_path = os.path.join(CONTENT_DIR, filepath) - # Ensure the file is within the repo directory + # Ensure the file is within the content directory try: file_path = os.path.realpath(file_path) - repo_path = os.path.realpath(os.path.join(REPOS_DIR, repo)) - if not file_path.startswith(repo_path): + content_path = os.path.realpath(CONTENT_DIR) + if not file_path.startswith(content_path): return "Access denied", 403 except Exception: return "Invalid path", 400 @@ -326,20 +329,33 @@ def search(): logger.error(f"Search error: {e}") return jsonify({'results': [], 'error': str(e)}) -@app.route('/view//') -def view_file(repo, filepath): +@app.route('/view/') +def view_file(filepath): """View a markdown file converted to HTML.""" # Security: prevent directory traversal if '..' in filepath or filepath.startswith('/'): return "Invalid path", 400 - file_path = os.path.join(REPOS_DIR, repo, filepath) + # Determine if looking for encrypted version + enc_filepath = filepath + '.enc' if not filepath.endswith('.enc') else filepath - # Ensure the file is within the repo directory + # Try encrypted file first if it exists + file_path = os.path.join(CONTENT_DIR, enc_filepath) + is_encrypted = False + + if os.path.exists(file_path): + is_encrypted = True + lookup_filepath = enc_filepath + else: + # Fall back to regular file + file_path = os.path.join(CONTENT_DIR, filepath) + lookup_filepath = filepath + + # Ensure the file is within the content directory try: file_path = os.path.realpath(file_path) - repo_path = os.path.realpath(os.path.join(REPOS_DIR, repo)) - if not file_path.startswith(repo_path): + content_path = os.path.realpath(CONTENT_DIR) + if not file_path.startswith(content_path): return "Access denied", 403 except Exception: return "Invalid path", 400 @@ -347,12 +363,38 @@ def view_file(repo, filepath): if not os.path.exists(file_path): return "File not found", 404 + # Check if file is protected and user is authenticated + if is_file_protected(lookup_filepath): + if not is_authenticated(lookup_filepath): + return render_template( + 'password_prompt.html', + filepath=lookup_filepath, + filename=os.path.basename(filepath) + ) + try: - with open(file_path, 'r', encoding='utf-8') as f: - md_content = f.read() + # Read file content + if is_encrypted: + with open(file_path, 'r', encoding='utf-8') as f: + encrypted_content = f.read() + + # Get password from session + file_key = get_file_key(lookup_filepath) + authenticated_files = session.get('authenticated_files', {}) + password = authenticated_files.get(file_key + '_password') + + if not password: + return "Unable to decrypt: password not found in session", 500 + + md_content = decrypt_file_content(encrypted_content, password) + if md_content is None: + return "Failed to decrypt file", 500 + else: + with open(file_path, 'r', encoding='utf-8') as f: + md_content = f.read() # Rewrite image paths to be served by Flask - md_content = rewrite_image_paths(md_content, repo, filepath) + md_content = rewrite_image_paths(md_content, filepath) # Convert markdown to HTML html_content = markdown.markdown( @@ -362,43 +404,141 @@ def view_file(repo, filepath): return render_template( 'view.html', - repo=repo, - filepath=filepath, + filepath=lookup_filepath, content=html_content, - filename=os.path.basename(filepath) + filename=os.path.basename(filepath), + is_protected=is_file_protected(lookup_filepath), + is_encrypted=is_encrypted ) except Exception as e: logger.error(f"Error reading file {file_path}: {e}") return f"Error reading file: {e}", 500 +@app.route('/api/auth/', methods=['POST']) +def authenticate(filepath): + """Authenticate user for a protected file.""" + # Security: prevent directory traversal + if '..' in filepath or filepath.startswith('/'): + return jsonify({'success': False, 'error': 'Invalid path'}), 400 + + password = request.form.get('password', '') + file_key = get_file_key(filepath) + + passwords = load_passwords() + file_data = passwords.get(file_key) + + if not file_data or not file_data.get('protected'): + return jsonify({'success': False, 'error': 'File not protected'}), 400 + + # Check password + if check_password_hash(file_data['password_hash'], password): + # Store in session + if 'authenticated_files' not in session: + session['authenticated_files'] = {} + session['authenticated_files'][file_key] = True + # Store the password for decryption if file is encrypted + if is_encrypted_file(filepath): + session['authenticated_files'][file_key + '_password'] = password + session.modified = True + + logger.info(f"User authenticated for {file_key}") + return jsonify({'success': True, 'redirect': url_for('view_file', filepath=filepath)}) + else: + logger.warning(f"Failed authentication attempt for {file_key}") + return jsonify({'success': False, 'error': 'Invalid password'}), 401 + +@app.route('/api/protect/', methods=['POST']) +def protect_file(filepath): + """Protect or unprotect a file with a password.""" + # This should be restricted to admin users in production + # For now, requires a master password via environment variable + master_password = os.environ.get('MARKMYWORDS_ADMIN_PASSWORD') + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'success': False, 'error': 'Missing authorization'}), 401 + + token = auth_header.split(' ')[1] + if not master_password or token != master_password: + return jsonify({'success': False, 'error': 'Invalid authorization'}), 401 + + # Security: prevent directory traversal + if '..' in filepath or filepath.startswith('/'): + return jsonify({'success': False, 'error': 'Invalid path'}), 400 + + action = request.json.get('action') # 'protect' or 'unprotect' + new_password = request.json.get('password') + encrypt_file = request.json.get('encrypt_file', False) # Whether to encrypt the file + + file_key = get_file_key(filepath) + passwords = load_passwords() + + if action == 'protect': + if not new_password: + return jsonify({'success': False, 'error': 'Password required'}), 400 + + passwords[file_key] = { + 'protected': True, + 'password_hash': generate_password_hash(new_password), + 'created_at': datetime.now().isoformat(), + 'encrypted': encrypt_file + } + + # If encryption is requested, encrypt the file + if encrypt_file and ENCRYPTION_ENABLED: + try: + file_path = os.path.join(CONTENT_DIR, filepath) + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + original_content = f.read() + + encrypted_content = encrypt_file_content(original_content, new_password) + if encrypted_content: + # Save encrypted file with .enc extension + enc_file_path = file_path + '.enc' + with open(enc_file_path, 'w', encoding='utf-8') as f: + f.write(encrypted_content) + # Delete original unencrypted file + os.remove(file_path) + logger.info(f"Encrypted file: {filepath}") + except Exception as e: + logger.error(f"Error encrypting file {filepath}: {e}") + return jsonify({'success': False, 'error': f"Failed to encrypt file: {e}"}), 500 + + logger.info(f"Protected file: {file_key}") + elif action == 'unprotect': + if file_key in passwords: + del passwords[file_key] + logger.info(f"Unprotected file: {file_key}") + else: + return jsonify({'success': False, 'error': 'Invalid action'}), 400 + + if save_passwords(passwords): + return jsonify({'success': True, 'message': f"File {action}ed successfully"}) + else: + return jsonify({'success': False, 'error': 'Failed to save password'}), 500 + @app.route('/api/status') def status(): """Return application status.""" - repos = load_repositories() - repo_status = {} - - for repo_name in repos.keys(): - repo_path = os.path.join(REPOS_DIR, repo_name) - repo_status[repo_name] = { - 'cloned': os.path.exists(repo_path), - 'last_modified': datetime.fromtimestamp( - os.path.getmtime(repo_path) - ).isoformat() if os.path.exists(repo_path) else None - } + content_status = { + 'exists': os.path.exists(CONTENT_DIR), + 'last_modified': datetime.fromtimestamp( + os.path.getmtime(CONTENT_DIR) + ).isoformat() if os.path.exists(CONTENT_DIR) else None + } return jsonify({ 'status': 'running', - 'repositories': repo_status, + 'content': content_status, + 'encryption_enabled': ENCRYPTION_ENABLED, 'timestamp': datetime.now().isoformat() }) if __name__ == '__main__': - # Perform initial pull and index + # Perform initial index build logger.info("Initializing markmywords") - scheduled_pull() - - # Setup scheduler - scheduler = setup_scheduler() + update_search_index() # Start Flask app app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1d99b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + markmywords: + build: . + container_name: markmywords + ports: + - "5000:5000" + volumes: + # Configuration directory with password file + - ./config:/config:rw + # Content directory for markdown files (can be encrypted) + - ./content:/content:rw + # Data directory for search index + - ./data:/data:rw + environment: + # Set Flask environment + FLASK_ENV: production + # Enable file encryption + ENCRYPTION_ENABLED: "true" + # Python unbuffered output for real-time logs + PYTHONUNBUFFERED: 1 + restart: unless-stopped diff --git a/manage_content.py b/manage_content.py new file mode 100644 index 0000000..7a7b4e4 --- /dev/null +++ b/manage_content.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +Utility script to manage content encryption for markmywords. + +Usage: + python manage_content.py encrypt + python manage_content.py decrypt + python manage_content.py view + python manage_content.py unprotect + python manage_content.py list [directory] + python manage_content.py status + +Notes: + - encrypt: Creates .md.enc file with automatic password protection + - Password protects the file AND encrypts it (same password for both) + - unprotect: Removes password protection (file stays encrypted, viewable in browser) + - Same password used for both encryption and browser authentication +""" + +import json +import os +import sys +from pathlib import Path +from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend +import base64 +import markdown + +PASSWORD_FILE = "config/page_passwords.json" +CONTENT_DIR = "content" +MARKDOWN_EXTENSIONS = ['.md', '.markdown'] + + +def derive_encryption_key(password): + """Derive an encryption key from a password.""" + salt = b'markmywords_salt' # Fixed salt for consistency + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(password.encode())) + return key + + +def encrypt_file_content(content, password): + """Encrypt file content using password-derived key.""" + try: + key = derive_encryption_key(password) + f = Fernet(key) + encrypted = f.encrypt(content.encode('utf-8')) + return encrypted.decode('utf-8') + except Exception as e: + print(f"Error encrypting content: {e}", file=sys.stderr) + return None + + +def decrypt_file_content(encrypted_content, password): + """Decrypt file content using password-derived key.""" + try: + key = derive_encryption_key(password) + f = Fernet(key) + decrypted = f.decrypt(encrypted_content.encode('utf-8')) + return decrypted.decode('utf-8') + except Exception as e: + print(f"Error decrypting content: {e}", file=sys.stderr) + return None + + +def is_encrypted_file(filepath): + """Check if a file is encrypted (has .enc extension).""" + return filepath.endswith('.enc') + + +def load_passwords(): + """Load password database from file.""" + if not os.path.exists(PASSWORD_FILE): + return {} + try: + with open(PASSWORD_FILE, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Error loading passwords: {e}", file=sys.stderr) + return {} + + +def save_passwords(passwords): + """Save password database to file.""" + try: + os.makedirs(os.path.dirname(PASSWORD_FILE), exist_ok=True) + with open(PASSWORD_FILE, 'w') as f: + json.dump(passwords, f, indent=2) + os.chmod(PASSWORD_FILE, 0o600) # Restrict file permissions + return True + except Exception as e: + print(f"Error saving passwords: {e}", file=sys.stderr) + return False + + +def get_file_key(filepath): + """Generate a consistent key for a file.""" + return filepath + + +def validate_password(password): + """Validate password strength.""" + if not password: + print("Error: Password cannot be empty", file=sys.stderr) + return False + + if len(password) < 4: + print("Error: Password should be at least 4 characters", file=sys.stderr) + return False + + return True + + +def encrypt(filepath, password): + """Encrypt a markdown file with automatic password protection.""" + if not validate_password(password): + return False + + # Normalize path + if not filepath.startswith(CONTENT_DIR): + filepath = os.path.join(CONTENT_DIR, filepath) + + # Remove .enc if present in input + if filepath.endswith('.enc'): + filepath = filepath[:-4] + + if not os.path.exists(filepath): + print(f"✗ File not found: {filepath}", file=sys.stderr) + return False + + # Check file extension + if not any(filepath.lower().endswith(ext) for ext in MARKDOWN_EXTENSIONS): + print(f"✗ Not a markdown file: {filepath}", file=sys.stderr) + return False + + try: + # Read original content + with open(filepath, 'r', encoding='utf-8') as f: + original_content = f.read() + + # Encrypt content + encrypted_content = encrypt_file_content(original_content, password) + if encrypted_content is None: + return False + + # Write encrypted file + enc_filepath = filepath + '.enc' + with open(enc_filepath, 'w', encoding='utf-8') as f: + f.write(encrypted_content) + + # Delete original + os.remove(filepath) + + # Update password database with encryption AND protection (same password) + file_key = get_file_key(enc_filepath) + passwords = load_passwords() + + passwords[file_key] = { + 'encrypted': True, + 'encrypted_at': datetime.now().isoformat(), + 'protected': True, + 'password_hash': generate_password_hash(password), + 'created_at': datetime.now().isoformat() + } + + save_passwords(passwords) + + display_path = enc_filepath.replace(CONTENT_DIR + '/', '') + print(f"✓ Encrypted & protected: {filepath} → {display_path}") + return True + + except Exception as e: + print(f"✗ Error encrypting {filepath}: {e}", file=sys.stderr) + return False + + +def decrypt(filepath, password): + """Decrypt a file and print content.""" + # Normalize path + if not filepath.startswith(CONTENT_DIR): + filepath = os.path.join(CONTENT_DIR, filepath) + + # Ensure .enc extension + if not filepath.endswith('.enc'): + filepath = filepath + '.enc' + + if not os.path.exists(filepath): + print(f"✗ File not found: {filepath}", file=sys.stderr) + return False + + try: + # Read encrypted content + with open(filepath, 'r', encoding='utf-8') as f: + encrypted_content = f.read() + + # Decrypt + decrypted_content = decrypt_file_content(encrypted_content, password) + if decrypted_content is None: + print(f"✗ Failed to decrypt {filepath}", file=sys.stderr) + return False + + # Print to stdout + sys.stdout.write(decrypted_content) + return True + + except Exception as e: + print(f"✗ Error reading {filepath}: {e}", file=sys.stderr) + return False + + +def view(filepath, password): + """Decrypt and display file as formatted markdown.""" + # Normalize path + if not filepath.startswith(CONTENT_DIR): + filepath = os.path.join(CONTENT_DIR, filepath) + + # Ensure .enc extension + if not filepath.endswith('.enc'): + filepath = filepath + '.enc' + + if not os.path.exists(filepath): + print(f"✗ File not found: {filepath}", file=sys.stderr) + return False + + try: + # Read encrypted content + with open(filepath, 'r', encoding='utf-8') as f: + encrypted_content = f.read() + + # Decrypt + decrypted_content = decrypt_file_content(encrypted_content, password) + if decrypted_content is None: + print(f"✗ Failed to decrypt {filepath}", file=sys.stderr) + return False + + # Convert to HTML + html_content = markdown.markdown( + decrypted_content, + extensions=['extra', 'codehilite', 'toc'] + ) + + # Print HTML + print("") + print("") + print("") + print("") + print("") + print("") + print("") + print("") + print(html_content) + print("") + print("") + + return True + + except Exception as e: + print(f"✗ Error processing {filepath}: {e}", file=sys.stderr) + return False + + +def unprotect(filepath): + """Remove password protection from a file (file stays encrypted, becomes publicly viewable).""" + # Normalize path + if not filepath.startswith(CONTENT_DIR): + filepath = os.path.join(CONTENT_DIR, filepath) + + file_key = get_file_key(filepath) + passwords = load_passwords() + + if file_key not in passwords: + print(f"✗ File not protected: {file_key}", file=sys.stderr) + return False + + # Only remove protection, keep encryption metadata + if 'protected' in passwords[file_key]: + del passwords[file_key]['protected'] + if 'password_hash' in passwords[file_key]: + del passwords[file_key]['password_hash'] + if 'created_at' in passwords[file_key]: + del passwords[file_key]['created_at'] + + if save_passwords(passwords): + display_path = filepath.replace(CONTENT_DIR + '/', '') + print(f"✓ Unprotected: {display_path} (file stays encrypted, viewable in browser)") + return True + else: + print(f"✗ Failed to unprotect: {file_key}", file=sys.stderr) + return False + + +def list_files(directory=None): + """List all protected and encrypted files.""" + passwords = load_passwords() + + if directory: + if not directory.startswith(CONTENT_DIR): + directory = os.path.join(CONTENT_DIR, directory) + directory = os.path.normpath(directory) + else: + directory = CONTENT_DIR + + protected_files = [] + encrypted_files = [] + + for filepath in Path(CONTENT_DIR).rglob('*'): + if filepath.is_file(): + rel_path = filepath.relative_to(CONTENT_DIR) + file_key = get_file_key(str(rel_path)) + + if directory != CONTENT_DIR: + dir_key = get_file_key(directory.replace(CONTENT_DIR + '/', '')) + if not str(rel_path).startswith(dir_key.replace(CONTENT_DIR + '/', '')): + continue + + if file_key in passwords: + data = passwords[file_key] + if data.get('protected'): + protected_files.append((str(rel_path), data.get('created_at', 'Unknown'))) + + if filepath.suffix == '.enc': + encrypted_files.append(str(rel_path)) + + if not protected_files and not encrypted_files: + if directory == CONTENT_DIR: + print("No protected or encrypted files found.") + else: + print(f"No protected or encrypted files found in {directory}") + return True + + if protected_files: + print("\nProtected Files:") + print(f"{'File':<50} {'Created':<30}") + print("-" * 80) + for filepath, created_at in sorted(protected_files): + try: + dt = datetime.fromisoformat(created_at) + created_str = dt.strftime('%Y-%m-%d %H:%M:%S') + except: + created_str = created_at + print(f"{filepath:<50} {created_str:<30}") + + if encrypted_files: + print("\nEncrypted Files:") + for filepath in sorted(encrypted_files): + print(f" {filepath}") + + if protected_files: + print(f"\nTotal: {len(protected_files)} protected file(s)", end="") + if encrypted_files: + if protected_files: + print(f", {len(encrypted_files)} encrypted file(s)") + else: + print(f"Total: {len(encrypted_files)} encrypted file(s)") + else: + print() + + return True + + +def status(filepath): + """Check if a file is protected or encrypted.""" + # Normalize path + if not filepath.startswith(CONTENT_DIR): + filepath = os.path.join(CONTENT_DIR, filepath) + + file_key = get_file_key(filepath) + passwords = load_passwords() + + is_encrypted = filepath.endswith('.enc') + is_protected = file_key in passwords and passwords[file_key].get('protected', False) + + display_path = filepath.replace(CONTENT_DIR + '/', '') + + print(f"\nFile: {display_path}") + print(f"Encrypted: {'Yes' if is_encrypted else 'No'}") + print(f"Protected: {'Yes' if is_protected else 'No'}") + + if is_protected: + data = passwords[file_key] + created_at = data.get('created_at', 'Unknown') + print(f"Created: {created_at}") + + return True + + +def main(): + if len(sys.argv) < 2: + print(__doc__, file=sys.stderr) + return 1 + + command = sys.argv[1] + + if command == 'unprotect': + if len(sys.argv) < 3: + print("Usage: manage_content.py unprotect ", file=sys.stderr) + return 1 + filepath = sys.argv[2] + return 0 if unprotect(filepath) else 1 + + elif command == 'encrypt': + if len(sys.argv) < 4: + print("Usage: manage_content.py encrypt ", file=sys.stderr) + return 1 + filepath = sys.argv[2] + password = sys.argv[3] + return 0 if encrypt(filepath, password) else 1 + + elif command == 'decrypt': + if len(sys.argv) < 4: + print("Usage: manage_content.py decrypt ", file=sys.stderr) + return 1 + filepath = sys.argv[2] + password = sys.argv[3] + # Output goes to stdout, don't print status + result = decrypt(filepath, password) + return 0 if result else 1 + + elif command == 'view': + if len(sys.argv) < 4: + print("Usage: manage_content.py view ", file=sys.stderr) + return 1 + filepath = sys.argv[2] + password = sys.argv[3] + return 0 if view(filepath, password) else 1 + + elif command == 'list': + directory = sys.argv[2] if len(sys.argv) > 2 else None + return 0 if list_files(directory) else 1 + + elif command == 'status': + if len(sys.argv) < 3: + print("Usage: manage_content.py status ", file=sys.stderr) + return 1 + filepath = sys.argv[2] + return 0 if status(filepath) else 1 + + else: + print(f"Unknown command: {command}", file=sys.stderr) + print(__doc__, file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index bcb2368..59628e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ Flask==3.0.0 -APScheduler==3.10.4 markdown==3.5.2 Whoosh==2.7.4 +cryptography==41.0.7 diff --git a/templates/password_prompt.html b/templates/password_prompt.html new file mode 100644 index 0000000..276e783 --- /dev/null +++ b/templates/password_prompt.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} + +{% block title %}Password Protected - markMyWords{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+

🔒 Protected Document

+

This document is password protected. Please enter the password to view it.

+ +
+
+ + +
+ +
+ 📄 {{ filename }} +
+
+
+ + +{% endblock %} diff --git a/templates/view.html b/templates/view.html index 4e1f485..cb2d326 100644 --- a/templates/view.html +++ b/templates/view.html @@ -2,19 +2,76 @@ {% block title %}{{ filename }} - markMyWords{% endblock %} +{% block extra_head %} + +{% endblock %} + {% block content %}
-

{{ filename }}

+
+

{{ filename }}

+
{{ content|safe }}
+ + {% endblock %}