#!/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) # Normalize file_key to relative path (without CONTENT_DIR prefix) display_path = enc_filepath.replace(CONTENT_DIR + '/', '') file_key = get_file_key(display_path) 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) 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) # Normalize file_key to relative path (without CONTENT_DIR prefix) display_path = filepath.replace(CONTENT_DIR + '/', '') file_key = get_file_key(display_path) 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): 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) # Normalize file_key to relative path (without CONTENT_DIR prefix) display_path = filepath.replace(CONTENT_DIR + '/', '') file_key = get_file_key(display_path) passwords = load_passwords() is_encrypted = filepath.endswith('.enc') is_protected = file_key in passwords and passwords[file_key].get('protected', False) 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())