From 4aa344b80595752369b118e197a385b3dc210557 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:42:33 +0200 Subject: [PATCH] commit --- .../CreateSecretImageCardModal.svelte | 100 +++++++++++++++++ .../SecretImageCard/SecretImageCard.svelte | 106 ++++++++++++++++++ src/lib/cards/core/SecretImageCard/crypto.ts | 98 ++++++++++++++++ src/lib/cards/core/SecretImageCard/index.ts | 57 ++++++++++ src/lib/cards/index.ts | 4 +- 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/lib/cards/core/SecretImageCard/CreateSecretImageCardModal.svelte create mode 100644 src/lib/cards/core/SecretImageCard/SecretImageCard.svelte create mode 100644 src/lib/cards/core/SecretImageCard/crypto.ts create mode 100644 src/lib/cards/core/SecretImageCard/index.ts diff --git a/src/lib/cards/core/SecretImageCard/CreateSecretImageCardModal.svelte b/src/lib/cards/core/SecretImageCard/CreateSecretImageCardModal.svelte new file mode 100644 index 0000000..4285e5c --- /dev/null +++ b/src/lib/cards/core/SecretImageCard/CreateSecretImageCardModal.svelte @@ -0,0 +1,100 @@ + + + +
{ + e.preventDefault(); + handleCreate(); + }} + class="flex flex-col gap-4" + > + Secret Image + +
+ + +
+ + + + {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ + +
+
+
diff --git a/src/lib/cards/core/SecretImageCard/SecretImageCard.svelte b/src/lib/cards/core/SecretImageCard/SecretImageCard.svelte new file mode 100644 index 0000000..1bc07b6 --- /dev/null +++ b/src/lib/cards/core/SecretImageCard/SecretImageCard.svelte @@ -0,0 +1,106 @@ + + +{#if item.cardData.preview} + +{/if} + +{#if decryptedUrl} + +{/if} + +{#if !decryptedUrl} +
+
+ {#if decrypting} + + + + + {:else} + + + + {/if} +
+
+{/if} diff --git a/src/lib/cards/core/SecretImageCard/crypto.ts b/src/lib/cards/core/SecretImageCard/crypto.ts new file mode 100644 index 0000000..085c4b9 --- /dev/null +++ b/src/lib/cards/core/SecretImageCard/crypto.ts @@ -0,0 +1,98 @@ +/** + * AES-GCM encryption/decryption using Web Crypto API with password-derived keys. + */ + +async function deriveKey(password: string, salt: Uint8Array): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt a Blob with a password. Returns a Blob containing salt + iv + ciphertext. + */ +export async function encryptBlob(blob: Blob, password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); + + const plaintext = await blob.arrayBuffer(); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); + + // Pack: salt (16) + iv (12) + ciphertext + const result = new Uint8Array(16 + 12 + ciphertext.byteLength); + result.set(salt, 0); + result.set(iv, 16); + result.set(new Uint8Array(ciphertext), 28); + + return new Blob([result], { type: 'application/octet-stream' }); +} + +/** + * Decrypt a Blob that was encrypted with encryptBlob. Returns the original Blob. + * Throws on wrong password. + */ +export async function decryptBlob(encryptedBlob: Blob, password: string): Promise { + const data = new Uint8Array(await encryptedBlob.arrayBuffer()); + + const salt = data.slice(0, 16); + const iv = data.slice(16, 28); + const ciphertext = data.slice(28); + + const key = await deriveKey(password, salt); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + + return new Blob([plaintext]); +} + +/** + * Create a tiny pixelated preview of an image (16x16 pixels stored as a base64 data URL). + */ +export function createPixelatedPreview( + file: Blob, + size: number = 16 +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + if (!e.target?.result) return reject(new Error('Failed to read file')); + img.src = e.target.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Failed to get canvas context')); + + ctx.imageSmoothingEnabled = true; + ctx.drawImage(img, 0, 0, size, size); + + resolve(canvas.toDataURL('image/webp', 0.5)); + }; + img.onerror = reject; + }); +} diff --git a/src/lib/cards/core/SecretImageCard/index.ts b/src/lib/cards/core/SecretImageCard/index.ts new file mode 100644 index 0000000..03026b9 --- /dev/null +++ b/src/lib/cards/core/SecretImageCard/index.ts @@ -0,0 +1,57 @@ +import { uploadBlob } from '$lib/atproto/methods'; +import type { CardDefinition } from '../../types'; +import CreateSecretImageCardModal from './CreateSecretImageCardModal.svelte'; +import SecretImageCard from './SecretImageCard.svelte'; + +export const SecretImageCardDefinition = { + type: 'secretImage', + contentComponent: SecretImageCard, + + creationModalComponent: CreateSecretImageCardModal, + + createNew: (card) => { + card.cardType = 'secretImage'; + card.cardData = { + encryptedImage: '', + preview: '' + }; + }, + + upload: async (item) => { + const img = item.cardData.encryptedImage; + if (!img) return item; + + // Already uploaded + if (typeof img === 'object' && img.$type === 'blob') return item; + + // Local blob from creation modal + if (img?.blob) { + if (img.objectUrl) { + URL.revokeObjectURL(img.objectUrl); + } + item.cardData.encryptedImage = await uploadBlob({ blob: img.blob }); + } + + return item; + }, + + name: 'Secret Image', + + keywords: ['secret', 'encrypted', 'password', 'hidden', 'private', 'locked'], + groups: ['Core'], + + icon: ` + + ` +} as CardDefinition & { type: 'secretImage' }; diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 640d06d..685de1b 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -56,6 +56,7 @@ import { GermDMCardDefinition } from './social/GermDMCard'; import { KichRecipeCardDefinition } from './social/KichRecipeCard'; import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard'; import { KichCookingLogCardDefinition } from './social/KichCookingLogCard'; +import { SecretImageCardDefinition } from './core/SecretImageCard'; // import { Model3DCardDefinition } from './visual/Model3DCard'; export const AllCardDefinitions = [ @@ -117,7 +118,8 @@ export const AllCardDefinitions = [ GermDMCardDefinition, KichRecipeCardDefinition, KichRecipeCollectionCardDefinition, - KichCookingLogCardDefinition + KichCookingLogCardDefinition, + SecretImageCardDefinition ] as const; export const CardDefinitionsByType = AllCardDefinitions.reduce( -- 2.51.2