SHA256
Updated to do local content w/ passwording and encryption
This commit is contained in:
+283
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: 
|
# Pattern to match markdown image syntax: 
|
||||||
@@ -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""
|
return f""
|
||||||
|
|
||||||
@@ -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
|
|
||||||
rel_path = str(md_file.relative_to(REPOS_DIR))
|
|
||||||
|
|
||||||
writer.add_document(
|
if is_encrypted_file(rel_path):
|
||||||
path=rel_path,
|
# Skip encrypted files in search index (they need authentication)
|
||||||
title=title,
|
logger.debug(f"Skipping encrypted file from search index: {rel_path}")
|
||||||
content=content
|
continue
|
||||||
)
|
|
||||||
except Exception as e:
|
content = md_file.read_text(encoding='utf-8', errors='ignore')
|
||||||
logger.error(f"Error indexing {md_file}: {e}")
|
writer.add_document(
|
||||||
|
path=rel_path,
|
||||||
|
title=title,
|
||||||
|
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({
|
@app.route('/image/<path:filepath>')
|
||||||
'name': repo_name,
|
def serve_image(filepath):
|
||||||
'tree': file_tree
|
"""Serve images from content directory."""
|
||||||
})
|
|
||||||
|
|
||||||
return render_template('index.html', repositories=repo_list)
|
|
||||||
|
|
||||||
@app.route('/image/<repo>/<path:filepath>')
|
|
||||||
def serve_image(repo, filepath):
|
|
||||||
"""Serve images from repository directories."""
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user