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:
parent
2e3694d2f7
commit
97ca1925d2
28 changed files with 1306 additions and 315 deletions
126
README.md
126
README.md
|
|
@ -1,78 +1,125 @@
|
||||||
# SpotNotes
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Interactive Map:** View and create notes on a map powered by Leaflet and OpenStreetMap.
|
- **Interactive Map:** View and create notes on a high-performance Leaflet & OpenStreetMap interface.
|
||||||
- **Location-Based Notes:** Every note is tied to specific geographic coordinates.
|
- **Location-Based Notes:** Every note is placed at precise coordinates.
|
||||||
- **Notepads:** Organize notes into collections called "Notepads".
|
- **Public & Private Notepads:** Choose between open public discovery or zero-knowledge private notes.
|
||||||
- **Privacy & Sharing:** Notepads can be private (only you), public (everyone), or shared via link tokens.
|
- **E2EE Collaboration:** Add collaborators with Read or Read/Write permissions with public-key key exchange.
|
||||||
- **User Authentication:** Secure registration and login system.
|
- **Zero-Knowledge Sharing Links:** Share private notes/notepads securely via link tokens and URL key fragments.
|
||||||
- **Favorites:** Save and easily access your favorite public notepads.
|
- **Local Decrypted Search & Geocoding:** Search notes across public feeds and client-side decrypted private notebooks, or geocode addresses globally.
|
||||||
- **Search & Geocoding:** Search through your notes or use geocoding to find locations worldwide.
|
- **Client-Side CSV Export:** Download clean CSV backups of your decrypted notes.
|
||||||
- **Admin Dashboard:** Manage users, notepads, and 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
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend:** Python, Flask, Flask-SQLAlchemy (with SQLite for development/PostgreSQL for production).
|
- **Backend:** Python, Flask, Flask-SQLAlchemy (SQLite for development / PostgreSQL for production), Gunicorn.
|
||||||
- **Frontend:** HTML5, CSS3 (Vanilla), Vanilla JavaScript, Leaflet.js for maps.
|
- **Frontend:** Vanilla HTML5, Modern CSS3 with Glassmorphism, Vanilla JavaScript (ES6+), Web Crypto API, Leaflet.js.
|
||||||
- **Deployment:** Configured for Gunicorn and standard WSGI environments (includes `Procfile` and `gunicorn_config.py`).
|
- **Security:** Web Crypto API (`crypto.subtle`), PBKDF2, AES-GCM, RSA-OAEP, Werkzeug password hashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
spotnotes.app/
|
spotnotes.app/
|
||||||
│
|
│
|
||||||
├── app.py # Main application entry point
|
├── app.py # Main application entry point & DB initialization
|
||||||
├── config.py # Configuration settings
|
├── config.py # Configuration settings
|
||||||
├── extensions.py # Flask extensions (SQLAlchemy, CORS)
|
├── extensions.py # Flask extensions (SQLAlchemy, CORS)
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
|
├── seed_notes.py # CLI tool to seed public landmark notes from CSV
|
||||||
│
|
│
|
||||||
├── blueprints/ # Flask Blueprints (Controllers)
|
├── blueprints/ # Flask Blueprints (Controllers)
|
||||||
│ ├── admin.py # Admin dashboard routes
|
│ ├── admin.py # Admin dashboard routes
|
||||||
│ ├── api.py # Main REST API for notes/notepads
|
│ ├── api.py # REST API for notes, notepads, keys, & collaborators
|
||||||
│ ├── auth.py # User authentication routes
|
│ ├── auth.py # User authentication with public key exchange
|
||||||
│ ├── search.py # Search and geocoding logic
|
│ ├── search.py # Search (public notes) and geocoding proxy
|
||||||
│ └── static_routes.py # Serving static HTML pages
|
│ └── static_routes.py # Serving static HTML pages
|
||||||
│
|
│
|
||||||
├── models/ # SQLAlchemy Database Models
|
├── models/ # SQLAlchemy Database Models
|
||||||
│ ├── banned_ip.py # Security model for IP bans
|
│ ├── banned_ip.py # Security model for IP bans
|
||||||
|
│ ├── collaborator.py # Notepad collaboration permissions
|
||||||
│ ├── favorite.py # User's favorited notepads
|
│ ├── 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
|
│ ├── 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
|
└── static/ # Frontend Assets
|
||||||
├── index.html # Main map interface
|
├── index.html # Main map interface
|
||||||
├── login.html # Login page
|
├── login.html # Login page with private key unlock
|
||||||
├── register.html # Registration page
|
├── register.html # Registration page with keypair generation
|
||||||
├── admin.html # Admin interface
|
├── admin.html # Admin moderation interface
|
||||||
|
├── about.html # About page & privacy overview
|
||||||
|
├── contact.html # Contact page
|
||||||
└── assets/
|
└── assets/
|
||||||
├── css/ # Modular stylesheets
|
├── css/ # Modular stylesheets (base, map, navbar, sidebar, modals)
|
||||||
├── js/ # Modular vanilla JS scripts
|
├── js/
|
||||||
└── images/ # Images and icons
|
│ ├── 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
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.8+
|
- Python 3.8+
|
||||||
- pip (Python package installer)
|
- Modern Web Browser (Chrome, Edge, Firefox, Safari) with Web Crypto API support
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
1. **Clone the repository** (or download the source):
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd spotnotes.app
|
cd spotnotes.app
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create a Virtual Environment (Optional but recommended):**
|
2. **Set up a Virtual Environment:**
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv .venv
|
||||||
source venv/bin/activate # On Windows use: venv\Scripts\activate
|
# On Windows:
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# On macOS/Linux:
|
||||||
|
source .venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install Dependencies:**
|
3. **Install Dependencies:**
|
||||||
|
|
@ -80,21 +127,20 @@ spotnotes.app/
|
||||||
pip install -r requirements.txt
|
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
|
```bash
|
||||||
python app.py
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
1
app.py
1
app.py
|
|
@ -21,6 +21,7 @@ with app.app_context():
|
||||||
from models.note import Note
|
from models.note import Note
|
||||||
from models.banned_ip import BannedIP
|
from models.banned_ip import BannedIP
|
||||||
from models.collaborator import Collaborator
|
from models.collaborator import Collaborator
|
||||||
|
from models.notepad_key import NotepadKey
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,11 @@ def delete_notepad(notepad_id):
|
||||||
for note in notepad.notes:
|
for note in notepad.notes:
|
||||||
db.session.delete(note)
|
db.session.delete(note)
|
||||||
from models.favorite import Favorite
|
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()
|
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.delete(notepad)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Notepad deleted'}), 200
|
return jsonify({'message': 'Notepad deleted'}), 200
|
||||||
|
|
@ -87,13 +91,19 @@ def delete_user(user_id):
|
||||||
# Clean up user's notepads and notes
|
# Clean up user's notepads and notes
|
||||||
notepads = Notepad.query.filter_by(user_id=user.id).all()
|
notepads = Notepad.query.filter_by(user_id=user.id).all()
|
||||||
from models.favorite import Favorite
|
from models.favorite import Favorite
|
||||||
|
from models.notepad_key import NotepadKey
|
||||||
|
from models.collaborator import Collaborator
|
||||||
for notepad in notepads:
|
for notepad in notepads:
|
||||||
for note in notepad.notes:
|
for note in notepad.notes:
|
||||||
db.session.delete(note)
|
db.session.delete(note)
|
||||||
Favorite.query.filter_by(notepad_id=notepad.id).delete()
|
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.delete(notepad)
|
||||||
|
|
||||||
Favorite.query.filter_by(user_id=user.id).delete()
|
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.delete(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'User deleted'}), 200
|
return jsonify({'message': 'User deleted'}), 200
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from models.note import Note
|
||||||
from models.collaborator import Collaborator
|
from models.collaborator import Collaborator
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from models.favorite import Favorite
|
from models.favorite import Favorite
|
||||||
|
from models.notepad_key import NotepadKey
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
import csv
|
import csv
|
||||||
from io import StringIO
|
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()
|
collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first()
|
||||||
return collab is not None
|
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'])
|
@api_bp.route('/notepads', methods=['GET'])
|
||||||
def get_notepads():
|
def get_notepads():
|
||||||
user_id = session.get('user_id')
|
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))
|
# 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())
|
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()
|
collab_write_pad_ids = set()
|
||||||
|
user_keys = {}
|
||||||
if user_id:
|
if user_id:
|
||||||
collab_write_pad_ids = {c.notepad_id for c in Collaborator.query.filter_by(user_id=user_id, permission='write').all()}
|
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 = []
|
results = []
|
||||||
for pad in pads:
|
for pad in pads:
|
||||||
note_count = note_counts.get(pad.id, 0)
|
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 user_id:
|
||||||
if pad.user_id == user_id:
|
if pad.user_id == user_id:
|
||||||
p_dict['share_token'] = get_share_token('notepad', pad.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):
|
if not check_pad_read_permission(notepad, user_id, token):
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
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)
|
p_dict['can_write'] = check_pad_write_permission(notepad, user_id)
|
||||||
if user_id and notepad.user_id == user_id:
|
if user_id and notepad.user_id == user_id:
|
||||||
p_dict['share_token'] = get_share_token('notepad', pad_id)
|
p_dict['share_token'] = get_share_token('notepad', pad_id)
|
||||||
|
|
@ -114,10 +142,27 @@ def create_notepad():
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
|
||||||
data = request.get_json()
|
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.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()
|
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'])
|
@api_bp.route('/notepads/<int:pad_id>/notes', methods=['GET'])
|
||||||
def get_notes(pad_id):
|
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()
|
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())
|
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 = []
|
results = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
n_dict = note.to_dict()
|
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['owner_id'] = user_id
|
||||||
n_dict['is_public'] = note.notepad.is_public
|
n_dict['is_public'] = note.notepad.is_public
|
||||||
n_dict['share_token'] = get_share_token('note', note.id)
|
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'])
|
@api_bp.route('/notes/default', methods=['GET'])
|
||||||
def get_default_notes():
|
def get_default_notes():
|
||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
|
user_keys = {}
|
||||||
if user_id:
|
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()]
|
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()]
|
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))
|
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()
|
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:
|
else:
|
||||||
# Logged out: All public notes
|
# Logged out: All public notes
|
||||||
notes = Note.query.join(Notepad).filter(Notepad.is_public == True, Note.is_hidden == False).all()
|
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())
|
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 = []
|
results = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
n_dict = note.to_dict()
|
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['owner_id'] = note.notepad.user_id
|
||||||
n_dict['is_public'] = note.notepad.is_public
|
n_dict['is_public'] = note.notepad.is_public
|
||||||
if user_id and note.notepad.user_id == user_id:
|
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):
|
if not check_pad_write_permission(notepad, user_id):
|
||||||
return jsonify({'error': 'Forbidden'}), 403
|
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(
|
new_note = Note(
|
||||||
title=data.get('title', 'Untitled'),
|
title=data.get('title', 'Untitled'),
|
||||||
content=data['content'],
|
content=data['content'],
|
||||||
lat=data['lat'],
|
lat=float(data.get('lat', 0.0)),
|
||||||
lng=data['lng'],
|
lng=float(data.get('lng', 0.0)),
|
||||||
|
is_encrypted=False,
|
||||||
notepad_id=notepad_id
|
notepad_id=notepad_id
|
||||||
)
|
)
|
||||||
db.session.add(new_note)
|
db.session.add(new_note)
|
||||||
|
|
@ -255,6 +314,11 @@ def update_note(note_id):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
note.title = data.get('title', note.title)
|
note.title = data.get('title', note.title)
|
||||||
note.content = data.get('content', note.content)
|
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()
|
db.session.commit()
|
||||||
return jsonify(note.to_dict())
|
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)
|
new_collab = Collaborator(notepad_id=pad_id, user_id=collaborator_user.id, permission=permission)
|
||||||
db.session.add(new_collab)
|
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()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Collaborator added/updated successfully'}), 200
|
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:
|
if not collab_to_remove:
|
||||||
return jsonify({'error': 'Collaborator not found'}), 404
|
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.delete(collab_to_remove)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Collaborator removed'}), 200
|
return jsonify({'message': 'Collaborator removed'}), 200
|
||||||
|
|
@ -413,6 +487,8 @@ def delete_notepad(pad_id):
|
||||||
for note in notepad.notes:
|
for note in notepad.notes:
|
||||||
db.session.delete(note)
|
db.session.delete(note)
|
||||||
Favorite.query.filter_by(notepad_id=notepad.id).delete()
|
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.delete(notepad)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Notepad deleted'}), 200
|
return jsonify({'message': 'Notepad deleted'}), 200
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ def register():
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
first_name = data.get('first_name')
|
first_name = data.get('first_name')
|
||||||
last_name = data.get('last_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:
|
if not email or not password or not first_name or not last_name:
|
||||||
return jsonify({'error': 'All fields are required'}), 400
|
return jsonify({'error': 'All fields are required'}), 400
|
||||||
|
|
@ -24,7 +27,15 @@ def register():
|
||||||
if User.query.filter_by(email=email).first():
|
if User.query.filter_by(email=email).first():
|
||||||
return jsonify({'error': 'Email already exists'}), 400
|
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)
|
new_user.set_password(password)
|
||||||
|
|
||||||
db.session.add(new_user)
|
db.session.add(new_user)
|
||||||
|
|
@ -52,7 +63,7 @@ def login():
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session['user_id'] = user.id
|
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
|
return jsonify({'error': 'Invalid username or password'}), 401
|
||||||
|
|
||||||
|
|
@ -69,5 +80,25 @@ def get_current_user():
|
||||||
|
|
||||||
user = User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
if user:
|
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
|
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
|
||||||
|
|
@ -5,7 +5,6 @@ from models.note import Note
|
||||||
from models.notepad import Notepad
|
from models.notepad import Notepad
|
||||||
from models.favorite import Favorite
|
from models.favorite import Favorite
|
||||||
from models.collaborator import Collaborator
|
from models.collaborator import Collaborator
|
||||||
from .api import check_pad_write_permission
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import json
|
import json
|
||||||
|
|
@ -18,26 +17,20 @@ def search_notes():
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
# Join Note and Notepad to filter by permissions
|
user_id = session.get('user_id')
|
||||||
# Explicitly join on the foreign key to ensure correct results
|
|
||||||
|
# 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(
|
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_(
|
or_(
|
||||||
Note.title.ilike(f'%{query}%'),
|
Note.title.ilike(f'%{query}%'),
|
||||||
Note.content.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()
|
results = sql_query.all()
|
||||||
|
|
||||||
# Get user favorites if logged in
|
# Get user favorites if logged in
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class Note(db.Model):
|
||||||
lng = db.Column(db.Float, nullable=False)
|
lng = db.Column(db.Float, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
is_hidden = db.Column(db.Boolean, default=False)
|
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)
|
notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
|
@ -21,5 +22,6 @@ class Note(db.Model):
|
||||||
'content': self.content,
|
'content': self.content,
|
||||||
'lat': self.lat,
|
'lat': self.lat,
|
||||||
'lng': self.lng,
|
'lng': self.lng,
|
||||||
|
'is_encrypted': self.is_encrypted,
|
||||||
'created_at': self.created_at.isoformat()
|
'created_at': self.created_at.isoformat()
|
||||||
}
|
}
|
||||||
|
|
@ -11,16 +11,19 @@ class Notepad(db.Model):
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
|
|
||||||
notes = db.relationship('Note', backref='notepad', lazy='dynamic', cascade="all, delete-orphan")
|
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):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def to_dict(self, note_count=None):
|
def to_dict(self, note_count=None, encrypted_key=None):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'is_public': self.is_public,
|
'is_public': self.is_public,
|
||||||
|
'is_encrypted': not self.is_public,
|
||||||
'owner_id': self.user_id,
|
'owner_id': self.user_id,
|
||||||
'color': self.color,
|
'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()
|
'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
29
models/notepad_key.py
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,9 @@ class User(db.Model):
|
||||||
first_name = db.Column(db.String(50), nullable=False)
|
first_name = db.Column(db.String(50), nullable=False)
|
||||||
last_name = db.Column(db.String(50), nullable=False)
|
last_name = db.Column(db.String(50), nullable=False)
|
||||||
password_hash = db.Column(db.String(256))
|
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_admin = db.Column(db.Boolean, default=False)
|
||||||
is_banned = db.Column(db.Boolean, default=False)
|
is_banned = db.Column(db.Boolean, default=False)
|
||||||
last_ip = db.Column(db.String(45))
|
last_ip = db.Column(db.String(45))
|
||||||
|
|
@ -20,13 +23,18 @@ class User(db.Model):
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self, include_secrets=False):
|
||||||
return {
|
data = {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'email': self.email,
|
'email': self.email,
|
||||||
'first_name': self.first_name,
|
'first_name': self.first_name,
|
||||||
'last_name': self.last_name,
|
'last_name': self.last_name,
|
||||||
|
'public_key': self.public_key,
|
||||||
'is_admin': self.is_admin,
|
'is_admin': self.is_admin,
|
||||||
'is_banned': self.is_banned,
|
'is_banned': self.is_banned,
|
||||||
'last_ip': self.last_ip
|
'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
101
seed_admin.py
Normal 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()
|
||||||
|
|
@ -53,6 +53,7 @@ def seed_from_csv(csv_file, notepad_name, is_public=True, owner_email=None):
|
||||||
content=row['content'],
|
content=row['content'],
|
||||||
lat=float(row['lat']),
|
lat=float(row['lat']),
|
||||||
lng=float(row['lng']),
|
lng=float(row['lng']),
|
||||||
|
is_encrypted=False,
|
||||||
notepad_id=notepad.id
|
notepad_id=notepad.id
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
||||||
<div style="flex: 1 1 300px;">
|
<div style="flex: 1 1 300px;">
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🌍 Public & Private Notepads</h3>
|
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🔒 Zero-Knowledge Encryption</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>
|
<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>
|
||||||
<div style="flex: 1 1 300px;">
|
<div style="flex: 1 1 300px;">
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">❤️ Favorites</h3>
|
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🌍 Public & Open Notepads</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>
|
<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>
|
||||||
<div style="flex: 1 1 300px;">
|
<div style="flex: 1 1 300px;">
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🛡️ Community Driven</h3>
|
<h3 style="font-size: 1.1rem; margin-bottom: 5px;">🤝 Secure E2EE Collaboration</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -65,18 +65,18 @@
|
||||||
<h2 style="margin-bottom: 1rem;">Frequently Asked Questions</h2>
|
<h2 style="margin-bottom: 1rem;">Frequently Asked Questions</h2>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<h4 style="margin-bottom: 5px;">Is SpotNotes free to use?</h4>
|
<h4 style="margin-bottom: 5px;">Can the server operator or host see my private notes?</h4>
|
||||||
<p style="color: #555;">Yes, SpotNotes is completely free. You can create unlimited notes and notepads.</p>
|
<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>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<h4 style="margin-bottom: 5px;">Do I need an account to view notes?</h4>
|
<h4 style="margin-bottom: 5px;">How does sharing private notepads work?</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>
|
<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>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<h4 style="margin-bottom: 5px;">Can I collaborate on notepads?</h4>
|
<h4 style="margin-bottom: 5px;">Do I need an account to view public notes?</h4>
|
||||||
<p style="color: #555;">Currently, public notepads can be viewed and edited by anyone.</p>
|
<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>
|
||||||
|
|
||||||
<div style="margin-top: 40px; text-align: center;">
|
<div style="margin-top: 40px; text-align: center;">
|
||||||
|
|
|
||||||
|
|
@ -133,16 +133,30 @@
|
||||||
|
|
||||||
.nav-toggle {
|
.nav-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
background: var(--bg-card);
|
||||||
border: none;
|
border: 1px solid var(--border);
|
||||||
font-size: 1.5rem;
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-main);
|
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 */
|
/* Mobile Navbar */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.nav-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
.navbar {
|
.navbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
|
||||||
|
|
@ -369,19 +369,18 @@
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
right: auto;
|
right: auto;
|
||||||
top: -40px;
|
top: -46px;
|
||||||
width: 48px;
|
width: 50px;
|
||||||
height: 40px;
|
height: 38px;
|
||||||
border: 1px solid var(--border-glass);
|
border: 1px solid var(--border-glass);
|
||||||
border-bottom: none;
|
border-radius: var(--radius-full);
|
||||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
||||||
transform: translateX(-50%) rotate(90deg);
|
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 {
|
.sidebar-toggle:hover {
|
||||||
width: 48px;
|
width: 50px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) rotate(90deg) scale(1.05);
|
transform: translateX(-50%) rotate(90deg) scale(1.06);
|
||||||
}
|
}
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|
|
||||||
|
|
@ -19,28 +19,57 @@ function initModals() {
|
||||||
if (formPad) {
|
if (formPad) {
|
||||||
formPad.addEventListener('submit', async (e) => {
|
formPad.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
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 isPublic = document.getElementById('pad-public').checked;
|
||||||
const color = document.getElementById('pad-color').value;
|
const color = document.getElementById('pad-color').value;
|
||||||
|
|
||||||
try {
|
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', {
|
const res = await fetch('/api/notepads', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (res.ok) {
|
||||||
const newPad = await res.json();
|
const newPad = await res.json();
|
||||||
modalPad.classList.add('hidden');
|
modalPad.classList.add('hidden');
|
||||||
formPad.reset();
|
formPad.reset();
|
||||||
|
|
||||||
|
if (!isPublic && padKeyObj) {
|
||||||
|
window.SpotCrypto.setCachedPadKey(newPad.id, padKeyObj);
|
||||||
|
}
|
||||||
|
|
||||||
// Transition to the new notepad
|
// 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 {
|
} else {
|
||||||
alert('Failed to create notepad');
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to create notepad');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
alert('An error occurred during notepad creation.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -52,40 +81,76 @@ function initModals() {
|
||||||
if (formNote) {
|
if (formNote) {
|
||||||
formNote.addEventListener('submit', async (e) => {
|
formNote.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!activeNotepadId) {
|
const targetPadId = activeNotepadId || window.SpotNotes.activePadId;
|
||||||
|
if (!targetPadId) {
|
||||||
alert("No notepad selected!");
|
alert("No notepad selected!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.getElementById('note-title').value;
|
const title = document.getElementById('note-title').value.trim();
|
||||||
const content = document.getElementById('note-content').value;
|
const content = document.getElementById('note-content').value;
|
||||||
|
|
||||||
// Use current map center
|
// 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 {
|
try {
|
||||||
const res = await fetch('/api/notes', {
|
let notePayload = {};
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (isPrivate) {
|
||||||
body: JSON.stringify({
|
// 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,
|
title,
|
||||||
content,
|
content,
|
||||||
lat: center.lat,
|
lat: center.lat,
|
||||||
lng: center.lng,
|
lng: center.lng
|
||||||
notepad_id: activeNotepadId
|
}, 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) {
|
if (res.ok) {
|
||||||
modalNote.classList.add('hidden');
|
modalNote.classList.add('hidden');
|
||||||
formNote.reset();
|
formNote.reset();
|
||||||
// Refresh notes
|
// Refresh notes
|
||||||
if (typeof window.SpotNotes.loadNotesForPad === 'function') window.SpotNotes.loadNotesForPad(activeNotepadId);
|
if (typeof window.SpotNotes.loadNotesForPad === 'function') {
|
||||||
|
window.SpotNotes.loadNotesForPad(targetPadId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to create note');
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to create note');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
alert('An error occurred during note creation.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,12 @@ async function loadNotes(notepadId) {
|
||||||
noteDiv.className = 'user-item';
|
noteDiv.className = 'user-item';
|
||||||
|
|
||||||
const infoDiv = document.createElement('div');
|
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)}`;
|
infoDiv.innerHTML = `<strong>${escapeHtml(note.title)}</strong>: ${escapeHtml(note.content)}`;
|
||||||
|
}
|
||||||
noteDiv.appendChild(infoDiv);
|
noteDiv.appendChild(infoDiv);
|
||||||
|
|
||||||
// Add delete button for each note
|
// Add delete button for each note
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
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
|
window.location.href = '/'; // Redirect to map
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || 'Login failed');
|
alert(data.error || 'Login failed');
|
||||||
|
|
@ -31,28 +56,48 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (registerForm) {
|
if (registerForm) {
|
||||||
registerForm.addEventListener('submit', async (e) => {
|
registerForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const email = document.getElementById('email').value;
|
const email = document.getElementById('email').value.trim();
|
||||||
const firstName = document.getElementById('first-name').value;
|
const firstName = document.getElementById('first-name').value.trim();
|
||||||
const lastName = document.getElementById('last-name').value;
|
const lastName = document.getElementById('last-name').value.trim();
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
const submitBtn = registerForm.querySelector('button[type="submit"]');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Generating Secure Keys...';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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', {
|
const res = await fetch('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert('Registration successful! Please log in.');
|
alert('Registration successful with client-side encryption! Please log in.');
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
} else {
|
} else {
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Register';
|
||||||
|
}
|
||||||
alert(data.error || 'Registration failed');
|
alert(data.error || 'Registration failed');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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
412
static/assets/js/crypto.js
Normal 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)));
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -9,6 +9,7 @@ window.SpotNotes.activePadId = null;
|
||||||
window.SpotNotes.activePadIsPublic = false;
|
window.SpotNotes.activePadIsPublic = false;
|
||||||
window.SpotNotes.activePadOwnerId = null;
|
window.SpotNotes.activePadOwnerId = null;
|
||||||
window.SpotNotes.activePadCanWrite = false;
|
window.SpotNotes.activePadCanWrite = false;
|
||||||
|
window.SpotNotes.activePadEncryptedKey = null;
|
||||||
window.SpotNotes.currentUser = null;
|
window.SpotNotes.currentUser = null;
|
||||||
window.SpotNotes.isTracking = false;
|
window.SpotNotes.isTracking = false;
|
||||||
window.SpotNotes.userMarker = null;
|
window.SpotNotes.userMarker = null;
|
||||||
|
|
@ -22,19 +23,48 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.SpotNotes.initSidebar();
|
window.SpotNotes.initSidebar();
|
||||||
window.SpotNotes.loadNotepads(true);
|
window.SpotNotes.loadNotepads(true);
|
||||||
|
|
||||||
|
// --- Add Collaborator Modal Handler ---
|
||||||
const form = document.getElementById('form-add-collaborator');
|
const form = document.getElementById('form-add-collaborator');
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const padId = document.getElementById('share-pad-id').value;
|
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;
|
const permission = document.getElementById('collaborator-permission').value;
|
||||||
|
|
||||||
try {
|
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`, {
|
const res = await fetch(`/api/notepads/${padId}/collaborators`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, permission })
|
body: JSON.stringify({ email, permission, encrypted_key: encryptedKey })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -47,11 +77,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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');
|
const formEditPad = document.getElementById('form-edit-pad');
|
||||||
if (formEditPad) {
|
if (formEditPad) {
|
||||||
formEditPad.addEventListener('submit', async (e) => {
|
formEditPad.addEventListener('submit', async (e) => {
|
||||||
|
|
@ -69,7 +100,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
document.getElementById('modal-edit-pad').classList.add('hidden');
|
document.getElementById('modal-edit-pad').classList.add('hidden');
|
||||||
window.SpotNotes.loadNotepads(); // Refresh everything
|
window.SpotNotes.loadNotepads();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to update notepad.');
|
alert('Failed to update notepad.');
|
||||||
}
|
}
|
||||||
|
|
@ -77,29 +108,52 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Edit Note Modal Handler ---
|
||||||
const formEditNote = document.getElementById('form-edit-note');
|
const formEditNote = document.getElementById('form-edit-note');
|
||||||
if (formEditNote) {
|
if (formEditNote) {
|
||||||
formEditNote.addEventListener('submit', async (e) => {
|
formEditNote.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const noteId = document.getElementById('note-id-edit').value;
|
const noteId = parseInt(document.getElementById('note-id-edit').value, 10);
|
||||||
const title = document.getElementById('note-title-edit').value;
|
const title = document.getElementById('note-title-edit').value.trim();
|
||||||
const content = document.getElementById('note-content-edit').value;
|
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 {
|
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}`, {
|
const res = await fetch(`/api/notes/${noteId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title, content })
|
body: JSON.stringify(bodyPayload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
document.getElementById('modal-edit-note').classList.add('hidden');
|
document.getElementById('modal-edit-note').classList.add('hidden');
|
||||||
// Refresh current view
|
|
||||||
if (window.SpotNotes.activePadId) {
|
if (window.SpotNotes.activePadId) {
|
||||||
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
|
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
|
||||||
} else {
|
} 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();
|
window.SpotNotes.loadNotepads();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -110,12 +164,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
|
window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
|
||||||
// Reset state
|
// Reset state
|
||||||
window.SpotNotes.activeNotes = [];
|
window.SpotNotes.activeNotes = [];
|
||||||
|
|
@ -128,6 +176,11 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
|
||||||
const authData = await authRes.json();
|
const authData = await authRes.json();
|
||||||
window.SpotNotes.isLoggedIn = authData.authenticated;
|
window.SpotNotes.isLoggedIn = authData.authenticated;
|
||||||
window.SpotNotes.currentUser = authData.user || null;
|
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); }
|
} catch (e) { console.error(e); }
|
||||||
|
|
||||||
// Reset Sidebar Header
|
// Reset Sidebar Header
|
||||||
|
|
@ -172,9 +225,9 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const listSection = document.querySelectorAll('.list-section-header');
|
const listSection = document.querySelectorAll('.list-section-header');
|
||||||
listSection.forEach(header => {
|
listSection.forEach(h => {
|
||||||
header.addEventListener('click',window.SpotNotes.toggleSection)
|
h.addEventListener('click', window.SpotNotes.toggleSection);
|
||||||
})
|
});
|
||||||
window.SpotNotes.setupPublicToggle();
|
window.SpotNotes.setupPublicToggle();
|
||||||
|
|
||||||
try {
|
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);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (isInitialLoad === true && params.has('pad')) {
|
if (isInitialLoad === true && params.has('pad')) {
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
|
|
@ -216,29 +269,22 @@ window.SpotNotes.loadNotepads = async function(isInitialLoad = false) {
|
||||||
} else {
|
} else {
|
||||||
await window.SpotNotes.loadDefaultNotes();
|
await window.SpotNotes.loadDefaultNotes();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.SpotNotes.toggleFavorite = async function(padId, action) {
|
window.SpotNotes.toggleFavorite = async function(padId, action) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notepads/${padId}/${action}`, { method: 'POST' });
|
const res = await fetch(`/api/notepads/${padId}/${action}`, { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Refresh the lists
|
|
||||||
window.SpotNotes.loadNotepadsOfType('favorites', 'favorite-notepads-list');
|
window.SpotNotes.loadNotepadsOfType('favorites', 'favorite-notepads-list');
|
||||||
window.SpotNotes.loadNotepadsOfType('mine', 'my-notepads-list');
|
window.SpotNotes.loadNotepadsOfType('mine', 'my-notepads-list');
|
||||||
window.SpotNotes.loadNotepadsOfType('public', 'public-notepads-list');
|
window.SpotNotes.loadNotepadsOfType('public', 'public-notepads-list');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert(`Failed to ${action} notepad`);
|
alert(`Failed to ${action} notepad`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.SpotNotes.loadNotepadsOfType = async function(type, containerId) {
|
window.SpotNotes.loadNotepadsOfType = async function(type, containerId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -250,30 +296,52 @@ window.SpotNotes.loadNotepadsOfType = async function(type, containerId) {
|
||||||
console.error(`Failed to load ${type} notepads`, err);
|
console.error(`Failed to load ${type} notepads`, err);
|
||||||
document.getElementById(containerId).innerHTML = '<p style="color:red; padding:10px;">Error loading data.</p>';
|
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;
|
window.SpotNotes.activePadId = padId;
|
||||||
|
|
||||||
// If metadata is missing (e.g. from URL share), fetch it
|
// If metadata is missing, fetch it
|
||||||
if (padName === undefined) {
|
if (padName === undefined || encryptedKey === undefined) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`);
|
const res = await fetch(`/api/notepads/${padId}?token=${token || ''}`);
|
||||||
const pad = await res.json();
|
const pad = await res.json();
|
||||||
padName = pad.name; isPublic = pad.is_public; ownerId = pad.owner_id; window.SpotNotes.activePadCanWrite = pad.can_write;
|
padName = pad.name;
|
||||||
} catch(e) { console.error(e); window.SpotNotes.activePadCanWrite = false; }
|
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 (padName !== undefined) window.SpotNotes.activePadName = padName;
|
||||||
if (isPublic !== undefined) window.SpotNotes.activePadIsPublic = isPublic;
|
if (isPublic !== undefined) window.SpotNotes.activePadIsPublic = isPublic;
|
||||||
if (ownerId !== undefined) window.SpotNotes.activePadOwnerId = ownerId;
|
if (ownerId !== undefined) window.SpotNotes.activePadOwnerId = ownerId;
|
||||||
|
if (encryptedKey !== null) window.SpotNotes.activePadEncryptedKey = encryptedKey;
|
||||||
|
|
||||||
if (canWrite !== undefined) {
|
if (canWrite !== undefined) {
|
||||||
window.SpotNotes.activePadCanWrite = canWrite;
|
window.SpotNotes.activePadCanWrite = canWrite;
|
||||||
} else if (padName !== undefined) {
|
} else if (padName !== undefined) {
|
||||||
// Fallback if not passed: assume true only if owner
|
|
||||||
window.SpotNotes.activePadCanWrite = (window.SpotNotes.currentUser && ownerId === window.SpotNotes.currentUser.id);
|
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 currentName = window.SpotNotes.activePadName;
|
||||||
const isOwner = window.SpotNotes.currentUser && window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id;
|
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) {
|
if (header) {
|
||||||
const addNoteBtn = window.SpotNotes.activePadCanWrite ? `<button onclick="openCreateNoteModal(${padId})" class="btn-add-note">+ Add Note</button>` : '';
|
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 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 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 = `
|
header.innerHTML = `
|
||||||
<div class="sidebar-header-top" style="display:flex; align-items:center; justify-content:space-between; width:100%;">
|
<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;">
|
<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;">←</button>
|
<button id="btn-back-pads" class="btn-action-icon" style="font-size:1.25rem; margin-right:4px;">←</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>
|
||||||
<div class="notepad-actions" style="display:flex; align-items:center; gap:4px; flex-shrink:0;">
|
<div class="notepad-actions" style="display:flex; align-items:center; gap:4px; flex-shrink:0;">
|
||||||
${shareBtn}
|
${shareBtn}
|
||||||
|
|
@ -318,9 +387,19 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notepads/${padId}/notes?token=${token || ''}`);
|
const res = await fetch(`/api/notepads/${padId}/notes?token=${token || ''}`);
|
||||||
const notes = await res.json();
|
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.renderActiveNotesList();
|
||||||
window.SpotNotes.renderMapMarkers();
|
window.SpotNotes.renderMapMarkers();
|
||||||
|
|
||||||
|
|
@ -331,15 +410,14 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (centerOnFirst && notes.length > 0) {
|
if (centerOnFirst && decryptedNotes.length > 0 && decryptedNotes[0].lat && decryptedNotes[0].lng) {
|
||||||
window.SpotNotes.map.setView([notes[0].lat, notes[0].lng], 13);
|
window.SpotNotes.map.setView([decryptedNotes[0].lat, decryptedNotes[0].lng], 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
sidebarBody.innerHTML = '<p style="color:red; padding:10px;">Error loading notes.</p>';
|
sidebarBody.innerHTML = '<p style="color:red; padding:10px;">Error loading notes.</p>';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
// Reset state
|
// Reset state
|
||||||
|
|
@ -354,9 +432,24 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notes/${noteId}?token=${token || ''}`);
|
const res = await fetch(`/api/notes/${noteId}?token=${token || ''}`);
|
||||||
if (!res.ok) throw new Error("Note not found or private");
|
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];
|
window.SpotNotes.activeNotes = [note];
|
||||||
|
|
||||||
// Update Header
|
// Update Header
|
||||||
|
|
@ -365,7 +458,7 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<div style="display:flex; align-items:center;">
|
<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;">←</button>
|
<button id="btn-back-pads" style="background:none; border:none; font-size:1.5rem; cursor:pointer; margin-right:10px;">←</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('btn-back-pads').addEventListener('click', window.SpotNotes.loadNotepads);
|
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');
|
const p = document.createElement('p');
|
||||||
p.appendChild(document.createTextNode('Viewing single note from '));
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = "#";
|
link.href = "#";
|
||||||
link.textContent = note.notepad.name;
|
link.textContent = note.notepad.name;
|
||||||
|
|
@ -390,7 +483,7 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
p.appendChild(link);
|
p.appendChild(link);
|
||||||
} else {
|
} else {
|
||||||
const strong = document.createElement('strong');
|
const strong = document.createElement('strong');
|
||||||
strong.textContent = note.notepad.name;
|
strong.textContent = (note.notepad && note.notepad.name) || 'Private Notepad';
|
||||||
p.appendChild(strong);
|
p.appendChild(strong);
|
||||||
}
|
}
|
||||||
p.appendChild(document.createTextNode('.'));
|
p.appendChild(document.createTextNode('.'));
|
||||||
|
|
@ -398,9 +491,11 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
sidebarBody.appendChild(noteInfoDiv);
|
sidebarBody.appendChild(noteInfoDiv);
|
||||||
|
|
||||||
window.SpotNotes.renderMapMarkers();
|
window.SpotNotes.renderMapMarkers();
|
||||||
|
if (note.lat && note.lng) {
|
||||||
window.SpotNotes.map.setView([note.lat, note.lng], 16);
|
window.SpotNotes.map.setView([note.lat, note.lng], 16);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.innerWidth <= 600) {
|
if (window.innerWidth <= 768) {
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
const toggleBtn = document.getElementById('sidebar-toggle');
|
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||||
if (sidebar && !sidebar.classList.contains('collapsed')) {
|
if (sidebar && !sidebar.classList.contains('collapsed')) {
|
||||||
|
|
@ -412,19 +507,15 @@ window.SpotNotes.loadSingleNote = async function(noteId, token = null) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sidebarBody.innerHTML = '<p style="color:red; padding:10px;">Failed to load note. It may be private or deleted.</p>';
|
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) {
|
window.SpotNotes.displaySearchResults = function(notes, location, query) {
|
||||||
// Reset Pad State to "Search Mode"
|
|
||||||
window.SpotNotes.activePadId = null;
|
window.SpotNotes.activePadId = null;
|
||||||
window.SpotNotes.activePadName = `Search: ${query}`;
|
window.SpotNotes.activePadName = `Search: ${query}`;
|
||||||
window.SpotNotes.activePadIsPublic = false; // Mixed results
|
window.SpotNotes.activePadIsPublic = false;
|
||||||
window.SpotNotes.activePadOwnerId = null; // Mixed results
|
window.SpotNotes.activePadOwnerId = null;
|
||||||
window.SpotNotes.activeNotes = notes;
|
window.SpotNotes.activeNotes = notes;
|
||||||
|
|
||||||
// Update Sidebar Header
|
|
||||||
const header = document.querySelector('.sidebar-header');
|
const header = document.querySelector('.sidebar-header');
|
||||||
if (header) {
|
if (header) {
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
|
|
@ -444,23 +535,21 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
|
||||||
window.SpotNotes.renderMapMarkers();
|
window.SpotNotes.renderMapMarkers();
|
||||||
return;
|
return;
|
||||||
} else if (notes.length === 0 && location) {
|
} 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.renderMapMarkers();
|
||||||
window.SpotNotes.map.setView([location.lat, location.lng], 13);
|
window.SpotNotes.map.setView([location.lat, location.lng], 13);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group notes by section and notepad
|
|
||||||
const sections = {
|
const sections = {
|
||||||
favorites: {}, // Map<padId, {pad, notes:[]}>
|
favorites: {},
|
||||||
mine: {},
|
mine: {},
|
||||||
public: {}
|
public: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
notes.forEach(note => {
|
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) => {
|
const addToSection = (secKey) => {
|
||||||
if (!sections[secKey][pad.id]) {
|
if (!sections[secKey][pad.id]) {
|
||||||
sections[secKey][pad.id] = { pad: pad, notes: [] };
|
sections[secKey][pad.id] = { pad: pad, notes: [] };
|
||||||
|
|
@ -473,7 +562,6 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
|
||||||
if (pad.is_public) addToSection('public');
|
if (pad.is_public) addToSection('public');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to render a collapsible section
|
|
||||||
const renderSection = (title, dataMap, isOpen) => {
|
const renderSection = (title, dataMap, isOpen) => {
|
||||||
const sectionDiv = document.createElement('div');
|
const sectionDiv = document.createElement('div');
|
||||||
sectionDiv.className = 'list-section';
|
sectionDiv.className = 'list-section';
|
||||||
|
|
@ -498,10 +586,9 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
|
||||||
const entry = dataMap[padId];
|
const entry = dataMap[padId];
|
||||||
const pad = entry.pad;
|
const pad = entry.pad;
|
||||||
|
|
||||||
// Notepad Header, styled like the default view
|
|
||||||
const padItem = document.createElement('div');
|
const padItem = document.createElement('div');
|
||||||
padItem.className = 'notepad-item';
|
padItem.className = 'notepad-item';
|
||||||
padItem.style.backgroundColor = '#e9ecef'; // Differentiate as a sub-header
|
padItem.style.backgroundColor = '#e9ecef';
|
||||||
padItem.style.cursor = 'default';
|
padItem.style.cursor = 'default';
|
||||||
|
|
||||||
let icon = pad.is_public ? '🌍' : '🔒';
|
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-title">${window.SpotNotes.escapeHtml(note.title)}</div>
|
||||||
<div class="note-card-body">${window.SpotNotes.escapeHtml(note.content)}</div>
|
<div class="note-card-body">${window.SpotNotes.escapeHtml(note.content)}</div>
|
||||||
<div class="note-card-meta">
|
<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>
|
||||||
<div class="note-card-actions">
|
<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>
|
<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', () => {
|
item.addEventListener('click', () => {
|
||||||
window.SpotNotes.stopTracking();
|
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);
|
contentDiv.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
@ -576,7 +663,7 @@ window.SpotNotes.displaySearchResults = function(notes, location, query) {
|
||||||
|
|
||||||
window.SpotNotes.renderMapMarkers();
|
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);
|
window.SpotNotes.map.setView([notes[0].lat, notes[0].lng], 15);
|
||||||
} else if (location) {
|
} else if (location) {
|
||||||
window.SpotNotes.map.setView([location.lat, location.lng], 13);
|
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) {
|
window.SpotNotes.reportNote = async function(noteId) {
|
||||||
if (!confirm("Report this note? It will be hidden from public view.")) return;
|
if (!confirm("Report this note? It will be hidden from public view.")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notes/${noteId}/report`, { method: 'POST' });
|
const res = await fetch(`/api/notes/${noteId}/report`, { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Note reported and hidden.");
|
alert("Note reported and hidden.");
|
||||||
// Refresh current pad
|
|
||||||
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId, window.SpotNotes.activePadName, window.SpotNotes.activePadIsPublic);
|
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId, window.SpotNotes.activePadName, window.SpotNotes.activePadIsPublic);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -600,11 +685,10 @@ window.SpotNotes.reportNote = async function(noteId) {
|
||||||
|
|
||||||
window.SpotNotes.deleteNotepad = async function(padId) {
|
window.SpotNotes.deleteNotepad = async function(padId) {
|
||||||
if (!confirm("Are you sure you want to delete this notepad? All notes inside will be lost.")) return;
|
if (!confirm("Are you sure you want to delete this notepad? All notes inside will be lost.")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notepads/${padId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/notepads/${padId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.SpotNotes.loadNotepads(); // Refresh list
|
window.SpotNotes.loadNotepads();
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to delete notepad.");
|
alert("Failed to delete notepad.");
|
||||||
}
|
}
|
||||||
|
|
@ -645,11 +729,10 @@ window.SpotNotes.openShareSettingsModal = async function(padId, padName) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
listDiv.innerHTML = `<p style="color:red;">${err.message}</p>`;
|
listDiv.innerHTML = `<p style="color:red;">${err.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.SpotNotes.removeCollaborator = async function(padId, userId) {
|
window.SpotNotes.removeCollaborator = async function(padId, userId) {
|
||||||
if (!confirm("Are you sure you want to remove this collaborator?")) return;
|
if (!confirm("Are you sure you want to remove this collaborator?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notepads/${padId}/collaborators/${userId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/notepads/${padId}/collaborators/${userId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -663,7 +746,7 @@ window.SpotNotes.removeCollaborator = async function(padId, userId) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('An error occurred.');
|
alert('An error occurred.');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.SpotNotes.loadAllMyNotes = async function() {
|
window.SpotNotes.loadAllMyNotes = async function() {
|
||||||
window.SpotNotes.activePadId = null;
|
window.SpotNotes.activePadId = null;
|
||||||
|
|
@ -674,7 +757,6 @@ window.SpotNotes.loadAllMyNotes = async function() {
|
||||||
window.SpotNotes.activePadOwnerId = window.SpotNotes.currentUser.id;
|
window.SpotNotes.activePadOwnerId = window.SpotNotes.currentUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing markers
|
|
||||||
window.SpotNotes.currentMarkers.forEach(marker => window.SpotNotes.map.removeLayer(marker));
|
window.SpotNotes.currentMarkers.forEach(marker => window.SpotNotes.map.removeLayer(marker));
|
||||||
window.SpotNotes.currentMarkers = [];
|
window.SpotNotes.currentMarkers = [];
|
||||||
window.SpotNotes.activeNotes = [];
|
window.SpotNotes.activeNotes = [];
|
||||||
|
|
@ -683,40 +765,106 @@ window.SpotNotes.loadAllMyNotes = async function() {
|
||||||
const res = await fetch('/api/notes/mine');
|
const res = await fetch('/api/notes/mine');
|
||||||
if (!res.ok) throw new Error("Failed to load");
|
if (!res.ok) throw new Error("Failed to load");
|
||||||
const notes = await res.json();
|
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();
|
window.SpotNotes.renderMapMarkers();
|
||||||
|
|
||||||
if (notes.length > 0) {
|
const validNotes = decryptedNotes.filter(n => n.lat && n.lng);
|
||||||
// Auto-center window.SpotNotes.map bounds cleanly around all the user's notes
|
if (validNotes.length > 0) {
|
||||||
const bounds = L.latLngBounds(notes.map(n => [n.lat, n.lng]));
|
const bounds = L.latLngBounds(validNotes.map(n => [n.lat, n.lng]));
|
||||||
window.SpotNotes.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
window.SpotNotes.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading all my notes:", 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}`;
|
let url = `${window.location.origin}/?pad=${padId}`;
|
||||||
if (token) url += `&token=${token}`;
|
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(() => {
|
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}`;
|
let url = `${window.location.origin}/?note=${noteId}`;
|
||||||
if (token) url += `&token=${token}`;
|
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(() => {
|
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) {
|
window.SpotNotes.escapeHtml = function(text) {
|
||||||
if (text === null || text === undefined) return '';
|
if (text === null || text === undefined) return '';
|
||||||
|
|
@ -726,21 +874,16 @@ window.SpotNotes.escapeHtml = function(text) {
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.SpotNotes.deleteNote = async function(noteId) {
|
window.SpotNotes.deleteNote = async function(noteId) {
|
||||||
if (!confirm("Delete this note?")) return;
|
if (!confirm("Delete this note?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// If we are in a pad view, reload the pad.
|
|
||||||
if (window.SpotNotes.activePadId) {
|
if (window.SpotNotes.activePadId) {
|
||||||
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
|
window.SpotNotes.loadNotesForPad(window.SpotNotes.activePadId);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise (e.g., search results), the safest refresh is to return to the main list.
|
|
||||||
alert("Note deleted. Returning to notepad list.");
|
alert("Note deleted. Returning to notepad list.");
|
||||||
window.SpotNotes.loadNotepads();
|
window.SpotNotes.loadNotepads();
|
||||||
}
|
}
|
||||||
|
|
@ -758,7 +901,7 @@ window.SpotNotes.renderActiveNotesList = function(filterText = '') {
|
||||||
|
|
||||||
const filterLower = filterText.toLowerCase();
|
const filterLower = filterText.toLowerCase();
|
||||||
const filteredNotes = window.SpotNotes.activeNotes.filter(note => {
|
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) {
|
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 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 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 = `
|
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-body">${window.SpotNotes.escapeHtml(note.content)}</div>
|
||||||
<div class="note-card-meta">
|
<div class="note-card-meta">
|
||||||
${dateStr} <br> ${note.lat.toFixed(5)}, ${note.lng.toFixed(5)}
|
${dateStr} ${coordStr ? '<br>' + coordStr : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="note-card-actions">
|
<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}
|
${shareBtn}
|
||||||
${actionBtns}
|
${actionBtns}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
window.SpotNotes.stopTracking();
|
window.SpotNotes.stopTracking();
|
||||||
|
if (note.lat && note.lng) {
|
||||||
window.SpotNotes.map.setView([note.lat, note.lng], 16);
|
window.SpotNotes.map.setView([note.lat, note.lng], 16);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
sidebarBody.appendChild(item);
|
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() {
|
window.SpotNotes.loadDefaultNotes = async function() {
|
||||||
|
|
@ -817,13 +958,26 @@ window.SpotNotes.loadDefaultNotes = async function() {
|
||||||
const res = await fetch('/api/notes/default');
|
const res = await fetch('/api/notes/default');
|
||||||
if (!res.ok) throw new Error("Failed to load");
|
if (!res.ok) throw new Error("Failed to load");
|
||||||
const notes = await res.json();
|
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();
|
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);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (notes.length > 0 && !params.has('pad') && !params.has('note')) {
|
const validCoordsNotes = decryptedNotes.filter(n => n.lat && n.lng);
|
||||||
const bounds = L.latLngBounds(notes.map(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 });
|
window.SpotNotes.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,14 @@ window.SpotNotes.renderMapMarkers = function() {
|
||||||
const showFull = zoom >= 14; // Zoom threshold
|
const showFull = zoom >= 14; // Zoom threshold
|
||||||
|
|
||||||
window.SpotNotes.activeNotes.forEach(note => {
|
window.SpotNotes.activeNotes.forEach(note => {
|
||||||
|
if (!note || note.lat === undefined || note.lng === undefined || (note.lat === 0 && note.lng === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let marker;
|
let marker;
|
||||||
let actionBtns = '';
|
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 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 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;
|
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 shareToken = note.share_token || '';
|
||||||
const shareBtn = `<button onclick="window.SpotNotes.shareNote(${note.id}, '${shareToken}')" class="btn-note-share">🔗 Share</button>`;
|
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 dateStr = new Date(note.created_at).toLocaleString();
|
||||||
|
|
||||||
const padColor = (note.notepad && note.notepad.color) ? note.notepad.color : '#3388ff';
|
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>`;
|
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: '',
|
className: '',
|
||||||
html: `<div class="map-note-card">
|
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;">
|
<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>
|
||||||
<div style="padding: 10px; background: white; border-radius: 0 0 4px 4px; border: 1px solid #ddd; border-top: none;">
|
<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>
|
<div style="white-space: pre-wrap; margin-bottom: 6px;">${window.SpotNotes.escapeHtml(note.content)}</div>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ function initLogoutListener() {
|
||||||
logoutLink.addEventListener('click', async (e) => {
|
logoutLink.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
if (window.SpotCrypto) window.SpotCrypto.clearSession();
|
||||||
await fetch('/auth/logout', { method: 'POST' });
|
await fetch('/auth/logout', { method: 'POST' });
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
// Show loading state if possible, or just proceed
|
const qLower = query.toLowerCase();
|
||||||
|
let publicNotes = [];
|
||||||
|
let privateNotes = [];
|
||||||
|
|
||||||
// 1. Search Notes (Backend)
|
// 1. Search Public Notes (Backend)
|
||||||
let notes = [];
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
notes = await res.json();
|
publicNotes = await res.json();
|
||||||
} else {
|
} else {
|
||||||
console.error("Search API failed:", res.status);
|
console.error("Search API failed:", res.status);
|
||||||
}
|
}
|
||||||
|
|
@ -23,18 +24,56 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.error("Search error:", e);
|
console.error("Search error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If notes found, display them and stop (don't geocode)
|
// 2. Search Private Notes (Client-Side Decryption for Zero-Knowledge Privacy)
|
||||||
if (notes.length > 0) {
|
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) {
|
if (window.SpotNotes && window.SpotNotes.displaySearchResults) {
|
||||||
window.SpotNotes.displaySearchResults(notes, null, query);
|
window.SpotNotes.displaySearchResults(combinedNotes, null, query);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Search Location (Nominatim) if no notes found
|
// 3. Fallback: Search Location (Nominatim) if no notes found
|
||||||
let location = null;
|
let location = null;
|
||||||
try {
|
try {
|
||||||
// Use local proxy to avoid CORS and set User-Agent
|
|
||||||
const nomRes = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`);
|
const nomRes = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`);
|
||||||
const nomData = await nomRes.json();
|
const nomData = await nomRes.json();
|
||||||
if (nomData && nomData.length > 0) {
|
if (nomData && nomData.length > 0) {
|
||||||
|
|
@ -47,13 +86,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.error("Geocoding error:", e);
|
console.error("Geocoding error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update UI via index.js helper
|
// 4. Update UI
|
||||||
if (window.SpotNotes && window.SpotNotes.displaySearchResults) {
|
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);
|
searchForm.addEventListener('submit', performSearch);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ window.SpotNotes.renderNotepadList = function(pads, containerId, isFavorite) {
|
||||||
</div>
|
</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);
|
container.appendChild(item);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<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>
|
</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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Marker Color</label>
|
<label>Marker Color</label>
|
||||||
|
|
@ -104,8 +105,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<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>
|
</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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Marker Color</label>
|
<label>Marker Color</label>
|
||||||
|
|
@ -189,6 +191,7 @@
|
||||||
<!-- Scripts -->
|
<!-- 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@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="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/nav.js"></script>
|
||||||
<script src="/assets/js/map.js"></script>
|
<script src="/assets/js/map.js"></script>
|
||||||
<script src="/assets/js/sidebar.js"></script>
|
<script src="/assets/js/sidebar.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
<a href="/">Back to Map</a>
|
<a href="/">Back to Map</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/js/crypto.js"></script>
|
||||||
<script src="/assets/js/auth.js"></script>
|
<script src="/assets/js/auth.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
<a href="/">Back to Map</a>
|
<a href="/">Back to Map</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/js/crypto.js"></script>
|
||||||
<script src="/assets/js/auth.js"></script>
|
<script src="/assets/js/auth.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -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.')
|
|
||||||
Loading…
Reference in a new issue