Merge remote-tracking branch 'origin/main' into feat/evolution-missions-to-kind-11125

# Conflicts:
#	package-lock.json
This commit is contained in:
Chad Curtis
2026-04-16 16:35:38 -05:00
31 changed files with 807 additions and 112 deletions
+12 -2
View File
@@ -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
+18
View File
@@ -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
+1 -1
View File
@@ -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.
+2
View File
@@ -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>
+2 -2
View File
@@ -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 = "";
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.7.0",
"version": "2.7.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+18
View File
@@ -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
+6 -2
View File
@@ -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');
+23
View File
@@ -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
View File
@@ -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>
);
+76 -20
View File
@@ -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>
);
+6 -2
View File
@@ -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')}
+15 -1
View File
@@ -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}
/>
);
+8 -3
View File
@@ -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;
}
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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);
+23 -10
View File
@@ -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;
};
+145
View File
@@ -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;
}
+54 -12
View File
@@ -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';
}
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+13
View File
@@ -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.
}
},
};
+180 -2
View File
@@ -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>
);
}