Repository: computing-den/unforget Branch: master Commit: e519aca810b4 Files: 64 Total size: 241.5 KB Directory structure: gitextract_bzf8m7j0/ ├── .gitignore ├── .ignore ├── .prettierrc ├── LICENSE.txt ├── README.md ├── esbuild.config.mjs ├── examples/ │ ├── example.py │ └── example.ts ├── package.json ├── public/ │ └── manifest.json ├── scripts/ │ ├── script.ts │ └── tsconfig.json ├── src/ │ ├── client/ │ │ ├── AboutPage.tsx │ │ ├── App.tsx │ │ ├── DemoPage.tsx │ │ ├── Editor.css │ │ ├── Editor.tsx │ │ ├── ExportPage.tsx │ │ ├── ImportPage.tsx │ │ ├── LoginPage.css │ │ ├── LoginPage.tsx │ │ ├── Menu.css │ │ ├── Menu.tsx │ │ ├── NotePage.css │ │ ├── NotePage.tsx │ │ ├── Notes.css │ │ ├── Notes.tsx │ │ ├── NotesPage.css │ │ ├── NotesPage.tsx │ │ ├── Notifications.css │ │ ├── Notifications.tsx │ │ ├── PageLayout.css │ │ ├── PageLayout.tsx │ │ ├── api.ts │ │ ├── appStore.tsx │ │ ├── appStoreActions.tsx │ │ ├── clientToServiceWorkerApi.ts │ │ ├── common.css │ │ ├── cookies.ts │ │ ├── cross-context-broadcast.ts │ │ ├── crypto.ts │ │ ├── custom.d.ts │ │ ├── hooks.tsx │ │ ├── icons.ts │ │ ├── index.tsx │ │ ├── logger.ts │ │ ├── normalize.css │ │ ├── notes/ │ │ │ ├── about.md │ │ │ ├── export.md │ │ │ ├── import.md │ │ │ └── welcome1.md │ │ ├── router.tsx │ │ ├── serviceWorker.ts │ │ ├── serviceWorkerToClientApi.ts │ │ ├── storage.ts │ │ ├── style.css │ │ └── sync.ts │ ├── common/ │ │ ├── mdFns.ts │ │ ├── types.ts │ │ └── util.ts │ └── server/ │ ├── db.ts │ ├── index.ts │ └── validateEnvVars.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules dist private tsconfig.tsbuildinfo .env deploy *# /examples/credentials.json ================================================ FILE: .ignore ================================================ package-lock.json ================================================ FILE: .prettierrc ================================================ { "trailingComma": "all", "bracketSpacing": true, "printWidth": 120, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": true, "jsxBracketSameLine": false, "arrowParens": "avoid" } ================================================ FILE: LICENSE.txt ================================================ Copyright (c) 2024 Computing Den, https://computing-den.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Unforget ![screenshot](doc/screenshots.png) *Start now without registering at [unforget.computing-den.com](https://unforget.computing-den.com/demo).* Unforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) featuring: - [x] Offline first - [x] Privacy first - [x] Progressive web app - [x] Open source MIT License - [x] End-to-end encrypted sync - [x] Desktop, Mobile, Web - [x] Markdown support - [x] Self hosted and cloud options - [x] One-click data export as JSON - [x] Optional one-click installation - [x] Public APIs, create your own client - [x] Import Google Keep - [x] Import Apple Notes - [x] Import Standard Notes *Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.* *Contact us at sean@computing-den.com* # Easy Signup [Sign up](https://unforget.computing-den.com/login) for free to back up your notes safely to the cloud fully encrypted and sync across devices. *No email or phone required.* # Optional installation Use it directly in your browser or install: | Browser | Installation | |-----------------|-----------------------------| | Chrome | Install icon in the URL bar | | Edge | Install icon in the URL bar | | Android Browser | Menu → Add to Home Screen | | Safari Desktop | Share → Add to Dock | | Safari iOS | Share → Add to Home Screen | | Firefox Desktop | *cannot install* | | Firefox Android | Install icon in the URL bar | # Organization and Workflow Notes are organized **chronologically**, with pinned notes displayed at the top. This organization has proven very effective despite its simplicity. The **search is very fast** (and done offline), allowing you to quickly narrow down notes by entering a few phrases. Additionally, you can search for non-alphabetic characters, enabling the use of **tags** such as #idea, #project, #work, #book, etc. There is **no limit** to the size of a note. For larger notes, you can insert a `---` on a line by itself to **collapse** the rest of the note. Notes are **immediately saved** as you type and synced every few seconds. If you edit a note from two devices and a **conflict** occurs during sync, the most recent edit will take precedence. # Security and Privacy Unforget does not receive or store any personal data. No email or phone is required to sign up. As long as you pick a strong password, your notes will be stored in the cloud fully encrypted and safe. Only your username and note modification dates are visible to Unforget servers. # Text Formatting The main differences with the [Github flavored markdown](https://github.github.com/gfm/) are: - If the first line of a note is followed by a blank line, it is a H1 header. - Anything after the first horizontal rule `---` in a note will be hidden and replaced with a "show more" button that will expand the note. ~~~ # H1 header ## H2 header ### H3 header #### H4 header ##### H5 header ###### H6 header *This is italic.*. **This is bold.**. ***This is bold and italic.*** ~~This is strikethrough~~ - This is a bullet point - Another bullet point - Inner bullet point - [ ] This is a checkbox And more text related to the checkbox. 1. This is an ordered list item 2. And another one [this is a link](https://unforget.computing-den.com) Inline `code` using back-ticks. Block of code: ```javascript function plusOne(a) { return a + 1; } ``` | Tables | Are | Cool | | ------------- |:-------------:| -----:| | col 3 is | right-aligned | $1600 | | col 2 is | centered | $12 | Horizontal rule: --- ~~~ # Build and Self Host To build Unforget for production, put a `.env` file in the project's root directory: ``` PORT=3000 NODE_ENV=production DISABLE_CACHE=0 LOG_TO_CONSOLE=0 FORWARD_LOGS_TO_SERVER=0 FORWARD_ERRORS_TO_SERVER=0 ``` and then run ``` cd unforget/ npm run build npm run start ``` It is recommended to use [Nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) and set up SSL certificate using [Let's Encrypt](https://letsencrypt.org/). # Development To build and run Unforget in development mode, put a `.env` file in the project's root directory: ``` PORT=3000 NODE_ENV=development DISABLE_CACHE=1 LOG_TO_CONSOLE=1 FORWARD_LOGS_TO_SERVER=0 FORWARD_ERRORS_TO_SERVER=0 ``` and then run ``` cd unforget/ npm install npm run dev ``` This will build the project and watch for changes in the source files. # Public APIs - write your own client Here, all paths are relative to either the official server at [https://unforget.computing-den.com](https://unforget.computing-den.com) or your own server if you're self hosting. ## Examples In the [examples/](examples/) directory you will find example code for TypeScript and Python. To run the **Typescript** example: ``` bash cd examples/ # Signup npx tsx example.ts signup USERNAME PASSWORD # Login npx tsx example.ts login USERNAME PASSWORD # Create new note npx tsx example.ts create "Hello world!" # Get all notes npx tsx example.ts get # Get note by ID npx tsx example.ts get ID ``` To run the **Python** example: ``` bash cd examples/ # Signup python3 example.py signup USERNAME PASSWORD # Login python3 example.py login USERNAME PASSWORD # Create new note python3 example.py create "Hello world!" # Get all notes python3 example.py get # Get note by ID python3 example.py get ID ``` ## Note Types ```ts type Note = { // UUID version 4 id: string; // Deleted notes have text: null text: string | null; // ISO 8601 format creation_date: string; // ISO 8601 format modification_date: string; // 0 means deleted, 1 means not deleted not_deleted: number; // 0 means archived, 1 means not archived not_archived: number; // 0 means not pinned, 1 means pinned pinned: number; // A higher number means higher on the list // Usually, by default it's milliseconds since the epoch order: number; } type EncryptedNote = { // UUID version 4 id: string; // ISO 8601 format modification_date: string; // The encrypted Note in base64 format encrypted_base64: string; // Initial vector, a random number, that was used for encrypting this specific note iv: string; } ``` The server only knows about ```EncryptedNote``` and never sees the actual ```Note```. So, the client must encrypt before sending to and decrypt after receiving notes from the server. Side note: the reason for using number (0 and 1) instead of boolean is to make it easier to store notes in SQLite which doesn't support boolean. And the reason why some of these fields are flipped (```not_deleted``` instead of ```deleted```) is to facilitate the use of IndexedDB which doesn't support indexing by multiple keys in arbitrary order. ## Signup, Login, Logout To sign up, send a POST request to ```/api/signup``` with a JSON payload of type ```SignupData```: ```ts type SignupData = { username: string; password_client_hash: string; encryption_salt: string; } ``` To log in, send a POST request to ```/api/login``` with a JSON payload of type ```LoginData```: ```ts type LoginData = { username: string; password_client_hash: string; } ``` In both cases, if the credentials are wrong you will receive a 401 error. Otherwise, the server will respond with ```LoginResponse``` and code 200: ```ts type LoginResponse = { username: string; token: string; encryption_salt: string; } ``` To log out, send a POST request to ```/api/login?token=TOKEN``` In the following sections, all the requests to the server must include the ```token``` either as a query parameter in the URL (e.g. ```/api/delta-sync?token=XXX```) or as a cookie named ```unforget_token```. Notice that we never send the raw password to the server. Instead we calculate its hash as ```password_client_hash``` which is derived from the username, password, and a static random number. It is important to use the exact same algorithm for calculating the hash if you want to be able to use the official Unforget client as well as your own. The ```encryption_salt``` is a random number used to derive the key for encryption and decryption of notes. It is stored on the server and provided on login. The [examples](#examples) show how to calculate the hash and generate the salt. ## Get Notes Send a POST request to ```/api/get-notes?token=TOKEN``` to get all notes. Optionally you can provide a JSON payload of type ```{ids: string[]}``` to get specific notes. You will receive ```EncryptedNote[]```. ## Merge Notes Send a POST request to ```/api/merge-notes?token=TOKEN``` with a JSON payload of type ```{notes: EncryptedNote[]}```. If the note doesn't already exist, it will be added. If its ```modification_date``` is larger than the existing note, it will replace the existing note. Otherwise, it will be thrown away. ## Delete Notes To delete a note set its `text: null` and `not_deleted: 0` and [merge](#merge-notes) it. This way, the stub will stay in the database and the fact that it was deleted will propogate to all the other clients. ## Sync and Merge For a long-running client, instead of using [Get Notes](#get-notes) and [Merge Notes](#merge-notes), you can use sync in the following manner. The client and the server each maintain a queue of changes to send to each other as well as a sync number. The exchange of these changes is called a **delta sync**. The sync number is 0 at login and is incremented by each side only after all the received changes have been merged and stored. At the start of each delta sync, if their sync numbers differ, it indicates that something went wrong in the last delta sync and so they must do a queue sync. A **queue sync** is when each side sends its sync number along with a list of IDs and modification dates of all the notes that it knows about. After a queue sync, both sides will know which changes the other side lacks and therefore can update their own queue and sync number. When the sync number is 0 (immediately after login), the server will send all notes in the first delta sync. To perform a **delta sync**, send a POST request to ```/api/delta-sync?token=TOKEN``` with a JSON payload of type ```SyncData```: ```ts type SyncData = { notes: EncryptedNote[]; syncNumber: number; } ``` If the server agrees with the ```syncNumber```, it will respond with ```DeltaSyncResNormal``` which includes the changes stored on the server for that client since the last sync. Otherwise, the server will respond with ```PartialSyncResRequireQueueSync``` requiring the client to initiate a queue sync. ```ts type DeltaSyncResNormal = { type: 'ok'; notes: EncryptedNote[]; syncNumber: number; } type DeltaSyncResRequireQueueSync = { type: 'require_queue_sync'; } ``` To perform a **queue sync**, send a POST request to ```/api/queue-sync?token=TOKEN``` with a JSON payload of type ```SyncHeadsData``` including the heads of all the notes known by the client and its sync number. You will then receive another ```SyncHeadsData``` including the heads of all the notes known by the server for that user along with the server's sync number for that client. ```ts type SyncHeadsData = { noteHeads: NoteHead[]; syncNumber: number; } type NoteHead = { id: string; modification_date: string; } ``` After a queue sync, each side updates its queue to include the changes the other side is mising as well as setting the new sync number to be the larger sync number + 1. It is important that the client and the server agree on how the **merging** of the notes is done so that they end up with a consistent state. We say that note A must replace note B if ```A.id == B.id``` and ```A.modification_date > B.modification_date```. ## Encryption and Decryption The details of encryption and decryption are more easily explained in code. See the [Examples](#examples) section. ## Error handling All the API calls will return an object of type ```ServerError``` when encountering an error with a status code >= 400: ```ts type ServerError { message: string; code: number; type: 'app_requires_update' | 'generic'; } ``` If you receive an error with type ```app_requires_update``` that indicates that you are using an older version of the API that is no longer supported. ================================================ FILE: esbuild.config.mjs ================================================ import 'dotenv/config'; import esbuild from 'esbuild'; const context = await esbuild.context({ entryPoints: ['src/client/index.tsx', 'src/client/style.css', 'src/client/serviceWorker.ts'], outdir: 'dist/public', minify: process.env.NODE_ENV === 'production', bundle: true, sourcemap: true, format: 'esm', treeShaking: true, define: Object.fromEntries(Object.keys(process.env).map(key => [`process.env.${key}`, `"${process.env[key]}"`])), plugins: [reporterPlugin()], loader: { '.svg': 'dataurl', '.txt': 'text', '.md': 'text' }, }); if (process.argv.includes('--watch')) { console.log('Watching ...'); await context.watch(); } else { console.log('Building ...'); await context.rebuild(); await context.dispose(); } function reporterPlugin() { return { name: 'reporter', setup(build) { build.onEnd(result => console.log(`Done - ${result.errors.length} errors, ${result.warnings.length} warnings`)); }, }; } ================================================ FILE: examples/example.py ================================================ # The following was generated by ChatGPT4o from the original example.ts with some trial and error. # It was manually inspected and tested. import json import sys import uuid import requests import hashlib from base64 import b64encode, b64decode, urlsafe_b64decode, urlsafe_b64encode from datetime import datetime import os from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend BASE_URL = 'https://unforget.computing-den.com' class Note: def __init__(self, id, text, creation_date, modification_date, not_deleted, not_archived, pinned, order): self.id = id self.text = text self.creation_date = creation_date self.modification_date = modification_date self.not_deleted = not_deleted self.not_archived = not_archived self.pinned = pinned self.order = order class EncryptedNote: def __init__(self, id, modification_date, encrypted_base64, iv): self.id = id self.modification_date = modification_date self.encrypted_base64 = encrypted_base64 self.iv = iv class LoginData: def __init__(self, username, password_client_hash): self.username = username self.password_client_hash = password_client_hash class SignupData(LoginData): def __init__(self, username, password_client_hash, encryption_salt): super().__init__(username, password_client_hash) self.encryption_salt = encryption_salt class LoginResponse: def __init__(self, username, token, encryption_salt): self.username = username self.token = token self.encryption_salt = encryption_salt class Credentials(LoginResponse): def __init__(self, username, token, encryption_salt, jwk): super().__init__(username, token, encryption_salt) self.jwk = jwk def main(): if len(sys.argv) < 2: usage_and_exit() command = sys.argv[1] if command == 'signup': if len(sys.argv) != 4: usage_and_exit() username = sys.argv[2] password = sys.argv[3] signup(username, password) elif command == 'login': if len(sys.argv) != 4: usage_and_exit() username = sys.argv[2] password = sys.argv[3] login(username, password) elif command == 'create': if len(sys.argv) != 3: usage_and_exit() text = sys.argv[2] create_note(text) elif command == 'get': id = sys.argv[2] if len(sys.argv) == 3 else None get_note(id) else: usage_and_exit() print('Success.') def usage_and_exit(): print(""" Usage: python3 script.py COMMAND Available commands: signup USERNAME PASSWORD login USERNAME PASSWORD create TEXT get [ID] """) sys.exit(1) def signup(username, password): # Generate a random salt for encryption key derivation salt = bytes_to_hex_string(os.urandom(16)) # Calculate password hash hash = calc_password_hash(username, password) # Prepare signup data data = SignupData(username, hash, salt) # Send signup request and handle response res_dict = post('/api/signup', data.__dict__) res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt']) # Create credentials including derived encryption key credentials = create_credentials(res, password) # Save credentials to file write_credentials(credentials) def login(username, password): # Calculate password hash hash = calc_password_hash(username, password) # Prepare login data data = LoginData(username, hash) # Send login request and handle response res_dict = post('/api/login', data.__dict__) res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt']) # Create credentials including derived encryption key credentials = create_credentials(res, password) # Save credentials to file write_credentials(credentials) def create_note(text): # Create a new note note = Note( str(uuid.uuid4()), text, datetime.utcnow().isoformat(), datetime.utcnow().isoformat(), 1, 1, 0, int(datetime.utcnow().timestamp() * 1000) ) # Read credentials from file credentials = read_credentials() # Import the encryption key from credentials key = import_key(credentials) # Encrypt the note encrypted_note = encrypt_note(note, key) # Send the encrypted note to the server post('/api/merge-notes', {'notes': [encrypted_note.__dict__]}, credentials) print(f'Create note with ID {note.id}') def get_note(id): # Read credentials from file credentials = read_credentials() # Import the encryption key from credentials key = import_key(credentials) # Prepare list of note IDs to retrieve (or None to get all) ids = [id] if id else None # Retrieve encrypted notes from the server encrypted_notes = post('/api/get-notes', {'ids': ids}, credentials) if not encrypted_notes: print('Not found') else: # Decrypt the received notes notes = [decrypt_note(EncryptedNote(**note), key) for note in encrypted_notes] # Print the decrypted notes for note in notes: print(json.dumps(note.__dict__, indent=2) + '\n') def encrypt_note(note, key): # Convert the note to bytes data = json.dumps(note.__dict__).encode() # Generate a random initialization vector (IV) iv = os.urandom(12) # Create an encryptor with AES-GCM mode encryptor = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()).encryptor() # Encrypt the note data encrypted = encryptor.update(data) + encryptor.finalize() # Combine the encrypted data and tag, then encode in base64 encrypted_base64 = b64encode(encrypted + encryptor.tag).decode('utf-8') # Return an EncryptedNote object return EncryptedNote(note.id, note.modification_date, encrypted_base64, bytes_to_hex_string(iv)) def decrypt_note(encrypted_note, key): # Decode the base64-encoded encrypted data encrypted_bytes_with_tag = b64decode(encrypted_note.encrypted_base64) # Extract the IV and tag from the encrypted data iv = hex_string_to_bytes(encrypted_note.iv) encrypted_bytes, tag = encrypted_bytes_with_tag[:-16], encrypted_bytes_with_tag[-16:] # Create a decryptor with AES-GCM mode decryptor = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend()).decryptor() # Decrypt the note data decrypted_bytes = decryptor.update(encrypted_bytes) + decryptor.finalize() # Convert the decrypted bytes back to a string and parse as JSON note_string = decrypted_bytes.decode('utf-8') return Note(**json.loads(note_string)) def read_credentials(): # Read and parse the credentials from a file with open('credentials.json', 'r') as file: return Credentials(**json.load(file)) def write_credentials(credentials): # Write the credentials to a file with open('credentials.json', 'w') as file: json.dump(credentials.__dict__, file, indent=2) print('Wrote credentials to ./credentials.json') def import_key(credentials): # Decode the base64-encoded key from the credentials key = urlsafe_b64decode(credentials.jwk['k'] + '==') return key def create_credentials(res, password): # Derive a PBKDF2 key from the password and the encryption salt kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=bytes.fromhex(res.encryption_salt), iterations=100000, backend=default_backend() ) key = kdf.derive(password.encode()) # Create a JSON Web Key (JWK) from the derived key jwk = {'key_ops': ['encrypt', 'decrypt'], 'ext': True, 'kty': 'oct', 'k': urlsafe_b64encode(key).decode('utf-8').rstrip('=')} # Return credentials including the JWK return Credentials(res.username, res.token, res.encryption_salt, jwk) def post(pathname, body, credentials=None): # Construct the full URL with the optional token as a query parameter url = f'{BASE_URL}{pathname}' if credentials: url += f'?token={credentials.token}' # Send a POST request with the provided body headers = {'Content-Type': 'application/json'} response = requests.post(url, headers=headers, json=body) response.raise_for_status() return response.json() def calc_password_hash(username, password): # Calculate a SHA-256 hash of the username, password, and a static random number text = f'{username}{password}32261572990560219427182644435912532' hash_buf = hashlib.sha256(text.encode()).digest() return bytes_to_hex_string(hash_buf) def bytes_to_hex_string(bytes): # Convert a byte array to a hex string return ''.join(f'{byte:02x}' for byte in bytes) def hex_string_to_bytes(hex_string): # Convert a hex string to a byte array if len(hex_string) % 2 != 0: raise ValueError('Invalid hex string') return bytes.fromhex(hex_string) if __name__ == "__main__": main() ================================================ FILE: examples/example.ts ================================================ import { webcrypto } from 'node:crypto'; import fs from 'node:fs'; type Note = { // UUID version 4 id: string; // Deleted notes have null text text: string | null; // ISO 8601 format creation_date: string; // ISO 8601 format modification_date: string; // 0 means deleted, 1 means not deleted not_deleted: number; // 0 means archived, 1 means not archived not_archived: number; // 0 means not pinned, 1 means pinned pinned: number; // A higher number means higher on the list // Usually, by default it's milliseconds since the epoch order: number; }; type EncryptedNote = { // UUID version 4 id: string; // ISO 8601 format modification_date: string; // The encrypted Note in base64 format encrypted_base64: string; // Initial vector, a random number, that was used for encrypting this specific note iv: string; }; type LoginData = { username: string; password_client_hash: string; }; type SignupData = { username: string; password_client_hash: string; encryption_salt: string; }; type LoginResponse = { username: string; token: string; encryption_salt: string; }; // In addition to LoginResponse, we want to locally store the CryptoKey which is derived from // the encryption salt and the raw password during login/signup and used for encryption/decryption. // However, since CryptoKey is not directly serializable, we convert it to JsonWebKey and use // importKey() to convert back later. type Credentials = LoginResponse & { jwk: webcrypto.JsonWebKey }; const BASE_URL = 'https://unforget.computing-den.com'; async function main() { switch (process.argv[2]) { case 'signup': { const username = process.argv[3]; const password = process.argv[4]; if (!username || !password) usageAndExit(); await signup(username, password); break; } case 'login': { const username = process.argv[3]; const password = process.argv[4]; if (!username || !password) usageAndExit(); await login(username, password); break; } case 'create': { const text = process.argv[3]; if (!text) usageAndExit(); await createNote(text); break; } case 'get': { const id = process.argv[3]; await getNote(id); break; } default: usageAndExit(); } console.log('Success.'); } function usageAndExit() { console.error(` Usage: npx tsx example.ts COMMAND Available commands: singup USERNAME PASSWORD login USERNAME PASSWORD create TEXT get [ID] `); process.exit(1); } async function signup(username: string, password: string) { const salt = bytesToHexString(webcrypto.getRandomValues(new Uint8Array(16))); const hash = await calcPasswordHash(username, password); const data: SignupData = { username, password_client_hash: hash, encryption_salt: salt }; const res = await post('/api/signup', data); const credentials = await createCredentials(res, password); writeCredentials(credentials); } async function login(username: string, password: string) { const hash = await calcPasswordHash(username, password); const data: LoginData = { username, password_client_hash: hash }; const res = await post('/api/login', data); const credentials = await createCredentials(res, password); writeCredentials(credentials); } async function createNote(text: string) { const note: Note = { id: webcrypto.randomUUID(), text, creation_date: new Date().toISOString(), modification_date: new Date().toISOString(), not_deleted: 1, not_archived: 1, pinned: 0, order: Date.now(), }; // Read the credentials and convert the key from JsonWebKey back to CryptoKey. const credentials = readCredentials(); const key = await importKey(credentials); const encryptedNote = await encryptNote(note, key); await post(`/api/merge-notes`, { notes: [encryptedNote] }, credentials); console.log(`Created note with ID ${note.id}`); } async function getNote(id?: string) { // Read the credentials and convert the key from JsonWebKey back to CryptoKey. const credentials = readCredentials(); const key = await importKey(credentials); // ids: [] would return no notes. ids: undefined or null would return everything. const ids = id ? [id] : null; const encryptedNotes = await post(`/api/get-notes`, { ids }, credentials); if (encryptedNotes.length === 0) { console.log('Not found'); } else { // Decrypt the received notes using the key. const notes = await Promise.all(encryptedNotes.map(x => decryptNote(x, key))); // Log to console. for (const note of notes) console.log(JSON.stringify(note, null, 2) + '\n'); } } async function encryptNote(note: Note, key: webcrypto.CryptoKey): Promise { // Encode the string to bytes. const data = new TextEncoder().encode(JSON.stringify(note)); // Generate the initial vector (iv). const iv = webcrypto.getRandomValues(new Uint8Array(12)); // Encrypt the bytes using the iv and the given key. const encrypted = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); // Encode as base64 to easily store in JSON. const encryptedBase64 = Buffer.from(encrypted).toString('base64'); // Create the EncryptedNote object. return { id: note.id, modification_date: note.modification_date, encrypted_base64: encryptedBase64, iv: bytesToHexString(iv), }; } async function decryptNote(encryptedNote: EncryptedNote, key: webcrypto.CryptoKey): Promise { // Decode the base64 string to bytes. const encryptedBytes = Buffer.from(encryptedNote.encrypted_base64, 'base64'); // Decrypt the bytes using note's initial vector (iv) and the given key. const iv = hexStringToBytes(encryptedNote.iv); const decryptedBytes = await webcrypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes); // Decode the decrypted bytes into string. const noteString = new TextDecoder().decode(decryptedBytes); // Parse the string to get the note JSON. return JSON.parse(noteString); } /** * Read the credentials from ./credentials.json */ function readCredentials(): Credentials { return JSON.parse(fs.readFileSync('./credentials.json', 'utf8')); } /** * Write the credentials to ./credentials.json. */ function writeCredentials(credentials: Credentials) { fs.writeFileSync('credentials.json', JSON.stringify(credentials, null, 2)); console.log('Wrote credentials to ./credentials.json'); } /** * Converts the JsonWebKey (credentials.jwk) which was exported from CryptoKey back to CryptoKey so * that it can be used for encrypting and decrypting notes. */ async function importKey(credentials: Credentials): Promise { return webcrypto.subtle.importKey('jwk', credentials.jwk, 'AES-GCM', true, ['encrypt', 'decrypt']); } /** * It derives a PBKDF2 CryptoKey from the password and the res.encryption_salt for encrypting and decrypting notes. * The CryptoKey is then exported to JsonWebKey so that we can serialize it and store it in credentials.json. * Use importKey() to convert back to CryptoKey. */ async function createCredentials(res: LoginResponse, password: string): Promise { const keyData = new TextEncoder().encode(password); const keyMaterial = await webcrypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']); const saltBuf = hexStringToBytes(res.encryption_salt); const key = await webcrypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBuf, iterations: 100000, hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], ); const jwk = await webcrypto.subtle.exportKey('jwk', key); return { ...res, jwk }; } /** * Send a POST request to BASE_URL and parse the resopnse as JSON. */ async function post(pathname: string, body?: any, credentials?: Credentials): Promise { const query = credentials ? `?token=${credentials.token}` : ''; const url = `${BASE_URL}${pathname}${query}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body && JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); return res.json(); } /** * The password hash is derived from the username, password, and a specific static random number. * It is important to use the exact same method for calculating the hash if you wish the * credentials to work with the official unforget app. */ async function calcPasswordHash(username: string, password: string): Promise { const text = username + password + '32261572990560219427182644435912532'; const encoder = new TextEncoder(); const textBuf = encoder.encode(text); const hashBuf = await webcrypto.subtle.digest('SHA-256', textBuf); return bytesToHexString(new Uint8Array(hashBuf)); } /** * bytesToHexString(Uint8Array.from([1, 2, 3, 10, 11, 12])) //=> '0102030a0b0c' */ function bytesToHexString(bytes: Uint8Array): string { return Array.from(bytes) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); } /** * hexStringToBytes('0102030a0b0c') //=> Uint8Array(6) [ 1, 2, 3, 10, 11, 12 ] */ function hexStringToBytes(str: string): Uint8Array { if (str.length % 2) throw new Error('hexStringToBytes invalid string'); const bytes = new Uint8Array(str.length / 2); for (let i = 0; i < str.length; i += 2) { bytes[i / 2] = parseInt(str.substring(i, i + 2), 16); } return bytes; } main(); ================================================ FILE: package.json ================================================ { "name": "unforget", "title": "Unforget", "version": "1.0.0", "description": "A minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js)", "type": "module", "scripts": { "start": "node --enable-source-maps dist/server/index.js", "clean": "barefront clean", "build": "barefront build", "dev": "barefront dev", "init-server": "barefront init-server", "clean-server": "barefront clean-server", "deploy": "barefront deploy" }, "exports": { "./*": "./dist/*" }, "barefront": { "library": false }, "author": "Sean Shirazi ", "keywords": [ "PWA", "Progressive Web App", "React", "SaaS", "note-taking", "note", "todo", "minimalist", "minimal", "simple", "E2EE", "privacy", "offline", "encrypted" ], "license": "MIT", "homepage": "https://unforget.computing-den.com/demo", "repository": "github:computing-den/unforget", "engines": { "node": ">=18.19.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.9", "@types/body-parser": "^1.19.5", "@types/cookie-parser": "^1.4.6", "@types/express": "^4.17.21", "@types/lodash": "^4.14.202", "@types/node": "^20.11.20", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", "@types/uuid": "^9.0.8", "barefront": "2.14.0", "esbuild": "^0.25.8", "typescript": "^5.9.2" }, "dependencies": { "better-sqlite3": "^9.4.3", "body-parser": "^1.20.2", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", "express": "^4.18.2", "hast-util-to-html": "^9.0.1", "immer": "^10.0.3", "lodash": "^4.17.21", "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm": "^3.0.0", "mdast-util-newline-to-break": "^2.0.0", "mdast-util-to-hast": "^13.1.0", "micromark-extension-gfm": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "unzipit": "^1.4.3", "uuid": "^9.0.1" } } ================================================ FILE: public/manifest.json ================================================ { "name": "Unforget", "short_name": "Unforget", "description": "Never forget a thing.", "theme_color": "#448199", "background_color": "#448199", "display": "standalone", "orientation": "portrait", "scope": "/", "start_url": "/", "icons": [ { "src": "/icon-256x256.png", "sizes": "256x256", "type": "image/png" } ], "splash_pages": null } ================================================ FILE: scripts/script.ts ================================================ let hello: string = 'Hello'; console.log(hello); ================================================ FILE: scripts/tsconfig.json ================================================ { "extends": "@tsconfig/node18/tsconfig.json" } ================================================ FILE: src/client/AboutPage.tsx ================================================ import React from 'react'; import * as appStore from './appStore.jsx'; import { createNewNote, CACHE_VERSION } from '../common/util.js'; import { PageLayout, PageHeader, PageBody } from './PageLayout.jsx'; import { Notes } from './Notes.jsx'; import _ from 'lodash'; import aboutMd from './notes/about.md'; const technicalDetails = `\n\n# Technical details\n\nCache version: ${CACHE_VERSION}`; const aboutNote = createNewNote(aboutMd + technicalDetails); function AboutPage() { const app = appStore.use(); return (
); } export default AboutPage; ================================================ FILE: src/client/App.tsx ================================================ import { Router, Route, useRouter } from './router.jsx'; import React, { useEffect } from 'react'; import * as appStore from './appStore.js'; import log from './logger.js'; import LoginPage from './LoginPage.jsx'; import AboutPage from './AboutPage.jsx'; import DemoPage from './DemoPage.jsx'; import { NotesPage, notesPageLoader } from './NotesPage.jsx'; import { NotePage, notePageLoader } from './NotePage.jsx'; import { ImportPage } from './ImportPage.jsx'; import { ExportPage } from './ExportPage.jsx'; import Notifications from './Notifications.jsx'; import _ from 'lodash'; export default function App() { const routes: Route[] = [ { path: '/login', element: , }, { path: '/about', element: , }, { path: '/demo', element: , }, { path: '/n/:noteId', element: ({ params }) => ( ), loader: notePageLoader, }, { path: '/import', element: ( ), }, { path: '/export', element: ( ), }, { path: '/archive', element: ( ), loader: notesPageLoader, }, { path: '/', element: ( ), loader: notesPageLoader, }, ]; return ( <> } /> ); } function Fallback() { return null; } function Auth(props: { children: React.ReactNode }) { const router = useRouter(); const app = appStore.use(); useEffect(() => { if (!app.user) { let params = ''; if (router.pathname !== '/') { params = new URLSearchParams({ from: router.pathname }).toString(); } const url = '/login' + (params ? `?${params}` : ''); history.replaceState(null, '', url); } }, [app.user, router]); return app.user ? props.children : null; } ================================================ FILE: src/client/DemoPage.tsx ================================================ import * as appStore from './appStore.js'; import * as actions from './appStoreActions.jsx'; import _ from 'lodash'; function DemoPage() { const user = appStore.get().user; if (!user || user.username === 'demo') { actions.setUpDemo().then(() => history.replaceState(null, '', '/')); } else { history.replaceState(null, '', '/'); } return null; } export default DemoPage; ================================================ FILE: src/client/Editor.css ================================================ textarea.editor { /* background: #eee; */ padding: 1rem 1rem; /* margin: 5rem; */ /* width: 100px; */ font-family: inherit; /* font-variant-ligatures: no-common-ligatures; */ white-space: pre-wrap; word-break: break-word; word-wrap: break-word; /* line-height: var(--note-line-height); */ resize: vertical; font-family: monospace; } ================================================ FILE: src/client/Editor.tsx ================================================ import log from './logger.js'; import React, { useState, useLayoutEffect, useRef, forwardRef, useImperativeHandle } from 'react'; import type * as t from '../common/types.js'; import * as cutil from '../common/util.js'; import * as md from '../common/mdFns.js'; import { MenuItem } from './Menu.js'; import { useClickWithoutDrag } from './hooks.jsx'; import _ from 'lodash'; import { v4 as uuid } from 'uuid'; type EditorProps = { value: string; onChange: (value: string) => any; id?: string; className?: string; placeholder?: string; // autoFocus?: boolean; readOnly?: boolean; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; autoExpand?: boolean; // onConfirm: () => any; // onDelete: () => any; // onTogglePinned: () => any; }; export type EditorContext = { cycleListStyle: () => any; focus: () => any; textareaRef: React.RefObject; }; type Selection = { start: number; end: number; direction: 'forward' | 'backward' | 'none' }; export const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef) { const textareaRef = useRef(null); // const [selection, setSelection] = useState({ start: 0, end: 0, direction: 'forward' }); // const [lastSelection, setLastSelection] = useState(); function replaceText(deleteStart: number, deleteEnd: number, text: string = '') { const textarea = textareaRef.current!; const currentValue = textarea.value; const before = currentValue.slice(0, deleteStart); const after = currentValue.slice(deleteEnd); const newCursor = deleteStart + text.length; props.onChange(before + text + after); requestAnimationFrame(() => { textarea.setSelectionRange(newCursor, newCursor); }); } function replaceListItemPrefix(listItem: md.ListItem, newListItem: md.ListItem, lineRange: md.Range) { const linePrefix = md.stringifyListItemPrefix(listItem); const newLinePrefix = md.stringifyListItemPrefix(newListItem); replaceText(lineRange.start, lineRange.start + linePrefix.length, newLinePrefix); } function cycleListStyle() { const textarea = textareaRef.current!; const text = textarea.value; // If there's not lastSelection, assume end of text const i = textarea.selectionStart; const lineRange = md.getLineRangeAt(text, i); const line = md.getLine(text, lineRange); const listItem = md.parseListItem(line); // console.log('lastSelection', lastSelection); // unstyled -> checkbox -> bulletpoint ... if (listItem.checkbox) { replaceListItemPrefix(listItem, md.removeListItemCheckbox(listItem), lineRange); } else if (listItem.type) { replaceListItemPrefix(listItem, md.removeListItemType(listItem), lineRange); // } else if (!lastSelection) { // replaceText(text.length, text.length, text.length > 0 ? '\n- [ ] ' : '- [ ] '); // textarea.setSelectionRange(textarea.value.length, textarea.value.length); } else { replaceListItemPrefix(listItem, md.addListItemCheckbox(listItem), lineRange); } textarea.focus(); // If there were no lastSelection, move cursor to the end. // if (!lastSelection) { // textarea.focus(); // textarea.setSelectionRange(textarea.value.length, textarea.value.length); // } } function focus() { textareaRef.current!.focus(); } useImperativeHandle(ref, () => ({ cycleListStyle, focus, textareaRef }), [ cycleListStyle, focus, textareaRef, ]); function changeCb() { props.onChange(textareaRef.current!.value); } function keyDownCb(e: React.KeyboardEvent) { const textarea = textareaRef.current!; if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) { // textarea.focus(); const text = textarea.value; const i = textarea.selectionStart; const lineRange = md.getLineRangeAt(text, i); const line = md.getLine(text, lineRange); const listItem = md.parseListItem(line); const listItemPrefix = md.stringifyListItemPrefix(listItem); // Ignore if cursor is before the line prefix if (i < lineRange.start + listItemPrefix.length) return; if (!listItemPrefix) return; e.preventDefault(); if (listItem.content) { // Increment list item number and set empty checkbox. let newListItem = md.incrementListItemNumber(listItem); if (listItem.checkbox) { newListItem = md.setListItemCheckbox(newListItem, false); } // Delete whitespace and insert the prefix. const afterWhitespace = md.skipWhitespaceSameLine(text, i); const before = text.slice(0, i); const after = text.slice(afterWhitespace); const insert = '\n' + md.stringifyListItemPrefix(newListItem); const newCursor = before.length + insert.length; props.onChange(before + insert + after); requestAnimationFrame(() => { textarea.setSelectionRange(newCursor, newCursor); }); } else { // Pressing enter on a line with prefix and empty content will clear the prefix. const before = text.slice(0, lineRange.start); const after = text.slice(lineRange.end); const newText = before + after; const newCursor = lineRange.start; props.onChange(newText); requestAnimationFrame(() => { textarea.setSelectionRange(newCursor, newCursor); }); } } } // function selectCb() { // const textarea = textareaRef.current!; // // setLastSelection(selection); // setSelection({ // start: textarea.selectionStart, // end: textarea.selectionEnd, // direction: textarea.selectionDirection, // }); // } function clickCb(e: React.MouseEvent) { const textarea = textareaRef.current!; const text = textarea.value; const i = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart; const lineRange = md.getLineRangeAt(text, i); const line = md.getLine(text, lineRange); const listItem = md.parseListItem(line); if (!md.isCursorOnCheckbox(listItem, i - lineRange.start)) return; const newListItem = md.toggleListItemCheckbox(listItem); const checkboxRange = md.getListItemCheckboxRange(listItem); const startPos = lineRange.start + checkboxRange.start; const endPos = lineRange.start + checkboxRange.end; const newText = text.slice(0, startPos) + newListItem.checkbox + text.slice(endPos); props.onChange(newText); requestAnimationFrame(() => { const newCursor = startPos + newListItem.checkbox.length; textarea.setSelectionRange(newCursor, newCursor); }); } const { onClick, onMouseDown } = useClickWithoutDrag(clickCb); function pasteCb(e: React.ClipboardEvent) { const textarea = textareaRef.current!; const pasteData = e.clipboardData.getData('text/plain'); // const start = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart; const text = textarea.value; const selectionStart = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; const lineRange = md.getLineRangeAt(text, selectionStart); const line = md.getLine(text, lineRange); const listItem = md.parseListItem(line); if (!listItem.type) return; e.preventDefault(); const pasteLines = pasteData.split(/\r?\n/g); const pasteListItems = pasteLines.map(md.parseListItem); pasteListItems[0].content = listItem.content + pasteListItems[0].content; // const linePrefix = md.stringifyListItemPrefix(listItem); const newLineItems: md.ListItem[] = []; let emptyListItem = { ...listItem, content: '' }; for (const pasteListItem of pasteListItems) { emptyListItem = md.incrementListItemNumber(emptyListItem); newLineItems.push({ ...emptyListItem, checkbox: pasteListItem.checkbox || emptyListItem.checkbox, content: pasteListItem.content, }); } const newText = newLineItems.map(md.stringifyListItem).join('\n'); replaceText(lineRange.start, selectionEnd, newText); } useLayoutEffect(() => { if (props.autoExpand) { const editor = textareaRef.current!; const style = window.getComputedStyle(editor); const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); editor.style.height = '0'; // Shrink it first. editor.style.height = `${editor.scrollHeight - padding}px`; } }, [props.value, props.autoExpand]); return (