complete rewrite and redo of database to move to an encryption first model in preperation of getting source ready to open source

This commit is contained in:
Ben Miller 2026-10-06 14:24:25 -05:00
parent 2e3694d2f7
commit 97ca1925d2
28 changed files with 1306 additions and 315 deletions

126
README.md
View file

@ -1,78 +1,125 @@
# SpotNotes
SpotNotes is a location-based note-taking web application. It allows users to drop notes on an interactive map, organize them into notepads, and optionally share them with the public or specific users.
SpotNotes is a privacy-first, location-based note-taking web application. It allows users to drop notes on an interactive map, organize them into notepads, and share them publicly or securely with specific users using zero-knowledge end-to-end encryption.
---
## 🔒 Encryption-First Architecture (Zero-Knowledge E2EE)
SpotNotes is built with an **encryption-first mindset**:
- **Public Notes:** Stored unencrypted at rest on the server and served openly for community guides, road trips, and landmarks.
- **Private Notes:** 100% **End-to-End Encrypted (E2EE)** at rest. All private notes (including title, content, and geographic coordinates) are encrypted client-side in the user's browser using the standard **Web Crypto API** before transmission.
- **Zero-Knowledge for Server Operators:** The server operator, database administrators, and hosting infrastructure cannot read private notes, note titles, or note coordinates. The server stores only ciphertext payloads.
- **Secure Asymmetric Sharing:**
- **Collaborators:** When an owner shares a private notepad with another user, the owner's browser retrieves the recipient's RSA public key and encrypts the symmetric notepad key with it. Only the collaborator's private key can decrypt the notepad.
- **Zero-Knowledge Link Sharing:** When sharing a private note or notepad via link, the decryption key is placed into the URL hash fragment (`#key=...`). Browsers never send the URL hash to the HTTP server, ensuring zero-knowledge link sharing.
- **Decrypted Client-Side CSV Export:** When exporting private notepads to CSV, notes are decrypted and formatted directly in the browser.
### Cryptographic Specifications
1. **User Keypairs:** RSA-OAEP (2048-bit modulus, SHA-256).
2. **Password Key Derivation (KEK):** PBKDF2 with SHA-256, 100,000 iterations, and a unique 16-byte random salt per user.
3. **Private Key Protection:** The user's RSA private key is encrypted with AES-GCM-256 using the password-derived KEK before being stored on the server.
4. **Notepad Keys:** Symmetric AES-GCM 256-bit keys, wrapped per user via RSA-OAEP.
5. **Note Payload Encryption:** AES-GCM-256 with a unique 12-byte cryptographic nonce (IV) per note revision.
---
## Features
- **Interactive Map:** View and create notes on a map powered by Leaflet and OpenStreetMap.
- **Location-Based Notes:** Every note is tied to specific geographic coordinates.
- **Notepads:** Organize notes into collections called "Notepads".
- **Privacy & Sharing:** Notepads can be private (only you), public (everyone), or shared via link tokens.
- **User Authentication:** Secure registration and login system.
- **Favorites:** Save and easily access your favorite public notepads.
- **Search & Geocoding:** Search through your notes or use geocoding to find locations worldwide.
- **Admin Dashboard:** Manage users, notepads, and notes.
- **Interactive Map:** View and create notes on a high-performance Leaflet & OpenStreetMap interface.
- **Location-Based Notes:** Every note is placed at precise coordinates.
- **Public & Private Notepads:** Choose between open public discovery or zero-knowledge private notes.
- **E2EE Collaboration:** Add collaborators with Read or Read/Write permissions with public-key key exchange.
- **Zero-Knowledge Sharing Links:** Share private notes/notepads securely via link tokens and URL key fragments.
- **Local Decrypted Search & Geocoding:** Search notes across public feeds and client-side decrypted private notebooks, or geocode addresses globally.
- **Client-Side CSV Export:** Download clean CSV backups of your decrypted notes.
- **Mobile-Optimized Interface:** Responsive bottom-sheet design with fully rounded open/close menu toggle handles.
- **Admin Panel:** Content moderation for public notes and IP management without exposing private ciphertext.
---
## Tech Stack
- **Backend:** Python, Flask, Flask-SQLAlchemy (with SQLite for development/PostgreSQL for production).
- **Frontend:** HTML5, CSS3 (Vanilla), Vanilla JavaScript, Leaflet.js for maps.
- **Deployment:** Configured for Gunicorn and standard WSGI environments (includes `Procfile` and `gunicorn_config.py`).
- **Backend:** Python, Flask, Flask-SQLAlchemy (SQLite for development / PostgreSQL for production), Gunicorn.
- **Frontend:** Vanilla HTML5, Modern CSS3 with Glassmorphism, Vanilla JavaScript (ES6+), Web Crypto API, Leaflet.js.
- **Security:** Web Crypto API (`crypto.subtle`), PBKDF2, AES-GCM, RSA-OAEP, Werkzeug password hashing.
---
## Project Structure
```
spotnotes.app/
│
├── app.py # Main application entry point
├── app.py # Main application entry point & DB initialization
├── config.py # Configuration settings
├── extensions.py # Flask extensions (SQLAlchemy, CORS)
├── requirements.txt # Python dependencies
├── seed_notes.py # CLI tool to seed public landmark notes from CSV
│
├── blueprints/ # Flask Blueprints (Controllers)
│ ├── admin.py # Admin dashboard routes
│ ├── api.py # Main REST API for notes/notepads
│ ├── auth.py # User authentication routes
│ ├── search.py # Search and geocoding logic
│ ├── api.py # REST API for notes, notepads, keys, & collaborators
│ ├── auth.py # User authentication with public key exchange
│ ├── search.py # Search (public notes) and geocoding proxy
│ └── static_routes.py # Serving static HTML pages
│
├── models/ # SQLAlchemy Database Models
│ ├── banned_ip.py # Security model for IP bans
│ ├── collaborator.py # Notepad collaboration permissions
│ ├── favorite.py # User's favorited notepads
│ ├── note.py # Core note data structure
│ ├── note.py # Note model (supports encrypted & plaintext notes)
│ ├── notepad.py # Collections of notes
│ └── user.py # User account data
│ ├── notepad_key.py # User-specific wrapped AES notepad keys
│ └── user.py # User model with RSA public key & encrypted private key
│
└── static/ # Frontend Assets
├── index.html # Main map interface
├── login.html # Login page
├── register.html # Registration page
├── admin.html # Admin interface
├── login.html # Login page with private key unlock
├── register.html # Registration page with keypair generation
├── admin.html # Admin moderation interface
├── about.html # About page & privacy overview
├── contact.html # Contact page
└── assets/
├── css/ # Modular stylesheets
├── js/ # Modular vanilla JS scripts
└── images/ # Images and icons
├── css/ # Modular stylesheets (base, map, navbar, sidebar, modals)
├── js/
│ ├── crypto.js # Client-side cryptographic engine (E2EE)
│ ├── actions.js # Notepad/note creation and modal actions
│ ├── index.js # Core app controller, note loader, and renderer
│ ├── map.js # Leaflet map configuration and marker logic
│ ├── sidebar.js # Collapsible sidebar & notepad navigation
│ ├── auth.js # Client authentication & key initialization
│ ├── search.js # Dual public + decrypted local note search
│ ├── nav.js # Navbar & session management
│ └── admin.js # Admin dashboard controller
└── images/ # Icons and branding
```
---
## Getting Started
### Prerequisites
- Python 3.8+
- pip (Python package installer)
- Modern Web Browser (Chrome, Edge, Firefox, Safari) with Web Crypto API support
### Installation
1. **Clone the repository** (or download the source):
1. **Clone the repository:**
```bash
git clone <repository-url>
cd spotnotes.app
```
2. **Create a Virtual Environment (Optional but recommended):**
2. **Set up a Virtual Environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows use: venv\Scripts\activate
python -m venv .venv
# On Windows:
.venv\Scripts\activate
# On macOS/Linux:
source .venv/bin/activate
```
3. **Install Dependencies:**
@ -80,21 +127,20 @@ spotnotes.app/
pip install -r requirements.txt
```
4. **Run the Application:**
4. **Seed Sample Public Notes (Optional):**
```bash
python seed_notes.py notes/general_public.csv "Global Public Notes"
python seed_notes.py notes/route66.csv "Route 66 Landmarks"
python seed_notes.py notes/birding.csv "Birding Hotspots"
```
5. **Run the Application:**
```bash
python app.py
```
The app will start on `http://127.0.0.1:5000/`. The database (`spotnotes.db`) will be created automatically upon the first run.
The application will start on `http://127.0.0.1:5000/`.
## Planned Features (Roadmap)
As detailed in the `GEMINI.md` file, the following features are actively planned or in development:
1. **Advanced Collaboration:** Share notepads with specific users granting explicit Read or Read/Write permissions.
2. **Content Editing:** Ability to edit existing notes and notepads.
3. **Rich Text & Multiline Notes:** Support for multiline text and bulleted lists.
4. **CSV Export:** Export all notes within a notepad to a `.csv` file.
5. **Location-Based Alerts:** Browser notifications when physically approaching a saved note.
---
## License

1
app.py
View file

@ -21,6 +21,7 @@ with app.app_context():
from models.note import Note
from models.banned_ip import BannedIP
from models.collaborator import Collaborator
from models.notepad_key import NotepadKey
db.create_all()

View file

@ -75,7 +75,11 @@ def delete_notepad(notepad_id):
for note in notepad.notes:
db.session.delete(note)
from models.favorite import Favorite
from models.notepad_key import NotepadKey
from models.collaborator import Collaborator
Favorite.query.filter_by(notepad_id=notepad.id).delete()
NotepadKey.query.filter_by(notepad_id=notepad.id).delete()
Collaborator.query.filter_by(notepad_id=notepad.id).delete()
db.session.delete(notepad)
db.session.commit()
return jsonify({'message': 'Notepad deleted'}), 200
@ -87,13 +91,19 @@ def delete_user(user_id):
# Clean up user's notepads and notes
notepads = Notepad.query.filter_by(user_id=user.id).all()
from models.favorite import Favorite
from models.notepad_key import NotepadKey
from models.collaborator import Collaborator
for notepad in notepads:
for note in notepad.notes:
db.session.delete(note)
Favorite.query.filter_by(notepad_id=notepad.id).delete()
NotepadKey.query.filter_by(notepad_id=notepad.id).delete()
Collaborator.query.filter_by(notepad_id=notepad.id).delete()
db.session.delete(notepad)
Favorite.query.filter_by(user_id=user.id).delete()
NotepadKey.query.filter_by(user_id=user.id).delete()
Collaborator.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
return jsonify({'message': 'User deleted'}), 200

View file

@ -6,6 +6,7 @@ from models.note import Note
from models.collaborator import Collaborator
from models.user import User
from models.favorite import Favorite
from models.notepad_key import NotepadKey
from sqlalchemy import func
import csv
from io import StringIO
@ -46,6 +47,25 @@ def check_pad_write_permission(notepad, user_id):
collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first()
return collab is not None
@api_bp.route('/users/lookup', methods=['GET'])
def lookup_user():
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
email = request.args.get('email')
if not email:
return jsonify({'error': 'Email is required'}), 400
user = User.query.filter_by(email=email).first()
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify({
'id': user.id,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'public_key': user.public_key
})
@api_bp.route('/notepads', methods=['GET'])
def get_notepads():
user_id = session.get('user_id')
@ -73,15 +93,17 @@ def get_notepads():
# Optimize note count query (O(1) database hit instead of O(N))
note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all())
# Optimize write permissions check
# Optimize write permissions check and keys
collab_write_pad_ids = set()
user_keys = {}
if user_id:
collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()}
user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()}
results = []
for pad in pads:
note_count = note_counts.get(pad.id, 0)
p_dict = pad.to_dict(note_count=note_count)
p_dict = pad.to_dict(note_count=note_count, encrypted_key=user_keys.get(pad.id))
if user_id:
if pad.user_id == user_id:
p_dict['share_token'] = get_share_token('notepad', pad.id)
@ -101,7 +123,13 @@ def get_notepad(pad_id):
if not check_pad_read_permission(notepad, user_id, token):
return jsonify({'error': 'Unauthorized'}), 403
p_dict = notepad.to_dict()
enc_key = None
if user_id and not notepad.is_public:
nk = NotepadKey.query.filter_by(notepad_id=pad_id, user_id=user_id).first()
if nk:
enc_key = nk.encrypted_key
p_dict = notepad.to_dict(encrypted_key=enc_key)
p_dict['can_write'] = check_pad_write_permission(notepad, user_id)
if user_id and notepad.user_id == user_id:
p_dict['share_token'] = get_share_token('notepad', pad_id)
@ -114,10 +142,27 @@ def create_notepad():
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
new_pad = Notepad(name=data['name'], is_public=data.get('is_public', False), user_id=user_id, color=data.get('color', '#3388ff'))
is_public = data.get('is_public', False)
new_pad = Notepad(
name=data['name'],
is_public=is_public,
user_id=user_id,
color=data.get('color', '#3388ff')
)
db.session.add(new_pad)
db.session.flush()
encrypted_key = data.get('encrypted_key')
if not is_public and encrypted_key:
notepad_key = NotepadKey(
notepad_id=new_pad.id,
user_id=user_id,
encrypted_key=encrypted_key
)
db.session.add(notepad_key)
db.session.commit()
return jsonify(new_pad.to_dict()), 201
return jsonify(new_pad.to_dict(encrypted_key=encrypted_key)), 201
@api_bp.route('/notepads/<int:pad_id>/notes', methods=['GET'])
def get_notes(pad_id):
@ -175,13 +220,14 @@ def get_my_all_notes():
notes = Note.query.join(Notepad).filter(Notepad.user_id == user_id, Note.is_hidden == False).all()
# Eagerly load note counts to avoid N+1 queries when calling pad.to_dict()
# Eagerly load note counts and user keys to avoid N+1 queries
note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all())
user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()}
results = []
for note in notes:
n_dict = note.to_dict()
n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0))
n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0), encrypted_key=user_keys.get(note.notepad_id))
n_dict['owner_id'] = user_id
n_dict['is_public'] = note.notepad.is_public
n_dict['share_token'] = get_share_token('note', note.id)
@ -191,24 +237,25 @@ def get_my_all_notes():
@api_bp.route('/notes/default', methods=['GET'])
def get_default_notes():
user_id = session.get('user_id')
user_keys = {}
if user_id:
# Get owned notepads + favorite notepads
# Get owned notepads + favorite notepads + shared notepads
owned_pad_ids = [p.id for p in Notepad.query.filter_by(user_id=user_id).all()]
fav_pad_ids = [f.notepad_id for f in Favorite.query.filter_by(user_id=user_id).all()]
all_visible_pad_ids = list(set(owned_pad_ids + fav_pad_ids))
notes = Note.query.filter(Note.notepad_id.in_(all_visible_pad_ids), Note.is_hidden == False).all()
user_keys = {k.notepad_id: k.encrypted_key for k in NotepadKey.query.filter_by(user_id=user_id).all()}
else:
# Logged out: All public notes
notes = Note.query.join(Notepad).filter(Notepad.is_public == True, Note.is_hidden == False).all()
# Eagerly load note counts to avoid N+1 queries when calling pad.to_dict()
note_counts = dict(db.session.query(Note.notepad_id, func.count(Note.id)).filter(Note.is_hidden == False).group_by(Note.notepad_id).all())
results = []
for note in notes:
n_dict = note.to_dict()
n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0))
n_dict['notepad'] = note.notepad.to_dict(note_count=note_counts.get(note.notepad_id, 0), encrypted_key=user_keys.get(note.notepad_id))
n_dict['owner_id'] = note.notepad.user_id
n_dict['is_public'] = note.notepad.is_public
if user_id and note.notepad.user_id == user_id:
@ -232,11 +279,23 @@ def create_note():
if not check_pad_write_permission(notepad, user_id):
return jsonify({'error': 'Forbidden'}), 403
is_encrypted = not notepad.is_public
if is_encrypted:
new_note = Note(
title=data.get('title', '🔒 Encrypted Note'),
content=data['content'],
lat=0.0,
lng=0.0,
is_encrypted=True,
notepad_id=notepad_id
)
else:
new_note = Note(
title=data.get('title', 'Untitled'),
content=data['content'],
lat=data['lat'],
lng=data['lng'],
lat=float(data.get('lat', 0.0)),
lng=float(data.get('lng', 0.0)),
is_encrypted=False,
notepad_id=notepad_id
)
db.session.add(new_note)
@ -255,6 +314,11 @@ def update_note(note_id):
data = request.get_json()
note.title = data.get('title', note.title)
note.content = data.get('content', note.content)
if not note.is_encrypted:
if 'lat' in data:
note.lat = float(data['lat'])
if 'lng' in data:
note.lng = float(data['lng'])
db.session.commit()
return jsonify(note.to_dict())
@ -358,6 +422,15 @@ def add_collaborator(pad_id):
new_collab = Collaborator(notepad_id=pad_id, user_id=collaborator_user.id, permission=permission)
db.session.add(new_collab)
encrypted_key = data.get('encrypted_key')
if not notepad.is_public and encrypted_key:
existing_key = NotepadKey.query.filter_by(notepad_id=pad_id, user_id=collaborator_user.id).first()
if existing_key:
existing_key.encrypted_key = encrypted_key
else:
new_key = NotepadKey(notepad_id=pad_id, user_id=collaborator_user.id, encrypted_key=encrypted_key)
db.session.add(new_key)
db.session.commit()
return jsonify({'message': 'Collaborator added/updated successfully'}), 200
@ -373,6 +446,7 @@ def remove_collaborator(pad_id, collaborator_user_id):
if not collab_to_remove:
return jsonify({'error': 'Collaborator not found'}), 404
NotepadKey.query.filter_by(notepad_id=pad_id, user_id=collaborator_user_id).delete()
db.session.delete(collab_to_remove)
db.session.commit()
return jsonify({'message': 'Collaborator removed'}), 200
@ -413,6 +487,8 @@ def delete_notepad(pad_id):
for note in notepad.notes:
db.session.delete(note)
Favorite.query.filter_by(notepad_id=notepad.id).delete()
NotepadKey.query.filter_by(notepad_id=notepad.id).delete()
Collaborator.query.filter_by(notepad_id=notepad.id).delete()
db.session.delete(notepad)
db.session.commit()
return jsonify({'message': 'Notepad deleted'}), 200

View file

@ -17,6 +17,9 @@ def register():
password = data.get('password')
first_name = data.get('first_name')
last_name = data.get('last_name')
public_key = data.get('public_key')
encrypted_private_key = data.get('encrypted_private_key')
encryption_salt = data.get('encryption_salt')
if not email or not password or not first_name or not last_name:
return jsonify({'error': 'All fields are required'}), 400
@ -24,7 +27,15 @@ def register():
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email already exists'}), 400
new_user = User(email=email, first_name=first_name, last_name=last_name, last_ip=ip_address)
new_user = User(
email=email,
first_name=first_name,
last_name=last_name,
last_ip=ip_address,
public_key=public_key,
encrypted_private_key=encrypted_private_key,
encryption_salt=encryption_salt
)
new_user.set_password(password)
db.session.add(new_user)
@ -52,7 +63,7 @@ def login():
db.session.commit()
session.permanent = True
session['user_id'] = user.id
return jsonify({'message': 'Login successful', 'user': user.to_dict()}), 200
return jsonify({'message': 'Login successful', 'user': user.to_dict(include_secrets=True)}), 200
return jsonify({'error': 'Invalid username or password'}), 401
@ -69,5 +80,25 @@ def get_current_user():
user = User.query.get(user_id)
if user:
return jsonify({'authenticated': True, 'user': user.to_dict()}), 200
return jsonify({'authenticated': True, 'user': user.to_dict(include_secrets=True)}), 200
return jsonify({'authenticated': False}), 200
@auth_bp.route('/init-keys', methods=['POST'])
def init_keys():
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json()
if not user.encrypted_private_key:
user.public_key = data.get('public_key')
user.encrypted_private_key = data.get('encrypted_private_key')
user.encryption_salt = data.get('encryption_salt')
db.session.commit()
return jsonify({'message': 'Keys initialized successfully', 'user': user.to_dict(include_secrets=True)}), 200
return jsonify({'message': 'Keys already initialized', 'user': user.to_dict(include_secrets=True)}), 200

View file

@ -5,7 +5,6 @@ from models.note import Note
from models.notepad import Notepad
from models.favorite import Favorite
from models.collaborator import Collaborator
from .api import check_pad_write_permission
import urllib.request
import urllib.parse
import json
@ -18,26 +17,20 @@ def search_notes():
if not query:
return jsonify([])
# Join Note and Notepad to filter by permissions
# Explicitly join on the foreign key to ensure correct results
user_id = session.get('user_id')
# Server searches public unencrypted notes.
# Private notes are encrypted client-side and searched locally in browser for zero-knowledge privacy.
sql_query = db.session.query(Note, Notepad).join(Notepad, Note.notepad_id == Notepad.id).filter(
Notepad.is_public == True,
Note.is_encrypted == False,
Note.is_hidden == False,
or_(
Note.title.ilike(f'%{query}%'),
Note.content.ilike(f'%{query}%')
)
)
user_id = session.get('user_id')
if user_id:
sql_query = sql_query.filter(
or_(
Notepad.is_public == True,
Notepad.user_id == user_id
)
)
else:
sql_query = sql_query.filter(Notepad.is_public == True)
results = sql_query.all()
# Get user favorites if logged in

View file

@ -9,6 +9,7 @@ class Note(db.Model):
lng = db.Column(db.Float, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
is_hidden = db.Column(db.Boolean, default=False)
is_encrypted = db.Column(db.Boolean, default=False, nullable=False)
notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False)
def __init__(self, **kwargs):
@ -21,5 +22,6 @@ class Note(db.Model):
'content': self.content,
'lat': self.lat,
'lng': self.lng,
'is_encrypted': self.is_encrypted,
'created_at': self.created_at.isoformat()
}

View file

@ -11,16 +11,19 @@ class Notepad(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
notes = db.relationship('Note', backref='notepad', lazy='dynamic', cascade="all, delete-orphan")
keys = db.relationship('NotepadKey', backref='notepad', lazy='dynamic', cascade="all, delete-orphan")
def __init__(self, **kwargs):
super().__init__(**kwargs)
def to_dict(self, note_count=None):
def to_dict(self, note_count=None, encrypted_key=None):
return {
'id': self.id,
'name': self.name,
'is_public': self.is_public,
'is_encrypted': not self.is_public,
'owner_id': self.user_id,
'color': self.color,
'encrypted_key': encrypted_key,
'note_count': note_count if note_count is not None else self.notes.filter_by(is_hidden=False).count()
}

29
models/notepad_key.py Normal file
View file

@ -0,0 +1,29 @@
from extensions import db
from datetime import datetime
class NotepadKey(db.Model):
__tablename__ = 'notepad_key'
id = db.Column(db.Integer, primary_key=True)
notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
encrypted_key = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', backref=db.backref('notepad_keys', cascade='all, delete-orphan', lazy='dynamic'))
__table_args__ = (
db.UniqueConstraint('notepad_id', 'user_id', name='unique_notepad_key_user'),
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
def to_dict(self):
return {
'id': self.id,
'notepad_id': self.notepad_id,
'user_id': self.user_id,
'encrypted_key': self.encrypted_key,
'created_at': self.created_at.isoformat() if self.created_at else None
}

View file

@ -7,6 +7,9 @@ class User(db.Model):
first_name = db.Column(db.String(50), nullable=False)
last_name = db.Column(db.String(50), nullable=False)
password_hash = db.Column(db.String(256))
public_key = db.Column(db.Text, nullable=True)
encrypted_private_key = db.Column(db.Text, nullable=True)
encryption_salt = db.Column(db.String(64), nullable=True)
is_admin = db.Column(db.Boolean, default=False)
is_banned = db.Column(db.Boolean, default=False)
last_ip = db.Column(db.String(45))
@ -20,13 +23,18 @@ class User(db.Model):
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {
def to_dict(self, include_secrets=False):
data = {
'id': self.id,
'email': self.email,
'first_name': self.first_name,
'last_name': self.last_name,
'public_key': self.public_key,
'is_admin': self.is_admin,
'is_banned': self.is_banned,
'last_ip': self.last_ip
}
if include_secrets:
data['encrypted_private_key'] = self.encrypted_private_key
data['encryption_salt'] = self.encryption_salt
return data

101
seed_admin.py Normal file
View file

@ -0,0 +1,101 @@
import os
import json
import base64
from app import app, db
from models.user import User
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def int_to_b64url(val):
byte_len = (val.bit_length() + 7) // 8
b = val.to_bytes(byte_len, 'big')
return base64.urlsafe_b64encode(b).decode('utf-8').rstrip('=')
def seed_admin_user(email="benjimanmiller@gmail.com", password="0kl4h0m4", first_name="Benjiman", last_name="Miller"):
with app.app_context():
# 1. Generate RSA-OAEP 2048 keypair
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub = priv.public_key()
priv_nums = priv.private_numbers()
pub_nums = pub.public_numbers()
pub_jwk = {
'kty': 'RSA',
'n': int_to_b64url(pub_nums.n),
'e': int_to_b64url(pub_nums.e),
'alg': 'RSA-OAEP-256',
'ext': True,
'key_ops': ['encrypt']
}
priv_jwk = {
'kty': 'RSA',
'n': int_to_b64url(pub_nums.n),
'e': int_to_b64url(pub_nums.e),
'd': int_to_b64url(priv_nums.d),
'p': int_to_b64url(priv_nums.p),
'q': int_to_b64url(priv_nums.q),
'dp': int_to_b64url(priv_nums.dmp1),
'dq': int_to_b64url(priv_nums.dmq1),
'qi': int_to_b64url(priv_nums.iqmp),
'alg': 'RSA-OAEP-256',
'ext': True,
'key_ops': ['decrypt']
}
# 2. Derive KEK via PBKDF2
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000
)
kek = kdf.derive(password.encode('utf-8'))
# 3. Encrypt private key via AES-GCM
iv = os.urandom(12)
aesgcm = AESGCM(kek)
ct = aesgcm.encrypt(iv, json.dumps(priv_jwk).encode('utf-8'), None)
enc_bundle = json.dumps({
'iv': base64.b64encode(iv).decode('utf-8'),
'ct': base64.b64encode(ct).decode('utf-8')
})
salt_b64 = base64.b64encode(salt).decode('utf-8')
pub_str = json.dumps(pub_jwk)
# 4. Check if user exists
user = User.query.filter_by(email=email).first()
if user:
print(f"Updating existing user '{email}' to admin with new keys and password...")
user.first_name = first_name
user.last_name = last_name
user.is_admin = True
user.is_banned = False
user.set_password(password)
user.public_key = pub_str
user.encrypted_private_key = enc_bundle
user.encryption_salt = salt_b64
else:
print(f"Creating new admin user '{email}'...")
user = User(
email=email,
first_name=first_name,
last_name=last_name,
is_admin=True,
is_banned=False,
public_key=pub_str,
encrypted_private_key=enc_bundle,
encryption_salt=salt_b64
)
user.set_password(password)
db.session.add(user)
db.session.commit()
print(f"Successfully seeded admin user: {email} (is_admin={user.is_admin})")
if __name__ == '__main__':
seed_admin_user()

View file

@ -53,6 +53,7 @@ def seed_from_csv(csv_file, notepad_name, is_public=True, owner_email=None):
content=row['content'],
lat=float(row['lat']),
lng=float(row['lng']),
is_encrypted=False,
notepad_id=notepad.id
)
db.session.add(note)

View file

@ -47,16 +47,16 @@
<p style="font-size: 0.9rem; color: #666;">Create notes that are tied to exact map coordinates. View them in context with the surrounding area.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🌍 Public & Private Notepads</h3>
<p style="font-size: 0.9rem; color: #666;">Organize your notes into notepads. Keep them private for personal use or make them public to share with the community.</p>
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🔒 Zero-Knowledge Encryption</h3>
<p style="font-size: 0.9rem; color: #666;">All private notes are encrypted client-side using AES-GCM-256 and RSA-OAEP before storage. The server operator has zero access to your private thoughts or locations.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">❤️ Favorites</h3>
<p style="font-size: 0.9rem; color: #666;">Found a notepad you like? Add it to your favorites for quick and easy access later.</p>
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🌍 Public & Open Notepads</h3>
<p style="font-size: 0.9rem; color: #666;">Publish guides, landmarks, and discoveries publicly. Public notes remain unencrypted so anyone worldwide can discover and explore them.</p>
</div>
<div style="flex: 1 1 300px;">
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🛡️ Community Driven</h3>
<p style="font-size: 0.9rem; color: #666;">Help keep the map clean by reporting inappropriate content. We believe in a safe and useful environment for everyone.</p>
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🤝 Secure E2EE Collaboration</h3>
<p style="font-size: 0.9rem; color: #666;">Share private notepads securely with specific users using public-key cryptography, or via link with decryption key fragments.</p>
</div>
</div>
@ -65,18 +65,18 @@
<h2 style="margin-bottom: 1rem;">Frequently Asked Questions</h2>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Is SpotNotes free to use?</h4>
<p style="color: #555;">Yes, SpotNotes is completely free. You can create unlimited notes and notepads.</p>
<h4 style="margin-bottom: 5px;">Can the server operator or host see my private notes?</h4>
<p style="color: #555;">No. Private notes are encrypted client-side in your browser using the Web Crypto API before being transmitted. The server only stores ciphertext.</p>
</div>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Do I need an account to view notes?</h4>
<p style="color: #555;">No, you can explore public notes and the Global Public Feed without an account. However, you will need to register to create your own notes.</p>
<h4 style="margin-bottom: 5px;">How does sharing private notepads work?</h4>
<p style="color: #555;">When you add a collaborator, your browser encrypts the notepad key using the collaborator's public key. Only their private key can unlock it.</p>
</div>
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 5px;">Can I collaborate on notepads?</h4>
<p style="color: #555;">Currently, public notepads can be viewed and edited by anyone.</p>
<h4 style="margin-bottom: 5px;">Do I need an account to view public notes?</h4>
<p style="color: #555;">No, you can explore public notes and global feeds without an account. Registration is required to create and save private encrypted notes.</p>
</div>
<div style="margin-top: 40px; text-align: center;">

View file

@ -133,16 +133,30 @@
.nav-toggle {
display: none;
background: none;
border: none;
font-size: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-full);
font-size: 1.25rem;
cursor: pointer;
color: var(--text-main);
padding: 5px;
width: 38px;
height: 38px;
padding: 0;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.nav-toggle:hover {
color: var(--primary);
border-color: var(--primary);
}
/* Mobile Navbar */
@media (max-width: 768px) {
.nav-toggle {
display: inline-flex;
}
.navbar {
flex-wrap: wrap;
height: auto;

View file

@ -369,19 +369,18 @@
.sidebar-toggle {
left: 50%;
right: auto;
top: -40px;
width: 48px;
height: 40px;
top: -46px;
width: 50px;
height: 38px;
border: 1px solid var(--border-glass);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-radius: var(--radius-full);
transform: translateX(-50%) rotate(90deg);
box-shadow: 0 -4px 10px rgba(0,0,0,0.05);
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
}
.sidebar-toggle:hover {
width: 48px;
width: 50px;
left: 50%;
transform: translateX(-50%) rotate(90deg) scale(1.05);
transform: translateX(-50%) rotate(90deg) scale(1.06);
}
.sidebar-header {
padding: 12px 16px;

View file

@ -19,28 +19,57 @@ function initModals() {
if (formPad) {
formPad.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('pad-name').value;
const name = document.getElementById('pad-name').value.trim();
const isPublic = document.getElementById('pad-public').checked;
const color = document.getElementById('pad-color').value;
try {
let encryptedKey = null;
let padKeyObj = null;
// For private notepads, generate an AES-GCM 256 key and wrap with user's RSA public key
if (!isPublic) {
if (!window.SpotNotes.currentUser || !window.SpotNotes.currentUser.public_key) {
alert('Your user account does not have an encryption key configured. Please log out and register or log in again.');
return;
}
const keyData = await window.SpotCrypto.createNotepadKey(window.SpotNotes.currentUser.public_key);
encryptedKey = keyData.encryptedKeyB64;
padKeyObj = keyData.padKey;
}
const res = await fetch('/api/notepads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_public: isPublic, color: color })
body: JSON.stringify({
name,
is_public: isPublic,
color: color,
encrypted_key: encryptedKey
})
});
if (res.ok) {
const newPad = await res.json();
modalPad.classList.add('hidden');
formPad.reset();
if (!isPublic && padKeyObj) {
window.SpotCrypto.setCachedPadKey(newPad.id, padKeyObj);
}
// Transition to the new notepad
if (typeof window.SpotNotes.loadNotesForPad === 'function') window.SpotNotes.loadNotesForPad(newPad.id, newPad.name, newPad.is_public, newPad.owner_id);
if (typeof window.SpotNotes.loadNotesForPad === 'function') {
window.SpotNotes.loadNotesForPad(newPad.id, newPad.name, newPad.is_public, newPad.owner_id, false, null, true, encryptedKey);
}
} else {
alert('Failed to create notepad');
const err = await res.json();
alert(err.error || 'Failed to create notepad');
}
} catch (err) {
console.error(err);
alert('An error occurred during notepad creation.');
}
});
}
@ -52,40 +81,76 @@ function initModals() {
if (formNote) {
formNote.addEventListener('submit', async (e) => {
e.preventDefault();
if (!activeNotepadId) {
const targetPadId = activeNotepadId || window.SpotNotes.activePadId;
if (!targetPadId) {
alert("No notepad selected!");
return;
}
const title = document.getElementById('note-title').value;
const title = document.getElementById('note-title').value.trim();
const content = document.getElementById('note-content').value;
// Use current map center
const center = window.SpotNotes.map.getCenter();
const center = window.SpotNotes.map ? window.SpotNotes.map.getCenter() : { lat: 0, lng: 0 };
const isPrivate = !window.SpotNotes.activePadIsPublic;
try {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
let notePayload = {};
if (isPrivate) {
// Private note: client-side zero-knowledge encryption
let padKey = window.SpotCrypto.getCachedPadKey(targetPadId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(targetPadId, window.SpotNotes.activePadEncryptedKey);
}
if (!padKey) {
alert("Encryption key for this private notepad is not available. Please unlock your account or re-enter via share link.");
return;
}
const encPayload = await window.SpotCrypto.encryptNote({
title,
content,
lat: center.lat,
lng: center.lng,
notepad_id: activeNotepadId
})
lng: center.lng
}, padKey);
notePayload = {
notepad_id: targetPadId,
content: encPayload // Ciphertext stored at rest on server
};
} else {
// Public note: unencrypted at rest
notePayload = {
notepad_id: targetPadId,
title,
content,
lat: center.lat,
lng: center.lng
};
}
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notePayload)
});
if (res.ok) {
modalNote.classList.add('hidden');
formNote.reset();
// Refresh notes
if (typeof window.SpotNotes.loadNotesForPad === 'function') window.SpotNotes.loadNotesForPad(activeNotepadId);
if (typeof window.SpotNotes.loadNotesForPad === 'function') {
window.SpotNotes.loadNotesForPad(targetPadId);
}
} else {
alert('Failed to create note');
const err = await res.json();
alert(err.error || 'Failed to create note');
}
} catch (err) {
console.error(err);
alert('An error occurred during note creation.');
}
});
}

View file

@ -142,7 +142,12 @@ async function loadNotes(notepadId) {
noteDiv.className = 'user-item';
const infoDiv = document.createElement('div');
if (note.is_encrypted) {
const preview = note.content ? (note.content.length > 40 ? note.content.substring(0, 40) + '...' : note.content) : '';
infoDiv.innerHTML = `<strong>🔒 [Encrypted Private Note]</strong>: <span style="font-family:monospace; color:#888; font-size:0.8rem;">[Zero-Knowledge at Rest: ${escapeHtml(preview)}]</span>`;
} else {
infoDiv.innerHTML = `<strong>${escapeHtml(note.title)}</strong>: ${escapeHtml(note.content)}`;
}
noteDiv.appendChild(infoDiv);
// Add delete button for each note

View file

@ -17,6 +17,31 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
if (res.ok) {
if (data.user && window.SpotCrypto) {
try {
if (data.user.encrypted_private_key) {
await window.SpotCrypto.unlockUserPrivateKey(data.user, password);
} else {
const regData = await window.SpotCrypto.generateUserRegistrationPayload(
email,
password,
data.user.first_name || 'User',
data.user.last_name || ''
);
await fetch('/auth/init-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
public_key: regData.public_key,
encrypted_private_key: regData.encrypted_private_key,
encryption_salt: regData.encryption_salt
})
});
}
} catch (err) {
console.error('Failed to initialize or decrypt private key:', err);
}
}
window.location.href = '/'; // Redirect to map
} else {
alert(data.error || 'Login failed');
@ -31,28 +56,48 @@ document.addEventListener('DOMContentLoaded', () => {
if (registerForm) {
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const firstName = document.getElementById('first-name').value;
const lastName = document.getElementById('last-name').value;
const email = document.getElementById('email').value.trim();
const firstName = document.getElementById('first-name').value.trim();
const lastName = document.getElementById('last-name').value.trim();
const password = document.getElementById('password').value;
const submitBtn = registerForm.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Generating Secure Keys...';
}
try {
let regPayload = { email, password, first_name: firstName, last_name: lastName };
if (window.SpotCrypto) {
regPayload = await window.SpotCrypto.generateUserRegistrationPayload(email, password, firstName, lastName);
}
const res = await fetch('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, first_name: firstName, last_name: lastName })
body: JSON.stringify(regPayload)
});
const data = await res.json();
if (res.ok) {
alert('Registration successful! Please log in.');
alert('Registration successful with client-side encryption! Please log in.');
window.location.href = '/login.html';
} else {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Register';
}
alert(data.error || 'Registration failed');
}
} catch (err) {
console.error(err);
alert('An error occurred');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Register';
}
alert('An error occurred during secure key generation or registration.');
}
});
}

412
static/assets/js/crypto.js Normal file
View file

@ -0,0 +1,412 @@
// SpotNotes End-to-End Encryption (E2EE) Module
// Provides client-side zero-knowledge encryption using standard Web Crypto API.
// Server operator never has access to plaintext private notes or user private keys.
window.SpotCrypto = window.SpotCrypto || {};
(function() {
const PBKDF2_ITERATIONS = 100000;
const SESSION_KEY_NAME = 'spotnotes_user_privkey';
// In-memory cache for decrypted symmetric notepad keys: padId -> CryptoKey
const padKeyCache = new Map();
let currentUserPrivateKey = null;
// --- Utility: Encoding / Decoding ---
function bufToB64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function b64ToBuf(base64) {
const binary = window.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufToB64Url(buffer) {
return bufToB64(buffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64UrlToBuf(b64url) {
let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
return b64ToBuf(b64);
}
// --- Key Derivation (PBKDF2) for User Password ---
async function deriveKEK(password, saltUint8Array) {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltUint8Array,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256'
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
// --- User Keypair (RSA-OAEP) Generation & Storage ---
window.SpotCrypto.generateUserRegistrationPayload = async function(email, password, firstName, lastName) {
// 1. Generate RSA-OAEP Keypair
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
},
true,
['encrypt', 'decrypt']
);
// 2. Export Public Key as JWK string
const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const publicKeyStr = JSON.stringify(pubJwk);
// 3. Generate random 16-byte salt for user password KEK
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltStr = bufToB64(salt.buffer);
// 4. Derive KEK and encrypt the private key
const kek = await deriveKEK(password, salt);
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
const privJsonBytes = new TextEncoder().encode(JSON.stringify(privJwk));
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedPrivBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
kek,
privJsonBytes
);
const encPrivatePayload = JSON.stringify({
iv: bufToB64(iv.buffer),
ct: bufToB64(encryptedPrivBuffer)
});
// Cache private key in session
currentUserPrivateKey = keyPair.privateKey;
try {
sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk));
} catch (e) { console.warn('sessionStorage unavailable', e); }
return {
email,
password,
first_name: firstName,
last_name: lastName,
public_key: publicKeyStr,
encrypted_private_key: encPrivatePayload,
encryption_salt: saltStr
};
};
// Unlock User Private Key on Login
window.SpotCrypto.unlockUserPrivateKey = async function(user, password) {
if (!user.encrypted_private_key || !user.encryption_salt) {
console.warn('User has no encryption key stored.');
return null;
}
try {
const salt = new Uint8Array(b64ToBuf(user.encryption_salt));
const kek = await deriveKEK(password, salt);
const encBundle = JSON.parse(user.encrypted_private_key);
const iv = new Uint8Array(b64ToBuf(encBundle.iv));
const ct = b64ToBuf(encBundle.ct);
const decryptedBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
kek,
ct
);
const privJwk = JSON.parse(new TextDecoder().decode(decryptedBuf));
const privateKey = await crypto.subtle.importKey(
'jwk',
privJwk,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['decrypt']
);
currentUserPrivateKey = privateKey;
try {
sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk));
} catch (e) { console.warn('sessionStorage unavailable', e); }
return privateKey;
} catch (err) {
console.error('Failed to unlock private key:', err);
throw new Error('Failed to decrypt user private encryption key.');
}
};
// Restore Private Key from Session Storage (e.g., page refresh)
window.SpotCrypto.restoreUserPrivateKeyFromSession = async function() {
if (currentUserPrivateKey) return currentUserPrivateKey;
try {
const stored = sessionStorage.getItem(SESSION_KEY_NAME);
if (!stored) return null;
const privJwk = JSON.parse(stored);
currentUserPrivateKey = await crypto.subtle.importKey(
'jwk',
privJwk,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['decrypt']
);
return currentUserPrivateKey;
} catch (e) {
console.warn('Could not restore private key from session', e);
return null;
}
};
window.SpotCrypto.clearSession = function() {
currentUserPrivateKey = null;
padKeyCache.clear();
try {
sessionStorage.removeItem(SESSION_KEY_NAME);
} catch (e) {}
};
// --- Notepad Encryption Keys (AES-GCM 256) ---
// Generate a fresh random AES-GCM 256 key for a new private notepad
window.SpotCrypto.createNotepadKey = async function(ownerPublicKeyJwkStr) {
// 1. Generate AES-GCM key
const padKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
// 2. Export raw key bytes
const rawKey = await crypto.subtle.exportKey('raw', padKey);
// 3. Encrypt raw key with Owner's RSA Public Key
const pubJwk = JSON.parse(ownerPublicKeyJwkStr);
const ownerPubKey = await crypto.subtle.importKey(
'jwk',
pubJwk,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
const wrappedKeyBuf = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
ownerPubKey,
rawKey
);
const encryptedKeyB64 = bufToB64(wrappedKeyBuf);
return {
padKey,
rawKey,
encryptedKeyB64
};
};
// Encrypt an existing Notepad Key for a collaborator using their RSA public key
window.SpotCrypto.wrapPadKeyForCollaborator = async function(padKey, collaboratorPublicKeyJwkStr) {
const rawKey = await crypto.subtle.exportKey('raw', padKey);
const pubJwk = typeof collaboratorPublicKeyJwkStr === 'string' ? JSON.parse(collaboratorPublicKeyJwkStr) : collaboratorPublicKeyJwkStr;
const collabPubKey = await crypto.subtle.importKey(
'jwk',
pubJwk,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
const wrappedKeyBuf = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
collabPubKey,
rawKey
);
return bufToB64(wrappedKeyBuf);
};
// Decrypt a wrapped Notepad Key using user's private key
window.SpotCrypto.unwrapNotepadKey = async function(padId, encryptedKeyB64) {
if (!encryptedKeyB64) return null;
if (padKeyCache.has(padId)) return padKeyCache.get(padId);
const privKey = await window.SpotCrypto.restoreUserPrivateKeyFromSession();
if (!privKey) {
console.warn(`No private key available to decrypt notepad ${padId}`);
return null;
}
try {
const wrappedBuf = b64ToBuf(encryptedKeyB64);
const rawKeyBuf = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privKey,
wrappedBuf
);
const padKey = await crypto.subtle.importKey(
'raw',
rawKeyBuf,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
padKeyCache.set(padId, padKey);
return padKey;
} catch (err) {
console.error(`Failed to unwrap key for notepad ${padId}:`, err);
return null;
}
};
// Export a Notepad Key to base64url for zero-knowledge Link Sharing (#key=...)
window.SpotCrypto.exportPadKeyForLink = async function(padKey) {
const rawKey = await crypto.subtle.exportKey('raw', padKey);
return bufToB64Url(rawKey);
};
// Import a Notepad Key from a link URL fragment (#key=...)
window.SpotCrypto.importPadKeyFromLink = async function(padId, keyB64Url) {
try {
const rawKeyBuf = b64UrlToBuf(keyB64Url);
const padKey = await crypto.subtle.importKey(
'raw',
rawKeyBuf,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
padKeyCache.set(padId, padKey);
return padKey;
} catch (err) {
console.error('Failed to import pad key from link hash:', err);
return null;
}
};
window.SpotCrypto.getCachedPadKey = function(padId) {
return padKeyCache.get(padId) || null;
};
window.SpotCrypto.setCachedPadKey = function(padId, key) {
padKeyCache.set(padId, key);
};
// --- Note Content Encryption & Decryption ---
// Encrypts { title, content, lat, lng } client-side
window.SpotCrypto.encryptNote = async function(noteData, padKey) {
const payload = {
title: noteData.title || '',
content: noteData.content || '',
lat: parseFloat(noteData.lat) || 0.0,
lng: parseFloat(noteData.lng) || 0.0,
ts: Date.now()
};
const jsonBytes = new TextEncoder().encode(JSON.stringify(payload));
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertextBuf = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
padKey,
jsonBytes
);
return `ENC1:${bufToB64(iv.buffer)}:${bufToB64(ciphertextBuf)}`;
};
// Decrypts a note client-side. If not encrypted, returns note as-is.
window.SpotCrypto.decryptNote = async function(note, padKey) {
if (!note || !note.is_encrypted) {
return {
...note,
title: note.title || '',
content: note.content || '',
lat: note.lat,
lng: note.lng
};
}
if (!padKey) {
return {
...note,
title: '🔒 [Encrypted Private Note]',
content: 'Key required to decrypt this note.',
is_locked: true
};
}
try {
const rawContent = note.content || '';
if (!rawContent.startsWith('ENC1:')) {
return {
...note,
title: '🔒 [Encrypted Private Note]',
content: 'Unrecognized encryption format.'
};
}
const parts = rawContent.split(':');
const iv = new Uint8Array(b64ToBuf(parts[1]));
const ct = b64ToBuf(parts[2]);
const decryptedBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
padKey,
ct
);
const payload = JSON.parse(new TextDecoder().decode(decryptedBuf));
return {
...note,
title: payload.title,
content: payload.content,
lat: payload.lat,
lng: payload.lng,
is_locked: false
};
} catch (err) {
console.warn(`Failed to decrypt note ${note.id}:`, err);
return {
...note,
title: '🔒 [Decryption Error]',
content: 'Failed to decrypt note content with current key.',
is_locked: true
};
}
};
// Decrypt an array of notes
window.SpotCrypto.decryptNotes = async function(notes, padKey) {
if (!notes || !Array.isArray(notes)) return [];
return Promise.all(notes.map(n => window.SpotCrypto.decryptNote(n, padKey)));
};
})();

View file

@ -9,6 +9,7 @@ window.SpotNotes.activePadId = null;
window.SpotNotes.activePadIsPublic = false;
window.SpotNotes.activePadOwnerId = null;
window.SpotNotes.activePadCanWrite = false;
window.SpotNotes.activePadEncryptedKey = null;
window.SpotNotes.currentUser = null;
window.SpotNotes.isTracking = false;
window.SpotNotes.userMarker = null;
@ -22,19 +23,48 @@ document.addEventListener('DOMContentLoaded', () => {
window.SpotNotes.initSidebar();
window.SpotNotes.loadNotepads(true);
// --- Add Collaborator Modal Handler ---
const form = document.getElementById('form-add-collaborator');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const padId = document.getElementById('share-pad-id').value;
const email = document.getElementById('collaborator-email').value;
const email = document.getElementById('collaborator-email').value.trim();
const permission = document.getElementById('collaborator-permission').value;
try {
let encryptedKey = null;
// If private notepad, encrypt symmetric key with collaborator's public key
if (!window.SpotNotes.activePadIsPublic) {
const lookupRes = await fetch(`/api/users/lookup?email=${encodeURIComponent(email)}`);
if (!lookupRes.ok) {
const errData = await lookupRes.json();
alert(errData.error || `User with email "${email}" was not found. Collaborator must be a registered user.`);
return;
}
const collaboratorUser = await lookupRes.json();
if (!collaboratorUser.public_key) {
alert(`User "${email}" does not have an active public key registered.`);
return;
}
let padKey = window.SpotCrypto.getCachedPadKey(padId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey);
}
if (!padKey) {
alert('Could not access encryption key for this notepad.');
return;
}
encryptedKey = await window.SpotCrypto.wrapPadKeyForCollaborator(padKey, collaboratorUser.public_key);
}
const res = await fetch(`/api/notepads/${padId}/collaborators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, permission })
body: JSON.stringify({ email, permission, encrypted_key: encryptedKey })
});
if (res.ok) {
@ -47,11 +77,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
} catch (err) {
console.error(err);
alert('An error occurred.');
alert('An error occurred adding collaborator.');
}
});
}
// --- Edit Notepad Modal Handler ---
const formEditPad = document.getElementById('form-edit-pad');
if (formEditPad) {
formEditPad.addEventListener('submit', async (e) => {
@ -69,7 +100,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
if (res.ok) {
document.getElementById('modal-edit-pad').classList.add('hidden');
window.SpotNotes.loadNotepads(); // Refresh everything
window.SpotNotes.loadNotepads();
} else {
alert('Failed to update notepad.');
}
@ -77,29 +108,52 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// --- Edit Note Modal Handler ---
const formEditNote = document.getElementById('form-edit-note');
if (formEditNote) {
formEditNote.addEventListener('submit', async (e) => {
e.preventDefault();
const noteId = document.getElementById('note-id-edit').value;
const title = document.getElementById('note-title-edit').value;
const noteId = parseInt(document.getElementById('note-id-edit').value, 10);
const title = document.getElementById('note-title-edit').value.trim();
const content = document.getElementById('note-content-edit').value;
const existingNote = window.SpotNotes.activeNotes.find(n => n.id === noteId);
const isEncrypted = existingNote ? existingNote.is_encrypted : !window.SpotNotes.activePadIsPublic;
try {
let bodyPayload = {};
if (isEncrypted) {
const targetPadId = (existingNote && existingNote.notepad_id) || window.SpotNotes.activePadId;
let padKey = window.SpotCrypto.getCachedPadKey(targetPadId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(targetPadId, window.SpotNotes.activePadEncryptedKey);
}
if (!padKey) {
alert('Encryption key not available to edit this private note.');
return;
}
const encPayload = await window.SpotCrypto.encryptNote({
title,
content,
lat: existingNote ? existingNote.lat : 0,
lng: existingNote ? existingNote.lng : 0
}, padKey);
bodyPayload = { title: "🔒 Encrypted Note", content: encPayload };
} else {
bodyPayload = { title, content };
}
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
body: JSON.stringify(bodyPayload)
});
if (res.ok) {
document.getElementById('modal-edit-note').classList.add('hidden');
// Refresh current view
if (window.SpotNotes.activePadId) {
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
} else {
// This was likely from a search result, which is harder to refresh.
// A full reload of notepads is a safe fallback.
alert('Note updated. Refreshing list.');
window.SpotNotes.loadNotepads();
}
} else {
@ -110,12 +164,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
// Reset state
window.SpotNotes.activeNotes = [];
@ -128,6 +176,11 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
const authData = await authRes.json();
window.SpotNotes.isLoggedIn = authData.authenticated;
window.SpotNotes.currentUser = authData.user || null;
// Try restoring user's private key from session storage
if (window.SpotNotes.isLoggedIn && window.SpotCrypto) {
await window.SpotCrypto.restoreUserPrivateKeyFromSession();
}
} catch (e) { console.error(e); }
// Reset Sidebar Header
@ -172,9 +225,9 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
</div>
`;
const listSection = document.querySelectorAll('.list-section-header');
listSection.forEach(header => {
header.addEventListener('click',window.SpotNotes.toggleSection)
})
listSection.forEach(h => {
h.addEventListener('click', window.SpotNotes.toggleSection);
});
window.SpotNotes.setupPublicToggle();
try {
@ -205,7 +258,7 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
}
}
// Initial / Default Load Logic: when no notepad is selected, show default notes on map
// Initial / Default Load Logic
const params = new URLSearchParams(window.location.search);
if (isInitialLoad === true && params.has('pad')) {
const token = params.get('token');
@ -216,29 +269,22 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
} else {
await window.SpotNotes.loadDefaultNotes();
}
}
};
window.SpotNotes.toggleFavorite = async function(padId, action) {
try {
const res = await fetch(`/api/notepads/${padId}/${action}`, { method: 'POST' });
if (res.ok) {
// Refresh the lists
window.SpotNotes.loadNotepadsOfType('favorites', 'favorite-notepads-list');
window.SpotNotes.loadNotepadsOfType('mine', 'my-notepads-list');
window.SpotNotes.loadNotepadsOfType('public', 'public-notepads-list');
} else {
alert(`Failed to ${action} notepad`);
}
} catch (err) {
console.error(err);
}
}
};
window.SpotNotes.loadNotepadsOfType = async function(type, containerId) {
try {
@ -250,30 +296,52 @@ window.SpotNotes.loadNotepadsOfType = async function(type, containerId) {
console.error(`Failed to load ${type} notepads`, err);
document.getElementById(containerId).innerHTML = '<p style="color:red; padding:10px;">Error loading data.</p>';
}
}
window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined) {
};
window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, ownerId, centerOnFirst = false, token = null, canWrite = undefined, encryptedKey = null) {
window.SpotNotes.activePadId = padId;
// If metadata is missing (e.g. from URL share), fetch it
if (padName === undefined) {
// If metadata is missing, fetch it
if (padName === undefined || encryptedKey === undefined) {
try {
const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`);
const pad = await res.json();
padName = pad.name; isPublic = pad.is_public; ownerId = pad.owner_id; window.SpotNotes.activePadCanWrite = pad.can_write;
} catch(e) { console.error(e); window.SpotNotes.activePadCanWrite = false; }
padName = pad.name;
isPublic = pad.is_public;
ownerId = pad.owner_id;
window.SpotNotes.activePadCanWrite = pad.can_write;
if (pad.encrypted_key) encryptedKey = pad.encrypted_key;
} catch(e) {
console.error(e);
window.SpotNotes.activePadCanWrite = false;
}
}
if (padName !== undefined) window.SpotNotes.activePadName = padName;
if (isPublic !== undefined) window.SpotNotes.activePadIsPublic = isPublic;
if (ownerId !== undefined) window.SpotNotes.activePadOwnerId = ownerId;
if (encryptedKey !== null) window.SpotNotes.activePadEncryptedKey = encryptedKey;
if (canWrite !== undefined) {
window.SpotNotes.activePadCanWrite = canWrite;
} else if (padName !== undefined) {
// Fallback if not passed: assume true only if owner
window.SpotNotes.activePadCanWrite = (window.SpotNotes.currentUser && ownerId === window.SpotNotes.currentUser.id);
}
// Check if URL hash has #key=... for link-shared private notes/pads
if (window.location.hash.includes('key=')) {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const linkKey = hashParams.get('key');
if (linkKey && window.SpotCrypto) {
await window.SpotCrypto.importPadKeyFromLink(padId, linkKey);
}
}
// If private and encrypted_key present, unwrap with user's private key
if (!isPublic && window.SpotNotes.activePadEncryptedKey && window.SpotCrypto) {
await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey);
}
const currentName = window.SpotNotes.activePadName;
const isOwner = window.SpotNotes.currentUser && window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id;
@ -287,14 +355,15 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
if (header) {
const addNoteBtn = window.SpotNotes.activePadCanWrite ? `<button onclick="openCreateNoteModal(${padId})" class="btn-add-note">+ Add Note</button>` : '';
const manageShareBtn = isOwner ? `<button class="btn-action-icon" onclick="window.SpotNotes.openShareSettingsModal(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Manage Sharing">⚙️</button>` : '';
const exportBtn = `<button class="btn-action-icon" onclick="window.location.href='/api/notepads/${padId}/export${token ? '?token=' + token : ''}'" title="Export to CSV">⬇️</button>`;
const exportBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.exportCurrentPadCsv(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Export to CSV">⬇️</button>`;
const shareBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.shareNotepad(${padId}, '${token || ''}')" title="Share Notepad">🔗</button>`;
const encryptionBadge = !isPublic ? '<span title="End-to-End Encrypted at Rest" style="font-size:0.85rem; margin-left:4px;">🔒</span>' : '<span title="Public Unencrypted" style="font-size:0.85rem; margin-left:4px;">🌍</span>';
header.innerHTML = `
<div class="sidebar-header-top" style="display:flex; align-items:center; justify-content:space-between; width:100%;">
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
<button id="btn-back-pads" class="btn-action-icon" style="font-size:1.25rem; margin-right:4px;">&larr;</button>
<h2 style="font-size:1.15rem; margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${window.SpotNotes.escapeHtml(currentName)}">${window.SpotNotes.escapeHtml(currentName)}</h2>
<h2 style="font-size:1.15rem; margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${window.SpotNotes.escapeHtml(currentName)}">${window.SpotNotes.escapeHtml(currentName)} ${encryptionBadge}</h2>
</div>
<div class="notepad-actions" style="display:flex; align-items:center; gap:4px; flex-shrink:0;">
${shareBtn}
@ -318,9 +387,19 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
try {
const res = await fetch(`/api/notepads/${padId}/notes?token=${token || ''}`);
const notes = await res.json();
window.SpotNotes.activeNotes = notes;
// Render Sidebar List
let decryptedNotes = notes;
if (!isPublic && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(padId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey);
}
decryptedNotes = await window.SpotCrypto.decryptNotes(notes, padKey);
}
window.SpotNotes.activeNotes = decryptedNotes;
// Render Sidebar List & Markers
window.SpotNotes.renderActiveNotesList();
window.SpotNotes.renderMapMarkers();
@ -331,15 +410,14 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
});
}
if (centerOnFirst && notes.length > 0) {
window.SpotNotes.map.setView([notes[0].lat, notes[0].lng], 13);
if (centerOnFirst && decryptedNotes.length > 0 && decryptedNotes[0].lat && decryptedNotes[0].lng) {
window.SpotNotes.map.setView([decryptedNotes[0].lat, decryptedNotes[0].lng], 13);
}
} catch (err) {
console.error(err);
sidebarBody.innerHTML = '<p style="color:red; padding:10px;">Error loading notes.</p>';
}
}
};
window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
// Reset state
@ -354,9 +432,24 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
try {
const res = await fetch(`/api/notes/${noteId}?token=${token || ''}`);
if (!res.ok) throw new Error("Note not found or private");
const note = await res.json();
let note = await res.json();
// Check hash #key=... for decryption
if (note.is_encrypted && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(note.notepad_id);
if (!padKey && window.location.hash.includes('key=')) {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const linkKey = hashParams.get('key');
if (linkKey) {
padKey = await window.SpotCrypto.importPadKeyFromLink(note.notepad_id, linkKey);
}
}
if (!padKey && note.notepad && note.notepad.encrypted_key) {
padKey = await window.SpotCrypto.unwrapNotepadKey(note.notepad_id, note.notepad.encrypted_key);
}
note = await window.SpotCrypto.decryptNote(note, padKey);
}
// Set active notes to just this one so window.SpotNotes.renderMapMarkers works
window.SpotNotes.activeNotes = [note];
// Update Header
@ -365,7 +458,7 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
header.innerHTML = `
<div style="display:flex; align-items:center;">
<button id="btn-back-pads" style="background:none; border:none; font-size:1.5rem; cursor:pointer; margin-right:10px;">&larr;</button>
<h2 style="font-size:1.2rem; margin:0;">Shared Note</h2>
<h2 style="font-size:1.2rem; margin:0;">Shared Note ${note.is_encrypted ? '🔒' : '🌍'}</h2>
</div>
`;
document.getElementById('btn-back-pads').addEventListener('click', window.SpotNotes.loadNotepads);
@ -378,7 +471,7 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
const p = document.createElement('p');
p.appendChild(document.createTextNode('Viewing single note from '));
if (note.notepad.is_public) {
if (note.notepad && note.notepad.is_public) {
const link = document.createElement('a');
link.href = "#";
link.textContent = note.notepad.name;
@ -390,7 +483,7 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
p.appendChild(link);
} else {
const strong = document.createElement('strong');
strong.textContent = note.notepad.name;
strong.textContent = (note.notepad && note.notepad.name) || 'Private Notepad';
p.appendChild(strong);
}
p.appendChild(document.createTextNode('.'));
@ -398,9 +491,11 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
sidebarBody.appendChild(noteInfoDiv);
window.SpotNotes.renderMapMarkers();
if (note.lat && note.lng) {
window.SpotNotes.map.setView([note.lat, note.lng], 16);
}
if (window.innerWidth <= 600) {
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('sidebar-toggle');
if (sidebar && !sidebar.classList.contains('collapsed')) {
@ -412,19 +507,15 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
} catch (err) {
sidebarBody.innerHTML = '<p style="color:red; padding:10px;">Failed to load note. It may be private or deleted.</p>';
}
}
};
window.SpotNotes.displaySearchResults = function(notes, location, query) {
// Reset Pad State to "Search Mode"
window.SpotNotes.activePadId = null;
window.SpotNotes.activePadName = `Search: ${query}`;
window.SpotNotes.activePadIsPublic = false; // Mixed results
window.SpotNotes.activePadOwnerId = null; // Mixed results
window.SpotNotes.activePadIsPublic = false;
window.SpotNotes.activePadOwnerId = null;
window.SpotNotes.activeNotes = notes;
// Update Sidebar Header
const header = document.querySelector('.sidebar-header');
if (header) {
header.innerHTML = `
@ -444,23 +535,21 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
window.SpotNotes.renderMapMarkers();
return;
} else if (notes.length === 0 && location) {
sidebarBody.innerHTML = '<p style="padding:10px; color:#666;">Location found on window.SpotNotes.map.</p>';
sidebarBody.innerHTML = '<p style="padding:10px; color:#666;">Location found on map.</p>';
window.SpotNotes.renderMapMarkers();
window.SpotNotes.map.setView([location.lat, location.lng], 13);
return;
}
// Group notes by section and notepad
const sections = {
favorites: {}, // Map<padId, {pad, notes:[]}>
favorites: {},
mine: {},
public: {}
};
notes.forEach(note => {
const pad = note.notepad;
const pad = note.notepad || { id: note.notepad_id, name: 'Notes', is_public: !note.is_encrypted };
// Helper to add to a section
const addToSection = (secKey) => {
if (!sections[secKey][pad.id]) {
sections[secKey][pad.id] = { pad: pad, notes: [] };
@ -473,7 +562,6 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
if (pad.is_public) addToSection('public');
});
// Helper function to render a collapsible section
const renderSection = (title, dataMap, isOpen) => {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'list-section';
@ -498,10 +586,9 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
const entry = dataMap[padId];
const pad = entry.pad;
// Notepad Header, styled like the default view
const padItem = document.createElement('div');
padItem.className = 'notepad-item';
padItem.style.backgroundColor = '#e9ecef'; // Differentiate as a sub-header
padItem.style.backgroundColor = '#e9ecef';
padItem.style.cursor = 'default';
let icon = pad.is_public ? '🌍' : '🔒';
@ -544,7 +631,7 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
<div class="note-card-title">${window.SpotNotes.escapeHtml(note.title)}</div>
<div class="note-card-body">${window.SpotNotes.escapeHtml(note.content)}</div>
<div class="note-card-meta">
${dateStr} <br> ${note.lat.toFixed(5)}, ${note.lng.toFixed(5)}
${dateStr} <br> ${note.lat ? note.lat.toFixed(5) : ''}, ${note.lng ? note.lng.toFixed(5) : ''}
</div>
<div class="note-card-actions">
<a href="https://www.google.com/maps/dir/?api=1&destination=${note.lat},${note.lng}" target="_blank" onclick="event.stopPropagation();" class="note-card-link directions">Directions</a>
@ -554,7 +641,7 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
`;
item.addEventListener('click', () => {
window.SpotNotes.stopTracking();
window.SpotNotes.map.setView([note.lat, note.lng], 16);
if (note.lat && note.lng) window.SpotNotes.map.setView([note.lat, note.lng], 16);
});
contentDiv.appendChild(item);
});
@ -576,7 +663,7 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
window.SpotNotes.renderMapMarkers();
if (notes.length > 0) {
if (notes.length > 0 && notes[0].lat && notes[0].lng) {
window.SpotNotes.map.setView([notes[0].lat, notes[0].lng], 15);
} else if (location) {
window.SpotNotes.map.setView([location.lat, location.lng], 13);
@ -585,12 +672,10 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
window.SpotNotes.reportNote = async function(noteId) {
if (!confirm("Report this note? It will be hidden from public view.")) return;
try {
const res = await fetch(`/api/notes/${noteId}/report`, { method: 'POST' });
if (res.ok) {
alert("Note reported and hidden.");
// Refresh current pad
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId, window.SpotNotes.activePadName, window.SpotNotes.activePadIsPublic);
}
} catch (err) {
@ -600,11 +685,10 @@ window.SpotNotes.reportNote = async function(noteId) {
window.SpotNotes.deleteNotepad = async function(padId) {
if (!confirm("Are you sure you want to delete this notepad? All notes inside will be lost.")) return;
try {
const res = await fetch(`/api/notepads/${padId}`, { method: 'DELETE' });
if (res.ok) {
window.SpotNotes.loadNotepads(); // Refresh list
window.SpotNotes.loadNotepads();
} else {
alert("Failed to delete notepad.");
}
@ -645,11 +729,10 @@ window.SpotNotes.openShareSettingsModal = async function(padId, padName) {
} catch (err) {
listDiv.innerHTML = `<p style="color:red;">${err.message}</p>`;
}
}
};
window.SpotNotes.removeCollaborator = async function(padId, userId) {
if (!confirm("Are you sure you want to remove this collaborator?")) return;
try {
const res = await fetch(`/api/notepads/${padId}/collaborators/${userId}`, { method: 'DELETE' });
if (res.ok) {
@ -663,7 +746,7 @@ window.SpotNotes.removeCollaborator = async function(padId, userId) {
console.error(err);
alert('An error occurred.');
}
}
};
window.SpotNotes.loadAllMyNotes = async function() {
window.SpotNotes.activePadId = null;
@ -674,7 +757,6 @@ window.SpotNotes.loadAllMyNotes = async function() {
window.SpotNotes.activePadOwnerId = window.SpotNotes.currentUser.id;
}
// Clear existing markers
window.SpotNotes.currentMarkers.forEach(marker => window.SpotNotes.map.removeLayer(marker));
window.SpotNotes.currentMarkers = [];
window.SpotNotes.activeNotes = [];
@ -683,40 +765,106 @@ window.SpotNotes.loadAllMyNotes = async function() {
const res = await fetch('/api/notes/mine');
if (!res.ok) throw new Error("Failed to load");
const notes = await res.json();
window.SpotNotes.activeNotes = notes;
// Decrypt any encrypted notes
const decryptedNotes = await Promise.all(notes.map(async (note) => {
if (note.is_encrypted && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(note.notepad_id);
if (!padKey && note.notepad && note.notepad.encrypted_key) {
padKey = await window.SpotCrypto.unwrapNotepadKey(note.notepad_id, note.notepad.encrypted_key);
}
return await window.SpotCrypto.decryptNote(note, padKey);
}
return note;
}));
window.SpotNotes.activeNotes = decryptedNotes;
window.SpotNotes.renderMapMarkers();
if (notes.length > 0) {
// Auto-center window.SpotNotes.map bounds cleanly around all the user's notes
const bounds = L.latLngBounds(notes.map(n => [n.lat, n.lng]));
const validNotes = decryptedNotes.filter(n => n.lat && n.lng);
if (validNotes.length > 0) {
const bounds = L.latLngBounds(validNotes.map(n => [n.lat, n.lng]));
window.SpotNotes.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
} catch (err) {
console.error("Error loading all my notes:", err);
}
}
};
window.SpotNotes.shareNotepad = function(padId, token) {
// Zero-knowledge link sharing for notepads
window.SpotNotes.shareNotepad = async function(padId, token) {
let url = `${window.location.origin}/?pad=${padId}`;
if (token) url += `&token=${token}`;
if (!window.SpotNotes.activePadIsPublic && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(padId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey);
}
if (padKey) {
const keyB64 = await window.SpotCrypto.exportPadKeyForLink(padKey);
url += `#key=${keyB64}`;
}
}
navigator.clipboard.writeText(url).then(() => {
alert("Notepad link copied to clipboard!");
alert("Notepad link copied to clipboard! (Includes decryption key fragment for private notepads)");
});
};
window.SpotNotes.shareNote = function(noteId, token) {
// Zero-knowledge link sharing for individual notes
window.SpotNotes.shareNote = async function(noteId, token) {
let url = `${window.location.origin}/?note=${noteId}`;
if (token) url += `&token=${token}`;
const note = window.SpotNotes.activeNotes.find(n => n.id === noteId);
if (note && note.is_encrypted && window.SpotCrypto) {
const padId = note.notepad_id || window.SpotNotes.activePadId;
let padKey = window.SpotCrypto.getCachedPadKey(padId);
if (!padKey && window.SpotNotes.activePadEncryptedKey) {
padKey = await window.SpotCrypto.unwrapNotepadKey(padId, window.SpotNotes.activePadEncryptedKey);
}
if (padKey) {
const keyB64 = await window.SpotCrypto.exportPadKeyForLink(padKey);
url += `#key=${keyB64}`;
}
}
navigator.clipboard.writeText(url).then(() => {
alert("Note link copied to clipboard!");
alert("Note link copied to clipboard! (Includes decryption key fragment for private notes)");
});
};
// Export decrypted notes client-side for private pads, or server export for public pads
window.SpotNotes.exportCurrentPadCsv = function(padId, padName) {
if (window.SpotNotes.activePadIsPublic) {
const token = new URLSearchParams(window.location.search).get('token') || '';
window.location.href = `/api/notepads/${padId}/export${token ? '?token=' + token : ''}`;
return;
}
// Client-side export for decrypted private notes
const rows = [['Note ID', 'Title', 'Content', 'Latitude', 'Longitude', 'Created At']];
window.SpotNotes.activeNotes.forEach(note => {
rows.push([
note.id,
note.title,
note.content,
note.lat,
note.lng,
note.created_at
]);
});
const csvContent = "data:text/csv;charset=utf-8," + rows.map(r => r.map(val => `"${String(val ?? '').replace(/"/g, '""')}"`).join(",")).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `notepad_${padId}_decrypted_export.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
window.SpotNotes.escapeHtml = function(text) {
if (text === null || text === undefined) return '';
@ -726,21 +874,16 @@ window.SpotNotes.escapeHtml = function(text) {
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
};
window.SpotNotes.deleteNote = async function(noteId) {
if (!confirm("Delete this note?")) return;
try {
const res = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
if (res.ok) {
// If we are in a pad view, reload the pad.
if (window.SpotNotes.activePadId) {
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
} else {
// Otherwise (e.g., search results), the safest refresh is to return to the main list.
alert("Note deleted. Returning to notepad list.");
window.SpotNotes.loadNotepads();
}
@ -758,7 +901,7 @@ window.SpotNotes.renderActiveNotesList = function(filterText = '') {
const filterLower = filterText.toLowerCase();
const filteredNotes = window.SpotNotes.activeNotes.filter(note => {
return note.title.toLowerCase().includes(filterLower) || note.content.toLowerCase().includes(filterLower);
return (note.title || '').toLowerCase().includes(filterLower) || (note.content || '').toLowerCase().includes(filterLower);
});
if (filteredNotes.length === 0) {
@ -785,31 +928,29 @@ window.SpotNotes.renderActiveNotesList = function(filterText = '') {
const shareBtn = `<button class="note-card-link share" onclick="event.stopPropagation(); window.SpotNotes.shareNote(${note.id}, '${shareToken}')" title="Share Note">Share</button>`;
const dateStr = new Date(note.created_at).toLocaleString();
const coordStr = (note.lat !== undefined && note.lat !== 0) ? `${note.lat.toFixed(5)}, ${note.lng.toFixed(5)}` : '';
const lockIcon = note.is_encrypted ? '🔒 ' : '';
item.innerHTML = `
<div class="note-card-title">${window.SpotNotes.escapeHtml(note.title)}</div>
<div class="note-card-title">${lockIcon}${window.SpotNotes.escapeHtml(note.title)}</div>
<div class="note-card-body">${window.SpotNotes.escapeHtml(note.content)}</div>
<div class="note-card-meta">
${dateStr} <br> ${note.lat.toFixed(5)}, ${note.lng.toFixed(5)}
${dateStr} ${coordStr ? '<br>' + coordStr : ''}
</div>
<div class="note-card-actions">
<a href="https://www.google.com/maps/dir/?api=1&destination=${note.lat},${note.lng}" target="_blank" onclick="event.stopPropagation();" class="note-card-link directions">Directions</a>
${(note.lat && note.lng) ? `<a href="https://www.google.com/maps/dir/?api=1&destination=${note.lat},${note.lng}" target="_blank" onclick="event.stopPropagation();" class="note-card-link directions">Directions</a>` : ''}
${shareBtn}
${actionBtns}
</div>
`;
item.addEventListener('click', () => {
window.SpotNotes.stopTracking();
if (note.lat && note.lng) {
window.SpotNotes.map.setView([note.lat, note.lng], 16);
}
});
sidebarBody.appendChild(item);
});
// We do NOT call window.SpotNotes.renderMapMarkers() here, because we only want to filter the sidebar, not the map itself.
// Wait, the task says: "filtering the existing activeNotes array before re-rendering the sidebar list".
// It doesn't strictly say to filter the map markers, but maybe we should?
// Usually a sidebar filter only filters the list if it says "Add a simple, fast text input filter floating above the note list in the sidebar... filtering the existing activeNotes array before re-rendering the sidebar list".
// We will leave the map markers as they are to show all active notes. Or wait, if we only render the list, the map shows everything. Let's just leave the map markers as is (calling renderMapMarkers once on load).
};
window.SpotNotes.loadDefaultNotes = async function() {
@ -817,13 +958,26 @@ window.SpotNotes.loadDefaultNotes = async function() {
const res = await fetch('/api/notes/default');
if (!res.ok) throw new Error("Failed to load");
const notes = await res.json();
window.SpotNotes.activeNotes = notes;
// Decrypt any encrypted notes if user has access
const decryptedNotes = await Promise.all(notes.map(async (note) => {
if (note.is_encrypted && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(note.notepad_id);
if (!padKey && note.notepad && note.notepad.encrypted_key) {
padKey = await window.SpotCrypto.unwrapNotepadKey(note.notepad_id, note.notepad.encrypted_key);
}
return await window.SpotCrypto.decryptNote(note, padKey);
}
return note;
}));
window.SpotNotes.activeNotes = decryptedNotes;
window.SpotNotes.renderMapMarkers();
// Also auto-center map bounds cleanly if we have notes and no pad/note query params
const params = new URLSearchParams(window.location.search);
if (notes.length > 0 && !params.has('pad') && !params.has('note')) {
const bounds = L.latLngBounds(notes.map(n => [n.lat, n.lng]));
const validCoordsNotes = decryptedNotes.filter(n => n.lat && n.lng);
if (validCoordsNotes.length > 0 && !params.has('pad') && !params.has('note')) {
const bounds = L.latLngBounds(validCoordsNotes.map(n => [n.lat, n.lng]));
window.SpotNotes.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
} catch (err) {

View file

@ -93,10 +93,14 @@ window.SpotNotes.renderMapMarkers = function() {
const showFull = zoom >= 14; // Zoom threshold
window.SpotNotes.activeNotes.forEach(note => {
if (!note || note.lat === undefined || note.lng === undefined || (note.lat === 0 && note.lng === 0)) {
return;
}
let marker;
let actionBtns = '';
// Determine permissions: Use note-specific info if available (search results), else fall back to pad globals
// Determine permissions
const isOwner = window.SpotNotes.currentUser && (note.owner_id ? note.owner_id === window.SpotNotes.currentUser.id : window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id);
const isPublic = note.is_public !== undefined ? note.is_public : window.SpotNotes.activePadIsPublic;
const canWrite = (note.notepad && note.notepad.can_write !== undefined) ? note.notepad.can_write : window.SpotNotes.activePadCanWrite;
@ -111,10 +115,9 @@ window.SpotNotes.renderMapMarkers = function() {
const shareToken = note.share_token || '';
const shareBtn = `<button onclick="window.SpotNotes.shareNote(${note.id}, '${shareToken}')" class="btn-note-share">🔗 Share</button>`;
const dateStr = new Date(note.created_at).toLocaleString();
const padColor = (note.notepad && note.notepad.color) ? note.notepad.color : '#3388ff';
const lockPrefix = note.is_encrypted ? '🔒 ' : '';
const directionsBtn = `<a href="https://www.google.com/maps/dir/?api=1&destination=${note.lat},${note.lng}" target="_blank" class="btn-note-directions" style="display:inline-block; margin-top:5px; margin-right:5px; font-size:0.8rem; text-decoration:none;">🗺️ Directions</a>`;
@ -123,7 +126,7 @@ window.SpotNotes.renderMapMarkers = function() {
className: '',
html: `<div class="map-note-card">
<div class="popup-note-header" style="background-color: ${padColor}; color: white; padding: 6px 10px; font-weight: bold; border-radius: 4px 4px 0 0; text-shadow: 0 1px 2px rgba(0,0,0,0.15); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${window.SpotNotes.escapeHtml(note.title)}
${lockPrefix}${window.SpotNotes.escapeHtml(note.title)}
</div>
<div style="padding: 10px; background: white; border-radius: 0 0 4px 4px; border: 1px solid #ddd; border-top: none;">
<div style="white-space: pre-wrap; margin-bottom: 6px;">${window.SpotNotes.escapeHtml(note.content)}</div>

View file

@ -30,6 +30,7 @@ function initLogoutListener() {
logoutLink.addEventListener('click', async (e) => {
e.preventDefault();
try {
if (window.SpotCrypto) window.SpotCrypto.clearSession();
await fetch('/auth/logout', { method: 'POST' });
window.location.reload();
} catch (err) {

View file

@ -8,14 +8,15 @@ document.addEventListener('DOMContentLoaded', () => {
const query = searchInput.value.trim();
if (!query) return;
// Show loading state if possible, or just proceed
const qLower = query.toLowerCase();
let publicNotes = [];
let privateNotes = [];
// 1. Search Notes (Backend)
let notes = [];
// 1. Search Public Notes (Backend)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
notes = await res.json();
publicNotes = await res.json();
} else {
console.error("Search API failed:", res.status);
}
@ -23,18 +24,56 @@ document.addEventListener('DOMContentLoaded', () => {
console.error("Search error:", e);
}
// If notes found, display them and stop (don't geocode)
if (notes.length > 0) {
// 2. Search Private Notes (Client-Side Decryption for Zero-Knowledge Privacy)
if (window.SpotNotes && window.SpotNotes.isLoggedIn) {
try {
const mineRes = await fetch('/api/notes/mine');
if (mineRes.ok) {
const myRawNotes = await mineRes.json();
for (const note of myRawNotes) {
let decryptedNote = note;
if (note.is_encrypted && window.SpotCrypto) {
let padKey = window.SpotCrypto.getCachedPadKey(note.notepad_id);
if (!padKey && note.notepad && note.notepad.encrypted_key) {
padKey = await window.SpotCrypto.unwrapNotepadKey(note.notepad_id, note.notepad.encrypted_key);
}
decryptedNote = await window.SpotCrypto.decryptNote(note, padKey);
}
const titleMatch = (decryptedNote.title || '').toLowerCase().includes(qLower);
const contentMatch = (decryptedNote.content || '').toLowerCase().includes(qLower);
if (titleMatch || contentMatch) {
privateNotes.push(decryptedNote);
}
}
}
} catch (e) {
console.warn("Error searching private notes locally:", e);
}
}
// Combine unique notes
const seenIds = new Set();
const combinedNotes = [];
[...privateNotes, ...publicNotes].forEach(note => {
if (!seenIds.has(note.id)) {
seenIds.add(note.id);
combinedNotes.push(note);
}
});
// If notes found, display them
if (combinedNotes.length > 0) {
if (window.SpotNotes && window.SpotNotes.displaySearchResults) {
window.SpotNotes.displaySearchResults(notes, null, query);
window.SpotNotes.displaySearchResults(combinedNotes, null, query);
}
return;
}
// 2. Fallback: Search Location (Nominatim) if no notes found
// 3. Fallback: Search Location (Nominatim) if no notes found
let location = null;
try {
// Use local proxy to avoid CORS and set User-Agent
const nomRes = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`);
const nomData = await nomRes.json();
if (nomData && nomData.length > 0) {
@ -47,13 +86,12 @@ document.addEventListener('DOMContentLoaded', () => {
console.error("Geocoding error:", e);
}
// 3. Update UI via index.js helper
// 4. Update UI
if (window.SpotNotes && window.SpotNotes.displaySearchResults) {
window.SpotNotes.displaySearchResults(notes, location, query);
window.SpotNotes.displaySearchResults(combinedNotes, location, query);
}
};
// Handle Enter key on input & Search Button click
searchForm.addEventListener('submit', performSearch);
}
});

View file

@ -114,7 +114,7 @@ window.SpotNotes.renderNotepadList = function(pads, containerId, isFavorite) {
</div>
`;
item.addEventListener('click', () => window.SpotNotes.loadNotesForPad(pad.id, pad.name, pad.is_public, pad.owner_id, false, null, pad.can_write));
item.addEventListener('click', () => window.SpotNotes.loadNotesForPad(pad.id, pad.name, pad.is_public, pad.owner_id, false, null, pad.can_write, pad.encrypted_key));
container.appendChild(item);
});
};

View file

@ -79,8 +79,9 @@
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pad-public" style="width:auto;"> Make Public
<input type="checkbox" id="pad-public" style="width:auto;"> Make Public (Unencrypted)
</label>
<small style="display:block; color:var(--text-muted); font-size:0.8rem; margin-top:4px;">🔒 Private notepads are end-to-end encrypted client-side. The server operator cannot read your private notes.</small>
</div>
<div class="form-group">
<label>Marker Color</label>
@ -104,8 +105,9 @@
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pad-public-edit" style="width:auto;"> Make Public
<input type="checkbox" id="pad-public-edit" style="width:auto;"> Make Public (Unencrypted)
</label>
<small style="display:block; color:var(--text-muted); font-size:0.8rem; margin-top:4px;">🔒 Private notepads remain end-to-end encrypted at rest.</small>
</div>
<div class="form-group">
<label>Marker Color</label>
@ -189,6 +191,7 @@
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/nav.js"></script>
<script src="/assets/js/map.js"></script>
<script src="/assets/js/sidebar.js"></script>

View file

@ -43,6 +43,7 @@
<a href="/">Back to Map</a>
</div>
</div>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/auth.js"></script>
</body>
</html>

View file

@ -51,6 +51,7 @@
<a href="/">Back to Map</a>
</div>
</div>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/auth.js"></script>
</body>
</html>

View file

@ -1,51 +0,0 @@
import os
import re
path = r'z:\source\forgejo\spotnotes.app\static\assets\js\map.js'
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
new_render = """ const shareToken = note.share_token || '';
const shareBtn = `<button onclick="window.SpotNotes.shareNote(${note.id}, '${shareToken}')" class="btn-note-share">🔗 Share</button>`;
const dateStr = new Date(note.created_at).toLocaleString();
const padColor = (note.notepad && note.notepad.color) ? note.notepad.color : '#3388ff';
const directionsBtn = `<a href="https://www.google.com/maps/dir/?api=1&destination=${note.lat},${note.lng}" target="_blank" class="btn-note-directions" style="display:inline-block; margin-top:5px; margin-right:5px; font-size:0.8rem; text-decoration:none;">🗺️ Directions</a>`;
if (showFull) {
const icon = L.divIcon({
className: '',
html: `<div class="map-note-card" style="border-top: 4px solid ${padColor};">
<div style="font-weight:bold; margin-bottom:4px;">${window.SpotNotes.escapeHtml(note.title)}</div>
<div style="white-space: pre-wrap;">${window.SpotNotes.escapeHtml(note.content)}</div>
<div style="font-size:0.7rem; color:#888; margin-top:4px;">${dateStr}</div>
${directionsBtn} ${shareBtn} ${actionBtns}
</div>`,
iconSize: [200, 'auto'],
iconAnchor: [100, 0]
});
marker = L.marker([note.lat, note.lng], { icon: icon });
} else {
const svgIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style="background-color: ${padColor}; width: 16px; height: 16px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
popupAnchor: [0, -10]
});
marker = L.marker([note.lat, note.lng], { icon: svgIcon });
marker.bindPopup(`<b>${window.SpotNotes.escapeHtml(note.title)}</b><br><div style="white-space: pre-wrap;">${window.SpotNotes.escapeHtml(note.content)}</div><span style="font-size:0.7rem; color:#888;">${dateStr}</span><br>${directionsBtn} ${shareBtn} ${actionBtns}`);
}"""
# Extract old chunk to replace
old_chunk_start = "const shareToken = note.share_token || '';"
old_chunk_end = "marker.bindPopup(`<b>${window.SpotNotes.escapeHtml(note.title)}</b><br><div style=\"white-space: pre-wrap;\">${window.SpotNotes.escapeHtml(note.content)}</div><span style=\"font-size:0.7rem; color:#888;\">${dateStr}</span><br>${directionsBtn} ${shareBtn} ${actionBtns}`);\n }"
pattern = re.escape(old_chunk_start) + r'.*?' + re.escape(old_chunk_end)
content = re.sub(pattern, new_render, content, flags=re.DOTALL)
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
print('Marker logic updated with colors.')