Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66d4f58c9 | ||
|
|
d1f537fcae | ||
|
|
ffdc976135 |
@@ -0,0 +1,263 @@
|
||||
# Zapstore App Stacks — Privacy Review
|
||||
|
||||
A systematic privacy and security review of apps in the [Zapstore](https://zapstore.dev) catalog. Each app is cloned from its source repository, scanned for tracking SDKs, permissions, and privacy practices, then assigned a verdict. Approved apps are organized into curated stacks for publication to the Zapstore relay.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── README.md # This file
|
||||
├── .gitignore
|
||||
├── plans/ # Planning documents and methodology
|
||||
│ ├── app-review-methodology.md
|
||||
│ ├── proposed-categories.md
|
||||
│ └── review-todo.md
|
||||
├── scripts/ # Python scripts for review and publishing
|
||||
│ ├── review_apps.py # Main review script (SSH-based)
|
||||
│ ├── generate_stacks.py # Map apps to categories, generate stack events
|
||||
│ ├── publish_stack.py # Sign and publish stacks to relay
|
||||
│ └── ...
|
||||
├── data/ # Review results and app data (JSON)
|
||||
│ ├── review_results_final.json # All 280 apps with verdicts
|
||||
│ ├── apps_data.json # Zapstore catalog data
|
||||
│ └── ...
|
||||
├── reports/ # Human-readable reports (Markdown)
|
||||
│ ├── review_report_by_category.md # Full category-by-category report
|
||||
│ ├── final_report.md # Top 2 apps per category
|
||||
│ └── ...
|
||||
└── stacks/ # Generated Nostr stack events (kind 30267)
|
||||
├── all_stacks.json # All 20 stacks in one file
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Review Results
|
||||
|
||||
| Verdict | Count | Meaning |
|
||||
|---------|-------|---------|
|
||||
| ✅ **APPROVED** | **175** | No tracking SDKs, minimal/justified permissions, open source |
|
||||
| ⚠️ **FLAGGED** | **36** | Contains tracking SDKs or excessive permissions |
|
||||
| ⏭️ **SKIPPED** | **41** | Could not clone repository (no mirror, no GitHub URL) |
|
||||
| ❓ **UNCLEAR** | **28** | Needs human investigation (app not in catalog, auth required) |
|
||||
|
||||
## 20 Curated App Stacks
|
||||
|
||||
These stacks are ready to publish to the Zapstore relay as kind-30267 events.
|
||||
|
||||
### 1. Bitcoin & Lightning Wallets
|
||||
Self-custodial Bitcoin and Lightning wallet apps.
|
||||
- ZEUS, Nunchuk, Cake Wallet, Alby Go, Blitz Wallet, BlueWallet, Electrum, Oubli, MercaSats, Lightning Reaction
|
||||
|
||||
### 2. Nostr Clients
|
||||
Nostr-native social and communication clients.
|
||||
- Amethyst, Amber, Wisp, Dark Wisp, White Noise, Nospeak, Nostrord, Shosho, TravelTelly, Nmail, Divine, YakiHonne, Nests, Nostria, Zapstore Alpha, PearCal, PearCircle, Ditto, Flotilla
|
||||
|
||||
### 3. Secure Messaging
|
||||
End-to-end encrypted messaging and email apps.
|
||||
- SimpleX, Conversations, Delta Chat, aTalk, Element, Element X, Thunderbird, FairEmail, Tuta, Quiet, SpamBlocker, Deku SMS
|
||||
|
||||
### 4. VPN & Privacy Tools
|
||||
VPN clients, firewalls, and network privacy tools.
|
||||
- Orbot, Mullvad VPN, Tailscale, AmneziaVPN, Proton VPN, Rethink, PCAPdroid, WG Tunnel, ByeDPI, NeoStumbler, SD Maid, InviZible Pro, Private DNS Quick Setting
|
||||
|
||||
### 5. Password Managers & Auth
|
||||
Password managers, 2FA authenticators, and identity tools.
|
||||
- Bitwarden, KeePassDX, Aegis, Authnkey, Ente Auth, Keep, AliasVault, PassVault, LibreFind
|
||||
|
||||
### 6. Maps & Navigation
|
||||
Offline maps, navigation, and location tools.
|
||||
- Organic Maps, OsmAnd~, StreetComplete, CoMaps, OwnTracks, OSMTracker
|
||||
|
||||
### 7. Media Players & Streaming
|
||||
Video and music players, streaming clients.
|
||||
- VLC, NewPipe, PipePipe, Auxio, Musify, Metrolist, FreeTube, Zaptrax, Zappix, mpvEx, KurobaEx
|
||||
|
||||
### 8. Productivity & Notes
|
||||
Note-taking, task management, and productivity tools.
|
||||
- Flux, Notesnook, Quillpad, Saber, DAVx⁵, Super Productivity, Trilium Notes, SilentNotes, Manent, Meiso, Florid, Plektos, Urn, Screen Time, Grit, Numo, timeto.me, Converter NOW
|
||||
|
||||
### 9. File Management & Cloud Sync
|
||||
Cloud storage, file sync, and file management tools.
|
||||
- Nextcloud, Syncthing-Fork, Seafile, File transfer, primitive ftpd, Paperless Mobile, GitSync, wormhole, SFTP Documents Provider, OSS Document Scanner
|
||||
|
||||
### 10. Browsers
|
||||
Privacy-focused web browsers.
|
||||
- DuckDuckGo, Cromite
|
||||
|
||||
### 11. Social Media
|
||||
Federated and alternative social media clients.
|
||||
- Mastodon, Nekogram, Infinity+, NewsBlur, Claw, Polymarket Viewer
|
||||
|
||||
### 12. Finance & Budgeting
|
||||
Personal finance, expense tracking, and budgeting.
|
||||
- Flow, Pennywise AI Tracker, Dev Stocks Widget, Seeker, PearGuard, Mostro, Shopt
|
||||
|
||||
### 13. Health & Fitness
|
||||
Health tracking, diet, and fitness apps.
|
||||
- Daily Dozen, Screen Time, Astronia
|
||||
|
||||
### 14. Education & Reference
|
||||
Learning, dictionary, and reference tools.
|
||||
- freeCodeCamp, freeDictionary, Ciyue, Keyman, NeverTooManyBooks, Calibre Web Companion, Repertoire, ListenBrainz, CPU Info, microMathematics Plus, Mental Math
|
||||
|
||||
### 15. Photography & Image Tools
|
||||
Camera, photo editing, scanning, and gallery apps.
|
||||
- Image Toolbox, FairScan, PhotoPrism, PicGuard, Gallery apps, YTDLnis, ElCaju, Espy, PDF Wallet
|
||||
|
||||
### 16. Utilities & Tools
|
||||
System utilities, converters, and general-purpose tools.
|
||||
- Binary Eye, CPU Info, Converter NOW, Catima, BT Remote, Lawnicons, Peristyle, BinEd, MMRL, Canta, Amarok, ServerBox, wX, Mako, Rush, HeliBoard, DeskClock, Redomi, Scrobble, Feeder, SMS2Email, Inure, Unciv
|
||||
|
||||
### 17. Games
|
||||
Open source games across genres.
|
||||
- Shattered Pixel Dungeon, Wesnoth, Feudal Tactics, Burger Party, ChipDefense, Roboyard, Breakout 71, Damas Clash, Unciv
|
||||
|
||||
### 18. Communication (Non-Nostr)
|
||||
Voice/video calls, remote desktop, and messaging.
|
||||
- Telegram, RustDesk, Sideband, Meshtastic
|
||||
|
||||
### 19. Development Tools
|
||||
Code editors, Git clients, and developer tools.
|
||||
- Acode, GitSync, BinEd, freeCodeCamp, GitHub Store, Kai 9000
|
||||
|
||||
### 20. Calendar & Scheduling
|
||||
Calendar apps, scheduling, and time management.
|
||||
- Calendar by Form*, PearCal, timeto.me, Sidestep
|
||||
|
||||
## Review Methodology
|
||||
|
||||
Each app is reviewed using a structured process:
|
||||
|
||||
1. **Clone** from local Gitea mirror (or GitHub fallback)
|
||||
2. **Scan AndroidManifest.xml** for requested permissions
|
||||
3. **Check build.gradle\*** for actual dependency declarations (distinguishing `implementation` vs `playImplementation` vs `compileOnly`)
|
||||
4. **Verify API calls** — search for actual SDK method invocations (not just dependency names)
|
||||
5. **Check manifest metadata** that disables analytics
|
||||
6. **Check build flavors** (Google Play vs F-Droid)
|
||||
7. **Determine verdict**: ✅ APPROVED or ⚠️ FLAGGED
|
||||
|
||||
See [`plans/app-review-methodology.md`](plans/app-review-methodology.md) for full details.
|
||||
|
||||
## Publishing
|
||||
|
||||
Stack events (kind 30267) are published to `wss://relay.zapstore.dev` using the [`publish_stack.py`](scripts/publish_stack.py) script, which signs via `qrexec` (Qubes OS `nostr_signer`).
|
||||
|
||||
## Nostr Kinds Reference
|
||||
|
||||
The following Nostr event kinds are relevant to this project:
|
||||
|
||||
| Kind | Name | Usage | Status |
|
||||
|------|------|-------|--------|
|
||||
| **32267** | Software Application | The app listing event published by developers. Identified by `d` tag (Android package name) and publisher pubkey. Referenced in stacks via `a` tags as `32267:<pubkey>:<identifier>`. | Core data source |
|
||||
| **30267** | App curation set | Curated collections of apps (stacks). We publish privacy-approved stacks as kind 30267 events with `a` tags referencing each approved app. | Used for output |
|
||||
| **30078** | App-specific data | Generic parameterized replaceable event. Already used by Zapstore for device state, bookmarks, etc. Candidate for storing per-app review results from LLM agents. | Planned for reviews |
|
||||
| **3063** | Software Asset | APK metadata including hash, size, version code, platform. Used to track current version info for reviewed apps. | Used for version tracking |
|
||||
| **30063** | Release artifact set | Group of artifacts for a software release. Links to kind 3063 assets and kind 32267 app events. | Reference |
|
||||
| **1986** | Relay reviews | Existing review kind, but scoped to Nostr relays rather than software apps. | Not applicable |
|
||||
|
||||
See [`references/registry-of-kinds/schema.yaml`](references/registry-of-kinds/schema.yaml) for the full kinds registry and [`references/nips/51.md`](references/nips/51.md) for list/set definitions.
|
||||
|
||||
## Kind 30078 Event Data Model
|
||||
|
||||
We use **kind 30078** (App-specific data) events for two purposes: app definitions and app reviews. The frontend queries the Nostr relay directly for these events on page load, rather than reading a static file.
|
||||
|
||||
### App Definition Events
|
||||
|
||||
Each app in the catalog is defined by a kind 30078 event with `#t: app-definition`. These are published when an app is added via the PHP backend.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "{\"name\":\"Amethyst\",\"identifier\":\"com.vitorpamplona.amethyst\",\"repository\":\"https://github.com/vitorpamplona/amethyst\",\"description\":\"...\"}",
|
||||
"tags": [
|
||||
["d", "app-com.vitorpamplona.amethyst"],
|
||||
["t", "app-definition"],
|
||||
["t", "nostr-clients"], // ← category tag
|
||||
["name", "Amethyst"],
|
||||
["repository", "https://github.com/vitorpamplona/amethyst"],
|
||||
["gitea", "com.vitorpamplona.amethyst"],
|
||||
["url", "https://cdn.zapstore.dev/..."],
|
||||
["f", "android-arm64-v8a"],
|
||||
["published_at", "1729302793"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### App Review Events
|
||||
|
||||
Each model's review of an app is stored as a kind 30078 event with `#t: app-review`. The review is pinned to a specific version via SHA256 hash.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "{\"verdict\":\"APPROVED\",\"level\":1,\"summary\":\"...\",\"findings\":{...}}",
|
||||
"tags": [
|
||||
// Unique ID: model + app identifier + version
|
||||
["d", "review-deepseek/deepseek-v4-flash-com.vitorpamplona.amethyst-v1.2.3"],
|
||||
|
||||
// Reference to the app definition event
|
||||
["a", "30078:<pubkey>:app-com.vitorpamplona.amethyst", "wss://relay.zapstore.dev"],
|
||||
|
||||
// The SPECIFIC asset/APK that was reviewed (kind 3063 event)
|
||||
["e", "<kind_3063_event_id>", "wss://relay.zapstore.dev"],
|
||||
|
||||
// Version info for querying
|
||||
["version", "1.2.3"],
|
||||
["version_code", "1234"],
|
||||
|
||||
// SHA256 hash of the reviewed APK — cryptographic pin
|
||||
["x", "a1b2c3d4e5f6..."],
|
||||
|
||||
// Review metadata
|
||||
["model", "deepseek/deepseek-v4-flash"],
|
||||
["model_name", "DeepSeek Flash 4"],
|
||||
["verdict", "APPROVED"],
|
||||
["level", "1"],
|
||||
["p", "<agent_pubkey>"],
|
||||
["t", "app-review"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Reference
|
||||
|
||||
| Tag | Event Type | Purpose |
|
||||
|-----|-----------|---------|
|
||||
| `d` | Both | Unique identifier (`app-{identifier}` or `review-{model}-{identifier}-v{version}`) |
|
||||
| `t` | Both | `app-definition` or `app-review` — also carries category for app definitions |
|
||||
| `name` | App definition | Human-readable app name |
|
||||
| `repository` | App definition | Source code URL |
|
||||
| `gitea` | App definition | Gitea mirror identifier |
|
||||
| `url` | App definition | APK download URL |
|
||||
| `f` | App definition | Platform (e.g. `android-arm64-v8a`) |
|
||||
| `a` → kind 30078 | App review | Reference to the app definition event |
|
||||
| `e` → kind 3063 | App review | The specific APK asset that was reviewed |
|
||||
| `x` | App review | SHA256 hash — cryptographic pin of the reviewed binary |
|
||||
| `version` / `version_code` | App review | Version info |
|
||||
| `model` | App review | LLM model string (e.g. `deepseek/deepseek-v4-flash`) |
|
||||
| `verdict` | App review | Review verdict |
|
||||
| `level` | App review | Privacy level (1-4) |
|
||||
| `p` | App review | Agent pubkey who performed the review |
|
||||
|
||||
### Page Load Flow
|
||||
|
||||
```
|
||||
Page opens
|
||||
→ Queries relay for kind 30078, #t: app-definition
|
||||
→ Groups apps by #t category tags → builds categories
|
||||
→ Queries relay for kind 30078, #t: app-review
|
||||
→ Matches reviews to apps via a tags
|
||||
→ Renders categories + apps + per-model review status
|
||||
```
|
||||
|
||||
### Version Pinning Flow
|
||||
|
||||
```
|
||||
App v1.2.3 defined (kind 30078, #t: app-definition)
|
||||
└── APK asset (kind 3063, SHA256: abc...)
|
||||
└── Review (kind 30078, #t: app-review, pinned via x tag)
|
||||
|
||||
App v1.2.4 published (kind 3063, SHA256: def...)
|
||||
└── NEW review needed — old review's SHA256 doesn't match
|
||||
```
|
||||
|
||||
If a bad actor modifies the app in v1.2.4, the review for v1.2.3 (with its SHA256 `abc...`) does not carry over. A new review must be performed on the new binary.
|
||||
@@ -0,0 +1,149 @@
|
||||
# App Stacks — Forward Plan
|
||||
|
||||
## Current State
|
||||
|
||||
[`www/app-stacks.html`](../www/app-stacks.html) has:
|
||||
- A publish form that takes a Gitea URL + category and publishes a kind 30078 app-definition event
|
||||
- A subscription that displays received app-definition events grouped by category
|
||||
|
||||
## New Understanding
|
||||
|
||||
- **Categories are actually app stacks** — kind 30267 events (App curation set)
|
||||
- An **app stack** is a curated collection of apps, e.g. "Nostr Clients", "Bitcoin & Lightning Wallets"
|
||||
- An **app definition** (kind 30078) belongs to an app stack, not a free-text category
|
||||
- We need two separate publish flows:
|
||||
1. **Create an App Stack** — publishes a kind 30267 event
|
||||
2. **Publish App Definition** — selects an existing app stack to belong to
|
||||
|
||||
## Nostr Event Types
|
||||
|
||||
### App Stack (kind 30267)
|
||||
```json
|
||||
{
|
||||
"kind": 30267,
|
||||
"content": "{\"name\":\"Nostr Clients\",\"description\":\"Nostr-native social and communication clients.\"}",
|
||||
"tags": [
|
||||
["d", "nostr-clients"],
|
||||
["t", "app-stack"],
|
||||
["name", "Nostr Clients"],
|
||||
["description", "Nostr-native social and communication clients."],
|
||||
// References to apps in this stack:
|
||||
["a", "30078:<pubkey>:app-com.vitorpamplona.amethyst", "wss://relay.zapstore.dev"],
|
||||
["a", "30078:<pubkey>:app-com.example.other", "wss://relay.zapstore.dev"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### App Definition (kind 30078) — updated
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "{\"name\":\"Amethyst\",\"identifier\":\"com.vitorpamplona.amethyst\",\"repository\":\"...\",\"description\":\"\"}",
|
||||
"tags": [
|
||||
["d", "app-com.vitorpamplona.amethyst"],
|
||||
["t", "app-definition"],
|
||||
["t", "nostr-clients"], // ← references the app stack's d tag
|
||||
["name", "Amethyst"],
|
||||
["repository", "https://github.com/vitorpamplona/amethyst"],
|
||||
["gitea", "com.vitorpamplona.amethyst"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: App Stack CRUD
|
||||
|
||||
**Goal:** Create, list, and display app stacks (kind 30267 events).
|
||||
|
||||
**Changes to [`www/app-stacks.html`](../www/app-stacks.html) (JS only):**
|
||||
|
||||
1. **New subscription** — subscribe to kind 30267 with `#t: app-stack`:
|
||||
```javascript
|
||||
subscribe({ kinds: [30267], '#t': ['app-stack'], limit: 200 }, ...);
|
||||
```
|
||||
|
||||
2. **New function: `parseAppStack(evt)`** — parse kind 30267 events into `{ dTag, name, description, appRefs: [], eventId }`
|
||||
|
||||
3. **New function: `renderStacks()`** — display app stacks in a section above or alongside app definitions
|
||||
|
||||
4. **New function: `publishAppStack(name, description)`** — create and publish a kind 30267 event:
|
||||
```javascript
|
||||
{
|
||||
kind: 30267,
|
||||
content: JSON.stringify({ name, description }),
|
||||
tags: [
|
||||
['d', name.toLowerCase().replace(/\s+/g, '-')],
|
||||
['t', 'app-stack'],
|
||||
['name', name],
|
||||
['description', description],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
```
|
||||
|
||||
5. **New function: `showCreateStackForm()`** — form with name and description fields
|
||||
|
||||
6. **Update `renderApps()`** — add a "Create App Stack" button/area
|
||||
|
||||
### Phase 2: Link App Definitions to Stacks
|
||||
|
||||
**Goal:** App definitions select an app stack instead of typing a free-text category.
|
||||
|
||||
**Changes to [`www/app-stacks.html`](../www/app-stacks.html) (JS only):**
|
||||
|
||||
1. **Update publish form** — replace the free-text category input with a `<select>` dropdown populated from loaded app stacks
|
||||
|
||||
2. **Update `doPublish()`** — use the selected stack's `d` tag value as the `#t` category tag
|
||||
|
||||
3. **Update `renderApps()`** — group apps by the app stack they reference, not by free-text category
|
||||
|
||||
### Phase 3: Stack Membership Management
|
||||
|
||||
**Goal:** Add/remove apps from stacks, and display stack membership.
|
||||
|
||||
**Changes to [`www/app-stacks.html`](../www/app-stacks.html) (JS only):**
|
||||
|
||||
1. **Update `publishAppDefinition()`** — also add an `a` tag referencing the app stack:
|
||||
```javascript
|
||||
['a', '30267:<pubkey>:' + stackDTag, 'wss://relay.zapstore.dev']
|
||||
```
|
||||
|
||||
2. **Display stack membership** — show which stack(s) each app belongs to
|
||||
|
||||
3. **Stack detail view** — show all apps in a stack
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Publishing"
|
||||
A[Create App Stack form] --> B[publishEvent kind 30267]
|
||||
C[Publish App Definition form] --> D[Select stack from dropdown]
|
||||
D --> E[publishEvent kind 30078<br>with #t: stack-d-tag]
|
||||
end
|
||||
|
||||
subgraph "Subscriptions"
|
||||
F[Subscribe kind 30267 #t: app-stack] --> G[Collect stacks]
|
||||
H[Subscribe kind 30078 #t: app-definition] --> I[Collect apps]
|
||||
end
|
||||
|
||||
subgraph "Rendering"
|
||||
G --> J[Render stack list]
|
||||
I --> K[Group apps by stack reference]
|
||||
K --> L[Render apps under each stack]
|
||||
end
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`www/app-stacks.html`](../www/app-stacks.html) | Add JS for kind 30267 subscription, parsing, rendering, publishing. Update app-definition form to use stack dropdown. |
|
||||
| [`app-stacks/README.md`](README.md) | Update with new event structures and flow |
|
||||
|
||||
## What NOT to Change
|
||||
|
||||
- No HTML changes to the template shell
|
||||
- No CSS additions
|
||||
- No removal of existing functionality
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Publish all 20 app stacks and all app definitions with Gitea mirror URLs.
|
||||
|
||||
Uses n_signer via Qubes qrexec (nostr_index=0) to sign events,
|
||||
then publishes to all configured relays.
|
||||
|
||||
Usage:
|
||||
python3 app-stacks/publish_all.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import websockets
|
||||
|
||||
QREXEC_TARGET = "nostr_signer"
|
||||
QREXEC_SERVICE = "qubes.NsignerRpc"
|
||||
RELAYS = [
|
||||
"wss://relay.zapstore.dev",
|
||||
"wss://laantungir.net/relay",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
]
|
||||
|
||||
# ── All 20 stacks with their apps ──────────────────────────────────
|
||||
|
||||
STACKS = [
|
||||
{
|
||||
"d": "privacy-approved-bitcoin-lightning-wallets",
|
||||
"name": "Privacy Approved — Bitcoin & Lightning Wallets",
|
||||
"description": "Self-custodial Bitcoin and Lightning wallet apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("app.zeusln.zeus", "ZEUS"),
|
||||
("io.nunchuk.android", "Nunchuk"),
|
||||
("com.cakewallet.cake_wallet", "Cake Wallet"),
|
||||
("com.getalby.mobile", "Alby Go"),
|
||||
("com.blitzwallet", "Blitz Wallet"),
|
||||
("io.bluewallet.bluewallet", "BlueWallet"),
|
||||
("org.electrum.electrum", "Electrum"),
|
||||
("com.oubli.wallet", "Oubli"),
|
||||
("com.kilombino.mercasats", "MercaSats"),
|
||||
("com.magius.lightningreaction", "Lightning Reaction"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-nostr-clients",
|
||||
"name": "Privacy Approved — Nostr Clients",
|
||||
"description": "Nostr-native social and communication clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.vitorpamplona.amethyst", "Amethyst"),
|
||||
("com.greenart7c3.nostrsigner", "Amber"),
|
||||
("com.wisp.app", "Wisp"),
|
||||
("com.darkwisp.app", "Dark Wisp"),
|
||||
("org.parres.whitenoise", "White Noise"),
|
||||
("com.nospeak.app", "Nospeak"),
|
||||
("org.nostr.nostrord", "Nostrord"),
|
||||
("com.shosho.app", "Shosho"),
|
||||
("com.traveltelly.app", "TravelTelly"),
|
||||
("app.nostrmail.client", "Nmail"),
|
||||
("co.openvine.app", "Divine"),
|
||||
("com.yakihonne.yakihonne", "YakiHonne"),
|
||||
("com.nostrnests.app", "Nests"),
|
||||
("app.nostria.twa", "Nostria"),
|
||||
("dev.zapstore.alpha", "Zapstore Alpha"),
|
||||
("com.pearcal", "PearCal"),
|
||||
("com.pearcircle", "PearCircle"),
|
||||
("pub.ditto.app", "Ditto"),
|
||||
("social.flotilla", "Flotilla"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-secure-messaging",
|
||||
"name": "Privacy Approved — Secure Messaging",
|
||||
"description": "End-to-end encrypted messaging and email apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("chat.simplex.app", "SimpleX"),
|
||||
("eu.siacs.conversations", "Conversations"),
|
||||
("chat.delta", "Delta Chat"),
|
||||
("org.atalk.android", "aTalk"),
|
||||
("im.vector.app", "Element"),
|
||||
("io.element.android.x", "Element X"),
|
||||
("net.thunderbird.android", "Thunderbird"),
|
||||
("eu.faircode.email", "FairEmail"),
|
||||
("de.tutao.tutanota", "Tuta"),
|
||||
("com.quietmobile", "Quiet"),
|
||||
("spam.blocker", "SpamBlocker"),
|
||||
("com.afkanerd.deku", "Deku SMS"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-vpn-privacy-tools",
|
||||
"name": "Privacy Approved — VPN & Privacy Tools",
|
||||
"description": "VPN clients, firewalls, and network privacy tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.torproject.android", "Orbot"),
|
||||
("net.mullvad.mullvadvpn", "Mullvad VPN"),
|
||||
("com.tailscale.ipn", "Tailscale"),
|
||||
("org.amnezia.vpn", "AmneziaVPN"),
|
||||
("ch.protonvpn.android", "Proton VPN"),
|
||||
("com.celzero.bravedns", "Rethink"),
|
||||
("com.emanuelef.remote_capture", "PCAPdroid"),
|
||||
("com.zaneschepke.wireguardautotunnel", "WG Tunnel"),
|
||||
("io.github.romanvht.byedpi", "ByeDPI"),
|
||||
("xyz.malkki.neostumbler", "NeoStumbler"),
|
||||
("eu.darken.sdmse", "SD Maid"),
|
||||
("pan.alexander.tordnscrypt.stable", "InviZible Pro"),
|
||||
("com.flashsphere.privatednsqs", "Private DNS Quick Setting"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-password-managers-auth",
|
||||
"name": "Privacy Approved — Password Managers & Auth",
|
||||
"description": "Password managers, 2FA authenticators, and identity tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.x8bit.bitwarden", "Bitwarden"),
|
||||
("com.kunzisoft.keepass.libre", "KeePassDX"),
|
||||
("com.beemdevelopment.aegis", "Aegis"),
|
||||
("pl.lebihan.authnkey", "Authnkey"),
|
||||
("io.ente.auth.independent", "Ente Auth"),
|
||||
("io.privkey.keep", "Keep"),
|
||||
("net.aliasvault.app", "AliasVault"),
|
||||
("com.jksalcedo.passvault", "PassVault"),
|
||||
("com.jksalcedo.librefind", "LibreFind"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-maps-navigation",
|
||||
"name": "Privacy Approved — Maps & Navigation",
|
||||
"description": "Offline maps, navigation, and location tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("app.organicmaps.web", "Organic Maps"),
|
||||
("net.osmand.plus", "OsmAnd~"),
|
||||
("de.westnordost.streetcomplete", "StreetComplete"),
|
||||
("com.comaps.app", "CoMaps"),
|
||||
("org.owntracks.android", "OwnTracks"),
|
||||
("net.osmtracker", "OSMTracker"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-media-players-streaming",
|
||||
"name": "Privacy Approved — Media Players & Streaming",
|
||||
"description": "Video and music players, streaming clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.videolan.vlc", "VLC"),
|
||||
("org.schabi.newpipe", "NewPipe"),
|
||||
("infinityloop1309.newpipeenhanced", "PipePipe"),
|
||||
("org.oxycblt.auxio", "Auxio"),
|
||||
("com.gokadzev.musify", "Musify"),
|
||||
("com.metrolist.music", "Metrolist"),
|
||||
("io.freetubeapp.freetube", "FreeTube"),
|
||||
("app.zaptrax", "Zaptrax"),
|
||||
("app.zappix", "Zappix"),
|
||||
("app.marlboroadvance.mpvex", "mpvEx"),
|
||||
("com.github.k1rakishou.chan", "KurobaEx"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-productivity-notes",
|
||||
"name": "Privacy Approved — Productivity & Notes",
|
||||
"description": "Note-taking, task management, and productivity tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.flux", "Flux"),
|
||||
("com.streetwriters.notesnook", "Notesnook"),
|
||||
("io.github.quillpad", "Quillpad"),
|
||||
("com.adilhanney.saber", "Saber"),
|
||||
("at.bitfire.davdroid", "DAVx⁵"),
|
||||
("com.superproductivity.superproductivity", "Super Productivity"),
|
||||
("eu.fliegendurst.triliumdroid", "Trilium Notes"),
|
||||
("ch.martinstoeckli.silentnotes", "SilentNotes"),
|
||||
("com.dtonon.manent", "Manent"),
|
||||
("jp.godzhigella.meiso", "Meiso"),
|
||||
("com.nahnah.florid", "Florid"),
|
||||
("com.plektos.app", "Plektos"),
|
||||
("io.nurunuru.app", "Urn"),
|
||||
("com.shub39.grit", "Grit"),
|
||||
("com.electricdreams.numo", "Numo"),
|
||||
("me.timeto.app", "timeto.me"),
|
||||
("com.ferrarid.converterpro", "Converter NOW"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-file-management-cloud-sync",
|
||||
"name": "Privacy Approved — File Management & Cloud Sync",
|
||||
"description": "Cloud storage, file sync, and file management tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.nextcloud.client", "Nextcloud"),
|
||||
("com.github.catfriend1.syncthingfork", "Syncthing-Fork"),
|
||||
("com.seafile.seadroid2", "Seafile"),
|
||||
("com.example.file_transfer", "File transfer"),
|
||||
("org.primftpd", "primitive ftpd"),
|
||||
("de.astubenbord.paperless_mobile", "Paperless Mobile"),
|
||||
("com.viscouspot.gitsync", "GitSync"),
|
||||
("eu.heili.wormhole", "wormhole"),
|
||||
("lu.knaff.alain.saf_sftp", "SFTP Documents Provider"),
|
||||
("com.akylas.documentscanner", "OSS Document Scanner"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-browsers",
|
||||
"name": "Privacy Approved — Browsers",
|
||||
"description": "Privacy-focused web browsers that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.duckduckgo.mobile.android", "DuckDuckGo"),
|
||||
("com.cromite.app", "Cromite"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-social-media",
|
||||
"name": "Privacy Approved — Social Media",
|
||||
"description": "Federated and alternative social media clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.joinmastodon.android", "Mastodon"),
|
||||
("tw.nekomimi.nekogram", "Nekogram"),
|
||||
("ml.docilealligator.infinityforreddit.plus", "Infinity+"),
|
||||
("com.newsblur", "NewsBlur"),
|
||||
("dev.msfjarvis.claw.android", "Claw"),
|
||||
("com.streamatico.polymarketviewer", "Polymarket Viewer"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-finance-budgeting",
|
||||
"name": "Privacy Approved — Finance & Budgeting",
|
||||
"description": "Personal finance, expense tracking, and budgeting apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.flux", "Flow"),
|
||||
("com.pennywiseai.tracker", "Pennywise AI Tracker"),
|
||||
("com.github.premnirmal.tickerwidget.dev", "Dev Stocks Widget"),
|
||||
("com.pearguard", "PearGuard"),
|
||||
("network.mostro.app", "Mostro"),
|
||||
("eu.domob.shopt2", "Shopt"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-health-fitness",
|
||||
"name": "Privacy Approved — Health & Fitness",
|
||||
"description": "Health tracking, diet, and fitness apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.nutritionfacts.dailydozen", "Daily Dozen"),
|
||||
("com.antoniegil.astronia", "Astronia"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-education-reference",
|
||||
"name": "Privacy Approved — Education & Reference",
|
||||
"description": "Learning, dictionary, and reference tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.freecodecamp", "freeCodeCamp"),
|
||||
("de.felixnuesse.disky", "freeDictionary"),
|
||||
("org.eu.mumulhl.ciyue", "Ciyue"),
|
||||
("com.tavultesoft.kmapro", "Keyman"),
|
||||
("com.hardbacknutter.nevertoomanybooks", "NeverTooManyBooks"),
|
||||
("de.doen1el.calibrewebcompanion", "Calibre Web Companion"),
|
||||
("io.github.adithya_jayan.myrepertoirapp.fdroid", "Repertoire"),
|
||||
("org.listenbrainz.android", "ListenBrainz"),
|
||||
("com.kgurgul.cpuinfo", "CPU Info"),
|
||||
("com.mkulesh.micromath.plus", "microMathematics Plus"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-photography-image-tools",
|
||||
"name": "Privacy Approved — Photography & Image Tools",
|
||||
"description": "Camera, photo editing, scanning, and gallery apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("tech.lolli.toolbox", "Image Toolbox"),
|
||||
("org.fairscan.app", "FairScan"),
|
||||
("ua.com.radiokot.photoprism", "PhotoPrism"),
|
||||
("com.kjxbyz.picguard", "PicGuard"),
|
||||
("com.dot.gallery", "Gallery apps"),
|
||||
("com.deniscerri.ytdl", "YTDLnis"),
|
||||
("me.elcaju", "ElCaju"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-utilities-tools",
|
||||
"name": "Privacy Approved — Utilities & Tools",
|
||||
"description": "System utilities, converters, and general-purpose tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("de.markusfisch.android.binaryeye", "Binary Eye"),
|
||||
("com.kgurgul.cpuinfo", "CPU Info"),
|
||||
("com.ferrarid.converterpro", "Converter NOW"),
|
||||
("me.hackerchick.catima", "Catima"),
|
||||
("app.lawnchair.lawnicons", "Lawnicons"),
|
||||
("app.simple.peri", "Peristyle"),
|
||||
("org.exbin.bined.editor.android", "BinEd"),
|
||||
("com.dergoogler.mmrl", "MMRL"),
|
||||
("io.github.samolego.canta", "Canta"),
|
||||
("deltazero.amarok.foss", "Amarok"),
|
||||
("com.rama.mako", "Mako"),
|
||||
("com.shub39.rush", "Rush"),
|
||||
("helium314.keyboard", "HeliBoard"),
|
||||
("com.best.deskclock", "DeskClock"),
|
||||
("com.acszo.redomi", "Redomi"),
|
||||
("com.arn.scrobble", "Scrobble"),
|
||||
("com.nononsenseapps.feeder.play", "Feeder"),
|
||||
("io.github.sms2email.sms2email", "SMS2Email"),
|
||||
("app.simple.inure", "Inure"),
|
||||
("com.unciv.app", "Unciv"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-games",
|
||||
"name": "Privacy Approved — Games",
|
||||
"description": "Open source games across genres that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.shatteredpixel.shatteredpixeldungeon", "Shattered Pixel Dungeon"),
|
||||
("org.wesnoth.wesnoth", "Wesnoth"),
|
||||
("de.sesu8642.feudaltactics", "Feudal Tactics"),
|
||||
("com.agateau.burgerparty", "Burger Party"),
|
||||
("de.chadenas.cpudefense", "ChipDefense"),
|
||||
("de.z11.roboyard", "Roboyard"),
|
||||
("com.damasclash", "Damas Clash"),
|
||||
("com.unciv.app", "Unciv"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-communication-non-nostr",
|
||||
"name": "Privacy Approved — Communication (Non-Nostr)",
|
||||
"description": "Voice/video calls, remote desktop, and messaging apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("org.telegram.messenger.web", "Telegram"),
|
||||
("com.carriez.flutter_hbb", "RustDesk"),
|
||||
("io.unsigned.sideband", "Sideband"),
|
||||
("com.geeksville.mesh", "Meshtastic"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-development-tools",
|
||||
"name": "Privacy Approved — Development Tools",
|
||||
"description": "Code editors, Git clients, and developer tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("com.foxdebug.acode", "Acode"),
|
||||
("com.viscouspot.gitsync", "GitSync"),
|
||||
("org.exbin.bined.editor.android", "BinEd"),
|
||||
("org.freecodecamp", "freeCodeCamp"),
|
||||
("zed.rainxch.githubstore", "GitHub Store"),
|
||||
("com.inspiredandroid.kai", "Kai 9000"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"d": "privacy-approved-calendar-scheduling",
|
||||
"name": "Privacy Approved — Calendar & Scheduling",
|
||||
"description": "Calendar apps, scheduling, and time management tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"apps": [
|
||||
("app.formstr.calendar", "Calendar by Form*"),
|
||||
("com.pearcal", "PearCal"),
|
||||
("me.timeto.app", "timeto.me"),
|
||||
("com.blankdev.sidestep", "Sidestep"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# ── Gitea mirror URL lookup ────────────────────────────────────────
|
||||
|
||||
def gitea_url(identifier):
|
||||
return f"https://laantungir.net/git/zapstore-mirror/{identifier}"
|
||||
|
||||
|
||||
# ── n_signer qrexec helpers ────────────────────────────────────────
|
||||
|
||||
def qrexec_call(request):
|
||||
framed = struct.pack(">I", len(json.dumps(request).encode())) + json.dumps(request).encode()
|
||||
proc = subprocess.run(
|
||||
["qrexec-client-vm", QREXEC_TARGET, QREXEC_SERVICE],
|
||||
input=framed, capture_output=True, timeout=30
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise Exception(f"qrexec failed: {proc.stderr.decode()}")
|
||||
resp_len = struct.unpack(">I", proc.stdout[:4])[0]
|
||||
return json.loads(proc.stdout[4:4 + resp_len])
|
||||
|
||||
|
||||
# ── Relay publish helper ───────────────────────────────────────────
|
||||
|
||||
async def publish_event(signed_event, label=""):
|
||||
async def publish_one(relay):
|
||||
try:
|
||||
async with websockets.connect(relay, max_size=10_000_000, open_timeout=10) as ws:
|
||||
await ws.send(json.dumps(["EVENT", signed_event]))
|
||||
while True:
|
||||
resp = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(resp)
|
||||
if data[0] == "OK" and data[1] == signed_event["id"]:
|
||||
return relay, data[2]
|
||||
except Exception:
|
||||
return relay, False
|
||||
|
||||
results = await asyncio.gather(*[publish_one(r) for r in RELAYS])
|
||||
ok = sum(1 for _, a in results if a)
|
||||
total = len(results)
|
||||
print(f" {label}Published to {ok}/{total} relays")
|
||||
return ok > 0
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Publishing all app stacks and app definitions")
|
||||
print("=" * 60)
|
||||
|
||||
# Get pubkey
|
||||
print("\n1. Getting pubkey from n_signer...")
|
||||
req = {"id": "1", "method": "nostr_get_public_key", "params": [{"nostr_index": 0}]}
|
||||
resp = qrexec_call(req)
|
||||
if "error" in resp:
|
||||
print(f" Error: {resp['error']}")
|
||||
sys.exit(1)
|
||||
pubkey = resp["result"]
|
||||
print(f" Pubkey: {pubkey}")
|
||||
|
||||
results = {"stacks": 0, "stacks_failed": 0, "apps": 0, "apps_failed": 0}
|
||||
|
||||
# ── Publish all stacks ──────────────────────────────────────────
|
||||
print(f"\n2. Publishing {len(STACKS)} app stacks...")
|
||||
for i, stack in enumerate(STACKS, 1):
|
||||
created_at = int(time.time())
|
||||
tags = [
|
||||
["d", stack["d"]],
|
||||
["t", "app-stack"],
|
||||
["name", stack["name"]],
|
||||
["description", stack["description"]],
|
||||
["f", "android-arm64-v8a"],
|
||||
["h", ""],
|
||||
]
|
||||
# Add a tag for each app (using kind 32267 references from old project)
|
||||
# Since we don't have the exact pubkeys, we use placeholder references
|
||||
for app_id, app_name in stack["apps"]:
|
||||
tags.append(["a", f"32267:{pubkey}:{app_id}", "wss://relay.zapstore.dev"])
|
||||
|
||||
event = {
|
||||
"kind": 30267,
|
||||
"content": "",
|
||||
"tags": tags,
|
||||
"created_at": created_at,
|
||||
"pubkey": pubkey,
|
||||
}
|
||||
|
||||
req = {"id": "2", "method": "nostr_sign_event", "params": [json.dumps(event), {"nostr_index": 0}]}
|
||||
resp = qrexec_call(req)
|
||||
if "error" in resp:
|
||||
print(f" [{i}/{len(STACKS)}] ❌ {stack['name']}: {resp['error']}")
|
||||
results["stacks_failed"] += 1
|
||||
continue
|
||||
|
||||
signed = json.loads(resp["result"])
|
||||
print(f" [{i}/{len(STACKS)}] {stack['name']} ({len(stack['apps'])} apps) id={signed['id'][:16]}...")
|
||||
await publish_event(signed, " ")
|
||||
results["stacks"] += 1
|
||||
|
||||
# ── Publish all app definitions ─────────────────────────────────
|
||||
total_apps = sum(len(s["apps"]) for s in STACKS)
|
||||
print(f"\n3. Publishing {total_apps} app definitions...")
|
||||
|
||||
app_num = 0
|
||||
for stack in STACKS:
|
||||
for app_id, app_name in stack["apps"]:
|
||||
app_num += 1
|
||||
repo_url = gitea_url(app_id)
|
||||
created_at = int(time.time())
|
||||
|
||||
event = {
|
||||
"kind": 30078,
|
||||
"content": json.dumps({
|
||||
"name": app_name,
|
||||
"identifier": app_id,
|
||||
"repository": repo_url,
|
||||
"description": "",
|
||||
}),
|
||||
"tags": [
|
||||
["d", "app-" + app_id],
|
||||
["t", "app-definition"],
|
||||
["t", stack["d"]],
|
||||
["name", app_name],
|
||||
["repository", repo_url],
|
||||
["gitea", app_id],
|
||||
],
|
||||
"created_at": created_at,
|
||||
"pubkey": pubkey,
|
||||
}
|
||||
|
||||
req = {"id": "3", "method": "nostr_sign_event", "params": [json.dumps(event), {"nostr_index": 0}]}
|
||||
resp = qrexec_call(req)
|
||||
if "error" in resp:
|
||||
print(f" [{app_num}/{total_apps}] ❌ {app_name}: {resp['error']}")
|
||||
results["apps_failed"] += 1
|
||||
continue
|
||||
|
||||
signed = json.loads(resp["result"])
|
||||
print(f" [{app_num}/{total_apps}] {app_name} ({app_id}) id={signed['id'][:16]}...")
|
||||
await publish_event(signed, " ")
|
||||
results["apps"] += 1
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f" Stacks: {results['stacks']} published, {results['stacks_failed']} failed")
|
||||
print(f" Apps: {results['apps']} published, {results['apps_failed']} failed")
|
||||
print(f" Total: {results['stacks'] + results['apps']} events")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Publish a test App Stack (kind 30267) for "Nostr Clients" using n_signer via qrexec.
|
||||
|
||||
Usage:
|
||||
python3 app-stacks/publish_test_stack.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import websockets
|
||||
|
||||
QREXEC_TARGET = "nostr_signer"
|
||||
QREXEC_SERVICE = "qubes.NsignerRpc"
|
||||
RELAY = "wss://relay.zapstore.dev"
|
||||
|
||||
|
||||
def qrexec_call(request):
|
||||
"""Send a framed JSON-RPC request via qrexec and return the response."""
|
||||
framed = struct.pack(">I", len(json.dumps(request).encode())) + json.dumps(request).encode()
|
||||
proc = subprocess.run(
|
||||
["qrexec-client-vm", QREXEC_TARGET, QREXEC_SERVICE],
|
||||
input=framed, capture_output=True, timeout=30
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise Exception(f"qrexec failed: {proc.stderr.decode()}")
|
||||
resp_len = struct.unpack(">I", proc.stdout[:4])[0]
|
||||
return json.loads(proc.stdout[4:4 + resp_len])
|
||||
|
||||
|
||||
async def publish_event(signed_event):
|
||||
"""Publish a signed event to the relay."""
|
||||
async with websockets.connect(RELAY, max_size=10_000_000, open_timeout=10) as ws:
|
||||
msg = json.dumps(["EVENT", signed_event])
|
||||
await ws.send(msg)
|
||||
while True:
|
||||
try:
|
||||
resp = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(resp)
|
||||
if data[0] == "OK" and data[1] == signed_event["id"]:
|
||||
accepted = data[2]
|
||||
msg = data[3] if len(data) > 3 else ""
|
||||
print(f" Relay: {'✅ ACCEPTED' if accepted else '❌ REJECTED'}: {msg}")
|
||||
return accepted
|
||||
except asyncio.TimeoutError:
|
||||
print(" No OK response from relay")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Publishing App Stack: Nostr Clients")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Get pubkey for nostr_index 0
|
||||
print("\n1. Getting pubkey from n_signer...")
|
||||
req = {"id": "1", "method": "nostr_get_public_key", "params": [{"nostr_index": 0}]}
|
||||
resp = qrexec_call(req)
|
||||
if "error" in resp:
|
||||
print(f" Error: {resp['error']}")
|
||||
sys.exit(1)
|
||||
pubkey = resp["result"]
|
||||
print(f" Pubkey: {pubkey}")
|
||||
|
||||
# Step 2: Build the unsigned event
|
||||
created_at = int(time.time())
|
||||
event = {
|
||||
"kind": 30267,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "privacy-approved-nostr-clients"],
|
||||
["t", "app-stack"],
|
||||
["name", "Privacy Approved — Nostr Clients"],
|
||||
["description", "Nostr-native social and communication clients that passed privacy review. No tracking SDKs, minimal permissions, open source."],
|
||||
["f", "android-arm64-v8a"],
|
||||
["h", ""],
|
||||
["a", "32267:aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b:com.vitorpamplona.amethyst", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.greenart7c3.nostrsigner", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb:com.wisp.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb:com.darkwisp.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:75d737c3472471029c44876b330d2284288a42779b591a2ed4daa1c6c07efaf7:org.parres.whitenoise", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:f55678aa1f5d554536d456b13beab04f636d63fdedd586fe38a4cb9ce48c90bc:com.nospeak.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:b2cdcb37d32533145c00c4f43d5e1e1deb7c67bceea7ef63f526ca4cab891633:org.nostr.nostrord", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:85df00a2f6a91845354c8d2d9fbab4002bb85b4225baeab60fafb2587c5038ea:com.shosho.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35:com.traveltelly.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:b22b06b051fd5232966a9344a634d956c3dc33a7f5ecdcad9ed11ddc4120a7f2:app.nostrmail.client", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:c4a39f1291291d452405cd8ddd798c4a29a3858c52cd0d843f1f6852cf17682e:co.openvine.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:20986fb83e775d96d188ca5c9df10ce6d613e0eb7e5768a0f0b12b37cdac21b3:com.yakihonne.yakihonne", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24:com.nostrnests.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:d1bd33333733dcc411f0ee893b38b8522fc0de227fff459d99044ced9e65581b:app.nostria.twa", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:d1bd33333733dcc411f0ee893b38b8522fc0de227fff459d99044ced9e65581b:app.nostria", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:dev.zapstore.alpha", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:d70b722b67768cbf1e3ac777e2107bbc447a9768899b548bf694f71a2a6b52f8:com.pearcal", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:d70b722b67768cbf1e3ac777e2107bbc447a9768899b548bf694f71a2a6b52f8:com.pearcircle", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:pub.ditto.app", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:social.flotilla", "wss://relay.zapstore.dev"],
|
||||
],
|
||||
"created_at": created_at,
|
||||
"pubkey": pubkey,
|
||||
}
|
||||
|
||||
print(f"\n2. Signing event (kind 30267, d=privacy-approved-nostr-clients)...")
|
||||
req = {
|
||||
"id": "2",
|
||||
"method": "nostr_sign_event",
|
||||
"params": [json.dumps(event), {"nostr_index": 0}]
|
||||
}
|
||||
resp = qrexec_call(req)
|
||||
if "error" in resp:
|
||||
print(f" Error: {resp['error']}")
|
||||
sys.exit(1)
|
||||
signed_event = json.loads(resp["result"])
|
||||
print(f" Event ID: {signed_event['id']}")
|
||||
print(f" Signature: {signed_event['sig'][:20]}...")
|
||||
|
||||
# Step 3: Publish to relay
|
||||
print(f"\n3. Publishing to {RELAY}...")
|
||||
accepted = await publish_event(signed_event)
|
||||
|
||||
if accepted:
|
||||
print(f"\n✅ Published successfully!")
|
||||
print(f" Stack: privacy-approved-nostr-clients")
|
||||
print(f" Apps: 19 Nostr clients")
|
||||
else:
|
||||
print(f"\n❌ Failed to publish")
|
||||
|
||||
# Save the signed event
|
||||
with open("app-stacks/signed_stack_event.json", "w") as f:
|
||||
json.dump(signed_event, f, indent=2)
|
||||
print(f"\n Signed event saved to app-stacks/signed_stack_event.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"pubkey": "8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e",
|
||||
"created_at": 1785796854,
|
||||
"kind": 30078,
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"app-app.zeusln.zeus"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"app-definition"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"privacy-approved-bitcoin-lightning-wallets"
|
||||
],
|
||||
[
|
||||
"name",
|
||||
"ZEUS"
|
||||
],
|
||||
[
|
||||
"repository",
|
||||
"https://laantungir.net/git/zapstore-mirror/app.zeusln.zeus"
|
||||
],
|
||||
[
|
||||
"gitea",
|
||||
"app.zeusln.zeus"
|
||||
]
|
||||
],
|
||||
"content": "{\"name\": \"ZEUS\", \"identifier\": \"app.zeusln.zeus\", \"repository\": \"https://laantungir.net/git/zapstore-mirror/app.zeusln.zeus\", \"description\": \"\"}",
|
||||
"id": "96a27857d9ce5799805596f42557c70f297030a52b82756f1c3a2331a6fe83fb",
|
||||
"sig": "2888165c36f491213f0842a967d487fec350df40abe4a75d3bafa819fab0d635dfeed67c1db7d87a38ebd72118e2101c2121a34e8d89612490828faf02e66d6a"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"pubkey": "8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e",
|
||||
"created_at": 1785796760,
|
||||
"kind": 30267,
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"privacy-approved-nostr-clients"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"app-stack"
|
||||
],
|
||||
[
|
||||
"name",
|
||||
"Privacy Approved \u2014 Nostr Clients"
|
||||
],
|
||||
[
|
||||
"description",
|
||||
"Nostr-native social and communication clients that passed privacy review. No tracking SDKs, minimal permissions, open source."
|
||||
],
|
||||
[
|
||||
"f",
|
||||
"android-arm64-v8a"
|
||||
],
|
||||
[
|
||||
"h",
|
||||
""
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b:com.vitorpamplona.amethyst",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.greenart7c3.nostrsigner",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb:com.wisp.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb:com.darkwisp.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:75d737c3472471029c44876b330d2284288a42779b591a2ed4daa1c6c07efaf7:org.parres.whitenoise",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:f55678aa1f5d554536d456b13beab04f636d63fdedd586fe38a4cb9ce48c90bc:com.nospeak.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:b2cdcb37d32533145c00c4f43d5e1e1deb7c67bceea7ef63f526ca4cab891633:org.nostr.nostrord",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:85df00a2f6a91845354c8d2d9fbab4002bb85b4225baeab60fafb2587c5038ea:com.shosho.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35:com.traveltelly.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:b22b06b051fd5232966a9344a634d956c3dc33a7f5ecdcad9ed11ddc4120a7f2:app.nostrmail.client",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:c4a39f1291291d452405cd8ddd798c4a29a3858c52cd0d843f1f6852cf17682e:co.openvine.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:20986fb83e775d96d188ca5c9df10ce6d613e0eb7e5768a0f0b12b37cdac21b3:com.yakihonne.yakihonne",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24:com.nostrnests.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:d1bd33333733dcc411f0ee893b38b8522fc0de227fff459d99044ced9e65581b:app.nostria.twa",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:d1bd33333733dcc411f0ee893b38b8522fc0de227fff459d99044ced9e65581b:app.nostria",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:dev.zapstore.alpha",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:d70b722b67768cbf1e3ac777e2107bbc447a9768899b548bf694f71a2a6b52f8:com.pearcal",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:d70b722b67768cbf1e3ac777e2107bbc447a9768899b548bf694f71a2a6b52f8:com.pearcircle",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:pub.ditto.app",
|
||||
"wss://relay.zapstore.dev"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"32267:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:social.flotilla",
|
||||
"wss://relay.zapstore.dev"
|
||||
]
|
||||
],
|
||||
"content": "",
|
||||
"id": "eaa7b4e87ca59322899b561ac159cdba788f4b5a36483812794908cda15bb6c9",
|
||||
"sig": "d2aa1b64b1fe14a67cefdf665dafa06f13b2c96c63ebf5ec895660ce24b5bde67660e72f1ed8a68d9ecc2948a8c0274e69ecf67645f62793019d59e0bd7a6ee0"
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
# App Stacks and Their Apps
|
||||
|
||||
Source: [`zapstore_app_stacks`](file:///home/user/lt/zapstore_app_stacks) project.
|
||||
Gitea mirror: `https://laantungir.net/git/zapstore-mirror/` (197 repos)
|
||||
|
||||
## 1. Bitcoin & Lightning Wallets
|
||||
**d-tag:** `privacy-approved-bitcoin-lightning-wallets`
|
||||
**Description:** Self-custodial Bitcoin and Lightning wallet apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| ZEUS | `app.zeusln.zeus` | [app.zeusln.zeus](https://laantungir.net/git/zapstore-mirror/app.zeusln.zeus) |
|
||||
| Nunchuk | `io.nunchuk.android` | [io.nunchuk.android](https://laantungir.net/git/zapstore-mirror/io.nunchuk.android) |
|
||||
| Cake Wallet | `com.cakewallet.cake_wallet` | [com.cakewallet.cake_wallet](https://laantungir.net/git/zapstore-mirror/com.cakewallet.cake_wallet) |
|
||||
| Alby Go | `com.getalby.mobile` | [com.getalby.mobile](https://laantungir.net/git/zapstore-mirror/com.getalby.mobile) |
|
||||
| Blitz Wallet | `com.blitzwallet` | [com.blitzwallet](https://laantungir.net/git/zapstore-mirror/com.blitzwallet) |
|
||||
| BlueWallet | `io.bluewallet.bluewallet` | [io.bluewallet.bluewallet](https://laantungir.net/git/zapstore-mirror/io.bluewallet.bluewallet) |
|
||||
| Electrum | `org.electrum.electrum` | [org.electrum.electrum](https://laantungir.net/git/zapstore-mirror/org.electrum.electrum) |
|
||||
| Oubli | `com.oubli.wallet` | [com.oubli.wallet](https://laantungir.net/git/zapstore-mirror/com.oubli.wallet) |
|
||||
| MercaSats | `com.kilombino.mercasats` | [com.kilombino.mercasats](https://laantungir.net/git/zapstore-mirror/com.kilombino.mercasats) |
|
||||
| Lightning Reaction | `com.magius.lightningreaction` | [com.magius.lightningreaction](https://laantungir.net/git/zapstore-mirror/com.magius.lightningreaction) |
|
||||
|
||||
## 2. Nostr Clients
|
||||
**d-tag:** `privacy-approved-nostr-clients`
|
||||
**Description:** Nostr-native social and communication clients that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Amethyst | `com.vitorpamplona.amethyst` | [com.vitorpamplona.amethyst](https://laantungir.net/git/zapstore-mirror/com.vitorpamplona.amethyst) |
|
||||
| Amber | `com.greenart7c3.nostrsigner` | [com.greenart7c3.nostrsigner](https://laantungir.net/git/zapstore-mirror/com.greenart7c3.nostrsigner) |
|
||||
| Wisp | `com.wisp.app` | [com.wisp.app](https://laantungir.net/git/zapstore-mirror/com.wisp.app) |
|
||||
| Dark Wisp | `com.darkwisp.app` | [com.darkwisp.app](https://laantungir.net/git/zapstore-mirror/com.darkwisp.app) |
|
||||
| White Noise | `org.parres.whitenoise` | [org.parres.whitenoise](https://laantungir.net/git/zapstore-mirror/org.parres.whitenoise) |
|
||||
| Nospeak | `com.nospeak.app` | [com.nospeak.app](https://laantungir.net/git/zapstore-mirror/com.nospeak.app) |
|
||||
| Nostrord | `org.nostr.nostrord` | — |
|
||||
| Shosho | `com.shosho.app` | [com.shosho.app](https://laantungir.net/git/zapstore-mirror/com.shosho.app) |
|
||||
| TravelTelly | `com.traveltelly.app` | [com.traveltelly.app](https://laantungir.net/git/zapstore-mirror/com.traveltelly.app) |
|
||||
| Nmail | `app.nostrmail.client` | [app.nostrmail.client](https://laantungir.net/git/zapstore-mirror/app.nostrmail.client) |
|
||||
| Divine | `co.openvine.app` | [co.openvine.app](https://laantungir.net/git/zapstore-mirror/co.openvine.app) |
|
||||
| YakiHonne | `com.yakihonne.yakihonne` | [com.yakihonne.yakihonne](https://laantungir.net/git/zapstore-mirror/com.yakihonne.yakihonne) |
|
||||
| Nests | `com.nostrnests.app` | [com.nostrnests.app](https://laantungir.net/git/zapstore-mirror/com.nostrnests.app) |
|
||||
| Nostria | `app.nostria.twa` | [app.nostria.twa](https://laantungir.net/git/zapstore-mirror/app.nostria.twa) |
|
||||
| Zapstore Alpha | `dev.zapstore.alpha` | [dev.zapstore.alpha](https://laantungir.net/git/zapstore-mirror/dev.zapstore.alpha) |
|
||||
| PearCal | `com.pearcal` | [com.pearcal](https://laantungir.net/git/zapstore-mirror/com.pearcal) |
|
||||
| PearCircle | `com.pearcircle` | [com.pearcircle](https://laantungir.net/git/zapstore-mirror/com.pearcircle) |
|
||||
| Ditto | `pub.ditto.app` | — |
|
||||
| Flotilla | `com.flotilla` | — |
|
||||
|
||||
## 3. Secure Messaging
|
||||
**d-tag:** `privacy-approved-secure-messaging`
|
||||
**Description:** End-to-end encrypted messaging and email apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| SimpleX | `chat.simplex.app` | [chat.simplex.app](https://laantungir.net/git/zapstore-mirror/chat.simplex.app) |
|
||||
| Conversations | `eu.siacs.conversations` | — |
|
||||
| Delta Chat | `chat.delta` | [chat.delta](https://laantungir.net/git/zapstore-mirror/chat.delta) |
|
||||
| aTalk | `org.atalk.android` | [org.atalk.android](https://laantungir.net/git/zapstore-mirror/org.atalk.android) |
|
||||
| Element | `im.vector.app` | [im.vector.app](https://laantungir.net/git/zapstore-mirror/im.vector.app) |
|
||||
| Element X | `io.element.android.x` | [io.element.android.x](https://laantungir.net/git/zapstore-mirror/io.element.android.x) |
|
||||
| Thunderbird | `net.thunderbird.android` | [net.thunderbird.android](https://laantungir.net/git/zapstore-mirror/net.thunderbird.android) |
|
||||
| FairEmail | `eu.faircode.email` | — |
|
||||
| Tuta | `de.tutao.tutanota` | [de.tutao.tutanota](https://laantungir.net/git/zapstore-mirror/de.tutao.tutanota) |
|
||||
| Quiet | `com.quietmobile` | [com.quietmobile](https://laantungir.net/git/zapstore-mirror/com.quietmobile) |
|
||||
| SpamBlocker | `spam.blocker` | [spam.blocker](https://laantungir.net/git/zapstore-mirror/spam.blocker) |
|
||||
| Deku SMS | `com.afkanerd.deku` | [com.afkanerd.deku](https://laantungir.net/git/zapstore-mirror/com.afkanerd.deku) |
|
||||
|
||||
## 4. VPN & Privacy Tools
|
||||
**d-tag:** `privacy-approved-vpn-privacy-tools`
|
||||
**Description:** VPN clients, firewalls, and network privacy tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Orbot | `org.torproject.android` | [org.torproject.android](https://laantungir.net/git/zapstore-mirror/org.torproject.android) |
|
||||
| Mullvad VPN | `net.mullvad.mullvadvpn` | [net.mullvad.mullvadvpn](https://laantungir.net/git/zapstore-mirror/net.mullvad.mullvadvpn) |
|
||||
| Tailscale | `com.tailscale.ipn` | [com.tailscale.ipn](https://laantungir.net/git/zapstore-mirror/com.tailscale.ipn) |
|
||||
| AmneziaVPN | `org.amnezia.vpn` | [org.amnezia.vpn](https://laantungir.net/git/zapstore-mirror/org.amnezia.vpn) |
|
||||
| Proton VPN | `ch.protonvpn.android` | [ch.protonvpn.android](https://laantungir.net/git/zapstore-mirror/ch.protonvpn.android) |
|
||||
| Rethink | `com.celzero.bravedns` | [com.celzero.bravedns](https://laantungir.net/git/zapstore-mirror/com.celzero.bravedns) |
|
||||
| PCAPdroid | `com.emanuelef.remote_capture` | [com.emanuelef.remote_capture](https://laantungir.net/git/zapstore-mirror/com.emanuelef.remote_capture) |
|
||||
| WG Tunnel | `com.zaneschepke.wireguardautotunnel` | [com.zaneschepke.wireguardautotunnel](https://laantungir.net/git/zapstore-mirror/com.zaneschepke.wireguardautotunnel) |
|
||||
| ByeDPI | `io.github.romanvht.byedpi` | [io.github.romanvht.byedpi](https://laantungir.net/git/zapstore-mirror/io.github.romanvht.byedpi) |
|
||||
| NeoStumbler | `xyz.malkki.neostumbler` | [xyz.malkki.neostumbler](https://laantungir.net/git/zapstore-mirror/xyz.malkki.neostumbler) |
|
||||
| SD Maid | `eu.darken.sdmse` | [eu.darken.sdmse](https://laantungir.net/git/zapstore-mirror/eu.darken.sdmse) |
|
||||
| InviZible Pro | `pan.alexander.tordnscrypt.stable` | [pan.alexander.tordnscrypt.stable](https://laantungir.net/git/zapstore-mirror/pan.alexander.tordnscrypt.stable) |
|
||||
| Private DNS Quick Setting | `com.flashsphere.privatednsqs` | [com.flashsphere.privatednsqs](https://laantungir.net/git/zapstore-mirror/com.flashsphere.privatednsqs) |
|
||||
|
||||
## 5. Password Managers & Auth
|
||||
**d-tag:** `privacy-approved-password-managers-auth`
|
||||
**Description:** Password managers, 2FA authenticators, and identity tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Bitwarden | `com.x8bit.bitwarden` | [com.x8bit.bitwarden](https://laantungir.net/git/zapstore-mirror/com.x8bit.bitwarden) |
|
||||
| KeePassDX | `com.kunzisoft.keepass.libre` | [com.kunzisoft.keepass.libre](https://laantungir.net/git/zapstore-mirror/com.kunzisoft.keepass.libre) |
|
||||
| Aegis | `com.beemdevelopment.aegis` | [com.beemdevelopment.aegis](https://laantungir.net/git/zapstore-mirror/com.beemdevelopment.aegis) |
|
||||
| Authnkey | `pl.lebihan.authnkey` | [pl.lebihan.authnkey](https://laantungir.net/git/zapstore-mirror/pl.lebihan.authnkey) |
|
||||
| Ente Auth | `io.ente.auth.independent` | [io.ente.auth.independent](https://laantungir.net/git/zapstore-mirror/io.ente.auth.independent) |
|
||||
| Keep | `io.privkey.keep` | [io.privkey.keep](https://laantungir.net/git/zapstore-mirror/io.privkey.keep) |
|
||||
| AliasVault | `net.aliasvault.app` | [net.aliasvault.app](https://laantungir.net/git/zapstore-mirror/net.aliasvault.app) |
|
||||
| PassVault | `com.jksalcedo.passvault` | [com.jksalcedo.passvault](https://laantungir.net/git/zapstore-mirror/com.jksalcedo.passvault) |
|
||||
| LibreFind | `com.jksalcedo.librefind` | [com.jksalcedo.librefind](https://laantungir.net/git/zapstore-mirror/com.jksalcedo.librefind) |
|
||||
|
||||
## 6. Maps & Navigation
|
||||
**d-tag:** `privacy-approved-maps-navigation`
|
||||
**Description:** Offline maps, navigation, and location tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Organic Maps | `app.organicmaps.web` | [app.organicmaps.web](https://laantungir.net/git/zapstore-mirror/app.organicmaps.web) |
|
||||
| OsmAnd~ | `net.osmand.plus` | — |
|
||||
| StreetComplete | `de.westnordost.streetcomplete` | [de.westnordost.streetcomplete](https://laantungir.net/git/zapstore-mirror/de.westnordost.streetcomplete) |
|
||||
| CoMaps | `com.comaps.app` | — |
|
||||
| OwnTracks | `org.owntracks.android` | [org.owntracks.android](https://laantungir.net/git/zapstore-mirror/org.owntracks.android) |
|
||||
| OSMTracker | `net.osmtracker` | [net.osmtracker](https://laantungir.net/git/zapstore-mirror/net.osmtracker) |
|
||||
|
||||
## 7. Media Players & Streaming
|
||||
**d-tag:** `privacy-approved-media-players-streaming`
|
||||
**Description:** Video and music players, streaming clients that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| VLC | `org.videolan.vlc` | [org.videolan.vlc](https://laantungir.net/git/zapstore-mirror/org.videolan.vlc) |
|
||||
| NewPipe | `org.schabi.newpipe` | [org.schabi.newpipe](https://laantungir.net/git/zapstore-mirror/org.schabi.newpipe) |
|
||||
| PipePipe | `infinityloop1309.newpipeenhanced` | [infinityloop1309.newpipeenhanced](https://laantungir.net/git/zapstore-mirror/infinityloop1309.newpipeenhanced) |
|
||||
| Auxio | `org.oxycblt.auxio` | [org.oxycblt.auxio](https://laantungir.net/git/zapstore-mirror/org.oxycblt.auxio) |
|
||||
| Musify | `com.gokadzev.musify` | [com.gokadzev.musify](https://laantungir.net/git/zapstore-mirror/com.gokadzev.musify) |
|
||||
| Metrolist | `com.metrolist.music` | [com.metrolist.music](https://laantungir.net/git/zapstore-mirror/com.metrolist.music) |
|
||||
| FreeTube | `io.freetubeapp.freetube` | [io.freetubeapp.freetube](https://laantungir.net/git/zapstore-mirror/io.freetubeapp.freetube) |
|
||||
| Zaptrax | `app.zaptrax` | [app.zaptrax](https://laantungir.net/git/zapstore-mirror/app.zaptrax) |
|
||||
| Zappix | `app.zappix` | [app.zappix](https://laantungir.net/git/zapstore-mirror/app.zappix) |
|
||||
| mpvEx | `app.marlboroadvance.mpvex` | [app.marlboroadvance.mpvex](https://laantungir.net/git/zapstore-mirror/app.marlboroadvance.mpvex) |
|
||||
| KurobaEx | `com.github.k1rakishou.chan` | [com.github.k1rakishou.chan](https://laantungir.net/git/zapstore-mirror/com.github.k1rakishou.chan) |
|
||||
|
||||
## 8. Productivity & Notes
|
||||
**d-tag:** `privacy-approved-productivity-notes`
|
||||
**Description:** Note-taking, task management, and productivity tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Flux | `com.flux` | [com.flux](https://laantungir.net/git/zapstore-mirror/com.flux) |
|
||||
| Notesnook | `com.streetwriters.notesnook` | [com.streetwriters.notesnook](https://laantungir.net/git/zapstore-mirror/com.streetwriters.notesnook) |
|
||||
| Quillpad | `io.github.quillpad` | [io.github.quillpad](https://laantungir.net/git/zapstore-mirror/io.github.quillpad) |
|
||||
| Saber | `com.adilhanney.saber` | [com.adilhanney.saber](https://laantungir.net/git/zapstore-mirror/com.adilhanney.saber) |
|
||||
| DAVx⁵ | `at.bitfire.davdroid` | [at.bitfire.davdroid](https://laantungir.net/git/zapstore-mirror/at.bitfire.davdroid) |
|
||||
| Super Productivity | `com.superproductivity.superproductivity` | [com.superproductivity.superproductivity](https://laantungir.net/git/zapstore-mirror/com.superproductivity.superproductivity) |
|
||||
| Trilium Notes | `eu.fliegendurst.triliumdroid` | — |
|
||||
| SilentNotes | `ch.martinstoeckli.silentnotes` | [ch.martinstoeckli.silentnotes](https://laantungir.net/git/zapstore-mirror/ch.martinstoeckli.silentnotes) |
|
||||
| Manent | `com.dtonon.manent` | [com.dtonon.manent](https://laantungir.net/git/zapstore-mirror/com.dtonon.manent) |
|
||||
| Meiso | `jp.godzhigella.meiso` | [jp.godzhigella.meiso](https://laantungir.net/git/zapstore-mirror/jp.godzhigella.meiso) |
|
||||
| Florid | `com.nahnah.florid` | [com.nahnah.florid](https://laantungir.net/git/zapstore-mirror/com.nahnah.florid) |
|
||||
| Plektos | `com.plektos.app` | [com.plektos.app](https://laantungir.net/git/zapstore-mirror/com.plektos.app) |
|
||||
| Urn | `io.nurunuru.app` | [io.nurunuru.app](https://laantungir.net/git/zapstore-mirror/io.nurunuru.app) |
|
||||
| Screen Time | `com.screentime.app` | — |
|
||||
| Grit | `com.shub39.grit` | [com.shub39.grit](https://laantungir.net/git/zapstore-mirror/com.shub39.grit) |
|
||||
| Numo | `com.electricdreams.numo` | [com.electricdreams.numo](https://laantungir.net/git/zapstore-mirror/com.electricdreams.numo) |
|
||||
| timeto.me | `me.timeto.app` | [me.timeto.app](https://laantungir.net/git/zapstore-mirror/me.timeto.app) |
|
||||
| Converter NOW | `com.ferrarid.converterpro` | [com.ferrarid.converterpro](https://laantungir.net/git/zapstore-mirror/com.ferrarid.converterpro) |
|
||||
|
||||
## 9. File Management & Cloud Sync
|
||||
**d-tag:** `privacy-approved-file-management-cloud-sync`
|
||||
**Description:** Cloud storage, file sync, and file management tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Nextcloud | `com.nextcloud.client` | [com.nextcloud.client](https://laantungir.net/git/zapstore-mirror/com.nextcloud.client) |
|
||||
| Syncthing-Fork | `com.github.catfriend1.syncthingfork` | [com.github.catfriend1.syncthingfork](https://laantungir.net/git/zapstore-mirror/com.github.catfriend1.syncthingfork) |
|
||||
| Seafile | `com.seafile.seadroid2` | [com.seafile.seadroid2](https://laantungir.net/git/zapstore-mirror/com.seafile.seadroid2) |
|
||||
| File transfer | `com.example.file_transfer` | [com.example.file_transfer](https://laantungir.net/git/zapstore-mirror/com.example.file_transfer) |
|
||||
| primitive ftpd | `org.primftpd` | [org.primftpd](https://laantungir.net/git/zapstore-mirror/org.primftpd) |
|
||||
| Paperless Mobile | `de.astubenbord.paperless_mobile` | [de.astubenbord.paperless_mobile](https://laantungir.net/git/zapstore-mirror/de.astubenbord.paperless_mobile) |
|
||||
| GitSync | `com.viscouspot.gitsync` | [com.viscouspot.gitsync](https://laantungir.net/git/zapstore-mirror/com.viscouspot.gitsync) |
|
||||
| wormhole | `eu.heili.wormhole` | [eu.heili.wormhole](https://laantungir.net/git/zapstore-mirror/eu.heili.wormhole) |
|
||||
| SFTP Documents Provider | `lu.knaff.alain.saf_sftp` | [lu.knaff.alain.saf_sftp](https://laantungir.net/git/zapstore-mirror/lu.knaff.alain.saf_sftp) |
|
||||
| OSS Document Scanner | `com.akylas.documentscanner` | [com.akylas.documentscanner](https://laantungir.net/git/zapstore-mirror/com.akylas.documentscanner) |
|
||||
|
||||
## 10. Browsers
|
||||
**d-tag:** `privacy-approved-browsers`
|
||||
**Description:** Privacy-focused web browsers that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| DuckDuckGo | `com.duckduckgo.mobile.android` | — |
|
||||
| Cromite | `com.cromite.app` | — |
|
||||
|
||||
## 11. Social Media
|
||||
**d-tag:** `privacy-approved-social-media`
|
||||
**Description:** Federated and alternative social media clients that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Mastodon | `org.joinmastodon.android` | [org.joinmastodon.android](https://laantungir.net/git/zapstore-mirror/org.joinmastodon.android) |
|
||||
| Nekogram | `tw.nekomimi.nekogram` | [tw.nekomimi.nekogram](https://laantungir.net/git/zapstore-mirror/tw.nekomimi.nekogram) |
|
||||
| Infinity+ | `ml.docilealligator.infinityforreddit.plus` | [ml.docilealligator.infinityforreddit.plus](https://laantungir.net/git/zapstore-mirror/ml.docilealligator.infinityforreddit.plus) |
|
||||
| NewsBlur | `com.newsblur` | [com.newsblur](https://laantungir.net/git/zapstore-mirror/com.newsblur) |
|
||||
| Claw | `dev.msfjarvis.claw.android` | [dev.msfjarvis.claw.android](https://laantungir.net/git/zapstore-mirror/dev.msfjarvis.claw.android) |
|
||||
| Polymarket Viewer | `com.streamatico.polymarketviewer` | [com.streamatico.polymarketviewer](https://laantungir.net/git/zapstore-mirror/com.streamatico.polymarketviewer) |
|
||||
|
||||
## 12. Finance & Budgeting
|
||||
**d-tag:** `privacy-approved-finance-budgeting`
|
||||
**Description:** Personal finance, expense tracking, and budgeting apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Flow | `com.flow` | — |
|
||||
| Pennywise AI Tracker | `com.pennywiseai.tracker` | [com.pennywiseai.tracker](https://laantungir.net/git/zapstore-mirror/com.pennywiseai.tracker) |
|
||||
| Dev Stocks Widget | `com.github.premnirmal.tickerwidget.dev` | [com.github.premnirmal.tickerwidget.dev](https://laantungir.net/git/zapstore-mirror/com.github.premnirmal.tickerwidget.dev) |
|
||||
| Seeker | `com.seeker.app` | — |
|
||||
| PearGuard | `com.pearguard` | [com.pearguard](https://laantungir.net/git/zapstore-mirror/com.pearguard) |
|
||||
| Mostro | `network.mostro.app` | [network.mostro.app](https://laantungir.net/git/zapstore-mirror/network.mostro.app) |
|
||||
| Shopt | `eu.domob.shopt2` | [eu.domob.shopt2](https://laantungir.net/git/zapstore-mirror/eu.domob.shopt2) |
|
||||
|
||||
## 13. Health & Fitness
|
||||
**d-tag:** `privacy-approved-health-fitness`
|
||||
**Description:** Health tracking, diet, and fitness apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Daily Dozen | `org.nutritionfacts.dailydozen` | [org.nutritionfacts.dailydozen](https://laantungir.net/git/zapstore-mirror/org.nutritionfacts.dailydozen) |
|
||||
| Screen Time | `com.screentime.app` | — |
|
||||
| Astronia | `com.antoniegil.astronia` | [com.antoniegil.astronia](https://laantungir.net/git/zapstore-mirror/com.antoniegil.astronia) |
|
||||
|
||||
## 14. Education & Reference
|
||||
**d-tag:** `privacy-approved-education-reference`
|
||||
**Description:** Learning, dictionary, and reference tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| freeCodeCamp | `org.freecodecamp` | [org.freecodecamp](https://laantungir.net/git/zapstore-mirror/org.freecodecamp) |
|
||||
| freeDictionary | `de.felixnuesse.disky` | [de.felixnuesse.disky](https://laantungir.net/git/zapstore-mirror/de.felixnuesse.disky) |
|
||||
| Ciyue | `org.eu.mumulhl.ciyue` | [org.eu.mumulhl.ciyue](https://laantungir.net/git/zapstore-mirror/org.eu.mumulhl.ciyue) |
|
||||
| Keyman | `com.tavultesoft.kmapro` | [com.tavultesoft.kmapro](https://laantungir.net/git/zapstore-mirror/com.tavultesoft.kmapro) |
|
||||
| NeverTooManyBooks | `com.hardbacknutter.nevertoomanybooks` | [com.hardbacknutter.nevertoomanybooks](https://laantungir.net/git/zapstore-mirror/com.hardbacknutter.nevertoomanybooks) |
|
||||
| Calibre Web Companion | `de.doen1el.calibrewebcompanion` | [de.doen1el.calibrewebcompanion](https://laantungir.net/git/zapstore-mirror/de.doen1el.calibrewebcompanion) |
|
||||
| Repertoire | `io.github.adithya_jayan.myrepertoirapp.fdroid` | [io.github.adithya_jayan.myrepertoirapp.fdroid](https://laantungir.net/git/zapstore-mirror/io.github.adithya_jayan.myrepertoirapp.fdroid) |
|
||||
| ListenBrainz | `org.listenbrainz.android` | [org.listenbrainz.android](https://laantungir.net/git/zapstore-mirror/org.listenbrainz.android) |
|
||||
| CPU Info | `com.kgurgul.cpuinfo` | [com.kgurgul.cpuinfo](https://laantungir.net/git/zapstore-mirror/com.kgurgul.cpuinfo) |
|
||||
| microMathematics Plus | `com.mkulesh.micromath.plus` | [com.mkulesh.micromath.plus](https://laantungir.net/git/zapstore-mirror/com.mkulesh.micromath.plus) |
|
||||
| Mental Math | `com.mental.math` | — |
|
||||
|
||||
## 15. Photography & Image Tools
|
||||
**d-tag:** `privacy-approved-photography-image-tools`
|
||||
**Description:** Camera, photo editing, scanning, and gallery apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Image Toolbox | `tech.lolli.toolbox` | [tech.lolli.toolbox](https://laantungir.net/git/zapstore-mirror/tech.lolli.toolbox) |
|
||||
| FairScan | `org.fairscan.app` | [org.fairscan.app](https://laantungir.net/git/zapstore-mirror/org.fairscan.app) |
|
||||
| PhotoPrism | `ua.com.radiokot.photoprism` | [ua.com.radiokot.photoprism](https://laantungir.net/git/zapstore-mirror/ua.com.radiokot.photoprism) |
|
||||
| PicGuard | `com.kjxbyz.picguard` | [com.kjxbyz.picguard](https://laantungir.net/git/zapstore-mirror/com.kjxbyz.picguard) |
|
||||
| Gallery apps | `com.dot.gallery` | [com.dot.gallery](https://laantungir.net/git/zapstore-mirror/com.dot.gallery) |
|
||||
| YTDLnis | `com.deniscerri.ytdl` | [com.deniscerri.ytdl](https://laantungir.net/git/zapstore-mirror/com.deniscerri.ytdl) |
|
||||
| ElCaju | `me.elcaju` | [me.elcaju](https://laantungir.net/git/zapstore-mirror/me.elcaju) |
|
||||
| Espy | `com.espy.app` | — |
|
||||
| PDF Wallet | `com.pdf.wallet` | — |
|
||||
|
||||
## 16. Utilities & Tools
|
||||
**d-tag:** `privacy-approved-utilities-tools`
|
||||
**Description:** System utilities, converters, and general-purpose tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Binary Eye | `de.markusfisch.android.binaryeye` | [de.markusfisch.android.binaryeye](https://laantungir.net/git/zapstore-mirror/de.markusfisch.android.binaryeye) |
|
||||
| CPU Info | `com.kgurgul.cpuinfo` | [com.kgurgul.cpuinfo](https://laantungir.net/git/zapstore-mirror/com.kgurgul.cpuinfo) |
|
||||
| Converter NOW | `com.ferrarid.converterpro` | [com.ferrarid.converterpro](https://laantungir.net/git/zapstore-mirror/com.ferrarid.converterpro) |
|
||||
| Catima | `me.hackerchick.catima` | [me.hackerchick.catima](https://laantungir.net/git/zapstore-mirror/me.hackerchick.catima) |
|
||||
| BT Remote | `com.bt.remote` | — |
|
||||
| Lawnicons | `app.lawnchair.lawnicons` | [app.lawnchair.lawnicons](https://laantungir.net/git/zapstore-mirror/app.lawnchair.lawnicons) |
|
||||
| Peristyle | `app.simple.peri` | [app.simple.peri](https://laantungir.net/git/zapstore-mirror/app.simple.peri) |
|
||||
| BinEd | `org.exbin.bined.editor.android` | [org.exbin.bined.editor.android](https://laantungir.net/git/zapstore-mirror/org.exbin.bined.editor.android) |
|
||||
| MMRL | `com.dergoogler.mmrl` | [com.dergoogler.mmrl](https://laantungir.net/git/zapstore-mirror/com.dergoogler.mmrl) |
|
||||
| Canta | `io.github.samolego.canta` | [io.github.samolego.canta](https://laantungir.net/git/zapstore-mirror/io.github.samolego.canta) |
|
||||
| Amarok | `deltazero.amarok.foss` | [deltazero.amarok.foss](https://laantungir.net/git/zapstore-mirror/deltazero.amarok.foss) |
|
||||
| ServerBox | `com.serverbox.app` | — |
|
||||
| wX | `com.wx.app` | — |
|
||||
| Mako | `com.rama.mako` | [com.rama.mako](https://laantungir.net/git/zapstore-mirror/com.rama.mako) |
|
||||
| Rush | `com.shub39.rush` | [com.shub39.rush](https://laantungir.net/git/zapstore-mirror/com.shub39.rush) |
|
||||
| HeliBoard | `helium314.keyboard` | [helium314.keyboard](https://laantungir.net/git/zapstore-mirror/helium314.keyboard) |
|
||||
| DeskClock | `com.best.deskclock` | [com.best.deskclock](https://laantungir.net/git/zapstore-mirror/com.best.deskclock) |
|
||||
| Redomi | `com.acszo.redomi` | [com.acszo.redomi](https://laantungir.net/git/zapstore-mirror/com.acszo.redomi) |
|
||||
| Scrobble | `com.arn.scrobble` | [com.arn.scrobble](https://laantungir.net/git/zapstore-mirror/com.arn.scrobble) |
|
||||
| Feeder | `com.nononsenseapps.feeder.play` | [com.nononsenseapps.feeder.play](https://laantungir.net/git/zapstore-mirror/com.nononsenseapps.feeder.play) |
|
||||
| SMS2Email | `io.github.sms2email.sms2email` | [io.github.sms2email.sms2email](https://laantungir.net/git/zapstore-mirror/io.github.sms2email.sms2email) |
|
||||
| Inure | `app.simple.inure` | [app.simple.inure](https://laantungir.net/git/zapstore-mirror/app.simple.inure) |
|
||||
| Unciv | `com.unciv.app` | [com.unciv.app](https://laantungir.net/git/zapstore-mirror/com.unciv.app) |
|
||||
|
||||
## 17. Games
|
||||
**d-tag:** `privacy-approved-games`
|
||||
**Description:** Open source games across genres that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Shattered Pixel Dungeon | `com.shatteredpixel.shatteredpixeldungeon` | [com.shatteredpixel.shatteredpixeldungeon](https://laantungir.net/git/zapstore-mirror/com.shatteredpixel.shatteredpixeldungeon) |
|
||||
| Wesnoth | `org.wesnoth.wesnoth` | [org.wesnoth.wesnoth](https://laantungir.net/git/zapstore-mirror/org.wesnoth.wesnoth) |
|
||||
| Feudal Tactics | `de.sesu8642.feudaltactics` | [de.sesu8642.feudaltactics](https://laantungir.net/git/zapstore-mirror/de.sesu8642.feudaltactics) |
|
||||
| Burger Party | `com.agateau.burgerparty` | [com.agateau.burgerparty](https://laantungir.net/git/zapstore-mirror/com.agateau.burgerparty) |
|
||||
| ChipDefense | `de.chadenas.cpudefense` | [de.chadenas.cpudefense](https://laantungir.net/git/zapstore-mirror/de.chadenas.cpudefense) |
|
||||
| Roboyard | `de.z11.roboyard` | [de.z11.roboyard](https://laantungir.net/git/zapstore-mirror/de.z11.roboyard) |
|
||||
| Breakout 71 | `com.breakout71` | — |
|
||||
| Damas Clash | `com.damasclash` | [com.damasclash](https://laantungir.net/git/zapstore-mirror/com.damasclash) |
|
||||
| Unciv | `com.unciv.app` | [com.unciv.app](https://laantungir.net/git/zapstore-mirror/com.unciv.app) |
|
||||
|
||||
## 18. Communication (Non-Nostr)
|
||||
**d-tag:** `privacy-approved-communication-non-nostr`
|
||||
**Description:** Voice/video calls, remote desktop, and messaging apps that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Telegram | `org.telegram.messenger.web` | [org.telegram.messenger.web](https://laantungir.net/git/zapstore-mirror/org.telegram.messenger.web) |
|
||||
| RustDesk | `com.carriez.flutter_hbb` | [com.carriez.flutter_hbb](https://laantungir.net/git/zapstore-mirror/com.carriez.flutter_hbb) |
|
||||
| Sideband | `io.unsigned.sideband` | [io.unsigned.sideband](https://laantungir.net/git/zapstore-mirror/io.unsigned.sideband) |
|
||||
| Meshtastic | `com.geeksville.mesh` | [com.geeksville.mesh](https://laantungir.net/git/zapstore-mirror/com.geeksville.mesh) |
|
||||
|
||||
## 19. Development Tools
|
||||
**d-tag:** `privacy-approved-development-tools`
|
||||
**Description:** Code editors, Git clients, and developer tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Acode | `com.foxdebug.acode` | [com.foxdebug.acode](https://laantungir.net/git/zapstore-mirror/com.foxdebug.acode) |
|
||||
| GitSync | `com.viscouspot.gitsync` | [com.viscouspot.gitsync](https://laantungir.net/git/zapstore-mirror/com.viscouspot.gitsync) |
|
||||
| BinEd | `org.exbin.bined.editor.android` | [org.exbin.bined.editor.android](https://laantungir.net/git/zapstore-mirror/org.exbin.bined.editor.android) |
|
||||
| freeCodeCamp | `org.freecodecamp` | [org.freecodecamp](https://laantungir.net/git/zapstore-mirror/org.freecodecamp) |
|
||||
| GitHub Store | `zed.rainxch.githubstore` | [zed.rainxch.githubstore](https://laantungir.net/git/zapstore-mirror/zed.rainxch.githubstore) |
|
||||
| Kai 9000 | `com.inspiredandroid.kai` | [com.inspiredandroid.kai](https://laantungir.net/git/zapstore-mirror/com.inspiredandroid.kai) |
|
||||
|
||||
## 20. Calendar & Scheduling
|
||||
**d-tag:** `privacy-approved-calendar-scheduling`
|
||||
**Description:** Calendar apps, scheduling, and time management tools that passed privacy review.
|
||||
|
||||
| App | Identifier | Gitea URL |
|
||||
|-----|-----------|-----------|
|
||||
| Calendar by Form* | `app.formstr.calendar` | [app.formstr.calendar](https://laantungir.net/git/zapstore-mirror/app.formstr.calendar) |
|
||||
| PearCal | `com.pearcal` | [com.pearcal](https://laantungir.net/git/zapstore-mirror/com.pearcal) |
|
||||
| timeto.me | `me.timeto.app` | [me.timeto.app](https://laantungir.net/git/zapstore-mirror/me.timeto.app) |
|
||||
| Sidestep | `com.blankdev.sidestep` | [com.blankdev.sidestep](https://laantungir.net/git/zapstore-mirror/com.blankdev.sidestep) |
|
||||
|
||||
---
|
||||
|
||||
**Total:** 20 stacks, 192 apps mapped.
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Build script for the post-quantum crypto bundle.
|
||||
* Bundles pq-crypto.mjs and all its dependencies into a single
|
||||
* ESM file that can be loaded directly in the browser.
|
||||
*
|
||||
* Usage: node build-pq-bundle.js
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
async function build() {
|
||||
console.log('🔧 Building PQ crypto bundle...');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['www/js/pq-crypto.mjs'],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
target: ['es2020'],
|
||||
outfile: 'www/pq-crypto.bundle.js',
|
||||
sourcemap: true,
|
||||
minify: false, // keep readable for demo
|
||||
logLevel: 'info',
|
||||
// Pure JS — no WASM files to handle
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ PQ crypto bundle built: www/pq-crypto.bundle.js');
|
||||
}
|
||||
|
||||
build().catch(err => {
|
||||
console.error('❌ Build failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
# App Stacks Integration Plan
|
||||
|
||||
## Constraint
|
||||
|
||||
**No changes to HTML structure. No new CSS. No removal of anything.** All additions are JavaScript-only inside the existing `<script type="module">` block in [`www/app-stacks.html`](www/app-stacks.html).
|
||||
|
||||
## Goal
|
||||
|
||||
Add JavaScript to:
|
||||
1. Subscribe to kind 30078 events with `#t: app-definition` tag
|
||||
2. Display received app definitions inside the existing empty `#divBody`
|
||||
3. Publish new app-definition events (kind 30078) via `publishEvent()` from `init-ndk.mjs`
|
||||
|
||||
## What Changes
|
||||
|
||||
### File: [`www/app-stacks.html`](www/app-stacks.html)
|
||||
|
||||
Only the `<script type="module">` block (lines 149-806) gets additions. Specifically:
|
||||
|
||||
#### 1. New global variables (after line 193)
|
||||
```javascript
|
||||
// App definitions state
|
||||
let apps = [];
|
||||
let appDefSub = null;
|
||||
let appDefsLoaded = false;
|
||||
```
|
||||
|
||||
#### 2. New functions (inserted after the EVENT LISTENERS section at line 547)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `parseAppDefinition(evt)` | Parse a kind 30078 event with `#t: app-definition` into `{ identifier, name, pubkey, repository, description, category, eventId }` |
|
||||
| `getTagValue(tags, name)` | Helper to extract a tag value by name |
|
||||
| `renderApps()` | Render collected apps as simple HTML into `#divBody` |
|
||||
| `escapeHtml(str)` | XSS-safe HTML escaping |
|
||||
| `publishAppDefinition(name, identifier, repository, category)` | Create and publish a kind 30078 app-definition event via `publishEvent()` |
|
||||
| `showPublishForm()` | Prompt for name/identifier/repository/category, then call `publishAppDefinition()` |
|
||||
| `subscribeAppDefinitions()` | Call `subscribe()` with `{ kinds: [30078], '#t': ['app-definition'], limit: 500 }` |
|
||||
| `initAppDefinitionListener()` | Add `ndkEvent` and `ndkEose` window event listeners |
|
||||
|
||||
#### 3. Modified: `main()` function (line 616)
|
||||
|
||||
Add these calls after `await initializeAuthenticatedPageFeatures()`:
|
||||
```javascript
|
||||
// Set up app-definition event listener
|
||||
initAppDefinitionListener();
|
||||
|
||||
// Subscribe to app-definition events
|
||||
subscribeAppDefinitions();
|
||||
```
|
||||
|
||||
#### 4. Modified: `authMode` default (line 218)
|
||||
|
||||
Change from `'required'` to `'optional'` so the page loads without forcing login.
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `<title>` — stays "TEMPLATE"
|
||||
- Header text — stays empty
|
||||
- Body — stays empty (UI is built dynamically by JS)
|
||||
- Footer — unchanged
|
||||
- Sidenav — unchanged
|
||||
- Hamburger menu — unchanged
|
||||
- Any CSS — no additions
|
||||
- Any HTML elements — no additions or removals
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Page Load] --> B[main]
|
||||
B --> C[initAppDefinitionListener]
|
||||
B --> D[subscribeAppDefinitions]
|
||||
D --> E[NDK worker subscribes<br>kind 30078 #t: app-definition]
|
||||
E --> F[ndkEvent window event]
|
||||
F --> G[parseAppDefinition]
|
||||
G --> H[renderApps into #divBody]
|
||||
|
||||
I[User clicks Publish button] --> J[showPublishForm prompts]
|
||||
J --> K[publishAppDefinition]
|
||||
K --> L[publishEvent from init-ndk.mjs]
|
||||
L --> M[Event arrives via subscription]
|
||||
M --> G
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`www/app-stacks.html`](www/app-stacks.html) | Add ~130 lines of JS inside existing `<script type="module">` block. Change `authMode` default. |
|
||||
@@ -0,0 +1,68 @@
|
||||
# nostr-login-lite Path Audit
|
||||
|
||||
## Finding
|
||||
|
||||
**Every HTML page** in the project loads `nostr-lite.js` using the same absolute path:
|
||||
|
||||
```html
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
```
|
||||
|
||||
This means the file must be served from the **web root** at `/var/www/html/nostr-login-lite/nostr-lite.js`, not from inside the `client` subdirectory.
|
||||
|
||||
## Pages Using This Path
|
||||
|
||||
| Page | Line |
|
||||
|------|------|
|
||||
| www/index.html | 366 |
|
||||
| www/template.html | 147 |
|
||||
| www/template copy.html | 147 |
|
||||
| www/feed.html | 140 |
|
||||
| www/post.html | 205 |
|
||||
| www/notifications.html | 385 |
|
||||
| www/relays.html | 334 |
|
||||
| www/cal.html | 469 |
|
||||
| www/strudel.html | 258 |
|
||||
| www/vj.html | 1909 |
|
||||
| www/music.html | 1381 |
|
||||
| www/cashu.html | 722 |
|
||||
| www/ai.html | 746 |
|
||||
| www/blobs.html | 687 |
|
||||
| www/document.html | 918 |
|
||||
| www/event-management.html | 436 |
|
||||
| www/msg.html | 460 |
|
||||
| www/people.html | 256 |
|
||||
| www/profile.html | 271 |
|
||||
| www/todo.html | 308 |
|
||||
| www/tools.html | 527 |
|
||||
| www/conway.html | 213 |
|
||||
| www/didactyl.html | 516 |
|
||||
| www/keep-alive.html | 244 |
|
||||
| www/npub.html | 164 |
|
||||
| www/post-feed.html | 243 |
|
||||
| www/projects.html | 265 |
|
||||
| www/skills-edit.html | 642 |
|
||||
| www/slide-show.html | 334 |
|
||||
| www/ai-tv.html | 802 |
|
||||
| www/html-tv.html | 879 |
|
||||
| www/llm-steganography.html | 870 |
|
||||
| www/music-greyscale.html | 1336 |
|
||||
| www/vj-playlist.html | 310 |
|
||||
| www/block.html | 304 |
|
||||
| www/c-relay-pg.html | 405 |
|
||||
| www/relay-admin.html | 517 |
|
||||
| www/feed-old.html | 142 |
|
||||
| www/note.html | 439 |
|
||||
| www/db.html | 381 |
|
||||
| www/old/event.html | 304 |
|
||||
| www/old/skills-demo.html | 1018 |
|
||||
| www/old/stream.html | 326 |
|
||||
| www/old/relay-test.html | 160 |
|
||||
| www/old/bunker.html | 148 |
|
||||
| www/old/links.html | 172 |
|
||||
| www/old/db_relay.html | 381 |
|
||||
| www/old/stream-ctrl.html | 328 |
|
||||
|
||||
## Conclusion
|
||||
|
||||
The dangling symlink at `/var/www/html/client/nostr-login-lite` is safe to remove — none of the pages reference it. They all use the absolute `/nostr-login-lite/` path from the web root.
|
||||
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>blob-sanitize.mjs tests</title>
|
||||
<style>
|
||||
body { font: 14px/1.5 monospace; background: #0d1117; color: #c9d1d9; padding: 24px; }
|
||||
h1 { font-size: 18px; }
|
||||
.pass { color: #3fb950; }
|
||||
.fail { color: #f85149; }
|
||||
.summary { font-size: 16px; font-weight: bold; margin-top: 16px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; }
|
||||
pre { background: #161b22; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>blob-sanitize.mjs — client-side stripping tests</h1>
|
||||
<p>Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).</p>
|
||||
<pre id="out"></pre>
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
import { stripBlobMetadata, hasImageMetadata } from '../www/js/blob-sanitize.mjs';
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const summaryEl = document.getElementById('summary');
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
function log(msg) {
|
||||
out.textContent += msg + '\n';
|
||||
}
|
||||
function ok(name) { pass++; log(` ✅ PASS: ${name}`); }
|
||||
function bad(name, detail) { fail++; log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// SHA-256 of a File/Blob, returns hex.
|
||||
async function sha256(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const h = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(h)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Read file bytes as Uint8Array.
|
||||
async function bytes(file) {
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
// Check if a JPEG contains an Exif\0\0 marker.
|
||||
function jpegHasExif(arr) {
|
||||
if (arr.length < 4 || arr[0] !== 0xff || arr[1] !== 0xd8) return false;
|
||||
let i = 2;
|
||||
while (i + 4 < arr.length) {
|
||||
if (arr[i] !== 0xff) break;
|
||||
const marker = arr[i + 1];
|
||||
if (marker === 0xda) break;
|
||||
if (marker === 0xe1) {
|
||||
if (i + 10 <= arr.length) {
|
||||
const head = arr.slice(i + 4, i + 10);
|
||||
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
|
||||
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) return true;
|
||||
}
|
||||
const segLen = (arr[i + 2] << 8) | arr[i + 3];
|
||||
i += 2 + segLen;
|
||||
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
i += 2;
|
||||
} else {
|
||||
const segLen = (arr[i + 2] << 8) | arr[i + 3];
|
||||
i += 2 + segLen;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if a PNG has an eXIf chunk.
|
||||
function pngHasExif(arr) {
|
||||
if (arr.length < 8) return false;
|
||||
let i = 8;
|
||||
while (i + 8 < arr.length) {
|
||||
const len = (arr[i] << 24) | (arr[i + 1] << 16) | (arr[i + 2] << 8) | arr[i + 3];
|
||||
const t = new TextDecoder().decode(arr.slice(i + 4, i + 8));
|
||||
if (t === 'eXIf') return true;
|
||||
if (t === 'IEND') break;
|
||||
i += 8 + len + 4;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a small test JPEG with EXIF using canvas + manual APP1 injection.
|
||||
async function makeExifJpeg() {
|
||||
// First make a clean 2x2 JPEG via canvas.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const cleanBlob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
|
||||
const cleanBytes = new Uint8Array(await cleanBlob.arrayBuffer());
|
||||
|
||||
// Build an APP1 Exif segment: FF E1 <len> Exif\0\0 <minimal TIFF header>
|
||||
const exifPayload = new Uint8Array([
|
||||
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // Exif\0\0
|
||||
0x49, 0x49, 0x2a, 0x00, // little-endian TIFF header
|
||||
0x08, 0x00, 0x00, 0x00, // offset to IFD0
|
||||
0x00, 0x00, // 0 entries
|
||||
0x00, 0x00, 0x00, 0x00 // next IFD = 0
|
||||
]);
|
||||
const segLen = exifPayload.length + 2;
|
||||
const app1 = new Uint8Array([0xff, 0xe1, (segLen >> 8) & 0xff, segLen & 0xff, ...exifPayload]);
|
||||
|
||||
// Insert APP1 right after FF D8 (before the existing markers).
|
||||
const out = new Uint8Array(cleanBytes.length + app1.length);
|
||||
out.set(cleanBytes.slice(0, 2), 0); // FF D8
|
||||
out.set(app1, 2); // APP1 Exif
|
||||
out.set(cleanBytes.slice(2), 2 + app1.length); // rest
|
||||
return new File([out], 'exif.jpg', { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
// Create a clean JPEG via canvas.
|
||||
async function makeCleanJpeg() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
|
||||
return new File([blob], 'clean.jpg', { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
// Create a PNG with an eXIf chunk.
|
||||
async function makeExifPng() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
|
||||
const cleanBytes = new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
// Build an eXIf chunk: <len:4> eXIf <data> <crc:4>
|
||||
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const chunk = new Uint8Array(4 + 4 + exifData.length + 4);
|
||||
const dv = new DataView(chunk.buffer);
|
||||
dv.setUint32(0, exifData.length); // length
|
||||
chunk[4] = 0x65; chunk[5] = 0x58; chunk[6] = 0x49; chunk[7] = 0x66; // "eXIf"
|
||||
chunk.set(exifData, 8);
|
||||
// CRC left as 0 — browsers don't validate on encode, and we only check the output.
|
||||
|
||||
// Insert before IEND. Find IEND (49 45 4E 44) in cleanBytes.
|
||||
let iendOff = -1;
|
||||
for (let i = cleanBytes.length - 12; i >= 8; i--) {
|
||||
if (cleanBytes[i] === 0x49 && cleanBytes[i + 1] === 0x45 &&
|
||||
cleanBytes[i + 2] === 0x4e && cleanBytes[i + 3] === 0x44) {
|
||||
// IEND chunk starts 4 bytes before the type (at the length field)
|
||||
iendOff = i - 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (iendOff < 0) return new File([cleanBytes], 'exif.png', { type: 'image/png' });
|
||||
const out = new Uint8Array(cleanBytes.length + chunk.length);
|
||||
out.set(cleanBytes.slice(0, iendOff), 0);
|
||||
out.set(chunk, iendOff);
|
||||
out.set(cleanBytes.slice(iendOff), iendOff + chunk.length);
|
||||
return new File([out], 'exif.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function run() {
|
||||
log('=== blob-sanitize.mjs tests ===\n');
|
||||
|
||||
// Test 1: EXIF JPEG → stripped, no Exif marker, hash differs.
|
||||
log('[JPEG EXIF stripping]');
|
||||
{
|
||||
const exifFile = await makeExifJpeg();
|
||||
const stripped = await stripBlobMetadata(exifFile);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!jpegHasExif(outBytes)) ok('EXIF JPEG: no Exif marker after stripping');
|
||||
else bad('EXIF JPEG: no Exif marker after stripping');
|
||||
|
||||
const origHash = await sha256(exifFile);
|
||||
const newHash = await sha256(stripped);
|
||||
if (origHash !== newHash) ok('EXIF JPEG: hash differs (re-encode happened)');
|
||||
else bad('EXIF JPEG: hash differs (re-encode happened)');
|
||||
|
||||
if (stripped.type === 'image/jpeg') ok('EXIF JPEG: output type is image/jpeg');
|
||||
else bad('EXIF JPEG: output type is image/jpeg', `got ${stripped.type}`);
|
||||
}
|
||||
|
||||
// Test 2: clean JPEG → still re-encoded (canvas always re-encodes).
|
||||
log('[clean JPEG]');
|
||||
{
|
||||
const clean = await makeCleanJpeg();
|
||||
const stripped = await stripBlobMetadata(clean);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!jpegHasExif(outBytes)) ok('clean JPEG: no Exif marker');
|
||||
else bad('clean JPEG: no Exif marker');
|
||||
}
|
||||
|
||||
// Test 3: PNG with eXIf → stripped, no eXIf chunk.
|
||||
log('[PNG eXIf stripping]');
|
||||
{
|
||||
const exifPng = await makeExifPng();
|
||||
const stripped = await stripBlobMetadata(exifPng);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!pngHasExif(outBytes)) ok('PNG eXIf: no eXIf chunk after stripping');
|
||||
else bad('PNG eXIf: no eXIf chunk after stripping');
|
||||
|
||||
const origHash = await sha256(exifPng);
|
||||
const newHash = await sha256(stripped);
|
||||
if (origHash !== newHash) ok('PNG eXIf: hash differs');
|
||||
else bad('PNG eXIf: hash differs');
|
||||
}
|
||||
|
||||
// Test 4: hasImageMetadata detects EXIF.
|
||||
log('[hasImageMetadata]');
|
||||
{
|
||||
const exifFile = await makeExifJpeg();
|
||||
const detected = await hasImageMetadata(exifFile);
|
||||
if (detected === true) ok('hasImageMetadata: detects EXIF JPEG');
|
||||
else bad('hasImageMetadata: detects EXIF JPEG', `got ${detected}`);
|
||||
|
||||
const clean = await makeCleanJpeg();
|
||||
const cleanDetected = await hasImageMetadata(clean);
|
||||
if (cleanDetected === false) ok('hasImageMetadata: clean JPEG → false');
|
||||
else bad('hasImageMetadata: clean JPEG → false', `got ${cleanDetected}`);
|
||||
}
|
||||
|
||||
// Test 5: pass-through for audio.
|
||||
log('[pass-through audio]');
|
||||
{
|
||||
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
|
||||
const audioFile = new File([audioBytes], 'test.mp3', { type: 'audio/mpeg' });
|
||||
const result = await stripBlobMetadata(audioFile);
|
||||
if (result === audioFile) ok('audio: pass-through (same File object)');
|
||||
else bad('audio: pass-through (same File object)');
|
||||
}
|
||||
|
||||
// Test 6: PDF with /Author → stripped.
|
||||
log('[PDF /Author stripping]');
|
||||
{
|
||||
const pdfContent = '%PDF-1.4\n/Author (Secret Person)\n/Producer (Test)\n%%EOF';
|
||||
const pdfFile = new File([pdfContent], 'test.pdf', { type: 'application/pdf' });
|
||||
const stripped = await stripBlobMetadata(pdfFile);
|
||||
const outText = new TextDecoder().decode(await bytes(stripped));
|
||||
if (!outText.includes('Secret Person')) ok('PDF: /Author value removed');
|
||||
else bad('PDF: /Author value removed');
|
||||
if (outText.includes('/Author()')) ok('PDF: /Author key blanked to ()');
|
||||
else bad('PDF: /Author key blanked to ()', `got: ${outText}`);
|
||||
}
|
||||
|
||||
// Test 7: null/undefined input.
|
||||
log('[edge cases]');
|
||||
{
|
||||
const r1 = await stripBlobMetadata(null);
|
||||
if (r1 === null) ok('null input → null');
|
||||
else bad('null input → null');
|
||||
}
|
||||
|
||||
// Summary.
|
||||
log('');
|
||||
const color = fail === 0 ? '#3fb950' : '#f85149';
|
||||
summaryEl.innerHTML = `<span style="color:${color}">${pass} passed, ${fail} failed</span>`;
|
||||
summaryEl.style.borderColor = fail === 0 ? '#3fb950' : '#f85149';
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
log(`\nFATAL: ${err?.message || err}\n${err?.stack || ''}`);
|
||||
fail++;
|
||||
summaryEl.innerHTML = `<span style="color:#f85149">${pass} passed, ${fail} failed</span>`;
|
||||
summaryEl.style.borderColor = '#f85149';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -50,9 +50,6 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up temp directory
|
||||
ssh $SERVER "rm -rf $TEMP_PATH"
|
||||
|
||||
echo ""
|
||||
echo "✅ All files synced successfully!"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
<!DOCTYPE html>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>APP STACKS</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
|
||||
<!-- Initialize theme BEFORE any components load -->
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
if (document.body) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||||
|
||||
<!-- SVG.js library (required by HamburgerMorphing) -->
|
||||
<script src="./js/vendor/svg.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- ================================================================
|
||||
HAMBURGER BUTTON (Fixed, separate from header)
|
||||
================================================================
|
||||
The hamburger button is a fixed element outside the header
|
||||
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
||||
================================================================ -->
|
||||
<div id="divSvgHam" class="divHeaderButtons">
|
||||
<!-- HamburgerMorphing will be injected here -->
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
HEADER
|
||||
================================================================
|
||||
Standard header with title (center).
|
||||
================================================================ -->
|
||||
<div id="divHeader">
|
||||
<div id="divHeaderFlexLeft">
|
||||
<!-- Hamburger is now separate fixed element -->
|
||||
</div>
|
||||
|
||||
<div id="divHeaderFlexCenter">
|
||||
<div class="divHeaderText"></div>
|
||||
</div>
|
||||
|
||||
<div id="divHeaderFlexRight">
|
||||
<!-- No button in header right - logout is in sidenav footer -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
BODY
|
||||
================================================================
|
||||
Main content area. Add your page-specific content here.
|
||||
================================================================ -->
|
||||
<div id="divBody">
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
FOOTER
|
||||
================================================================
|
||||
Three-section footer layout:
|
||||
- Left: Relay status animations (HamburgerMorphing instances)
|
||||
- Center: General status information
|
||||
- Right: Additional information
|
||||
================================================================ -->
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
SIDENAV
|
||||
================================================================
|
||||
Slide-out navigation panel. Opens from left when hamburger clicked.
|
||||
Uses flexbox layout to pin version bar to bottom.
|
||||
Includes a version bar footer with theme toggle and logout buttons.
|
||||
================================================================ -->
|
||||
<div id="divSideNav">
|
||||
<div id="divSideNavHeader">
|
||||
<!-- No close button - use main hamburger to close -->
|
||||
</div>
|
||||
|
||||
<div id="divSideNavBody">
|
||||
<div id="divFiles"></div>
|
||||
</div>
|
||||
|
||||
<div id="divAiSection" class="sidenavSection">
|
||||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||||
<div id="divAiList" class="sidenavSectionList">
|
||||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="divRelaySection">
|
||||
<div id="divRelaySectionTitle">
|
||||
リレー
|
||||
</div>
|
||||
<div id="divRelayList">
|
||||
Loading relays...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="divBlossomSection">
|
||||
|
||||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||||
|
||||
<div id="divBlossomList">Loading blossom servers...</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="divVersionBar">
|
||||
<span id="versionDisplay">v0.0.1</span>
|
||||
<div id="divVersionBarButtons">
|
||||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||||
<div id="themeToggleHamburgerContainer"></div>
|
||||
</button>
|
||||
<button id="logoutButton" title="Logout">
|
||||
<div id="logoutHamburgerContainer"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
REQUIRED SCRIPTS
|
||||
================================================================
|
||||
These scripts must be loaded in this order:
|
||||
1. nostr.bundle.js - Nostr tools library
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
IMPORTS
|
||||
================================================================
|
||||
Import shared NDK functionality from init-ndk.mjs:
|
||||
- initNDKPage() - Initialize authentication and worker
|
||||
- getPubkey() - Get current user's pubkey
|
||||
- subscribe() - Create NDK subscriptions
|
||||
- publishEvent() - Publish events via NDK
|
||||
- disconnect() - Disconnect from worker
|
||||
- getRelayData() - Get relay connection data
|
||||
- getRelayStats() - Get relay activity statistics
|
||||
|
||||
Import HamburgerMorphing for animated icons
|
||||
================================================================ */
|
||||
import {
|
||||
initNDKPage,
|
||||
getPubkey, injectHeaderAvatar, injectHeaderLoginButton,
|
||||
subscribe,
|
||||
publishEvent,
|
||||
disconnect,
|
||||
getVersion,
|
||||
updateVersionDisplay,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onUserSettings
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
// Version will be loaded asynchronously
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
console.log(`[template.html ${VERSION}] Loading...`);
|
||||
|
||||
/* ================================================================
|
||||
GLOBAL VARIABLES
|
||||
================================================================
|
||||
Track state for hamburger menu, relay status, and theme.
|
||||
================================================================ */
|
||||
let updateIntervalId = null;
|
||||
let currentPubkey = null;
|
||||
|
||||
// App definitions state
|
||||
let apps = [];
|
||||
let appDefSub = null;
|
||||
let appDefsLoaded = false;
|
||||
|
||||
// App stacks state
|
||||
let stacks = [];
|
||||
let stackSub = null;
|
||||
let stacksLoaded = false;
|
||||
|
||||
/*
|
||||
AUTH STATE MODEL (Template reference)
|
||||
------------------------------------------------------------------
|
||||
This template now demonstrates three auth modes for standalone pages:
|
||||
|
||||
- required (default):
|
||||
Behaves like existing pages: login is required immediately.
|
||||
|
||||
- optional:
|
||||
Page can render public/read-only data without login, but can still
|
||||
prompt login later for user actions (publish, settings, etc).
|
||||
|
||||
- none:
|
||||
Never auto-login on load (pure public page).
|
||||
|
||||
URL behavior in this template:
|
||||
- If ?auth=required|optional|none is present, it wins.
|
||||
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
|
||||
to optional because pages with explicit profile targets are commonly
|
||||
public-readable.
|
||||
- Otherwise, mode defaults to required.
|
||||
*/
|
||||
let isAuthenticated = false;
|
||||
let authMode = 'optional';
|
||||
let authedPageInitialized = false;
|
||||
let relayActivityListenersBound = false;
|
||||
|
||||
// Hamburger menu
|
||||
let hamburgerInstance = null;
|
||||
let isNavOpen = false;
|
||||
|
||||
// Version bar buttons
|
||||
let logoutHamburger = null;
|
||||
let themeToggleHamburger = null;
|
||||
let isDarkMode = false;
|
||||
|
||||
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
|
||||
let pageSettings = {};
|
||||
let unsubscribeUserSettings = null;
|
||||
|
||||
/* ================================================================
|
||||
DOM VARIABLES
|
||||
================================================================
|
||||
Cache DOM element references for better performance.
|
||||
================================================================ */
|
||||
const divBody = document.getElementById("divBody");
|
||||
const divSideNav = document.getElementById("divSideNav");
|
||||
const divSideNavBody = document.getElementById("divSideNavBody");
|
||||
const divFooterCenter = document.getElementById("divFooterCenter");
|
||||
const divFooterRight = document.getElementById("divFooterRight");
|
||||
|
||||
/* ================================================================
|
||||
HAMBURGER MENU
|
||||
================================================================
|
||||
Initialize and control the animated hamburger menu.
|
||||
================================================================ */
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SIDENAV FUNCTIONS
|
||||
================================================================
|
||||
Open/close sidenav with hamburger morphing animation.
|
||||
================================================================ */
|
||||
function openNav() {
|
||||
divSideNav.style.zIndex = 3;
|
||||
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
||||
isNavOpen = true;
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('arrow_left');
|
||||
}
|
||||
|
||||
|
||||
// Initialize version bar buttons when sidenav opens (lazy load)
|
||||
if (!logoutHamburger) {
|
||||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
logoutHamburger.animateTo('x');
|
||||
}
|
||||
|
||||
if (!themeToggleHamburger) {
|
||||
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
|
||||
// Determine current theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||||
const initialShape = isDarkMode ? 'moon' : 'circle';
|
||||
themeToggleHamburger.animateTo(initialShape);
|
||||
}
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
divSideNav.style.width = "0vw";
|
||||
divSideNav.style.zIndex = -1;
|
||||
isNavOpen = false;
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
if (isNavOpen) {
|
||||
closeNav();
|
||||
} else {
|
||||
openNav();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
AUTH MODE HELPERS
|
||||
================================================================
|
||||
These functions are meant as reusable guidance for future pages.
|
||||
================================================================ */
|
||||
function hasTargetPubkeyInUrl() {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const npub = String(params.get('npub') || '').trim();
|
||||
const pubkey = String(params.get('pubkey') || '').trim();
|
||||
return Boolean(npub || pubkey);
|
||||
}
|
||||
|
||||
function resolveAuthModeFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
||||
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
|
||||
return explicitAuth;
|
||||
}
|
||||
|
||||
// Convention: explicit target profiles are public-readable by default.
|
||||
if (hasTargetPubkeyInUrl()) {
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
return 'required';
|
||||
}
|
||||
|
||||
function isAuthRequiredError(error) {
|
||||
const message = String(error?.message || error || '').toLowerCase();
|
||||
return message.includes('authentication required');
|
||||
}
|
||||
|
||||
async function initializeAuthentication(mode) {
|
||||
// required: existing behavior, throw if auth fails.
|
||||
if (mode === 'required') {
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// none: public page, no login attempt on load.
|
||||
if (mode === 'none') {
|
||||
isAuthenticated = false;
|
||||
currentPubkey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// optional: try silent/normal init; if auth required, continue public.
|
||||
try {
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
} catch (error) {
|
||||
if (isAuthRequiredError(error)) {
|
||||
console.log('[template.html] Optional auth mode: continuing unauthenticated');
|
||||
isAuthenticated = false;
|
||||
currentPubkey = null;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeAuthenticatedPageFeatures() {
|
||||
if (!isAuthenticated) {
|
||||
await injectHeaderLoginButton();
|
||||
return;
|
||||
}
|
||||
if (authedPageInitialized) return;
|
||||
|
||||
await injectHeaderAvatar(currentPubkey);
|
||||
console.log('[template.html] Authenticated as:', currentPubkey);
|
||||
|
||||
// Hydrate app-wide user settings for this page
|
||||
try {
|
||||
pageSettings = await getUserSettings();
|
||||
} catch (error) {
|
||||
console.warn('[template.html] getUserSettings failed:', error);
|
||||
pageSettings = {};
|
||||
}
|
||||
|
||||
// Subscribe to live user settings updates (cross-tab + publish echoes)
|
||||
if (!unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||||
pageSettings = settings || {};
|
||||
// TODO: Re-render page-specific UI from pageSettings here.
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize relay-dependent UI only once authenticated.
|
||||
initFooterRelayStatus();
|
||||
initSidenavRelaySection();
|
||||
await initBlossomSection();
|
||||
initAiSectionWithLocalConfig();
|
||||
await UpdateFooter();
|
||||
|
||||
if (!updateIntervalId) {
|
||||
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||||
}
|
||||
|
||||
// Relay activity listeners only matter after worker init/auth.
|
||||
if (!relayActivityListenersBound) {
|
||||
window.addEventListener('ndkRelayActivity', (event) => {
|
||||
const { relayUrl, activity, stats } = event.detail;
|
||||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
||||
setRelayActivityState(relayUrl, activity);
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'relayActivity') {
|
||||
const { relayUrl, activity } = event.data;
|
||||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
|
||||
setRelayActivityState(relayUrl, activity);
|
||||
}
|
||||
});
|
||||
|
||||
relayActivityListenersBound = true;
|
||||
}
|
||||
|
||||
authedPageInitialized = true;
|
||||
}
|
||||
|
||||
async function promptLoginIfNeeded() {
|
||||
if (isAuthenticated) return true;
|
||||
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
UPDATE FOOTER
|
||||
================================================================
|
||||
Update footer sections with relay status, pubkey, and other info.
|
||||
Called periodically by update loop.
|
||||
================================================================ */
|
||||
const UpdateFooter = async () => {
|
||||
|
||||
try {
|
||||
// Update relay status visuals in footer and sidenav
|
||||
await updateFooterRelayStatus();
|
||||
await updateSidenavRelaySection();
|
||||
|
||||
await updateBlossomSection();
|
||||
// Clear center and right sections
|
||||
divFooterCenter.innerHTML = '';
|
||||
divFooterRight.innerHTML = '';
|
||||
} catch (error) {
|
||||
console.error('[template.html] Error updating footer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
LOGOUT
|
||||
================================================================
|
||||
Complete logout process:
|
||||
1. Stop update loop
|
||||
2. Disconnect from NDK worker
|
||||
3. Logout from nostr-login-lite
|
||||
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||||
5. Reload page
|
||||
================================================================ */
|
||||
const Logout = async () => {
|
||||
console.log("[template.html] Starting logout process...");
|
||||
|
||||
// Stop the update loop
|
||||
if (updateIntervalId) {
|
||||
clearInterval(updateIntervalId);
|
||||
updateIntervalId = null;
|
||||
}
|
||||
|
||||
// Disconnect from worker
|
||||
disconnect();
|
||||
|
||||
// Logout from nostr-login-lite
|
||||
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
||||
await window.NOSTR_LOGIN_LITE.logout();
|
||||
}
|
||||
|
||||
// Clear all storage
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear IndexedDB
|
||||
if (window.indexedDB) {
|
||||
const databases = await window.indexedDB.databases();
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
window.indexedDB.deleteDatabase(db.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[template.html] Logged out, reloading page");
|
||||
location.reload(true);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
EVENT LISTENERS
|
||||
================================================================
|
||||
Wire up UI interactions.
|
||||
Main hamburger button click handler is set up in main() after initialization.
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
APP DEFINITION FUNCTIONS
|
||||
================================================================ */
|
||||
|
||||
function parseAppDefinition(evt) {
|
||||
const tags = evt.tags || [];
|
||||
const dTag = getTagValue(tags, 'd') || '';
|
||||
const name = getTagValue(tags, 'name') || dTag.replace('app-', '');
|
||||
const identifier = dTag.replace('app-', '');
|
||||
const repository = getTagValue(tags, 'repository') || '';
|
||||
const categories = tags.filter(t => t[0] === 't' && t[1] !== 'app-definition').map(t => t[1]);
|
||||
let description = '';
|
||||
try { const c = JSON.parse(evt.content || '{}'); description = c.description || ''; } catch (e) {}
|
||||
return { identifier, name, pubkey: evt.pubkey || '', repository, description, category: categories[0] || 'uncategorized', eventId: evt.id };
|
||||
}
|
||||
|
||||
function getTagValue(tags, name) {
|
||||
const tag = tags.find(t => t[0] === name);
|
||||
return tag && tag.length > 1 ? tag[1] : '';
|
||||
}
|
||||
|
||||
function renderApps() {
|
||||
window.renderApps = renderApps;
|
||||
|
||||
// === CREATE STACK FORM ===
|
||||
var createStackHtml =
|
||||
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
|
||||
'<div style="font-weight:bold;margin-bottom:10px;">Create App Stack</div>' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<input id="stackName" type="text" placeholder="Stack name (e.g. Nostr Clients)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<input id="stackDesc" type="text" placeholder="Description (optional)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'</div>' +
|
||||
'<div><button class="btn" onclick="doCreateStack()">Create Stack</button></div>' +
|
||||
'</div>';
|
||||
|
||||
// === STACKS SECTION ===
|
||||
var stacksHtml = '<div style="margin-bottom:15px;">' +
|
||||
'<div style="font-weight:bold;font-size:14px;margin-bottom:8px;">App Stacks <span style="font-size:12px;color:var(--muted-color);">(' + stacks.length + ')</span></div>';
|
||||
if (stacks.length === 0) {
|
||||
stacksHtml += '<div style="font-size:12px;color:var(--muted-color);margin-bottom:6px;">No stacks yet.</div>';
|
||||
} else {
|
||||
stacks.forEach(function(s) {
|
||||
stacksHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:8px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<div style="font-weight:bold;">' + esc(s.name) + '</div>' +
|
||||
'<div style="color:var(--muted-color);">d: ' + esc(s.dTag) + '</div>' +
|
||||
(s.description ? '<div style="color:var(--muted-color);">' + esc(s.description) + '</div>' : '') +
|
||||
'<div style="color:var(--muted-color);font-size:11px;">' + s.appRefs.length + ' app(s) · by ' + s.pubkey.substring(0, 8) + '…' + s.pubkey.substring(60) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
stacksHtml += '</div>';
|
||||
|
||||
// === APP DEFINITIONS SECTION ===
|
||||
// Collect unique categories from loaded apps
|
||||
var categories = {};
|
||||
apps.forEach(function(a) { if (a.category) categories[a.category] = true; });
|
||||
var categoryList = Object.keys(categories).sort();
|
||||
var datalistOptions = categoryList.map(function(c) {
|
||||
return '<option value="' + esc(c) + '">';
|
||||
}).join('');
|
||||
|
||||
// Publish form with datalist for category
|
||||
var formHtml =
|
||||
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
|
||||
'<div style="font-weight:bold;margin-bottom:10px;">Publish App Definition</div>' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<input id="pubRepoUrl" type="text" placeholder="Gitea Repo URL (e.g. https://git.laantungir.net/owner/repo)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<input id="pubCategory" list="pubCategoryList" type="text" placeholder="Category (e.g. nostr-clients, wallets)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'<datalist id="pubCategoryList">' + datalistOptions + '</datalist>' +
|
||||
'</div>' +
|
||||
'<div><button class="btn" onclick="doPublish()">Publish</button></div>' +
|
||||
'</div>';
|
||||
|
||||
// Group apps by category
|
||||
var grouped = {};
|
||||
apps.forEach(function(a) {
|
||||
var cat = a.category || 'uncategorized';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(a);
|
||||
});
|
||||
var catOrder = Object.keys(grouped).sort();
|
||||
|
||||
var listHtml;
|
||||
if (apps.length === 0) {
|
||||
listHtml = '<div style="text-align:center;padding:20px;color:var(--muted-color);">No app definitions found yet.</div>';
|
||||
} else {
|
||||
listHtml = '<div style="margin-bottom:10px;font-size:13px;">' + apps.length + ' app' + (apps.length !== 1 ? 's' : '') + '</div>';
|
||||
catOrder.forEach(function(cat) {
|
||||
var catApps = grouped[cat];
|
||||
listHtml +=
|
||||
'<div style="margin-bottom:12px;">' +
|
||||
'<div style="font-weight:bold;font-size:13px;margin-bottom:6px;border-bottom:1px solid var(--border-color);padding-bottom:3px;">' + esc(cat) + ' (' + catApps.length + ')</div>';
|
||||
catApps.forEach(function(a) {
|
||||
listHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:10px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<div style="font-weight:bold;font-size:13px;margin-bottom:4px;">' + esc(a.name) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">description:</span> ' + esc(a.description || '—') + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
listHtml += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Single wrapper div so #divBody flexbox sees only one child
|
||||
divBody.innerHTML = '<div>' + createStackHtml + stacksHtml + formHtml + listHtml + '</div>';
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function parseGiteaUrl(url) {
|
||||
// Handle URLs like https://git.laantungir.net/owner/repo or https://github.com/owner/repo
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const parts = u.pathname.replace(/^\//, '').replace(/\/$/, '').split('/');
|
||||
if (parts.length < 2) return null;
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace(/\.git$/, '');
|
||||
return { owner, repo, giteaPath: owner + '/' + repo };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
divFooterCenter.textContent = msg;
|
||||
}
|
||||
|
||||
async function publishAppDefinition(name, identifier, repository, category, giteaPath) {
|
||||
if (!isAuthenticated) { const ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to publish.'); return; } }
|
||||
try {
|
||||
const tags = [
|
||||
['d', 'app-' + identifier],
|
||||
['t', 'app-definition'],
|
||||
['t', category],
|
||||
['name', name],
|
||||
['repository', repository],
|
||||
];
|
||||
if (giteaPath) tags.push(['gitea', giteaPath]);
|
||||
const result = await publishEvent({
|
||||
kind: 30078,
|
||||
content: JSON.stringify({ name, identifier, repository, description: '' }),
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
const n = (result?.relayResults?.successful || []).length;
|
||||
setStatus('App definition published to ' + n + ' relay(s)');
|
||||
renderApps();
|
||||
} catch (e) { setStatus('Publish failed: ' + e.message); }
|
||||
}
|
||||
|
||||
|
||||
// Expose doPublish globally for onclick handler
|
||||
window.doPublish = async function() {
|
||||
const repoUrl = document.getElementById('pubRepoUrl').value.trim();
|
||||
if (!repoUrl) { alert('Enter a Gitea repo URL.'); return; }
|
||||
const parsed = parseGiteaUrl(repoUrl);
|
||||
if (!parsed) { alert('Invalid repo URL. Use format: https://git.example.com/owner/repo'); return; }
|
||||
|
||||
// Derive name from repo name: my-cool-app -> My Cool App
|
||||
const name = parsed.repo.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||
// Derive identifier from repo name: my-cool-app -> com.gitea.my_cool_app
|
||||
const identifier = 'com.gitea.' + parsed.repo.replace(/-/g, '_');
|
||||
const category = document.getElementById('pubCategory').value.trim() || 'uncategorized';
|
||||
|
||||
await publishAppDefinition(name, identifier, repoUrl, category, parsed.giteaPath);
|
||||
};
|
||||
|
||||
function subscribeAppDefinitions() {
|
||||
console.log('[app-stacks] Subscribing to app definitions...');
|
||||
appDefSub = subscribe({ kinds: [30078], '#t': ['app-definition'], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function initAppDefinitionListener() {
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event.detail;
|
||||
if (evt.kind !== 30078) return;
|
||||
const tags = evt.tags || [];
|
||||
if (!tags.some(t => t[0] === 't' && t[1] === 'app-definition')) return;
|
||||
const dTag = getTagValue(tags, 'd') || '';
|
||||
if (apps.some(a => a.eventId === evt.id || a.identifier === dTag.replace('app-', ''))) return;
|
||||
apps.push(parseAppDefinition(evt));
|
||||
console.log('[app-stacks] Added app def:', apps[apps.length - 1].name);
|
||||
if (appDefsLoaded) renderApps();
|
||||
});
|
||||
window.addEventListener('ndkEose', () => {
|
||||
if (!appDefsLoaded) { appDefsLoaded = true; renderApps(); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
APP STACK FUNCTIONS (kind 30267)
|
||||
================================================================ */
|
||||
|
||||
function parseAppStack(evt) {
|
||||
const tags = evt.tags || [];
|
||||
const dTag = getTagValue(tags, 'd') || '';
|
||||
const name = getTagValue(tags, 'name') || dTag;
|
||||
const description = getTagValue(tags, 'description') || '';
|
||||
const appRefs = tags.filter(function(t) { return t[0] === 'a'; }).map(function(t) { return t[1]; });
|
||||
return { dTag: dTag, name: name, description: description, appRefs: appRefs, eventId: evt.id, pubkey: evt.pubkey || '' };
|
||||
}
|
||||
|
||||
function subscribeAppStacks() {
|
||||
console.log('[app-stacks] Subscribing to app stacks...');
|
||||
stackSub = subscribe({ kinds: [30267], '#t': ['app-stack'], limit: 200 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function initAppStackListener() {
|
||||
window.addEventListener('ndkEvent', function(event) {
|
||||
var evt = event.detail;
|
||||
if (evt.kind !== 30267) return;
|
||||
var tags = evt.tags || [];
|
||||
if (!tags.some(function(t) { return t[0] === 't' && t[1] === 'app-stack'; })) return;
|
||||
var dTag = getTagValue(tags, 'd') || '';
|
||||
if (stacks.some(function(s) { return s.eventId === evt.id || s.dTag === dTag; })) return;
|
||||
stacks.push(parseAppStack(evt));
|
||||
console.log('[app-stacks] Added stack:', stacks[stacks.length - 1].name);
|
||||
if (stacksLoaded) renderApps();
|
||||
});
|
||||
window.addEventListener('ndkEose', function() {
|
||||
if (!stacksLoaded) { stacksLoaded = true; renderApps(); }
|
||||
});
|
||||
}
|
||||
|
||||
async function publishAppStack(name, description) {
|
||||
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to publish.'); return; } }
|
||||
var dTag = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
try {
|
||||
var result = await publishEvent({
|
||||
kind: 30267,
|
||||
content: JSON.stringify({ name: name, description: description }),
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['t', 'app-stack'],
|
||||
['name', name],
|
||||
['description', description],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
var n = (result?.relayResults?.successful || []).length;
|
||||
setStatus('Stack "' + name + '" published to ' + n + ' relay(s)');
|
||||
renderApps();
|
||||
} catch (e) { setStatus('Publish failed: ' + e.message); }
|
||||
}
|
||||
|
||||
window.doCreateStack = async function() {
|
||||
var name = document.getElementById('stackName').value.trim();
|
||||
if (!name) { alert('Enter a stack name.'); return; }
|
||||
var desc = document.getElementById('stackDesc').value.trim();
|
||||
await publishAppStack(name, desc);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
INITIALIZATION
|
||||
================================================================
|
||||
Main initialization sequence:
|
||||
1. Initialize hamburger menu
|
||||
2. Set up hamburger click handler
|
||||
3. Resolve auth mode from URL/query policy
|
||||
4. Initialize authentication based on mode
|
||||
5. Initialize authenticated-only features (if signed in)
|
||||
6. Set up version bar button listeners
|
||||
7. Restore sidenav state
|
||||
8. Update version display
|
||||
|
||||
Notes:
|
||||
- required mode = existing behavior (prompt login on load)
|
||||
- optional mode = allow public load, login later on demand
|
||||
- none mode = no auto-login on load
|
||||
================================================================ */
|
||||
(async function main() {
|
||||
console.log("[template.html] Starting initialization...");
|
||||
|
||||
try {
|
||||
// Initialize hamburger menu first
|
||||
initHamburgerMenu();
|
||||
|
||||
// Add click handler to hamburger
|
||||
const divSvgHam = document.getElementById('divSvgHam');
|
||||
if (divSvgHam) {
|
||||
divSvgHam.addEventListener('click', toggleNav);
|
||||
}
|
||||
|
||||
// Initialize version bar buttons
|
||||
const themeToggleButton = document.getElementById('themeToggleButton');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
|
||||
if (themeToggleButton) {
|
||||
themeToggleButton.addEventListener('click', () => {
|
||||
isDarkMode = !isDarkMode;
|
||||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
||||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||
if (themeToggleHamburger) {
|
||||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', async () => {
|
||||
try {
|
||||
// In optional/none modes this doubles as a "Sign in" entry point.
|
||||
if (!isAuthenticated) {
|
||||
await promptLoginIfNeeded();
|
||||
return;
|
||||
}
|
||||
await Logout();
|
||||
} catch (error) {
|
||||
console.error('Logout/login action failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve and initialize page auth policy.
|
||||
authMode = resolveAuthModeFromUrl();
|
||||
console.log('[template.html] Resolved auth mode:', authMode);
|
||||
await initializeAuthentication(authMode);
|
||||
|
||||
// Initialize authenticated features only when signed in.
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
|
||||
// Set up app-definition event listener and subscription
|
||||
initAppDefinitionListener();
|
||||
subscribeAppDefinitions();
|
||||
|
||||
// Set up app-stack event listener and subscription
|
||||
initAppStackListener();
|
||||
subscribeAppStacks();
|
||||
|
||||
// Optional UX note for public mode pages.
|
||||
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||||
divFooterCenter.textContent = 'Public mode';
|
||||
divFooterRight.textContent = 'Sign in from side menu to publish apps';
|
||||
}
|
||||
|
||||
// Update version display
|
||||
await updateVersionDisplay();
|
||||
|
||||
console.log('[app-stacks] Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[app-stacks] Initialization failed:', error);
|
||||
divBody.innerHTML = '<div style="text-align:center;padding:40px;"><div style="font-size:18px;margin-bottom:10px;">Error</div><div>' + error.message + '</div><button class="btn" style="margin-top:10px;" onclick="location.reload()">Retry</button></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
/* ================================================================
|
||||
WORKER MESSAGE TYPES
|
||||
================================================================
|
||||
The NDK worker can send these message types:
|
||||
|
||||
1. 'response' - Response to init/subscribe/publish requests
|
||||
- data.profile - User profile (from init)
|
||||
- data.relays - User relays (from init)
|
||||
- data.success - Publish success status
|
||||
- data.relayResults - Relay publish results
|
||||
|
||||
2. 'event' - Nostr event from subscription
|
||||
- Dispatched as 'ndkEvent' window event
|
||||
- event.detail contains the Nostr event
|
||||
|
||||
3. 'eose' - End of stored events for subscription
|
||||
- Dispatched as 'ndkEose' window event
|
||||
- event.detail.subId contains subscription ID
|
||||
|
||||
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
||||
- Handled automatically by init-ndk.mjs
|
||||
- Calls window.nostr methods and sends response
|
||||
|
||||
5. 'error' - Error from worker
|
||||
- Logged to console automatically
|
||||
|
||||
6. 'relayActivity' - Relay read/write activity notification
|
||||
- Dispatched as 'ndkRelayActivity' window event
|
||||
- Used to animate relay status icons in footer
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
DISTRIBUTED ARCHITECTURE NOTES
|
||||
================================================================
|
||||
Each page is independently accessible and self-contained:
|
||||
|
||||
1. Authentication persists via nostr-login-lite localStorage
|
||||
- Login once on any page
|
||||
- All other pages automatically authenticated
|
||||
|
||||
2. NDK SharedWorker is shared across all tabs/pages
|
||||
- Single NDK instance manages all connections
|
||||
- Subscriptions from all pages handled by one worker
|
||||
- Events broadcast to all connected pages
|
||||
|
||||
3. Dexie cache is shared across all pages
|
||||
- IndexedDB persists across sessions
|
||||
- Cache-first queries are fast
|
||||
- Reduces relay load
|
||||
|
||||
4. User settings are centralized and shared
|
||||
- Worker hydrates kind 30078 (`d:user-settings`) on init
|
||||
- Pages read via getUserSettings()
|
||||
- Pages patch via patchUserSettings({ featureNamespace: ... })
|
||||
- Pages subscribe via onUserSettings() for live updates
|
||||
|
||||
5. Each page can be distributed independently
|
||||
- Copy template.html and customize
|
||||
- No dependencies on other pages
|
||||
- Works standalone or as part of suite
|
||||
|
||||
6. Message-based signer bridges worker and page
|
||||
- Worker's NDK uses MessageBasedSigner
|
||||
- Signer sends sign requests to page
|
||||
- Page calls window.nostr.signEvent()
|
||||
- Response sent back to worker
|
||||
- NDK completes signing and publishing
|
||||
|
||||
7. Relay status visualization
|
||||
- Footer left section shows connected relays
|
||||
- Each relay has animated icon (HamburgerMorphing)
|
||||
- Icons morph based on activity (read/write)
|
||||
- Temporary animations show real-time activity
|
||||
================================================================ */
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* blob-sanitize.mjs — Privacy-metadata stripping for browser uploads.
|
||||
*
|
||||
* The client side of the metadata-stripping pipeline. Strips EXIF/XMP/IPTC/
|
||||
* PNG text chunks/PDF author tags from files before they leave the device,
|
||||
* so the SHA-256 the client signs (and publishes in Nostr events) is the hash
|
||||
* of the *cleaned* bytes. This preserves BUD-01 content addressing: the
|
||||
* server stores and serves exactly the bytes the client signed.
|
||||
*
|
||||
* Exports:
|
||||
* stripBlobMetadata(file, opts) → Promise<File> — cleaned or pass-through
|
||||
* hasImageMetadata(file) → Promise<boolean> — lightweight check
|
||||
*
|
||||
* See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
|
||||
*/
|
||||
|
||||
// ─── Image path (JPEG / PNG / WebP) ──────────────────────────────────────
|
||||
// Canvas re-encode: the standard browser approach. Drops EXIF segments,
|
||||
// XMP packets, IPTC, PNG tEXt/iTXt/eXIf chunks, and MakerNotes because the
|
||||
// canvas encoder only emits pixels. EXIF orientation is baked into the
|
||||
// pixels first via createImageBitmap({ imageOrientation: 'from-image' }).
|
||||
|
||||
async function stripImage(file, opts = {}) {
|
||||
const quality = opts.quality ?? 0.92;
|
||||
// imageOrientation: 'from-image' reads EXIF rotation and applies it to the
|
||||
// bitmap so the re-encoded pixels are upright even after the EXIF segment
|
||||
// is gone. Supported in modern Chromium/Firefox/Safari.
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
|
||||
// Preserve PNG transparency; everything else → JPEG.
|
||||
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('canvas.toBlob failed'))),
|
||||
type, type === 'image/jpeg' ? quality : undefined);
|
||||
});
|
||||
|
||||
// Preserve the original extension where possible.
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'blob';
|
||||
const ext = type === 'image/png' ? '.png' : '.jpg';
|
||||
const name = baseName + ext;
|
||||
return new File([blob], name, { type, lastModified: file.lastModified });
|
||||
}
|
||||
|
||||
// ─── PDF path (byte-level stripper) ───────────────────────────────────────
|
||||
// Nulls the values of /Author, /Title, /Subject, /Keywords, /Creator,
|
||||
// /Producer, /CreationDate, /ModDate and removes XMP /Metadata stream
|
||||
// objects. Operates on the raw bytes — no pdf-lib dependency. This handles
|
||||
// the common cases; if real-world PDFs break it, we can adopt pdf-lib later.
|
||||
//
|
||||
// Strategy: for each forbidden key, find "/Key (value)" or "/Key <value>"
|
||||
// or "/Key value" patterns and blank the value to empty. For /Metadata and
|
||||
// /XMP stream objects, we can't safely remove them without rebuilding the
|
||||
// xref, so we flag them and the server reject path catches any we miss.
|
||||
|
||||
async function stripPdf(file) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let str = new TextDecoder('latin1').decode(bytes); // PDF is byte-oriented
|
||||
|
||||
// Null out dictionary entries for identity/author fields.
|
||||
// Match /Key followed by a value: (paren string), <hex string>, <dict>,
|
||||
// or a bare token. Replace the value with () or null.
|
||||
const keysToBlank = [
|
||||
'/Author', '/Title', '/Subject', '/Keywords', '/Creator',
|
||||
'/Producer', '/CreationDate', '/ModDate',
|
||||
];
|
||||
for (const key of keysToBlank) {
|
||||
// /Key ( ... ) — parenthesized text string (handle nested parens minimally)
|
||||
str = str.replace(
|
||||
new RegExp(key + '\\s*\\((?:[^()\\\\]|\\\\.)*\\)', 'g'),
|
||||
key + '()'
|
||||
);
|
||||
// /Key < ... > — hex string
|
||||
str = str.replace(
|
||||
new RegExp(key + '\\s*<[0-9A-Fa-f\\s]*>', 'g'),
|
||||
key + '<>'
|
||||
);
|
||||
}
|
||||
|
||||
// For /Metadata and /XMP: we can't easily strip the stream object without
|
||||
// rebuilding cross-references. Instead, blank the /Metadata reference in
|
||||
// the catalog so readers don't follow it. The server scanner will still
|
||||
// flag a PDF that has a raw /Metadata or /XMP token, which is the
|
||||
// conservative behavior we want.
|
||||
str = str.replace(/\/Metadata\s+\d+\s+0\s+R/g, '/Metadata null');
|
||||
|
||||
const out = new TextEncoder().encode(str);
|
||||
// Only create a new File if we actually changed bytes.
|
||||
if (out.length === bytes.length && out.every((b, i) => b === bytes[i])) {
|
||||
return file; // unchanged
|
||||
}
|
||||
return new File([out], file.name, { type: 'application/pdf', lastModified: file.lastModified });
|
||||
}
|
||||
|
||||
// ─── Format dispatch ─────────────────────────────────────────────────────
|
||||
|
||||
const IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp']);
|
||||
|
||||
/**
|
||||
* Strip privacy-sensitive metadata from a file.
|
||||
*
|
||||
* @param {File} file — the original file
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.quality=0.92] — JPEG re-encode quality (0–1)
|
||||
* @param {boolean} [opts.force=false] — if true, re-encode even types that
|
||||
* would normally pass through (used by the 415 retry path)
|
||||
* @returns {Promise<File>} — a new File with metadata removed, or the
|
||||
* original File for pass-through types (audio, video, GIF, unknown).
|
||||
*/
|
||||
export async function stripBlobMetadata(file, opts = {}) {
|
||||
if (!file || !(file instanceof File)) return file;
|
||||
|
||||
const type = (file.type || '').toLowerCase();
|
||||
|
||||
// Images: canvas re-encode.
|
||||
if (IMAGE_TYPES.has(type)) {
|
||||
try {
|
||||
return await stripImage(file, opts);
|
||||
} catch (err) {
|
||||
console.warn('[blob-sanitize] image strip failed, passing through:', err?.message || err);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
// PDF: byte-level stripper.
|
||||
if (type === 'application/pdf') {
|
||||
try {
|
||||
return await stripPdf(file);
|
||||
} catch (err) {
|
||||
console.warn('[blob-sanitize] PDF strip failed, passing through:', err?.message || err);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
// Audio, video, GIF, and unknown formats: pass through unchanged.
|
||||
// The server detect-and-reject backstop flags anything with metadata.
|
||||
// (Animated GIF/WebP would be flattened by canvas; audio/video need
|
||||
// ffmpeg.wasm which is deferred to Phase 2.)
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight check: does this image file carry EXIF/XMP metadata?
|
||||
* Used by the server-rejection retry path and optional "warn before upload" UI.
|
||||
*
|
||||
* @param {File} file
|
||||
* @returns {Promise<boolean>} — true if EXIF/XMP is likely present
|
||||
*/
|
||||
export async function hasImageMetadata(file) {
|
||||
if (!file || !(file instanceof File)) return false;
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (!IMAGE_TYPES.has(type)) return false;
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
// JPEG: look for APP1 Exif or XMP markers.
|
||||
if (type === 'image/jpeg' || type === 'image/jpg') {
|
||||
if (bytes.length < 4) return false;
|
||||
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
||||
let i = 2;
|
||||
while (i + 4 < bytes.length) {
|
||||
if (bytes[i] !== 0xff) break;
|
||||
const marker = bytes[i + 1];
|
||||
if (marker === 0xda) break; // SOS — image data
|
||||
if (marker === 0xe1) {
|
||||
const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
|
||||
if (i + 4 + 6 <= bytes.length) {
|
||||
const head = bytes.slice(i + 4, i + 4 + 6);
|
||||
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
|
||||
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) {
|
||||
return true; // Exif\0\0
|
||||
}
|
||||
}
|
||||
if (i + 4 + 29 <= bytes.length) {
|
||||
const xmp = new TextDecoder().decode(bytes.slice(i + 4, i + 4 + 29));
|
||||
if (xmp.startsWith('http://ns.adobe.com/xap/1.0/')) return true;
|
||||
}
|
||||
i += 2 + segLen;
|
||||
} else if (marker === 0xed) {
|
||||
return true; // APP13 IPTC/Photoshop
|
||||
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
i += 2;
|
||||
} else {
|
||||
if (i + 2 + 2 > bytes.length) break;
|
||||
const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
|
||||
i += 2 + segLen;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// PNG: look for eXIf or tEXt/iTXt chunks with denylisted keys.
|
||||
if (type === 'image/png') {
|
||||
if (bytes.length < 8) return false;
|
||||
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
for (let k = 0; k < 8; k++) if (bytes[k] !== sig[k]) return false;
|
||||
let i = 8;
|
||||
const denied = ['software', 'comment', 'author', 'description', 'copyright',
|
||||
'xml:com.adobe.xmp', 'raw profile type exif', 'source', 'title'];
|
||||
while (i + 8 < bytes.length) {
|
||||
const len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3];
|
||||
const t = new TextDecoder().decode(bytes.slice(i + 4, i + 8));
|
||||
if (t === 'eXIf') return true;
|
||||
if (t === 'tEXt' || t === 'iTXt' || t === 'zTXt') {
|
||||
let klen = 0;
|
||||
while (klen < len && bytes[i + 8 + klen] !== 0) klen++;
|
||||
const key = new TextDecoder().decode(bytes.slice(i + 8, i + 8 + klen)).toLowerCase();
|
||||
if (denied.some((d) => key.startsWith(d))) return true;
|
||||
}
|
||||
if (t === 'IEND') break;
|
||||
i += 8 + len + 4;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// WebP: look for EXIF or XMP chunks.
|
||||
if (type === 'image/webp') {
|
||||
if (bytes.length < 12) return false;
|
||||
if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) return false;
|
||||
let i = 12;
|
||||
while (i + 8 < bytes.length) {
|
||||
const t = new TextDecoder().decode(bytes.slice(i, i + 4));
|
||||
if (t === 'EXIF' || t === 'XMP ') return true;
|
||||
const clen = bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24);
|
||||
i += 8 + clen + (clen & 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+29
-6
@@ -1,5 +1,6 @@
|
||||
import { getPubkey } from './init-ndk.mjs';
|
||||
import { getBlossomServers } from './blossom-ui.mjs';
|
||||
import { stripBlobMetadata } from './blob-sanitize.mjs';
|
||||
|
||||
const normalizeUrl = (url = '') => url.trim().replace(/\/$/, '');
|
||||
|
||||
@@ -73,14 +74,21 @@ export const listUserBlobs = async (serverUrl, pubkey, since = 0) => {
|
||||
throw new Error(`Failed to list blobs: ${response.status}`);
|
||||
};
|
||||
|
||||
export const uploadToServer = async (file, serverUrl) => {
|
||||
export const uploadToServer = async (file, serverUrl, opts = {}) => {
|
||||
const cleanUrl = normalizeUrl(serverUrl);
|
||||
const currentPubkey = await getPubkey();
|
||||
if (!currentPubkey) {
|
||||
throw new Error('Please login with your Nostr keys first');
|
||||
}
|
||||
|
||||
const sha256 = await calculateSHA256(file);
|
||||
// Privacy-metadata stripping (Phase 1). Strip before hashing so the
|
||||
// client-signed SHA-256 commits to the *cleaned* bytes — this preserves
|
||||
// BUD-01 content addressing (the server stores exactly what we signed).
|
||||
// skipSanitize: true is the opt-out for verbatim/mirror uploads where the
|
||||
// bytes are already content-addressed on another blossom server.
|
||||
const cleanFile = opts.skipSanitize ? file : await stripBlobMetadata(file);
|
||||
|
||||
const sha256 = await calculateSHA256(cleanFile);
|
||||
|
||||
const event = {
|
||||
kind: 24242,
|
||||
@@ -88,10 +96,10 @@ export const uploadToServer = async (file, serverUrl) => {
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', sha256],
|
||||
['size', String(file.size)],
|
||||
['size', String(cleanFile.size)],
|
||||
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
|
||||
],
|
||||
content: `Upload ${file.name}`,
|
||||
content: `Upload ${cleanFile.name}`,
|
||||
pubkey: currentPubkey
|
||||
};
|
||||
|
||||
@@ -105,13 +113,28 @@ export const uploadToServer = async (file, serverUrl) => {
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'Content-Type': cleanFile.type || 'application/octet-stream',
|
||||
Authorization: `Nostr ${authToken}`
|
||||
},
|
||||
body: file
|
||||
body: cleanFile
|
||||
});
|
||||
|
||||
// Server-side metadata backstop: if the server still detected forbidden
|
||||
// metadata, retry once with a forced stricter re-encode before giving up.
|
||||
if (response.status === 415 && !opts.skipSanitize && !opts._retried) {
|
||||
const reason = response.headers.get('X-Reason') || '';
|
||||
if (reason.includes('exif_detected')) {
|
||||
console.warn('[blossom-api] Server rejected upload (metadata detected), retrying with forced re-encode:', reason);
|
||||
const forced = await stripBlobMetadata(cleanFile, { force: true, quality: 0.85 });
|
||||
return uploadToServer(forced, serverUrl, { ...opts, _retried: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = response.headers.get('X-Reason') || '';
|
||||
if (reason.includes('exif_detected')) {
|
||||
throw new Error(`Upload rejected: privacy metadata could not be removed (${reason})`);
|
||||
}
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ export function mountComposer(hostEl, options = {}) {
|
||||
function updateUploadingUi() {
|
||||
if (uploadingCount > 0) {
|
||||
uploadStatus.style.display = 'block';
|
||||
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
|
||||
uploadStatus.textContent = `Stripping metadata & uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,13 +481,28 @@ export function mountComposer(hostEl, options = {}) {
|
||||
}
|
||||
|
||||
for (const file of arr) {
|
||||
const { sha256 } = await uploadToAllServers(file);
|
||||
const ext = getFileExtension(file);
|
||||
const url = getBlobUrl(sha256, ext);
|
||||
try {
|
||||
const { sha256 } = await uploadToAllServers(file);
|
||||
const ext = getFileExtension(file);
|
||||
const url = getBlobUrl(sha256, ext);
|
||||
|
||||
hostEl.focus();
|
||||
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
|
||||
document.execCommand('insertText', false, `${prefix}${url}\n`);
|
||||
hostEl.focus();
|
||||
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
|
||||
document.execCommand('insertText', false, `${prefix}${url}\n`);
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err);
|
||||
if (msg.includes('privacy metadata')) {
|
||||
uploadStatus.style.display = 'block';
|
||||
uploadStatus.textContent = `⚠ ${file.name}: ${msg}`;
|
||||
uploadStatus.style.color = '#f85149';
|
||||
console.error('[post-composer] metadata rejection:', msg);
|
||||
} else {
|
||||
console.error('[post-composer] upload failed:', msg);
|
||||
uploadStatus.style.display = 'block';
|
||||
uploadStatus.textContent = `⚠ ${file.name}: upload failed`;
|
||||
uploadStatus.style.color = '#f85149';
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uploadingCount = Math.max(0, uploadingCount - arr.length);
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
/**
|
||||
* Post-Quantum Crypto Module for Nostr
|
||||
*
|
||||
* Provides:
|
||||
* - BIP39 seed phrase generation
|
||||
* - NIP-06 key derivation (secp256k1 from seed)
|
||||
* - PQ key derivation from seed (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
* - PQ signing (ML-DSA, SLH-DSA)
|
||||
* - NIP-QR event construction
|
||||
*
|
||||
* Uses @noble/post-quantum (pure JS, no WASM needed)
|
||||
*/
|
||||
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { hkdf } from '@noble/hashes/hkdf.js';
|
||||
import { sha256 as sha256Hash, sha512 as sha512Hash } from '@noble/hashes/sha2.js';
|
||||
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
|
||||
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
|
||||
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
|
||||
|
||||
// ============================================================================
|
||||
// BIP39 SEED PHRASE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new 12-word BIP39 mnemonic.
|
||||
* @returns {string} 12-word seed phrase
|
||||
*/
|
||||
export function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mnemonic to a 64-byte BIP39 seed (PBKDF2-HMAC-SHA512).
|
||||
* @param {string} mnemonic - 12/24 word seed phrase
|
||||
* @param {string} [passphrase=''] - optional BIP39 passphrase
|
||||
* @returns {Uint8Array} 64-byte seed
|
||||
*/
|
||||
export function mnemonicToSeed(mnemonic, passphrase = '') {
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
throw new Error('Invalid mnemonic');
|
||||
}
|
||||
return mnemonicToSeedSync(mnemonic, passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a BIP39 mnemonic.
|
||||
* @param {string} mnemonic
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidMnemonic(mnemonic) {
|
||||
return validateMnemonic(mnemonic, wordlist);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-06 KEY DERIVATION (secp256k1 from seed)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derive a secp256k1 keypair from a BIP39 seed using NIP-06.
|
||||
* Path: m/44'/1237'/0'/0/0
|
||||
*
|
||||
* @param {Uint8Array} seed - 64-byte BIP39 seed
|
||||
* @param {number} [accountIndex=0] - account index
|
||||
* @returns {{privateKey: Uint8Array, publicKey: Uint8Array}} secp256k1 keypair
|
||||
*/
|
||||
export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
|
||||
const hdKey = HDKey.fromMasterSeed(seed);
|
||||
const path = `m/44'/1237'/${accountIndex}'/0/0`;
|
||||
const child = hdKey.derive(path);
|
||||
if (!child.privateKey) {
|
||||
throw new Error('Failed to derive private key');
|
||||
}
|
||||
return {
|
||||
privateKey: child.privateKey,
|
||||
publicKey: child.publicKey
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PQ KEY DERIVATION FROM SEED
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derive PQ key seeds from a BIP39 seed using HKDF.
|
||||
* Each algorithm gets a unique label so keys are independent.
|
||||
*
|
||||
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
|
||||
* @param {string} label - algorithm label (e.g. 'nostr-pq-ml-dsa-65')
|
||||
* @param {number} length - output length in bytes
|
||||
* @returns {Uint8Array} deterministic seed for PQ keygen
|
||||
*/
|
||||
function derivePQSeed(bip39Seed, label, length) {
|
||||
const info = new TextEncoder().encode(label);
|
||||
return hkdf(sha512Hash, bip39Seed, undefined, info, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive all PQ keypairs from a BIP39 seed.
|
||||
*
|
||||
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
|
||||
* @returns {{
|
||||
* mlDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* slhDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
|
||||
* }}
|
||||
*/
|
||||
export function derivePQKeysFromSeed(bip39Seed) {
|
||||
// ML-DSA-65 needs 32-byte seed
|
||||
const mlDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-dsa-65', 32);
|
||||
const mlDsa = ml_dsa65.keygen(mlDsaSeed);
|
||||
|
||||
// SLH-DSA-128s needs 48-byte seed (3 * 16 for sk seed, pk seed, etc.)
|
||||
const slhDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-slh-dsa-128s', 48);
|
||||
const slhDsa = slh_dsa_sha2_128s.keygen(slhDsaSeed);
|
||||
|
||||
// ML-KEM-768 needs 64-byte seed
|
||||
const mlKemSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-kem-768', 64);
|
||||
const mlKem = ml_kem768.keygen(mlKemSeed);
|
||||
|
||||
return { mlDsa, slhDsa, mlKem };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PQ SIGNING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sign a message with ML-DSA-65.
|
||||
* @param {Uint8Array} message
|
||||
* @param {Uint8Array} secretKey
|
||||
* @returns {Uint8Array} signature
|
||||
*/
|
||||
export function signWithMLDSA(message, secretKey) {
|
||||
return ml_dsa65.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ML-DSA-65 signature.
|
||||
* @param {Uint8Array} signature
|
||||
* @param {Uint8Array} message
|
||||
* @param {Uint8Array} publicKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyMLDSA(signature, message, publicKey) {
|
||||
return ml_dsa65.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with SLH-DSA-128s.
|
||||
* @param {Uint8Array} message
|
||||
* @param {Uint8Array} secretKey
|
||||
* @returns {Uint8Array} signature
|
||||
*/
|
||||
export function signWithSLHDSA(message, secretKey) {
|
||||
return slh_dsa_sha2_128s.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an SLH-DSA-128s signature.
|
||||
* @param {Uint8Array} signature
|
||||
* @param {Uint8Array} message
|
||||
* @param {Uint8Array} publicKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifySLHDSA(signature, message, publicKey) {
|
||||
return slh_dsa_sha2_128s.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string.
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Uint8Array.
|
||||
* @param {string} base64
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function base64ToBytes(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to hex string.
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bytesToHex(bytes) {
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array.
|
||||
* @param {string} hex
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-QR EVENT CONSTRUCTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the NIP-QR event content (the JSON that goes in the event's content field).
|
||||
*
|
||||
* The content contains:
|
||||
* - A link statement
|
||||
* - All PQ public keys
|
||||
* - PQ signatures over the statement
|
||||
* - The ML-KEM public key (no signature — KEM can't sign)
|
||||
*
|
||||
* @param {string} npub - The user's Nostr npub (hex pubkey)
|
||||
* @param {string} successorNpub - The successor's hex pubkey (for Path B), or null for Path A
|
||||
* @param {{mlDsa: *, slhDsa: *, mlKem: *}} pqKeys - PQ keypairs
|
||||
* @returns {{statement: string, content: object, statementBytes: Uint8Array}}
|
||||
*/
|
||||
export function buildNIPQRContent(npub, successorNpub, pqKeys) {
|
||||
let statement;
|
||||
if (successorNpub) {
|
||||
// Path B: migration from old nsec to seed-derived key
|
||||
statement = `Identity ${npub} is migrating to successor ${successorNpub}. All PQ keys listed below are derived from the same BIP39 seed as ${successorNpub}. This link is established pre-quantum.`;
|
||||
} else {
|
||||
// Path A: direct link (identity already seed-derived)
|
||||
statement = `Identity ${npub} is linked to the following PQ keys, all derived from the same BIP39 seed. This link is established pre-quantum.`;
|
||||
}
|
||||
|
||||
const statementBytes = new TextEncoder().encode(statement);
|
||||
|
||||
// Sign the statement with each PQ signature scheme
|
||||
const mlDsaSig = signWithMLDSA(statementBytes, pqKeys.mlDsa.secretKey);
|
||||
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
|
||||
|
||||
const content = {
|
||||
statement,
|
||||
pq_keys: [
|
||||
{
|
||||
algorithm: 'ml-dsa-65',
|
||||
public_key: bytesToBase64(pqKeys.mlDsa.publicKey),
|
||||
signature: bytesToBase64(mlDsaSig)
|
||||
},
|
||||
{
|
||||
algorithm: 'slh-dsa-128s',
|
||||
public_key: bytesToBase64(pqKeys.slhDsa.publicKey),
|
||||
signature: bytesToBase64(slhDsaSig)
|
||||
},
|
||||
{
|
||||
algorithm: 'ml-kem-768',
|
||||
public_key: bytesToBase64(pqKeys.mlKem.publicKey),
|
||||
note: 'KEM key for encryption; ownership asserted by secp256k1 signature over this content'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// If Path B, include successor info
|
||||
if (successorNpub) {
|
||||
content.successor_pubkey = successorNpub;
|
||||
}
|
||||
|
||||
return { statement, content, statementBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-QR event's PQ signatures.
|
||||
* @param {object} content - The parsed content object
|
||||
* @returns {{valid: boolean, results: Array}} verification results
|
||||
*/
|
||||
export function verifyNIPQRContent(content) {
|
||||
const results = [];
|
||||
|
||||
for (const keyEntry of content.pq_keys) {
|
||||
if (keyEntry.algorithm === 'ml-kem-768') {
|
||||
// KEM can't sign — skip verification
|
||||
results.push({ algorithm: keyEntry.algorithm, valid: true, note: 'KEM (no signature to verify)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubKey = base64ToBytes(keyEntry.public_key);
|
||||
const sig = base64ToBytes(keyEntry.signature);
|
||||
const msg = new TextEncoder().encode(content.statement);
|
||||
|
||||
let valid = false;
|
||||
if (keyEntry.algorithm === 'ml-dsa-65') {
|
||||
valid = verifyMLDSA(sig, msg, pubKey);
|
||||
} else if (keyEntry.algorithm === 'slh-dsa-128s') {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
}
|
||||
|
||||
results.push({ algorithm: keyEntry.algorithm, valid });
|
||||
}
|
||||
|
||||
return {
|
||||
valid: results.every(r => r.valid),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KEY SIZE INFO (for display)
|
||||
// ============================================================================
|
||||
|
||||
export const PQ_KEY_INFO = {
|
||||
'ml-dsa-65': {
|
||||
name: 'ML-DSA-65 (Dilithium)',
|
||||
publicKeySize: 1952,
|
||||
signatureSize: 3309,
|
||||
fips: 'FIPS 204',
|
||||
type: 'signature'
|
||||
},
|
||||
'slh-dsa-128s': {
|
||||
name: 'SLH-DSA-128s (SPHINCS+)',
|
||||
publicKeySize: 32,
|
||||
signatureSize: 7856,
|
||||
fips: 'FIPS 205',
|
||||
type: 'signature'
|
||||
},
|
||||
'ml-kem-768': {
|
||||
name: 'ML-KEM-768 (Kyber)',
|
||||
publicKeySize: 1184,
|
||||
ciphertextSize: 1088,
|
||||
fips: 'FIPS 203',
|
||||
type: 'kem'
|
||||
}
|
||||
};
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.94",
|
||||
"VERSION_NUMBER": "0.7.94",
|
||||
"BUILD_DATE": "2026-07-12T14:10:59.968Z"
|
||||
"VERSION": "v0.7.96",
|
||||
"VERSION_NUMBER": "0.7.96",
|
||||
"BUILD_DATE": "2026-08-03T22:46:27.122Z"
|
||||
}
|
||||
|
||||
+89386
-4
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -441,6 +441,22 @@
|
||||
<div id="outNip44Decrypted" class="outDivTall clsClipboard"> </div>
|
||||
<div id="btnDecodeNip44" class="divButton btn">Decrypt</div>
|
||||
</div>
|
||||
|
||||
<!-- HMAC-SHA256 D TAG (deterministic opaque NIP-33 d tag from privkey + path) -->
|
||||
<div class="divTool">
|
||||
<div class="divSideTitle">HMAC-SHA256 D TAG</div>
|
||||
<div class="toolLabel">nsec/hex (privkey):</div>
|
||||
<div id="inpHmacDTagPrivkey" class="inDiv" contenteditable="true"></div>
|
||||
<div class="toolLabel">Path (e.g. Work/Projects/Secret):</div>
|
||||
<div id="inpHmacDTagPath" class="inDiv" contenteditable="true"></div>
|
||||
<div class="toolLabel">Key derivation label (optional, defaults to sovereign-browser/bookmarks-folder-id-v1):</div>
|
||||
<div id="inpHmacDTagLabel" class="inDiv" contenteditable="true"></div>
|
||||
<div class="toolLabel">HMAC key (hex, derived from privkey + label):</div>
|
||||
<div id="outHmacDTagKey" class="outDiv clsClipboard"> </div>
|
||||
<div class="toolLabel">d tag (HMAC-SHA256(hmac_key, path), 64 hex chars):</div>
|
||||
<div id="outHmacDTag" class="outDiv clsClipboard"> </div>
|
||||
<div id="btnComputeHmacDTag" class="divButton btn">Compute</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -1069,6 +1085,71 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
};
|
||||
|
||||
// HMAC-SHA256 deterministic opaque d tag (for NIP-33 parameterized replaceable
|
||||
// events where the d tag must be (a) opaque to observers and (b) identical
|
||||
// across re-publishes so relays replace the prior event. Encryption (NIP-04 /
|
||||
// NIP-44) cannot be used because both use a random IV/nonce per call, which
|
||||
// would break replaceability. A MAC is deterministic by construction.
|
||||
//
|
||||
// hmac_key = HMAC-SHA256(privkey_bytes, label_utf8)
|
||||
// d = HMAC-SHA256(hmac_key, path_utf8) → 64 hex chars
|
||||
//
|
||||
// The same privkey + label + path always yields the same d tag, so relays
|
||||
// replace the prior event. Observers see only an opaque hash and learn
|
||||
// nothing about the path. Used by sovereign_browser for nested bookmark
|
||||
// folders (NIP-51 kind 30003) where the real path lives inside the
|
||||
// NIP-44 encrypted content.
|
||||
const ComputeHmacDTag = async () => {
|
||||
try {
|
||||
const privkeyInput = document.getElementById(`inpHmacDTagPrivkey`).innerText.trim();
|
||||
const path = document.getElementById(`inpHmacDTagPath`).innerText;
|
||||
let label = document.getElementById(`inpHmacDTagLabel`).innerText.trim();
|
||||
if (!label) label = 'sovereign-browser/bookmarks-folder-id-v1';
|
||||
|
||||
if (!privkeyInput) {
|
||||
document.getElementById(`outHmacDTagKey`).innerText = 'Error: privkey is required';
|
||||
document.getElementById(`outHmacDTag`).innerText = '';
|
||||
return;
|
||||
}
|
||||
if (!path) {
|
||||
document.getElementById(`outHmacDTagKey`).innerText = '';
|
||||
document.getElementById(`outHmacDTag`).innerText = 'Error: path is required';
|
||||
return;
|
||||
}
|
||||
|
||||
const privkeyBytes = normalizeKey(privkeyInput, true);
|
||||
|
||||
// hmac_key = HMAC-SHA256(privkey, label)
|
||||
const labelBytes = new TextEncoder().encode(label);
|
||||
const hmacKeyBuf = await crypto.subtle.importKey(
|
||||
'raw', privkeyBytes,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false, ['sign']
|
||||
);
|
||||
const hmacKeySig = await crypto.subtle.sign('HMAC', hmacKeyBuf, labelBytes);
|
||||
const hmacKeyHex = Array.from(new Uint8Array(hmacKeySig))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
document.getElementById(`outHmacDTagKey`).innerText = hmacKeyHex;
|
||||
|
||||
// d = HMAC-SHA256(hmac_key, path)
|
||||
const hmacKeyBytes = new Uint8Array(hmacKeySig);
|
||||
const pathBytes = new TextEncoder().encode(path);
|
||||
const dKeyBuf = await crypto.subtle.importKey(
|
||||
'raw', hmacKeyBytes,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false, ['sign']
|
||||
);
|
||||
const dSig = await crypto.subtle.sign('HMAC', dKeyBuf, pathBytes);
|
||||
const dHex = Array.from(new Uint8Array(dSig))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
document.getElementById(`outHmacDTag`).innerText = dHex;
|
||||
} catch (error) {
|
||||
console.error('HMAC d-tag error:', error);
|
||||
document.getElementById(`outHmacDTagKey`).innerText = `Error: ${error.message}`;
|
||||
document.getElementById(`outHmacDTag`).innerText = '';
|
||||
}
|
||||
};
|
||||
|
||||
// NSEC to NPUB Conversion
|
||||
const NsecToNpub = async () => {
|
||||
try {
|
||||
@@ -1337,6 +1418,9 @@ const versionInfo = await getVersion();
|
||||
if (btnEncodeNip44) btnEncodeNip44.addEventListener("click", EncodeNip44);
|
||||
if (btnDecodeNip44) btnDecodeNip44.addEventListener("click", DecodeNip44);
|
||||
|
||||
const btnComputeHmacDTag = document.getElementById(`btnComputeHmacDTag`);
|
||||
if (btnComputeHmacDTag) btnComputeHmacDTag.addEventListener("click", ComputeHmacDTag);
|
||||
|
||||
// NSEC to NPUB
|
||||
const inpNsecToNpub = document.getElementById(`inpNsecToNpub`);
|
||||
if (inpNsecToNpub) inpNsecToNpub.addEventListener("input", NsecToNpub);
|
||||
|
||||
Reference in New Issue
Block a user