Updated to do local content w/ passwording and encryption

This commit is contained in:
Discsearcher
2026-08-28 14:59:19 -04:00
parent 2dc05e72d4
commit af59158ff9
9 changed files with 1642 additions and 233 deletions
+283
View File
@@ -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 <filepath> <password>
# 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 <filepath> <password>
# 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.
+206
View File
@@ -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 <filepath> <password>
# 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 <filepath>
# 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 <filepath>
# 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 <password>
```
## 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)
+111 -50
View File
@@ -1,53 +1,48 @@
# markMyWords # 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 ## Features
- 🚀 **Self-hosted**: Run entirely on your own infrastructure - 🚀 **Self-hosted**: Run entirely on your own infrastructure
- 📦 **Docker-ready**: Simple Docker Compose setup - 📦 **Docker-ready**: Simple Docker Compose setup
- 📅 **Scheduled Pulls**: Configure cron-style schedules for each repository
- 🔍 **Full-text Search**: Search across all markdown files - 🔍 **Full-text Search**: Search across all markdown files
- 📄 **Markdown Rendering**: Beautiful HTML rendering with syntax highlighting - 📄 **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 ## Quick Start
### 1. Clone and Configure ### 1. Setup Directory
```bash ```bash
cd markmywords cd markmywords
# Create the config directory
mkdir -p config
# Copy the example configuration # Create necessary directories
cp config/repositories.json.example config/repositories.json mkdir -p content config data
# Edit the configuration with your repositories
nano config/repositories.json
``` ```
### 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 ```bash
{ cp /path/to/your/markdown/files/* ./content/
"repo-name": {
"url": "https://github.com/username/repo.git",
"enabled": true,
"schedule": "0 */6 * * *"
}
}
``` ```
**Schedule Format**: Standard cron expression (minute, hour, day of month, month, day of week) Organize with subdirectories as needed:
Common schedules: ```
- `0 * * * *` - Every hour content/
- `0 */6 * * *` - Every 6 hours (default) ├── README.md
- `0 9 * * *` - Daily at 9 AM ├── docs/
- `0 0 * * 0` - Weekly (Sunday at midnight) │ ├── guide.md
│ └── tutorial.md
└── blog/
├── post1.md
└── post2.md
```
### 3. Run with Docker Compose ### 3. Run with Docker Compose
@@ -60,43 +55,109 @@ The application will be available at `http://localhost:5000`
### 4. Access the Application ### 4. Access the Application
- **Main Page**: http://localhost:5000 - **Main Page**: http://localhost:5000
- View all repositories and their markdown files - Browse all files organized by directory
- Search across all files - 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**: ### 5. (Optional) Encrypt Files
- `/api/status` - Application status and repository info
- `/api/search?q=<query>` - Search markdown 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 # In browser: User enters password → file is decrypted and displayed
{ # Same password used for both encryption and browser access
"repo-name": {
"url": "https://github.com/username/repo.git",
"enabled": true,
"schedule": "0 */6 * * *"
}
}
``` ```
**Fields**: See [ENCRYPTION.md](ENCRYPTION.md) for more details.
- `url`: Git repository URL (HTTPS recommended, SSH with proper key mounting)
- `enabled`: Boolean to enable/disable this repository ## File Management
- `schedule`: Cron expression for pull schedule
### 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 ### Docker Compose Configuration
The `docker-compose.yml` file manages: The `docker-compose.yml` file manages:
- Port mapping (default 5000) - Port mapping (default 5000)
- Volume management for configuration and data - Volume mounts for content and data
- Restart policies - 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/<filepath>` - View markdown file as HTML
- `GET /image/<filepath>` - Serve images from content
- `GET /api/search?q=<query>` - Full-text search
- `GET /api/status` - Application status
- `POST /api/auth/<filepath>` - Authenticate for protected file
- `POST /api/protect/<filepath>` - Protect/unprotect files (admin only)
```yaml ```yaml
ports: ports:
+321 -181
View File
@@ -1,38 +1,125 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
markmywords - Self-hosted markdown repository viewer with search markmywords - Self-hosted markdown viewer with search and encryption
""" """
import os import os
import subprocess
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from flask import Flask, render_template, request, jsonify, send_file from flask import Flask, render_template, request, jsonify, send_file, session, redirect, url_for
from apscheduler.schedulers.background import BackgroundScheduler from werkzeug.security import generate_password_hash, check_password_hash
from apscheduler.triggers.cron import CronTrigger
import markdown import markdown
import logging import logging
import re import re
from whoosh.index import create_in, open_dir from whoosh.index import create_in, open_dir
from whoosh.fields import Schema, TEXT, ID from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser 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 = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
# Configuration # Configuration
CONFIG_FILE = "/config/repositories.json" CONTENT_DIR = "/content"
REPOS_DIR = "/data/repos"
INDEX_DIR = "/data/search_index" INDEX_DIR = "/data/search_index"
PASSWORD_FILE = "/config/page_passwords.json"
MARKDOWN_EXTENSIONS = ['.md', '.markdown'] MARKDOWN_EXTENSIONS = ['.md', '.markdown']
ENCRYPTION_ENABLED = os.environ.get('ENCRYPTION_ENABLED', 'true').lower() == 'true'
# Setup logging # Setup logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Ensure directories exist # 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(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 # Initialize search index
def create_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 # Check all parts of the path including the filename
return any(part.startswith('.') for part in path_obj.parts) 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.""" """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) file_dir = os.path.dirname(filepath)
# Pattern to match markdown image syntax: ![alt](path) # Pattern to match markdown image syntax: ![alt](path)
@@ -71,7 +158,7 @@ def rewrite_image_paths(md_content, repo, filepath):
# Resolve relative paths # Resolve relative paths
if img_path.startswith('/'): if img_path.startswith('/'):
# Absolute path from repo root # Absolute path from content root
resolved_path = img_path.lstrip('/') resolved_path = img_path.lstrip('/')
else: else:
# Relative path - resolve it relative to the file's directory # 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 # Ensure forward slashes for URL
resolved_path = resolved_path.replace(os.sep, '/') 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})" 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) md_content = re.sub(r'!\[([^\]]*)\]\(([^\)]+)\)', replace_image_path, md_content)
return 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.""" """Build a hierarchical tree of markdown files organized by directory."""
tree = {} tree = {}
if not os.path.exists(repo_path): if not os.path.exists(CONTENT_DIR):
logger.warning(f"Repo path does not exist: {repo_path}") logger.warning(f"Content directory does not exist: {CONTENT_DIR}")
return tree return tree
file_count = 0 file_count = 0
for md_file in Path(repo_path).rglob('*'): # Include both .md and .md.enc files
if md_file.suffix.lower() not in MARKDOWN_EXTENSIONS: 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 continue
if is_hidden_path(md_file): if is_hidden_path(md_file):
logger.debug(f"Skipping hidden path: {md_file}") logger.debug(f"Skipping hidden path: {md_file}")
continue continue
file_count += 1 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 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})") logger.debug(f"Adding to tree: {rel_path} (name: {filename})")
@@ -125,168 +223,73 @@ def build_directory_tree(repo_path):
current['_files'] = [] current['_files'] = []
current['_files'].append({ current['_files'].append({
'name': filename, '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 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(): def update_search_index():
"""Update the search index with all markdown files.""" """Update the search index with all markdown files."""
try: try:
writer = ix.writer() writer = ix.writer()
for repo_dir in Path(REPOS_DIR).iterdir(): for md_file in Path(CONTENT_DIR).rglob('*'):
if not repo_dir.is_dir(): if md_file.suffix.lower() not in MARKDOWN_EXTENSIONS and not md_file.suffix == '.enc':
continue
if is_hidden_path(md_file):
continue continue
for md_file in repo_dir.rglob('*'): try:
if md_file.suffix.lower() in MARKDOWN_EXTENSIONS and not is_hidden_path(md_file): # Handle encrypted files
try: rel_path = str(md_file.relative_to(CONTENT_DIR))
content = md_file.read_text(encoding='utf-8', errors='ignore') title = md_file.stem
# Extract title from filename or first heading
title = md_file.stem if is_encrypted_file(rel_path):
rel_path = str(md_file.relative_to(REPOS_DIR)) # Skip encrypted files in search index (they need authentication)
logger.debug(f"Skipping encrypted file from search index: {rel_path}")
writer.add_document( continue
path=rel_path,
title=title, content = md_file.read_text(encoding='utf-8', errors='ignore')
content=content writer.add_document(
) path=rel_path,
except Exception as e: title=title,
logger.error(f"Error indexing {md_file}: {e}") content=content
)
except Exception as e:
logger.error(f"Error indexing {md_file}: {e}")
writer.commit() writer.commit()
logger.info("Search index updated successfully") logger.info("Search index updated successfully")
except Exception as e: except Exception as e:
logger.error(f"Error updating search index: {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 # Routes
@app.route('/') @app.route('/')
def index(): def index():
"""Display the main page with list of repositories and search.""" """Display the main page with file navigation and search."""
repos = load_repositories() file_tree = build_directory_tree()
repo_list = []
for repo_name in repos.keys(): return render_template('index.html', repositories=[{
repo_path = os.path.join(REPOS_DIR, repo_name) 'name': 'Content',
file_tree = build_directory_tree(repo_path) 'tree': file_tree
}])
repo_list.append({
'name': repo_name,
'tree': file_tree
})
return render_template('index.html', repositories=repo_list)
@app.route('/image/<repo>/<path:filepath>') @app.route('/image/<path:filepath>')
def serve_image(repo, filepath): def serve_image(filepath):
"""Serve images from repository directories.""" """Serve images from content directory."""
# Security: prevent directory traversal # Security: prevent directory traversal
if '..' in filepath or filepath.startswith('/'): if '..' in filepath or filepath.startswith('/'):
return "Invalid path", 400 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: try:
file_path = os.path.realpath(file_path) file_path = os.path.realpath(file_path)
repo_path = os.path.realpath(os.path.join(REPOS_DIR, repo)) content_path = os.path.realpath(CONTENT_DIR)
if not file_path.startswith(repo_path): if not file_path.startswith(content_path):
return "Access denied", 403 return "Access denied", 403
except Exception: except Exception:
return "Invalid path", 400 return "Invalid path", 400
@@ -326,20 +329,33 @@ def search():
logger.error(f"Search error: {e}") logger.error(f"Search error: {e}")
return jsonify({'results': [], 'error': str(e)}) return jsonify({'results': [], 'error': str(e)})
@app.route('/view/<repo>/<path:filepath>') @app.route('/view/<path:filepath>')
def view_file(repo, filepath): def view_file(filepath):
"""View a markdown file converted to HTML.""" """View a markdown file converted to HTML."""
# Security: prevent directory traversal # Security: prevent directory traversal
if '..' in filepath or filepath.startswith('/'): if '..' in filepath or filepath.startswith('/'):
return "Invalid path", 400 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: try:
file_path = os.path.realpath(file_path) file_path = os.path.realpath(file_path)
repo_path = os.path.realpath(os.path.join(REPOS_DIR, repo)) content_path = os.path.realpath(CONTENT_DIR)
if not file_path.startswith(repo_path): if not file_path.startswith(content_path):
return "Access denied", 403 return "Access denied", 403
except Exception: except Exception:
return "Invalid path", 400 return "Invalid path", 400
@@ -347,12 +363,38 @@ def view_file(repo, filepath):
if not os.path.exists(file_path): if not os.path.exists(file_path):
return "File not found", 404 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: try:
with open(file_path, 'r', encoding='utf-8') as f: # Read file content
md_content = f.read() 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 # 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 # Convert markdown to HTML
html_content = markdown.markdown( html_content = markdown.markdown(
@@ -362,43 +404,141 @@ def view_file(repo, filepath):
return render_template( return render_template(
'view.html', 'view.html',
repo=repo, filepath=lookup_filepath,
filepath=filepath,
content=html_content, 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: except Exception as e:
logger.error(f"Error reading file {file_path}: {e}") logger.error(f"Error reading file {file_path}: {e}")
return f"Error reading file: {e}", 500 return f"Error reading file: {e}", 500
@app.route('/api/auth/<path:filepath>', 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/<path:filepath>', 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') @app.route('/api/status')
def status(): def status():
"""Return application status.""" """Return application status."""
repos = load_repositories() content_status = {
repo_status = {} 'exists': os.path.exists(CONTENT_DIR),
'last_modified': datetime.fromtimestamp(
for repo_name in repos.keys(): os.path.getmtime(CONTENT_DIR)
repo_path = os.path.join(REPOS_DIR, repo_name) ).isoformat() if os.path.exists(CONTENT_DIR) else None
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
}
return jsonify({ return jsonify({
'status': 'running', 'status': 'running',
'repositories': repo_status, 'content': content_status,
'encryption_enabled': ENCRYPTION_ENABLED,
'timestamp': datetime.now().isoformat() 'timestamp': datetime.now().isoformat()
}) })
if __name__ == '__main__': if __name__ == '__main__':
# Perform initial pull and index # Perform initial index build
logger.info("Initializing markmywords") logger.info("Initializing markmywords")
scheduled_pull() update_search_index()
# Setup scheduler
scheduler = setup_scheduler()
# Start Flask app # Start Flask app
app.run(host='0.0.0.0', port=5000, debug=False) app.run(host='0.0.0.0', port=5000, debug=False)
+21
View File
@@ -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
+462
View File
@@ -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())
+1 -1
View File
@@ -1,4 +1,4 @@
Flask==3.0.0 Flask==3.0.0
APScheduler==3.10.4
markdown==3.5.2 markdown==3.5.2
Whoosh==2.7.4 Whoosh==2.7.4
cryptography==41.0.7
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block title %}Password Protected - markMyWords{% endblock %}
{% block extra_head %}
<style>
.password-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.password-box {
background: white;
border: 2px solid #ddd;
border-radius: 8px;
padding: 40px;
max-width: 400px;
width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.password-box h2 {
margin: 0 0 10px 0;
color: #333;
text-align: center;
}
.password-box p {
margin: 0 0 25px 0;
color: #666;
text-align: center;
font-size: 14px;
}
.password-form {
display: flex;
flex-direction: column;
}
.password-form input[type="password"] {
padding: 12px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.password-form input[type="password"]:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
}
.password-form button {
padding: 12px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: background-color 0.3s;
}
.password-form button:hover {
background-color: #45a049;
}
.password-form button:active {
background-color: #3d8b40;
}
.error-message {
color: #d32f2f;
margin-bottom: 15px;
padding: 10px;
background-color: #ffebee;
border-radius: 4px;
display: none;
text-align: center;
font-size: 14px;
}
.file-name {
color: #999;
font-size: 12px;
text-align: center;
margin-top: 15px;
border-top: 1px solid #eee;
padding-top: 15px;
}
</style>
{% endblock %}
{% block content %}
<div class="password-container">
<div class="password-box">
<h2>🔒 Protected Document</h2>
<p>This document is password protected. Please enter the password to view it.</p>
<form class="password-form" id="passwordForm">
<div class="error-message" id="errorMessage"></div>
<input
type="password"
id="passwordInput"
placeholder="Enter password"
required
autofocus
>
<button type="submit">Unlock</button>
</form>
<div class="file-name">
📄 {{ filename }}
</div>
</div>
</div>
<script>
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('passwordInput').value;
const errorDiv = document.getElementById('errorMessage');
const button = e.target.querySelector('button');
// Clear previous errors
errorDiv.style.display = 'none';
errorDiv.textContent = '';
// Disable button while submitting
button.disabled = true;
button.textContent = 'Authenticating...';
try {
const response = await fetch('/api/auth/{{ repo }}/{{ filepath }}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'password': password
})
});
const data = await response.json();
if (data.success) {
// Redirect to the file
window.location.href = data.redirect;
} else {
// Show error
errorDiv.textContent = data.error || 'Authentication failed';
errorDiv.style.display = 'block';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
} catch (error) {
errorDiv.textContent = 'An error occurred. Please try again.';
errorDiv.style.display = 'block';
console.error('Authentication error:', error);
} finally {
button.disabled = false;
button.textContent = 'Unlock';
}
});
// Allow Enter key to submit
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
document.getElementById('passwordForm').dispatchEvent(new Event('submit'));
}
});
</script>
{% endblock %}
+58 -1
View File
@@ -2,19 +2,76 @@
{% block title %}{{ filename }} - markMyWords{% endblock %} {% block title %}{{ filename }} - markMyWords{% endblock %}
{% block extra_head %}
<style>
.protection-badge {
display: inline-block;
background-color: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
margin-left: 10px;
}
.protection-badge::before {
content: "🔒 ";
}
.admin-controls {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
display: none;
}
.admin-controls.visible {
display: block;
}
.admin-button {
padding: 6px 12px;
margin-right: 10px;
border: 1px solid #ddd;
background-color: #f5f5f5;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.admin-button:hover {
background-color: #e0e0e0;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a href="/">Home</a> > <a href="/">Home</a> >
<span class="breadcrumb-repo">{{ repo }}</span> > <span class="breadcrumb-repo">{{ repo }}</span> >
<span class="breadcrumb-file">{{ filename }}</span> <span class="breadcrumb-file">{{ filename }}</span>
{% if is_protected %}<span class="protection-badge" title="This document is password protected"></span>{% endif %}
</div> </div>
<article class="markdown-content"> <article class="markdown-content">
<h1>{{ filename }}</h1> <div style="display: flex; justify-content: space-between; align-items: center;">
<h1>{{ filename }}</h1>
</div>
<div class="content-body"> <div class="content-body">
{{ content|safe }} {{ content|safe }}
</div> </div>
</article> </article>
</div> </div>
<script>
// Check if admin controls should be shown via query parameter
const params = new URLSearchParams(window.location.search);
if (params.has('admin')) {
document.querySelector('.admin-controls').classList.add('visible');
}
</script>
{% endblock %} {% endblock %}