Compare commits

...
4 Commits
Author SHA1 Message Date
Laan Tungir d4dfd31263 Rewrite kind 3063 fetch to use #i tag filtering via direct WebSocket, bypassing NDK grouping/cache issues 2026-08-04 11:37:02 -04:00
Laan Tungir b0233f8bc3 Fix relay reconnect for write-only relays: trailing-slash-tolerant pool lookup + add write-only relays to pool on demand 2026-08-04 09:32:52 -04:00
Laan Tungir d66d4f58c9 Publish all 20 app stacks and 182 app definitions via n_signer qrexec 2026-08-03 18:46:27 -04:00
Laan Tungir d1f537fcae Phase 1: client-side metadata stripping
Add blob-sanitize.mjs with stripBlobMetadata() (canvas re-encode for
JPEG/PNG/WebP, byte-level PDF /Author stripper, pass-through for audio/video)
and hasImageMetadata() (lightweight EXIF/XMP detection). Wire into
uploadToServer() before calculateSHA256 so the client-signed hash commits
to cleaned bytes (preserves BUD-01 content addressing). Add skipSanitize
opt-out for mirror/verbatim uploads. On 415 + X-Reason: exif_detected,
retry once with forced stricter re-encode. Update post-composer status
text and surface metadata-rejection errors.

See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
2026-07-31 07:25:56 -04:00
20 changed files with 111302 additions and 50 deletions
+513
View File
@@ -0,0 +1,513 @@
# 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_all.py`](publish_all.py) script, which signs via `qrexec` (Qubes OS `nostr_signer`).
## Zapstore Display Requirements
The Zapstore Android app ([source](../zapstore_app_stacks/references/zapstore/)) filters which stacks appear on the home screen. A kind 30267 stack event must satisfy **all three** of these conditions to appear with apps:
### Quick Reference: The Three Rules
1. **`h` tag = community pubkey** — The app filters by `#h` matching `acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84`. Set to `""` to hide a stack from the app.
2. **`content` = empty string** — The app treats any non-empty `content` as encrypted/private data. If `content` is non-empty, the app skips the `a` tags and shows "no apps". Always use `content: ""` for public stacks.
3. **`a` tags reference real kind 32267 events** — Each `a` tag must be `32267:<real-developer-pubkey>:<package-id>` pointing to an actual kind 32267 event on the relay. Apps without kind 32267 events won't appear in the stack.
### Complete Example
```json
{
"kind": 30267,
"content": "",
"tags": [
["d", "privacy-approved-health-fitness"],
["t", "app-stack"],
["name", "Privacy Approved — Health & Fitness"],
["description", "Health tracking, diet, and fitness apps..."],
["f", "android-arm64-v8a"],
["h", "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"],
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:org.nutritionfacts.dailydozen", "wss://relay.zapstore.dev"],
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.antoniegil.astronia", "wss://relay.zapstore.dev"]
]
}
```
### Required Tags
| Tag | Value | Purpose |
|-----|-------|---------|
| `h` | `acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84` | **Zapstore community pubkey** — the app filters by `#h` matching this value. Set to empty string to hide the stack from the zapstore app. |
| `f` | `android-arm64-v8a` | Platform filter — the app filters by `#f` matching the device platform |
| `d` | e.g. `privacy-approved-nostr-clients` | Unique identifier for the stack |
| `name` | e.g. `Privacy Approved — Nostr Clients` | Display name |
| `description` | Human-readable description | Shown in stack details |
| `a` | `32267:<pubkey>:<identifier>` | References to kind 32267 app events — **must point to real events on the relay** |
### The `h` Tag (Community Pubkey)
The Zapstore app's [`app_stacks_screen.dart`](../zapstore_app_stacks/references/zapstore/lib/screens/app_stacks_screen.dart) queries stacks with:
```dart
Map<String, Set<String>> get _tags => {
'#f': {platform},
'#h': {kZapstoreCommunityPubkey},
};
```
Where `kZapstoreCommunityPubkey` is defined in [`app_constants.dart`](../zapstore_app_stacks/references/zapstore/lib/constants/app_constants.dart) as:
```
acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84
```
(npub: `npub14nl2afh9zsswsp5043zxe2w304afaa496gxe8z2w2rlw84ys92zqlnjx5u`)
**If the `h` tag is empty or doesn't match this value, the stack will NOT appear in the Zapstore app.**
### The `content` Field (Must Be Empty)
The Zapstore app checks `stack.content.isNotEmpty` to determine if a stack is **encrypted/private**. If the content is non-empty, the app attempts to decrypt it and parse private app IDs from it. If decryption fails, it shows "no apps" or "could not be decrypted" instead of reading the `a` tags.
**The `content` field must be an empty string `""` for public stacks.** The stack name and description are read from the `name` and `description` tags, not from the content field.
### The `a` Tags (App References)
The `a` tags in stacks reference kind 32267 (Software Application) events. The Zapstore app resolves these references to display app cards within each stack. The `a` tag format is:
```
32267:<app-developer-pubkey>:<android-package-identifier>
```
The pubkey must be the **actual app developer's pubkey** who published the kind 32267 event — not the stack publisher's pubkey. If the referenced kind 32267 event doesn't exist on the relay, the app won't show that app in the stack.
A lookup file of app identifiers to real developer pubkeys is maintained at [`app_pubkeys.json`](app_pubkeys.json), fetched from `wss://relay.zapstore.dev`.
### Stack Comments (Kind 1111)
The Zapstore app displays comments under each stack. Comments are kind 1111 events (not kind 1 text notes) with an `A` tag referencing the stack's addressable ID.
The zapstore app queries comments in [`comments_section.dart`](../zapstore_app_stacks/references/zapstore/lib/widgets/comments_section.dart) with:
```dart
query<Comment>(
tags: {
'#A': {stack.id}, // stack.id = "30267:<pubkey>:<d-tag>"
},
source: LocalAndRemoteSource(stream: true, relays: 'AppCatalog'),
)
```
#### Comment Event Structure
```json
{
"kind": 1111,
"content": "LLM Privacy Review: Both apps have been scanned...",
"tags": [
["A", "30267:<stack-pubkey>:<stack-d-tag>", "wss://relay.zapstore.dev"],
["K", "30267"]
],
"created_at": 1785840000,
"pubkey": "<commenter-pubkey>"
}
```
| Tag | Value | Purpose |
|-----|-------|---------|
| `A` | `30267:<pubkey>:<d-tag>` | Addressable reference to the stack event (NIP-22 root) |
| `K` | `30267` | Root kind (the kind of the referenced event) |
#### Important: Kind 1 is Rejected
`relay.zapstore.dev` does **not** accept kind 1 (text note) events. Comments **must** use kind 1111. The relay's allowed kinds are: `[0 5 11 1111 1984 9735 10222 3063 30063 30509 30267 30078 32267]`.
#### Use Case: LLM Review Comments
Comments can be used to attach LLM privacy review summaries to stacks. The comment content is free text (supports markdown via the app's note parser). This allows posting review results like:
> "LLM Privacy Review: Both apps in this stack have been scanned for tracking SDKs, excessive permissions, and privacy concerns. No issues found — both are approved at privacy level 1."
### Relay Acceptance Rules
| Relay | Kind 30267 (Stacks) | Kind 30078 (App Definitions) | Kind 32267 (App Listings) | Kind 1111 (Comments) |
|-------|---------------------|------------------------------|---------------------------|----------------------|
| `wss://relay.zapstore.dev` | ✅ Accepts (requires `a` tags use kind 32267) | ❌ Rejects `d: app-*` (only allows `zapstore-device-*`) | ✅ Accepts | ✅ Accepts |
| `wss://nos.lol` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
| `wss://relay.primal.net` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
| `wss://laantungir.net/relay` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
## 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 |
| **1111** | Comment | Purplebase comment kind (NIP-22). Used by the Zapstore app for stack and app comments. References the root event via `A` tag. **Not** kind 1 (text note) — `relay.zapstore.dev` rejects kind 1. | Used for stack comments |
| **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.
## How Kinds Interact on Zapstore
The diagram below shows how the different Nostr event kinds relate to each other
in the zapstore ecosystem — from app discovery to installation to review.
```
ZAPSTORE APP (Android)
═════════════════════
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ DISCOVER │ │ INSTALL │ │ UPDATE │
│ (browse) │ │ (download│ │ (notify) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ │ │
┌─────▼──────────────▼──────────────▼─────┐
│ NOSTR RELAY │
│ (relay.zapstore.dev) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ KIND 30267 (App Stack/Curation) │ │
│ │ ───────────────────────────────── │ │
│ │ • d: privacy-approved-nostr-clients│ │
│ │ • h: <community pubkey> │ │
│ │ • f: android-arm64-v8a │ │
│ │ • a: 32267:<pubkey>:<app-id> ─────┼──┼──┐
│ │ • content: "" (must be empty!) │ │ │
│ └────────────────────────────────────┘ │ │
│ │ │
│ ┌────────────────────────────────────┐ │ │
│ │ KIND 32267 (Software Application)│◄─┼──┘
│ │ ───────────────────────────────── │ │
│ │ • d: com.vitorpamplona.amethyst │ │
│ │ • name: Amethyst │ │
│ │ • f: android-arm64-v8a │ │
│ │ • Published by APP DEVELOPER │ │
│ └───────────────┬────────────────────┘ │
│ │ │
│ │ (linked via) │
│ │ │
│ ┌───────────────▼────────────────────┐ │
│ │ KIND 30063 (Release Artifact Set)│ │
│ │ ───────────────────────────────── │ │
│ │ • a: 32267:<pubkey>:<app-id> │ │
│ │ • e: <kind 3063 event id> ────────┼──┼──┐
│ │ • Groups assets for a release │ │ │
│ └────────────────────────────────────┘ │ │
│ │ │
│ ┌────────────────────────────────────┐ │ │
│ │ KIND 3063 (Software Asset/APK) │◄─┼──┘
│ │ ───────────────────────────────── │ │
│ │ • i: com.vitorpamplona.amethyst │ │
│ │ • version: 1.13.1 │ │
│ │ • versionCode: 11301 │ │
│ │ • x: <SHA256 hash of APK> │ │
│ │ • url: https://cdn.zapstore.dev/..│ │
│ │ • size: 84849702 │ │
│ │ • f: android-arm64-v8a │ │
│ │ • THIS IS THE INSTALLABLE BINARY │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ KIND 1111 (Comment/Review) │ │
│ │ ───────────────────────────────── │ │
│ │ • A: 30267:<pubkey>:<stack-d-tag> │ │
│ │ • K: 30267 │ │
│ │ • content: "LLM Privacy Review..."│ │
│ │ • Appears under stack in app │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ KIND 30078 (App-Specific Data) │ │
│ │ ───────────────────────────────── │ │
│ │ • t: app-definition │ │
│ │ • d: app-<identifier> │ │
│ │ • Our app catalog metadata │ │
│ │ • (NOT used by zapstore app — │ │
│ │ │ only our frontend uses these) │ │
│ └────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────┘
OUR PRIVACY REVIEW FLOW
═══════════════════════
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ GITEA │ │ AI AGENT│ │ KIND │ │ KIND │
│ MIRROR │────▶│ SCANS │────▶│ 3063 │────▶│ 1111 │
│ │ │ SOURCE │ │ (APK │ │ COMMENT│
│ Source │ │ CODE │ │ HASH) │ │ ON │
│ code │ │ │ │ │ │ STACK │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ If approved,
│ app is included
│ in kind 30267
│ stack
┌───────────┐
│ KIND 30267│
│ (Stack) │
│ a: 32267 │
└───────────┘
KEY INSIGHT: An app must have BOTH kind 32267 AND kind 3063
events on the relay to be installable on zapstore. Without
kind 3063, there is no APK to download, no version to display,
and nothing for AI agents to verify.
```
## 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.
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
# 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
## Corrections Discovered During Implementation
### 1. Stack `a` Tags Must Use Kind 32267 (Not 30078)
The relay `wss://relay.zapstore.dev` **validates** that `a` tags in kind 30267 events reference kind **32267** (Software Application). Using kind 30078 results in `invalid app identifier: expected kind 32267, got 30078`.
The `a` tags in stacks reference the **original app listing events** (kind 32267) published by app developers, not the app-definition events (kind 30078) that the frontend loads. The frontend independently subscribes to kind 30078 `#t: app-definition` and groups them by the `#t` category tag matching the stack's `d` tag — it does not follow the `a` tags.
**Current state in** [`app-stacks/publish_all.py`](../app-stacks/publish_all.py:431):
```python
["a", f"32267:{pubkey}:{app_id}", "wss://relay.zapstore.dev"]
```
The pubkey is still a placeholder (the signer's own pubkey) since we don't have the actual per-app pubkeys from the original kind 32267 events. This is acceptable because the frontend doesn't use the `a` tags for display — it uses the `#t` category tag on the app-definition events.
### 2. Stack Events Should Have JSON Content
The plan specifies `content: JSON.stringify({ name, description })` but the original `publish_all.py` used `content: ""`. The frontend reads name/description from tags, so this is non-critical, but it's inconsistent with the spec.
**Fixed in** [`app-stacks/publish_all.py`](../app-stacks/publish_all.py:435):
```python
"content": json.dumps({"name": stack["name"], "description": stack["description"]}),
```
### 3. `relay.zapstore.dev` Rejects Kind 30078 with `d: app-*`
The relay `wss://relay.zapstore.dev` has a whitelist for kind 30078 `d` tags — only `zapstore-device-state` and `zapstore-device-key-backup` are accepted. App definitions with `d: app-<identifier>` are rejected with `invalid 'd' tag`.
**Workaround:** App definitions are published to the other 3 relays (`nos.lol`, `primal.net`, `laantungir.net/relay`). The frontend's NDK subscription queries user-configured relays, so it will find them as long as the user has at least one of these relays configured.
### 4. Published Event Counts
| Event Type | Expected | Published | Relays |
|-----------|----------|-----------|--------|
| App Stacks (kind 30267) | 20 | 20 | All 4 relays |
| App Definitions (kind 30078) | 182 | 174 | 3 relays (not zapstore.dev) |
The 8 missing app definitions are duplicates (e.g., `com.flux` appears in both "Productivity & Notes" and "Finance & Budgeting", `com.unciv.app` in both "Utilities & Tools" and "Games"). The script overwrites the previous event with the same `d` tag, so only the last one persists.
### 5. Old Stack Had No `#t: app-stack` Tag
The previously published stack (`privacy-approved-notes`) was missing the `["t", "app-stack"]` tag entirely. All 20 new stacks include it, which is required for the frontend's subscription filter `#t: ['app-stack']` to find them.
+538
View File
@@ -0,0 +1,538 @@
#!/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 os
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",
]
# Zapstore community pubkey — required in the `h` tag for stacks to appear
# in the Zapstore Android app. See app-stacks/README.md for details.
KZAPSTORE_COMMUNITY_PUBKEY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"
# Load real app developer pubkeys (kind 32267 events) so stack `a` tags
# reference events that actually exist on the relay.
# Generated by fetching kind 32267 events from wss://relay.zapstore.dev.
_PUBKEYS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app_pubkeys.json")
try:
with open(_PUBKEYS_PATH) as f:
APP_PUBKEYS = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
APP_PUBKEYS = {}
# ── 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.",
"visible": True, # Only this stack is visible on the Zapstore app (test stack)
"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())
# Only stacks with the community pubkey in the `h` tag appear in the
# Zapstore app. Set "visible": True in the stack dict to make it visible.
h_tag = KZAPSTORE_COMMUNITY_PUBKEY if stack.get("visible") else ""
tags = [
["d", stack["d"]],
["t", "app-stack"],
["name", stack["name"]],
["description", stack["description"]],
["f", "android-arm64-v8a"],
["h", h_tag],
]
# Add a tag for each app (kind 32267 Software Application references)
# relay.zapstore.dev validates that a-tags in kind 30267 events reference kind 32267.
# Use real app developer pubkeys from app_pubkeys.json where available;
# skip apps that don't have a kind 32267 event on the relay.
included = 0
skipped = 0
for app_id, app_name in stack["apps"]:
real_pubkey = APP_PUBKEYS.get(app_id)
if real_pubkey:
tags.append(["a", f"32267:{real_pubkey}:{app_id}", "wss://relay.zapstore.dev"])
included += 1
else:
skipped += 1
if skipped:
print(f" [{i}/{len(STACKS)}] {stack['name']}: {included} apps included, {skipped} skipped (no kind 32267 event)")
event = {
"kind": 30267,
"content": "", # Must be empty — zapstore treats non-empty content as encrypted
"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())
+142
View File
@@ -0,0 +1,142 @@
#!/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"
# Zapstore community pubkey — required in the `h` tag for stacks to appear
# in the Zapstore Android app. See app-stacks/README.md for details.
KZAPSTORE_COMMUNITY_PUBKEY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"
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": "", # Must be empty — zapstore treats non-empty content as encrypted
"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", KZAPSTORE_COMMUNITY_PUBKEY],
["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())
+34
View File
@@ -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"
}
+134
View File
@@ -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"
}
+340
View File
@@ -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.
+90
View File
@@ -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. |
+68
View File
@@ -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.
+268
View File
@@ -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>
-3
View File
@@ -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 ""
+1131
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -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 (01)
* @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
View File
@@ -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}`);
}
+22 -7
View File
@@ -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);
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.95",
"VERSION_NUMBER": "0.7.95",
"BUILD_DATE": "2026-07-31T10:32:03.777Z"
"VERSION": "v0.7.98",
"VERSION_NUMBER": "0.7.98",
"BUILD_DATE": "2026-08-04T15:37:02.089Z"
}
+89543 -31
View File
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