Merge remote-tracking branch 'origin/main' into feat/evolution-missions-to-kind-11125
# Conflicts: # package-lock.json
This commit is contained in:
@@ -1596,7 +1596,7 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
@@ -1604,7 +1604,17 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
|
||||
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
5. **Base64-encode** the key file:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
base64 -w0 service-account.json
|
||||
|
||||
# macOS
|
||||
base64 -i service-account.json | tr -d '\n'
|
||||
```
|
||||
|
||||
6. Add the base64-encoded value as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. Do **not** paste the raw JSON — the CI script expects base64 and will fail to decode a raw value.
|
||||
|
||||
#### Key Points
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [2.7.1] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
|
||||
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
|
||||
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
|
||||
|
||||
### Changed
|
||||
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
|
||||
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
|
||||
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
|
||||
- Android's automatic cloud backup now excludes your wallet credentials
|
||||
|
||||
### Fixed
|
||||
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
|
||||
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
|
||||
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
|
||||
|
||||
## [2.7.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.7.0"
|
||||
versionName "2.7.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android Auto Backup rules (Android 11 and below).
|
||||
|
||||
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
|
||||
any shared_prefs that hold sensitive credentials so they don't end up in
|
||||
Google Drive backups. Keychain/KeyStore entries used by
|
||||
capacitor-secure-storage-plugin are not backed up by default, so we don't
|
||||
need to exclude those explicitly; but we also exclude the plugin's
|
||||
SharedPreferences for defense in depth.
|
||||
|
||||
See: https://developer.android.com/guide/topics/data/autobackup
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
|
||||
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
|
||||
<!-- Capacitor preferences plugin — may contain app-level settings -->
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android 12+ data extraction rules.
|
||||
|
||||
Separate rules apply to cloud backups (Google Drive) and device-to-device
|
||||
transfers. Both exclude WebView storage and sensitive SharedPreferences so
|
||||
wallet credentials, login tokens, and cached private data don't leak.
|
||||
|
||||
See: https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -327,7 +327,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
MARKETING_VERSION = 2.7.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -351,7 +351,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
MARKETING_VERSION = 2.7.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [2.7.1] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
|
||||
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
|
||||
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
|
||||
|
||||
### Changed
|
||||
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
|
||||
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
|
||||
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
|
||||
- Android's automatic cloud backup now excludes your wallet credentials
|
||||
|
||||
### Fixed
|
||||
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
|
||||
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
|
||||
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
|
||||
|
||||
## [2.7.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -105,8 +105,12 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
// Sanitize image URLs to reject non-https schemes (http IP leaks, data: URIs,
|
||||
// etc.). The CSP \`img-src\` already blocks most of these, but sanitizing
|
||||
// defense-in-depth matches the treatment of the website URL below and keeps
|
||||
// the component safe if it is ever rendered outside the app's own CSP.
|
||||
const picture = sanitizeUrl(metadata.picture);
|
||||
const banner = sanitizeUrl(metadata.banner);
|
||||
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
|
||||
@@ -905,6 +905,29 @@ export function DMProvider({ children, config }: DMProviderProps) {
|
||||
const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content);
|
||||
const messageEvent = JSON.parse(messageContent) as NostrEvent;
|
||||
|
||||
// NIP-17: clients MUST verify that the inner rumor's pubkey matches the
|
||||
// seal's pubkey. Without this check, anyone can gift-wrap a rumor whose
|
||||
// `pubkey` field claims to be someone else and impersonate that user.
|
||||
// The seal signature authenticates only the seal author, not whatever
|
||||
// pubkey appears inside the (unsigned) rumor.
|
||||
if (messageEvent.pubkey !== sealEvent.pubkey) {
|
||||
console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, {
|
||||
giftWrapId: event.id,
|
||||
sealPubkey: sealEvent.pubkey,
|
||||
innerPubkey: messageEvent.pubkey,
|
||||
});
|
||||
return {
|
||||
processedMessage: {
|
||||
...event,
|
||||
content: '',
|
||||
decryptedContent: '',
|
||||
error: 'Inner event pubkey does not match seal pubkey (possible impersonation)',
|
||||
},
|
||||
conversationPartner: event.pubkey,
|
||||
sealEvent,
|
||||
};
|
||||
}
|
||||
|
||||
// Accept both kind 14 (text) and kind 15 (files/attachments)
|
||||
if (messageEvent.kind !== 14 && messageEvent.kind !== 15) {
|
||||
console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, {
|
||||
|
||||
+73
-30
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { LandingHero } from '@/components/LandingHero';
|
||||
@@ -19,8 +19,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
|
||||
@@ -355,11 +355,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a saved search feed using useStreamPosts (live streaming). */
|
||||
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
|
||||
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
|
||||
@@ -368,32 +368,62 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
user?.pubkey ?? '',
|
||||
);
|
||||
|
||||
const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : '';
|
||||
const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined;
|
||||
const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined;
|
||||
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
|
||||
// to match the behavior of the core feeds and ensure latest native Nostr
|
||||
// posts are returned.
|
||||
const augmentedFilter = useMemo(() => {
|
||||
if (!resolvedFilter) return null;
|
||||
const existing = resolvedFilter.search ?? '';
|
||||
const search = existing.includes('protocol:nostr')
|
||||
? existing
|
||||
: existing
|
||||
? `${existing} protocol:nostr`
|
||||
: 'protocol:nostr';
|
||||
return { ...resolvedFilter, search };
|
||||
}, [resolvedFilter]);
|
||||
|
||||
const { posts, isLoading: isStreamLoading } = useStreamPosts(search, {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
kindsOverride,
|
||||
authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined,
|
||||
});
|
||||
const {
|
||||
data: rawData,
|
||||
isLoading: isFeedLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
|
||||
|
||||
const isLoading = isResolving || isStreamLoading;
|
||||
const isLoading = isResolving || isFeedLoading;
|
||||
|
||||
// useStreamPosts doesn't use TanStack Query, so refresh by invalidating the
|
||||
// resolution query and letting the stream reconnect via remount.
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] });
|
||||
}, [queryClient]);
|
||||
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
|
||||
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
|
||||
const queryKey = useMemo(
|
||||
() => ['tab-feed', `saved-${feed.id}`],
|
||||
[feed.id],
|
||||
);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
// Simple scroll-based load more isn't available with useStreamPosts (it's a stream),
|
||||
// but we still wire the ref for future pagination support
|
||||
// Infinite scroll: fetch next page when sentinel is in view
|
||||
useEffect(() => {
|
||||
// intentionally empty — useStreamPosts handles its own streaming
|
||||
}, [inView]);
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading && posts.length === 0) {
|
||||
// Flatten pages, deduplicate, and filter muted content
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return rawData.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -403,10 +433,10 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". The search may return results as new content arrives.`} />
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -414,10 +444,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
<div ref={scrollRef} className="py-2" />
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Heart,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { saveNsec } from "@/lib/credentialManager";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -255,7 +257,7 @@ function SetupQuestionnaire({
|
||||
isSignup?: boolean;
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const { updateConfig } = useAppContext();
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const login = useLoginActions();
|
||||
@@ -300,6 +302,18 @@ function SetupQuestionnaire({
|
||||
// Continue handler for the download step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the next step.
|
||||
//
|
||||
// If the user dismisses the iOS credential prompt, `saveNsec` resolves to
|
||||
// `'dismissed'` and we still advance — dismissal is a legitimate choice
|
||||
// (e.g. the user is saving the key in their own password manager).
|
||||
//
|
||||
// On Android, if no credential provider is available (e.g. GrapheneOS or
|
||||
// other de-Googled devices), `saveNsec` falls back to writing the key to
|
||||
// the app's Documents folder and returns `'saved-to-file'`. We surface a
|
||||
// toast so the user knows where to find the backup file.
|
||||
//
|
||||
// Only unexpected errors (decode failure, filesystem write failure)
|
||||
// surface as a destructive toast.
|
||||
const handleDownloadContinue = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
@@ -308,7 +322,15 @@ function SetupQuestionnaire({
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
await saveNsec(npub, nsec);
|
||||
const result = await saveNsec(npub, nsec, config.appName);
|
||||
|
||||
if (result === "saved-to-file") {
|
||||
toast({
|
||||
title: "Secret key saved",
|
||||
description:
|
||||
"Your secret key was saved to the Documents folder on your device.",
|
||||
});
|
||||
}
|
||||
|
||||
login.nsec(nsec);
|
||||
next();
|
||||
@@ -320,7 +342,7 @@ function SetupQuestionnaire({
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [nsec, login, next]);
|
||||
}, [nsec, login, next, config.appName]);
|
||||
|
||||
// Save settings and transition to the follows step (or outro if they have follows)
|
||||
const handleSaveAndContinue = useCallback(async () => {
|
||||
@@ -496,7 +518,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed max-w-xs mx-auto">
|
||||
Your identity on Nostr is a cryptographic key pair. We'll generate one
|
||||
Your identity on Nostr is a cryptographic key. We'll generate one
|
||||
for you now.
|
||||
</p>
|
||||
</div>
|
||||
@@ -506,7 +528,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
className="w-full max-w-xs gap-2 rounded-full h-12"
|
||||
onClick={onGenerate}
|
||||
>
|
||||
Generate my keys
|
||||
Generate my key
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -518,18 +540,34 @@ function DownloadStep({
|
||||
onContinue,
|
||||
}: {
|
||||
nsec: string;
|
||||
onContinue: () => void;
|
||||
onContinue: () => Promise<void> | void;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Wrap the continue handler in an in-flight guard so rapid double-taps
|
||||
// don't trigger multiple credential prompts. `finally` guarantees the
|
||||
// button is re-enabled even if the handler throws, so users can never
|
||||
// get stuck on a disabled button.
|
||||
const handleClick = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onContinue();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
Save your secret key
|
||||
Your secret key
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is your only way to access your account. Keep it somewhere safe.
|
||||
This secret key controls your account on {config.appName}. You'll need it to log in later. Without it, you'll lose your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -538,6 +576,8 @@ function DownloadStep({
|
||||
type={showKey ? "text" : "password"}
|
||||
value={nsec}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
className="pr-10 font-mono text-base md:text-sm"
|
||||
/>
|
||||
<Button
|
||||
@@ -555,23 +595,39 @@ function DownloadStep({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-xs font-semibold text-amber-800 dark:text-amber-200 mb-1">
|
||||
Important
|
||||
</p>
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
This key is your only means of accessing your account. If you lose it,
|
||||
there is no way to recover it.
|
||||
</p>
|
||||
</div>
|
||||
{showKey && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
NEVER share your secret key with anyone. Avoid screenshotting your key or pasting it anywhere except a password manager. If shared, others will be able to access your account.{" "}
|
||||
<a
|
||||
href="https://soapbox.pub/blog/managing-nostr-keys/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openUrl("https://soapbox.pub/blog/managing-nostr-keys/");
|
||||
}}
|
||||
className="underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full gap-2 rounded-full h-12"
|
||||
onClick={onContinue}
|
||||
onClick={handleClick}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Continue
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" /> Save Key
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Shared classes for all editable fields — static muted bg when idle, border on hover/focus */
|
||||
const editableBase = [
|
||||
@@ -129,6 +130,9 @@ export function ProfileCard({
|
||||
const initial = displayName[0]?.toUpperCase() ?? '?';
|
||||
const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v });
|
||||
|
||||
// Sanitize banner URL from untrusted metadata before CSS url() interpolation
|
||||
const bannerUrl = sanitizeUrl(metadata.banner);
|
||||
|
||||
// Read shape from metadata (it's a custom property passed through the loose schema)
|
||||
const rawShape = metadata.shape;
|
||||
const shape: AvatarShape | undefined = isValidAvatarShape(rawShape) ? rawShape : undefined;
|
||||
@@ -187,8 +191,8 @@ export function ProfileCard({
|
||||
<div
|
||||
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
|
||||
style={
|
||||
metadata.banner
|
||||
? { backgroundImage: `url(${metadata.banner})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => editable && onPickImage?.('banner')}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SandboxFrameProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id' | 'sandbox'> {
|
||||
/** HMAC-derived subdomain identifier. */
|
||||
id: string;
|
||||
/**
|
||||
@@ -324,6 +324,20 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
// Defense-in-depth on top of the cross-origin subdomain isolation.
|
||||
// - allow-scripts + allow-same-origin: required for apps to run JS and
|
||||
// use origin-keyed storage (localStorage, IndexedDB) and to register
|
||||
// the iframe.diy Service Worker that proxies fetches. Because the
|
||||
// iframe lives on a distinct HMAC-derived subdomain, it is still a
|
||||
// different origin from the parent app.
|
||||
// - allow-forms / allow-modals / allow-popups(+escape-sandbox) /
|
||||
// allow-downloads: normal web-app affordances (form submission,
|
||||
// alert/confirm/prompt, opening links in new tabs, exporting files)
|
||||
// that webxdc/nsite content may legitimately rely on.
|
||||
// Notably omitted: allow-top-navigation (prevents window.top.location
|
||||
// phishing redirects) and allow-pointer-lock / allow-presentation /
|
||||
// allow-orientation-lock (unused niche capabilities).
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads"
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
// Only scroll to top on PUSH navigation (user clicked a link).
|
||||
// On POP (back/forward), let the browser restore scroll position naturally.
|
||||
if (navigationType === 'PUSH') {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [pathname, navigationType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { toast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
|
||||
import { ensureLetterFonts } from '@/lib/letterUtils';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
import { StationeryBackground } from './StationeryBackground';
|
||||
import { useStationeryColors } from '@/hooks/useStationeryColors';
|
||||
import { LetterStickers } from './LetterStickers';
|
||||
@@ -98,7 +99,10 @@ export function LetterCard({ letter, mode }: LetterCardProps) {
|
||||
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
|
||||
|
||||
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
|
||||
const rawFont = effectiveStationery?.fontFamily;
|
||||
// Sanitize event-sourced font family before CSS interpolation (M-6).
|
||||
const rawFont = effectiveStationery?.fontFamily
|
||||
? sanitizeCssString(effectiveStationery.fontFamily)
|
||||
: undefined;
|
||||
const letterFontFamily = rawFont
|
||||
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
|
||||
: FONT_OPTIONS[0].family;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolve
|
||||
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
|
||||
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
|
||||
import { ensureLetterFonts } from '@/lib/letterUtils';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
import { StationeryBackground } from './StationeryBackground';
|
||||
import { useStationeryColors } from '@/hooks/useStationeryColors';
|
||||
import { LetterStickers } from './LetterStickers';
|
||||
@@ -204,7 +205,10 @@ export function LetterDetailSheet({ letter, onClose, onReply }: LetterDetailShee
|
||||
const effectiveFrameTint = effectiveStationery?.frameTint;
|
||||
|
||||
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
|
||||
const rawFont = effectiveStationery?.fontFamily;
|
||||
// Sanitize event-sourced font family before CSS interpolation (M-6).
|
||||
const rawFont = effectiveStationery?.fontFamily
|
||||
? sanitizeCssString(effectiveStationery.fontFamily)
|
||||
: undefined;
|
||||
const letterFontFamily = rawFont
|
||||
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
|
||||
: FONT_OPTIONS[0].family;
|
||||
|
||||
@@ -199,7 +199,7 @@ export function StationeryBackground({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: s.imageMode === 'tile' ? 'auto' : 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: s.imageMode === 'tile' ? 'repeat' : 'no-repeat',
|
||||
@@ -243,7 +243,7 @@ function ThemeMockup({ stationery }: { stationery: Stationery }) {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
opacity: 0.35,
|
||||
@@ -312,7 +312,7 @@ export function StationeryPreview({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { LN } from '@getalby/sdk';
|
||||
|
||||
@@ -26,8 +26,12 @@ export function useNWCInternal(userPubkey?: string) {
|
||||
// be accessible without a user anyway since zap actions require login).
|
||||
const storagePrefix = userPubkey ? `nwc-connections:${userPubkey}` : 'nwc-connections';
|
||||
const activePrefix = userPubkey ? `nwc-active-connection:${userPubkey}` : 'nwc-active-connection';
|
||||
const [connections, setConnections] = useLocalStorage<NWCConnection[]>(storagePrefix, []);
|
||||
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>(activePrefix, null);
|
||||
// NWC connection strings embed a secret that authorizes Lightning payments,
|
||||
// so on native platforms we store them in Keychain/KeyStore via secureStorage
|
||||
// rather than plaintext localStorage. On web the behavior matches the
|
||||
// previous useLocalStorage implementation (plaintext localStorage).
|
||||
const [connections, setConnections] = useSecureLocalStorage<NWCConnection[]>(storagePrefix, []);
|
||||
const [activeConnection, setActiveConnection] = useSecureLocalStorage<string | null>(activePrefix, null);
|
||||
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
|
||||
|
||||
// Add new connection
|
||||
|
||||
@@ -99,6 +99,15 @@ export function useNip05Resolve(identifier: string | undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate that the returned value is a well-formed 64-char lowercase
|
||||
// hex pubkey. Without this check, a malicious or broken NIP-05 server
|
||||
// could return arbitrary strings that get persisted to IndexedDB and
|
||||
// later fed into Nostr filters or passed to downstream consumers.
|
||||
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
|
||||
void deleteNip05Cached(identifier);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Persist the successful resolution to IndexedDB (fire-and-forget).
|
||||
void setNip05Cached(identifier, pubkey);
|
||||
|
||||
|
||||
@@ -101,16 +101,24 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
|
||||
const client = new NostrPushClient(SERVER_PUBKEY, RPC_RELAYS);
|
||||
clientRef.current = client;
|
||||
let cancelled = false;
|
||||
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
(async () => {
|
||||
// Load the device key from secure storage before the rest of the bring-up
|
||||
// sequence; everything below depends on \`clientRef.current\` being set.
|
||||
const client = await NostrPushClient.create(SERVER_PUBKEY, RPC_RELAYS);
|
||||
if (cancelled) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
clientRef.current = client;
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
swRegistrationRef.current = reg;
|
||||
return navigator.serviceWorker.ready;
|
||||
})
|
||||
.then(async (reg) => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (cancelled) return;
|
||||
|
||||
// Pre-fetch and cache the VAPID key so it is ready before the user
|
||||
// clicks "Enable". This keeps pushManager.subscribe() as the first
|
||||
// async step inside enable(), satisfying the browser's user-gesture
|
||||
@@ -125,6 +133,7 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
console.warn('[push] Failed to pre-fetch VAPID key:', err);
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (vapidKey) {
|
||||
vapidKeyRef.current = vapidKey;
|
||||
}
|
||||
@@ -133,16 +142,20 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
// subscription exists, restore the enabled state silently.
|
||||
if (Notification.permission === 'granted') {
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (cancelled) return;
|
||||
if (existing) {
|
||||
pushSubRef.current = existing;
|
||||
setPermission('granted');
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[push] SW registration failed:', err));
|
||||
} catch (err) {
|
||||
console.error('[push] SW registration failed:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clientRef.current?.destroy();
|
||||
clientRef.current = null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { secureStorage } from '@/lib/secureStorage';
|
||||
|
||||
/**
|
||||
* Hook for storing sensitive state that should not live in plaintext
|
||||
* localStorage on native platforms.
|
||||
*
|
||||
* - On web: uses `localStorage` (identical to `useLocalStorage`).
|
||||
* - On native (Capacitor iOS/Android): uses the native Keychain / KeyStore via
|
||||
* `secureStorage`. Any existing plaintext value in localStorage for the same
|
||||
* key is migrated on first read and the plaintext copy is removed.
|
||||
*
|
||||
* This is async under the hood, so the hook additionally exposes a `ready`
|
||||
* flag indicating whether the initial load has completed. While `!ready`, the
|
||||
* state is `defaultValue` and callers should avoid making decisions based on
|
||||
* an "empty" state (e.g. do not persist a default back if the user has real
|
||||
* data stored that hasn't been loaded yet).
|
||||
*
|
||||
* The return tuple is `[state, setValue, ready]`.
|
||||
*/
|
||||
export function useSecureLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
serializer?: {
|
||||
serialize: (value: T) => string;
|
||||
deserialize: (value: string) => T;
|
||||
},
|
||||
) {
|
||||
const serialize = serializer?.serialize || JSON.stringify;
|
||||
const deserialize = serializer?.deserialize || JSON.parse;
|
||||
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
// On web we can read synchronously during initialization (same behavior as
|
||||
// useLocalStorage). On native we must wait for the async read, so we start
|
||||
// with defaultValue and flip `ready` once loaded.
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (isNative) return defaultValue;
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? deserialize(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load ${key} from localStorage:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
const [ready, setReady] = useState<boolean>(!isNative);
|
||||
|
||||
// Track the most-recently-requested key so stale async reads don't clobber
|
||||
// state after the caller swapped to a different key.
|
||||
const currentKeyRef = useRef(key);
|
||||
currentKeyRef.current = key;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
if (isNative) {
|
||||
// Check secureStorage first (handles migration from plaintext
|
||||
// localStorage internally per secureStorage.getItem).
|
||||
const item = await secureStorage.getItem(key);
|
||||
if (cancelled || currentKeyRef.current !== key) return;
|
||||
setState(item ? deserialize(item) : defaultValue);
|
||||
} else {
|
||||
const item = localStorage.getItem(key);
|
||||
if (cancelled || currentKeyRef.current !== key) return;
|
||||
setState(item ? deserialize(item) : defaultValue);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn(`Failed to load ${key} from secure storage:`, error);
|
||||
setState(defaultValue);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
setReady(false);
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// defaultValue and deserialize are intentionally excluded — we only want to
|
||||
// re-read when the key identity changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, isNative]);
|
||||
|
||||
const setValue = (value: T | ((prev: T) => T)) => {
|
||||
const persist = (next: T) => {
|
||||
try {
|
||||
const serialized = serialize(next);
|
||||
if (isNative) {
|
||||
// Fire-and-forget; errors are logged but shouldn't block the caller.
|
||||
void secureStorage.setItem(key, serialized).catch((error) => {
|
||||
console.warn(`Failed to save ${key} to secure storage:`, error);
|
||||
});
|
||||
} else {
|
||||
localStorage.setItem(key, serialized);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to serialize ${key}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
if (value instanceof Function) {
|
||||
setState((prev) => {
|
||||
const next = (value as (p: T) => T)(prev);
|
||||
if (next === prev) return prev;
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setState((prev) => {
|
||||
if (value === prev) return prev;
|
||||
persist(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync with cross-tab changes on web. Native secure storage has no
|
||||
// cross-tab concept.
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === key && e.newValue !== null) {
|
||||
try {
|
||||
setState(deserialize(e.newValue));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync ${key} from localStorage:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [key, isNative, deserialize]);
|
||||
|
||||
return [state, setValue, ready] as const;
|
||||
}
|
||||
@@ -125,40 +125,82 @@ export async function getNsecCredential(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a `saveNsec` call. */
|
||||
export type SaveNsecResult = 'saved' | 'saved-to-file' | 'dismissed';
|
||||
|
||||
/** Build the filename used for the fallback `.nsec.txt` file. */
|
||||
function nsecFilename(npub: string, appName?: string): string {
|
||||
// Slugify the app name so it's filesystem-safe. On Capacitor `location.hostname`
|
||||
// is always `localhost`, which produces meaningless filenames — prefer the
|
||||
// app name when the caller provides it.
|
||||
const slug = (appName ?? location.hostname)
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'nostr';
|
||||
return `${slug}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Nostr secret key using the best method available on the platform.
|
||||
*
|
||||
* - **Native (iOS / Android)**: Prompts the credential manager
|
||||
* (iCloud Keychain / Google). Throws if the user dismisses so the caller
|
||||
* can block progression and retry.
|
||||
* - **Android Capacitor**: Tries the AndroidX Credential Manager first
|
||||
* (which delegates to Google Password Manager or any registered provider).
|
||||
* On de-Googled devices (GrapheneOS, /e/OS, etc.) there may be no provider
|
||||
* available and the call fails — in that case we fall back to writing the
|
||||
* key to the app's Documents directory so the user always has a backup.
|
||||
* Returns `'saved'` on keychain success, `'saved-to-file'` on fallback.
|
||||
*
|
||||
* - **iOS Capacitor**: Prompts iCloud Keychain via
|
||||
* `SecAddSharedWebCredential`. Returns `'dismissed'` if the user dismisses
|
||||
* the sheet — dismissal is a legitimate user choice and not an error, so
|
||||
* callers can proceed anyway. No file fallback on iOS: the Documents
|
||||
* folder is accessible without authentication, so silently writing a
|
||||
* plaintext nsec there would violate user intent.
|
||||
*
|
||||
* - **Web**: Downloads the key as a `.nsec.txt` file (always), and also
|
||||
* attempts to store it via `PasswordCredential` as a bonus (Chromium).
|
||||
* The bonus store is fire-and-forget — it never blocks or throws.
|
||||
* Resolves to `'saved'` once the file download completes.
|
||||
*
|
||||
* Real errors (e.g. filesystem write failure on native) still throw.
|
||||
*
|
||||
* @param npub - The user's npub (credential username / account)
|
||||
* @param nsec - The user's nsec (credential password)
|
||||
* @param name - Optional display name (Chromium only)
|
||||
* @throws On native platforms if the user dismisses the credential prompt.
|
||||
* @param name - Optional app/display name. Used as the Chromium password-
|
||||
* manager entry name, and as the filename slug for any
|
||||
* fallback `.nsec.txt` file written to disk. On Capacitor
|
||||
* `location.hostname` is always `localhost`, so passing the
|
||||
* app name is the only way to get a meaningful filename.
|
||||
* @returns `'saved'` if stored in the platform credential manager or
|
||||
* downloaded as a file on web; `'saved-to-file'` if stored as a
|
||||
* file via the Android fallback; `'dismissed'` if the user
|
||||
* dismissed the iOS credential prompt.
|
||||
*/
|
||||
export async function saveNsec(
|
||||
npub: string,
|
||||
nsec: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
// Native: credential manager is the sole save mechanism.
|
||||
): Promise<SaveNsecResult> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const saved = await storeNsecCredential(npub, nsec, name);
|
||||
if (!saved) {
|
||||
throw new Error('Credential save was dismissed');
|
||||
if (saved) return 'saved';
|
||||
|
||||
// Android fallback: write the key to Documents so de-Googled devices
|
||||
// (no credential provider installed) still get a persistent backup.
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
await downloadTextFile(nsecFilename(npub, name), nsec);
|
||||
return 'saved-to-file';
|
||||
}
|
||||
return;
|
||||
|
||||
// iOS: dismissal is a deliberate user choice, no automatic fallback.
|
||||
return 'dismissed';
|
||||
}
|
||||
|
||||
// Web: always download the file as the primary save mechanism.
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
await downloadTextFile(filename, nsec);
|
||||
await downloadTextFile(nsecFilename(npub, name), nsec);
|
||||
|
||||
// Bonus: also try to store in the browser's password manager (Chromium).
|
||||
storeNsecCredential(npub, nsec, name).catch(() => {});
|
||||
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ import { findBundledFont, loadBundledFont, resolveCssFamily } from '@/lib/fonts'
|
||||
* Sanitize a string for safe interpolation into a double-quoted CSS context.
|
||||
* Uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens,
|
||||
* underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
*
|
||||
* Use whenever event-sourced strings flow into a CSS declaration value
|
||||
* (e.g. `font-family`) to prevent CSS-string breakout.
|
||||
*/
|
||||
function sanitizeCssString(value: string): string {
|
||||
export function sanitizeCssString(value: string): string {
|
||||
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -1,4 +1,6 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
export const LETTER_KIND = 8211;
|
||||
export const COLOR_MOMENT_KIND = 3367;
|
||||
@@ -96,11 +98,15 @@ export interface ResolvedStationery {
|
||||
|
||||
/** Resolve a Stationery into full rendering attributes by reading event tags. */
|
||||
export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
// Sanitize event-sourced font family before CSS interpolation (see SECURITY_AUDIT M-6).
|
||||
// Applied at the parse layer so every consumer gets a safe value.
|
||||
const safeFontFamily = s.fontFamily ? sanitizeCssString(s.fontFamily) : undefined;
|
||||
|
||||
const base: ResolvedStationery = {
|
||||
color: s.color,
|
||||
emoji: s.emoji,
|
||||
emojiMode: s.emojiMode ?? 'tile',
|
||||
fontFamily: s.fontFamily,
|
||||
fontFamily: safeFontFamily,
|
||||
frame: s.frame,
|
||||
frameTint: s.frameTint,
|
||||
imageMode: 'cover',
|
||||
@@ -131,22 +137,25 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
const bgTag = event.tags.find(([n]) => n === 'bg');
|
||||
if (bgTag) {
|
||||
for (const slot of bgTag.slice(1)) {
|
||||
if (slot.startsWith('url ')) base.imageUrl = slot.slice(4);
|
||||
// Sanitize event-sourced URL before CSS `url(...)` interpolation (H-2).
|
||||
if (slot.startsWith('url ')) base.imageUrl = sanitizeUrl(slot.slice(4));
|
||||
else if (slot === 'mode tile') base.imageMode = 'tile';
|
||||
else if (slot === 'mode cover') base.imageMode = 'cover';
|
||||
}
|
||||
}
|
||||
if (!base.imageUrl) {
|
||||
base.imageUrl = event.tags.find(([n]) => n === 'image')?.[1];
|
||||
base.imageUrl = sanitizeUrl(event.tags.find(([n]) => n === 'image')?.[1]);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
|
||||
// No event or unknown kind — use legacy flat fallbacks (old letters, presets).
|
||||
// Legacy `imageUrl` may carry user-supplied URLs from pre-sanitization letters,
|
||||
// so sanitize here as well for defense-in-depth.
|
||||
base.textColor = s.textColor;
|
||||
base.primaryColor = s.primaryColor;
|
||||
base.layout = s.layout;
|
||||
base.imageUrl = s.imageUrl;
|
||||
base.imageUrl = sanitizeUrl(s.imageUrl);
|
||||
base.imageMode = s.imageMode ?? 'cover';
|
||||
return base;
|
||||
}
|
||||
|
||||
+26
-6
@@ -19,6 +19,8 @@ import { nip44, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-too
|
||||
import { SimplePool } from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
import { secureStorage } from '@/lib/secureStorage';
|
||||
|
||||
// ─── Ephemeral device key ─────────────────────────────────────────────────────
|
||||
|
||||
const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
|
||||
@@ -26,14 +28,19 @@ const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
|
||||
/**
|
||||
* Get or generate a persistent ephemeral key for this device.
|
||||
* Used to sign nostr-push RPC events without prompting the user's signer.
|
||||
*
|
||||
* Routed through \`secureStorage\` so native builds keep the key in the iOS
|
||||
* Keychain / Android KeyStore. Web falls back to localStorage (the key is
|
||||
* ephemeral and per-device, so a plaintext copy only leaks which Nostr
|
||||
* events this device wants pushed — not the user's identity).
|
||||
*/
|
||||
function getDeviceSecretKey(): Uint8Array {
|
||||
const stored = localStorage.getItem(DEVICE_KEY_STORAGE);
|
||||
async function getDeviceSecretKey(): Promise<Uint8Array> {
|
||||
const stored = await secureStorage.getItem(DEVICE_KEY_STORAGE);
|
||||
if (stored) {
|
||||
return hexToBytes(stored);
|
||||
}
|
||||
const sk = generateSecretKey();
|
||||
localStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
|
||||
await secureStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
|
||||
return sk;
|
||||
}
|
||||
|
||||
@@ -116,15 +123,28 @@ export class NostrPushClient {
|
||||
private secretKey: Uint8Array;
|
||||
private publicKey: string;
|
||||
|
||||
constructor(
|
||||
private constructor(
|
||||
/** The nostr-push server's pubkey (hex). */
|
||||
private readonly serverPubkey: string,
|
||||
/** Relays to publish RPC calls to. */
|
||||
private readonly relays: string[],
|
||||
secretKey: Uint8Array,
|
||||
) {
|
||||
this.pool = new SimplePool();
|
||||
this.secretKey = getDeviceSecretKey();
|
||||
this.publicKey = getPublicKey(this.secretKey);
|
||||
this.secretKey = secretKey;
|
||||
this.publicKey = getPublicKey(secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client, loading (or generating) the device key from
|
||||
* platform-appropriate secure storage.
|
||||
*/
|
||||
static async create(
|
||||
serverPubkey: string,
|
||||
relays: string[],
|
||||
): Promise<NostrPushClient> {
|
||||
const secretKey = await getDeviceSecretKey();
|
||||
return new NostrPushClient(serverPubkey, relays, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-2
@@ -64,12 +64,15 @@ export const ThemeColorsCompatSchema = z.union([
|
||||
/** Zod schema for ThemeFont */
|
||||
export const ThemeFontSchema = z.object({
|
||||
family: z.string(),
|
||||
url: z.string().optional(),
|
||||
// Reject non-URL strings at the schema layer. Downstream consumers still
|
||||
// run the value through \`sanitizeUrl()\` to enforce \`https:\` and strip
|
||||
// \`javascript:\`/\`data:\` URIs before use — this is defense-in-depth.
|
||||
url: z.url().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for ThemeBackground */
|
||||
export const ThemeBackgroundSchema = z.object({
|
||||
url: z.string(),
|
||||
url: z.url(),
|
||||
mode: z.enum(['cover', 'tile']).optional(),
|
||||
dimensions: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
|
||||
@@ -41,4 +41,17 @@ export const secureStorage = {
|
||||
|
||||
await SecureStoragePlugin.set({ key, value });
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await SecureStoragePlugin.remove({ key });
|
||||
} catch {
|
||||
// Key didn't exist — ignore.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,8 +2,12 @@ import { useSeoMeta } from '@unhead/react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Loader2, Plus, Trash2, ChevronDown,
|
||||
Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle, CloudSun,
|
||||
Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, EyeOff, Copy, Check, Download, KeyRound, AlertTriangle, CloudSun,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
|
||||
import { saveNsec } from '@/lib/credentialManager';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
@@ -866,7 +870,7 @@ export function ProfileSettings() {
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 [[data-state=open]_&]:rotate-180" strokeWidth={4} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<CollapsibleContent className="pt-3 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot"
|
||||
@@ -882,6 +886,11 @@ export function ProfileSettings() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Your Key — private-key backup. Rendered inside Advanced but is not part of the form. */}
|
||||
<div className="pt-2">
|
||||
<BackupKeySection />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -890,3 +899,172 @@ export function ProfileSettings() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Backup Key section ────────────────────────────────────────────────────────
|
||||
|
||||
function BackupKeySection() {
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const { toast } = useToast();
|
||||
const current = logins[0];
|
||||
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const heading = (
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<KeyRound className="size-4 text-primary/70" />
|
||||
<h2 className="text-sm font-semibold">Your Key</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Not applicable for extension / bunker logins — key isn't available in Ditto.
|
||||
if (!current) return null;
|
||||
|
||||
if (current.type === 'extension') {
|
||||
return (
|
||||
<div>
|
||||
{heading}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
You're signed in with a browser extension (NIP-07). Your secret key is stored there — manage or export it from the extension itself.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.type === 'bunker') {
|
||||
return (
|
||||
<div>
|
||||
{heading}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
You're signed in with a remote signer (NIP-46). Your secret key is held by that signer and cannot be exported from {config.appName}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.type !== 'nsec') {
|
||||
// Unknown future login type — don't guess.
|
||||
return null;
|
||||
}
|
||||
|
||||
const nsec = current.data.nsec;
|
||||
const npub = nip19.npubEncode(current.pubkey);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(nsec);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Could not access the clipboard. Reveal the key and copy it manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveNsec(npub, nsec, config.appName);
|
||||
if (result === 'saved-to-file') {
|
||||
toast({
|
||||
title: 'Secret key saved',
|
||||
description: 'Your secret key was saved to the Documents folder on your device.',
|
||||
});
|
||||
} else if (result === 'saved') {
|
||||
toast({ title: 'Secret key saved' });
|
||||
}
|
||||
// 'dismissed' is a deliberate user choice — no toast.
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Save failed',
|
||||
description: 'Could not save the key. Please copy it manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{heading}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This secret key controls your account on {config.appName}. Anyone with it can post, read your DMs, and impersonate you. Store it in a password manager or somewhere else only you can access.
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={nsec}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
className="pr-20 font-mono text-base md:text-sm"
|
||||
aria-label="Your secret key"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 h-full flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full px-2 hover:bg-transparent"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy secret key"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full px-2 hover:bg-transparent"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? 'Hide secret key' : 'Reveal secret key'}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showKey && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300 leading-relaxed">
|
||||
NEVER share your secret key with anyone. Avoid screenshotting it or pasting it anywhere except a password manager. If shared, others will be able to access your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full gap-2 rounded-full h-12"
|
||||
onClick={handleBackup}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" /> Back Up Key
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user