SHA256
Updated to do local content w/ passwording and encryption
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Utility script to manage content encryption for markmywords.
|
||||
|
||||
Usage:
|
||||
python manage_content.py encrypt <filepath> <password>
|
||||
python manage_content.py decrypt <filepath> <password>
|
||||
python manage_content.py view <filepath> <password>
|
||||
python manage_content.py unprotect <filepath>
|
||||
python manage_content.py list [directory]
|
||||
python manage_content.py status <filepath>
|
||||
|
||||
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("<!DOCTYPE html>")
|
||||
print("<html>")
|
||||
print("<head>")
|
||||
print("<meta charset='utf-8'>")
|
||||
print("<meta name='viewport' content='width=device-width, initial-scale=1'>")
|
||||
print("<style>")
|
||||
print("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 900px; margin: 0 auto; padding: 20px; }")
|
||||
print("code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', monospace; }")
|
||||
print("pre { background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }")
|
||||
print("table { border-collapse: collapse; width: 100%; }")
|
||||
print("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }")
|
||||
print("th { background-color: #f0f0f0; }")
|
||||
print("</style>")
|
||||
print("</head>")
|
||||
print("<body>")
|
||||
print(html_content)
|
||||
print("</body>")
|
||||
print("</html>")
|
||||
|
||||
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 <filepath>", 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 <filepath> <password>", 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 <filepath> <password>", 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 <filepath> <password>", 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 <filepath>", 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())
|
||||
Reference in New Issue
Block a user