NoteDiscovery/backend/share.py

239 lines
6.4 KiB
Python
Raw Normal View History

2026-01-15 09:07:51 +00:00
"""
Share Token Management for NoteDiscovery
Handles creating, storing, and revoking share tokens for public note access.
"""
import json
import secrets
import string
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, Dict, Any
import threading
# Thread lock for safe concurrent access
_lock = threading.Lock()
def generate_token(length: int = 16) -> str:
"""Generate a URL-safe random token."""
# Use alphanumeric + underscore/hyphen (URL-safe)
alphabet = string.ascii_letters + string.digits + '_-'
return ''.join(secrets.choice(alphabet) for _ in range(length))
def get_tokens_file_path(data_dir: str) -> Path:
"""Get the path to the share tokens file."""
return Path(data_dir) / '.share-tokens.json'
def load_tokens(data_dir: str) -> Dict[str, Dict[str, Any]]:
"""Load share tokens from file."""
tokens_file = get_tokens_file_path(data_dir)
if not tokens_file.exists():
return {}
try:
with open(tokens_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def save_tokens(data_dir: str, tokens: Dict[str, Dict[str, Any]]) -> bool:
"""Save share tokens to file."""
tokens_file = get_tokens_file_path(data_dir)
try:
# Ensure parent directory exists
tokens_file.parent.mkdir(parents=True, exist_ok=True)
with open(tokens_file, 'w', encoding='utf-8') as f:
json.dump(tokens, f, indent=2, ensure_ascii=False)
return True
except IOError as e:
print(f"Failed to save share tokens: {e}")
return False
def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> Optional[str]:
"""
Create a share token for a note.
If the note already has a token, returns the existing one.
Args:
data_dir: Path to the data directory
note_path: Path to the note (relative to notes_dir)
theme: The theme to use when viewing the shared note
Returns:
The share token, or None on error
"""
with _lock:
tokens = load_tokens(data_dir)
# Check if note already has a token
for token, info in tokens.items():
if info.get('path') == note_path:
return token
# Generate new token
token = generate_token()
# Ensure uniqueness (extremely unlikely collision, but check anyway)
while token in tokens:
token = generate_token()
# Store token with theme
tokens[token] = {
'path': note_path,
'theme': theme,
'created': datetime.now(timezone.utc).isoformat()
}
if save_tokens(data_dir, tokens):
return token
return None
def get_share_token(data_dir: str, note_path: str) -> Optional[str]:
"""
Get the share token for a note, if it exists.
Args:
data_dir: Path to the data directory
note_path: Path to the note
Returns:
The share token, or None if not shared
"""
tokens = load_tokens(data_dir)
for token, info in tokens.items():
if info.get('path') == note_path:
return token
return None
def get_note_by_token(data_dir: str, token: str) -> Optional[Dict[str, Any]]:
"""
Get the note info for a share token.
Args:
data_dir: Path to the data directory
token: The share token
Returns:
Dict with 'path' and 'theme', or None if token not found
"""
tokens = load_tokens(data_dir)
if token in tokens:
return {
'path': tokens[token].get('path'),
'theme': tokens[token].get('theme', 'light')
}
return None
def get_all_shared_paths(data_dir: str) -> list:
"""
Get a list of all currently shared note paths.
Used for displaying share indicators in the UI.
Args:
data_dir: Path to the data directory
Returns:
List of note paths that are currently shared
"""
tokens = load_tokens(data_dir)
return [info.get('path') for info in tokens.values() if info.get('path')]
def revoke_share_token(data_dir: str, note_path: str) -> bool:
"""
Revoke (delete) the share token for a note.
Args:
data_dir: Path to the data directory
note_path: Path to the note
Returns:
True if token was revoked, False if not found or error
"""
with _lock:
tokens = load_tokens(data_dir)
# Find and remove token for this note
token_to_remove = None
for token, info in tokens.items():
if info.get('path') == note_path:
token_to_remove = token
break
if token_to_remove:
del tokens[token_to_remove]
return save_tokens(data_dir, tokens)
return False
def get_share_info(data_dir: str, note_path: str) -> Optional[Dict[str, Any]]:
"""
Get share information for a note.
Args:
data_dir: Path to the data directory
note_path: Path to the note
Returns:
Dict with token, theme, and created date, or None if not shared
"""
tokens = load_tokens(data_dir)
for token, info in tokens.items():
if info.get('path') == note_path:
return {
'token': token,
'theme': info.get('theme', 'light'),
'created': info.get('created'),
'shared': True
}
return {'shared': False}
def update_token_path(data_dir: str, old_path: str, new_path: str) -> bool:
"""
Update the path for a token when a note is moved/renamed.
Args:
data_dir: Path to the data directory
old_path: Old note path
new_path: New note path
Returns:
True if updated, False if not found or error
"""
with _lock:
tokens = load_tokens(data_dir)
for token, info in tokens.items():
if info.get('path') == old_path:
info['path'] = new_path
return save_tokens(data_dir, tokens)
return False
def delete_token_for_note(data_dir: str, note_path: str) -> bool:
"""
Delete the share token when a note is deleted.
Alias for revoke_share_token for clarity.
"""
return revoke_share_token(data_dir, note_path)