Harden CSS/URL handling, NWC storage, and Android backup
- Sanitize event-sourced URLs before CSS url() interpolation in ProfileCard banner and letter stationery background (closes H-1, H-2) - Sanitize event-sourced font families at the parse layer and in letter card/detail consumers that bypass resolveStationery (closes M-6) - Export sanitizeCssString for broader reuse - Route NWC wallet connection URIs and active pointer through a new useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore on native (closes M-1) - Add removeItem to secureStorage - Add Android backup/data-extraction rules that exclude WebView storage and Capacitor secure-storage SharedPreferences so wallet credentials don't leak via Google Auto Backup (closes M-5) - Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded to match what the CI job expects (closes M-2)
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"version": "2.7.0",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user