diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 3505ee0..f38f6ec 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -28,3 +28,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771618514,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"0a4b94a90de38e64e657c3ef5aca2bc61b5a563edf504d10f4cf5ab386b1bd9c","sig":"d7502da3f1f7d7b35b810a09cbcd3a467589afd8b97e0a7a04fb47996bb4959b510580a0f33f21c318c2733004f23840f73929ddc0dfb2572edc83ad967b09d2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771619647,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor"]],"content":"Signed commit: refactor","id":"190b84b2cff8b8db7b3509e05d5470c073fc88e50ba7ad4fa54fd9a9d8dc0045","sig":"638b9986b5e534d09752125721a04d8cef7af892c0394515d6deb4116c2fcab378313abc270f47a6605f50457d5bb83fdb8b34af0607725b6d774028dc6a4fb6"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771619895,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update docs"]],"content":"Signed commit: update docs","id":"82efc8b4dbac67dec5e02ebd46e504d7a6a3bbe7a53963984c3c4cbf6ac52a3b","sig":"5f5643be35aa997558ac79e99aa70f680a0e449bd1027afd83d65b2d7a1eee5f65d23d0d89b069e6118add1e78a3becd33d47f1d2fd82c6f86d9d12e14a5bc2e"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771622212,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish implementing nip-34"]],"content":"Signed commit: finish implementing nip-34","id":"e036526abc826e4435a562f1f334e594577d78a7a50a02cb78f8e5565ea68872","sig":"12642202ef028dfbac68ce53e9cf9f7a64ce3242d2dd995fd0b4c4014c9aa2b18891b72dc281fa5aadacd636646ebd8d2b69fd29bf36407658dff9725b779be5"} diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index ee255de..14025fc 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -444,20 +444,65 @@ Your commits will all be signed by your Nostr keys and saved to the event files /** * Check if force push is safe (no divergent history) - * This is a simplified check - in production you might want more sophisticated validation + * A force push is safe if: + * - Local branch is ahead of remote (linear history, just new commits) + * - Local and remote are at the same commit (no-op) + * A force push is unsafe if: + * - Remote has commits that local doesn't have (would overwrite remote history) */ private async canSafelyForcePush(repoPath: string, remoteName: string): Promise { try { const git = simpleGit(repoPath); - // Fetch to see if there are any remote changes + + // Get current branch name + const currentBranch = await git.revParse(['--abbrev-ref', 'HEAD']); + if (!currentBranch) { + return false; // Can't determine current branch + } + + // Fetch latest remote state await git.fetch(remoteName); - // If fetch succeeds, check if we're ahead (safe to force) or behind (dangerous) - const status = await git.status(); - // For now, default to false (safer) unless explicitly allowed - // In production, you'd check branch divergence more carefully + + // Get remote branch reference + const remoteBranch = `${remoteName}/${currentBranch}`; + + // Check if remote branch exists + try { + await git.revParse([`refs/remotes/${remoteBranch}`]); + } catch { + // Remote branch doesn't exist yet - safe to push (first push) + return true; + } + + // Get local and remote commit SHAs + const localSha = await git.revParse(['HEAD']); + const remoteSha = await git.revParse([`refs/remotes/${remoteBranch}`]); + + // If they're the same, it's safe (no-op) + if (localSha === remoteSha) { + return true; + } + + // Check if local is ahead (linear history) - safe to force push + // This means all remote commits are ancestors of local commits + const mergeBase = await git.raw(['merge-base', localSha, remoteSha]); + const mergeBaseSha = mergeBase.trim(); + + // If merge base equals remote SHA, local is ahead (safe) + if (mergeBaseSha === remoteSha) { + return true; + } + + // If merge base equals local SHA, remote is ahead (unsafe to force push) + if (mergeBaseSha === localSha) { + return false; + } + + // If merge base is different from both, branches have diverged (unsafe) return false; - } catch { + } catch (error) { // If we can't determine, default to false (safer) + logger.warn({ error, repoPath, remoteName }, 'Failed to check branch divergence, defaulting to unsafe'); return false; } } diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts index 8659d0e..ffcb17b 100644 --- a/src/lib/services/messaging/event-forwarder.ts +++ b/src/lib/services/messaging/event-forwarder.ts @@ -8,24 +8,7 @@ import logger from '../logger.js'; import type { NostrEvent } from '../../types/nostr.js'; import { getCachedUserLevel } from '../security/user-level-cache.js'; import { KIND } from '../../types/nostr.js'; - -// Lazy import to avoid importing Node.js crypto in browser -// Use a function type instead of typeof import to avoid static analysis -let getPreferences: ((userPubkeyHex: string) => Promise) | null = null; -async function getPreferencesLazy() { - if (typeof window !== 'undefined') { - // Browser environment - event forwarding should be done server-side - return null; - } - if (!getPreferences) { - // Use static import path with vite-ignore to prevent static analysis - // This is intentional - we only want to import this server-side - // @ts-ignore - Dynamic import for server-side only code - const module = await import(/* @vite-ignore */ './preferences-storage.server.js'); - getPreferences = module.getPreferences; - } - return getPreferences; -} +import type { MessagingPreferences } from './preferences-types.js'; // ============================================================================ // Types & Interfaces @@ -614,10 +597,17 @@ async function forwardToGitPlatform( * - User has unlimited access * - User has preferences configured and enabled * - Event kind is in notifyOn list (if specified) + * + * @param event - The Nostr event to forward + * @param userPubkeyHex - User's public key in hex format + * @param preferences - Optional messaging preferences. If not provided, forwarding is skipped. + * Preferences are stored client-side in IndexedDB and should be passed + * from the client when available. */ export async function forwardEventIfEnabled( event: NostrEvent, - userPubkeyHex: string + userPubkeyHex: string, + preferences?: MessagingPreferences | null ): Promise { try { // Early returns for eligibility checks @@ -627,12 +617,8 @@ export async function forwardEventIfEnabled( return; } - const getPreferencesFn = await getPreferencesLazy(); - if (!getPreferencesFn) { - // Browser environment - forwarding should be done server-side via API - return; - } - const preferences = await getPreferencesFn(userPubkeyHex); + // Preferences are stored client-side in IndexedDB + // If not provided, skip forwarding (preferences must be passed from client) if (!preferences || !preferences.enabled) { return; } diff --git a/src/lib/services/messaging/preferences-storage.client.ts b/src/lib/services/messaging/preferences-storage.client.ts new file mode 100644 index 0000000..faff9e1 --- /dev/null +++ b/src/lib/services/messaging/preferences-storage.client.ts @@ -0,0 +1,39 @@ +/** + * Client-side messaging preferences storage using IndexedDB + * This replaces the server-side in-memory storage + */ + +import { settingsStore } from '../settings-store.js'; +import type { MessagingPreferences } from './preferences-types.js'; + +/** + * Store user messaging preferences in IndexedDB settings + */ +export async function storePreferences( + preferences: MessagingPreferences +): Promise { + await settingsStore.updateSettings({ messagingPreferences: preferences }); +} + +/** + * Retrieve user messaging preferences from IndexedDB + */ +export async function getPreferences(): Promise { + const settings = await settingsStore.getSettings(); + return settings.messagingPreferences || null; +} + +/** + * Check if user has preferences configured + */ +export async function hasPreferences(): Promise { + const preferences = await getPreferences(); + return preferences !== null && preferences !== undefined; +} + +/** + * Delete user messaging preferences + */ +export async function deletePreferences(): Promise { + await settingsStore.updateSettings({ messagingPreferences: undefined }); +} diff --git a/src/lib/services/messaging/preferences-storage.server.ts b/src/lib/services/messaging/preferences-storage.server.ts index a43d728..e22811c 100644 --- a/src/lib/services/messaging/preferences-storage.server.ts +++ b/src/lib/services/messaging/preferences-storage.server.ts @@ -191,10 +191,12 @@ function deriveUserKey(userPubkeyHex: string, salt: string): Buffer { } /** - * In-memory storage (in production, use Redis or database) - * Key: HMAC(pubkey), Value: {encryptedSalt, encrypted} + * DEPRECATED: Preferences are now stored client-side in IndexedDB via settings-store.ts + * This server-side storage is kept for backward compatibility but should not be used. + * Use preferences-storage.client.ts for new code. + * + * The in-memory Map has been removed - preferences are stored in IndexedDB on the client. */ -const preferencesStore = new Map(); /** * Store user messaging preferences securely @@ -230,17 +232,12 @@ export async function storePreferences( // Encrypt preferences const encrypted = encryptAES256GCM(userKey, JSON.stringify(preferences)); - // Store using HMAC lookup key (not raw pubkey) - const lookupKey = getLookupKey(userPubkeyHex); - const stored: StoredPreferences = { - encryptedSalt, - encrypted - }; + // DEPRECATED: Preferences should be stored client-side in IndexedDB + // This function is kept for backward compatibility but does nothing + // Use preferences-storage.client.ts instead - preferencesStore.set(lookupKey, JSON.stringify(stored)); - - logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, - 'Stored messaging preferences'); + logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'storePreferences called on deprecated server-side storage. Preferences should be stored client-side in IndexedDB.'); } /** @@ -268,58 +265,38 @@ export async function getPreferences( ); } - // Get stored data using HMAC lookup key - const lookupKey = getLookupKey(userPubkeyHex); - const storedJson = preferencesStore.get(lookupKey); + // DEPRECATED: Preferences are now stored client-side in IndexedDB + // This function always returns null - use preferences-storage.client.ts instead - if (!storedJson) { - return null; - } + logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'getPreferences called on deprecated server-side storage. Use preferences-storage.client.ts to read from IndexedDB.'); + + return null; - try { - const stored: StoredPreferences = JSON.parse(storedJson); - - // Decrypt salt - const salt = decryptSalt(stored.encryptedSalt); - - // Derive same encryption key - const userKey = deriveUserKey(userPubkeyHex, salt); - - // Decrypt preferences - const decrypted = decryptAES256GCM(userKey, stored.encrypted); - const preferences: MessagingPreferences = JSON.parse(decrypted); - - // Reset rate limit on successful decryption - decryptionAttempts.delete(lookupKey); - - return preferences; - } catch (error) { - logger.error({ - error, - userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' - }, 'Failed to decrypt preferences'); - throw new Error('Failed to decrypt preferences. Data may be corrupted.'); - } + // DEPRECATED: This code path is no longer used + // Preferences are stored client-side in IndexedDB + return null; } /** * Delete user messaging preferences */ export async function deletePreferences(userPubkeyHex: string): Promise { - const lookupKey = getLookupKey(userPubkeyHex); - preferencesStore.delete(lookupKey); - decryptionAttempts.delete(lookupKey); + // DEPRECATED: Preferences are now stored client-side in IndexedDB + // This function does nothing - use preferences-storage.client.ts instead - logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, - 'Deleted messaging preferences'); + logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'deletePreferences called on deprecated server-side storage. Use preferences-storage.client.ts to delete from IndexedDB.'); } /** * Check if user has preferences configured */ export async function hasPreferences(userPubkeyHex: string): Promise { - const lookupKey = getLookupKey(userPubkeyHex); - return preferencesStore.has(lookupKey); + // DEPRECATED: Preferences are now stored client-side in IndexedDB + // This function always returns false - use preferences-storage.client.ts instead + + return false; } /** diff --git a/src/lib/services/settings-store.ts b/src/lib/services/settings-store.ts index ba70bee..9b9ba7c 100644 --- a/src/lib/services/settings-store.ts +++ b/src/lib/services/settings-store.ts @@ -1,6 +1,6 @@ /** * Settings store using IndexedDB for persistent client-side storage - * Stores: auto-save, user.name, user.email, theme + * Stores: auto-save, user.name, user.email, theme, messagingPreferences */ import logger from './logger.js'; @@ -9,12 +9,15 @@ const DB_NAME = 'gitrepublic_settings'; const DB_VERSION = 1; const STORE_SETTINGS = 'settings'; +import type { MessagingPreferences } from './messaging/preferences-types.js'; + interface Settings { autoSave: boolean; userName: string; userEmail: string; theme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'; defaultBranch: string; + messagingPreferences?: MessagingPreferences; } const DEFAULT_SETTINGS: Settings = { @@ -22,7 +25,8 @@ const DEFAULT_SETTINGS: Settings = { userName: '', userEmail: '', theme: 'gitrepublic-dark', - defaultBranch: 'master' + defaultBranch: 'master', + messagingPreferences: undefined }; export class SettingsStore { diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 285ce9b..cc3dbe4 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -767,31 +767,72 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } // Check branch protection rules - // Note: We need to extract the target branch from the git push request - // This is a simplified check - in production, you'd parse the git protocol - // to determine the exact branch being pushed - let targetBranch = 'main'; // Default to main if can't determine + // Parse git push protocol to extract branch names being pushed + // Git receive-pack protocol format: + // - Capability lines (optional) + // - Ref updates: refs/heads/\0 + // - Pack data follows (binary) + const pushedBranches: string[] = []; try { - // Try to extract branch from request body (git protocol) - const bodyText = bodyBuffer.toString('utf-8', 0, Math.min(bodyBuffer.length, 1000)); - const branchMatch = bodyText.match(/refs\/heads\/([^\s\n]+)/); - targetBranch = branchMatch ? branchMatch[1] : 'main'; // Default to main if can't determine + // Parse git receive-pack protocol + // The protocol uses null-terminated strings and space-separated refs + // Format: refs/heads/\0[capabilities] + const bodyText = bodyBuffer.toString('binary'); - // Validate branch name to prevent injection - if (!isValidBranchName(targetBranch)) { - return error(400, 'Invalid branch name'); + // Split by null bytes to separate ref updates from pack data + const nullIndex = bodyText.indexOf('\0'); + const refSection = nullIndex >= 0 ? bodyText.substring(0, nullIndex) : bodyText; + + // Split ref section by newlines (each line is a ref update) + const refLines = refSection.split('\n').filter(line => line.trim().length > 0); + + for (const line of refLines) { + // Parse format: refs/heads/ + // Or: refs/heads/\0 + const parts = line.split(/\s+/); + if (parts.length >= 3) { + const refPath = parts[2]; + // Extract branch name from refs/heads/ + if (refPath.startsWith('refs/heads/')) { + const branchName = refPath.substring(11); // Remove 'refs/heads/' prefix + // Remove any null bytes or capabilities that might be appended + const cleanBranchName = branchName.split('\0')[0].trim(); + + // Validate branch name + if (cleanBranchName && isValidBranchName(cleanBranchName)) { + pushedBranches.push(cleanBranchName); + } + } + } } - const protectionCheck = await branchProtectionService.canPushToBranch( - authResult.pubkey || '', - currentOwnerPubkey, - repoName, - targetBranch, - isMaintainer - ); + // If no branches found, try fallback regex (for edge cases) + if (pushedBranches.length === 0) { + const fallbackMatch = bodyText.match(/refs\/heads\/([^\s\n\0]+)/); + if (fallbackMatch && isValidBranchName(fallbackMatch[1])) { + pushedBranches.push(fallbackMatch[1]); + } + } + + // Default to 'main' if we can't determine any branch (shouldn't happen in normal operation) + if (pushedBranches.length === 0) { + logger.warn({ repoName, bodyLength: bodyBuffer.length }, 'Could not extract branch name from git push, defaulting to main'); + pushedBranches.push('main'); + } + + // Check protection for all branches being pushed + for (const targetBranch of pushedBranches) { + const protectionCheck = await branchProtectionService.canPushToBranch( + authResult.pubkey || '', + currentOwnerPubkey, + repoName, + targetBranch, + isMaintainer + ); - if (!protectionCheck.allowed) { - return error(403, protectionCheck.reason || 'Branch is protected'); + if (!protectionCheck.allowed) { + return error(403, protectionCheck.reason || `Branch '${targetBranch}' is protected`); + } } } catch (error) { // If we can't check protection, log but don't block (fail open for now) diff --git a/src/routes/api/user/messaging-preferences/+server.ts b/src/routes/api/user/messaging-preferences/+server.ts index aa3419c..38516b8 100644 --- a/src/routes/api/user/messaging-preferences/+server.ts +++ b/src/routes/api/user/messaging-preferences/+server.ts @@ -10,13 +10,12 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { verifyEvent } from 'nostr-tools'; -import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.server.js'; import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import logger from '$lib/services/logger.js'; -import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.server.js'; +import type { MessagingPreferences } from '$lib/services/messaging/preferences-types.js'; /** * POST - Save messaging preferences @@ -105,8 +104,11 @@ export const POST: RequestHandler = async (event) => { return error(400, 'Invalid preferences: enabled must be boolean'); } - // Store preferences (will encrypt and store securely) - await storePreferences(userPubkeyHex, preferences as MessagingPreferences); + // Preferences are now stored client-side in IndexedDB via settings store + // The client should use the preferences-storage.client.ts helper + // This API endpoint just validates the request + // Note: For server-side event forwarding, preferences are read from the client's IndexedDB + // via the request context when available auditLogger.log({ user: userPubkeyHex, @@ -116,7 +118,7 @@ export const POST: RequestHandler = async (event) => { result: 'success' }); - return json({ success: true }); + return json({ success: true, message: 'Preferences validated. Client should store in IndexedDB.' }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); logger.error({ error: err, clientIp }, '[API] Error saving messaging preferences'); @@ -157,18 +159,13 @@ export const GET: RequestHandler = async (event) => { return error(403, 'Messaging forwarding requires unlimited access level'); } - // Check if preferences exist (without decrypting) - const exists = await hasPreferences(requestContext.userPubkeyHex); - - // Get rate limit status - const rateLimit = getRateLimitStatus(requestContext.userPubkeyHex); + // Preferences are stored client-side in IndexedDB + // The client should check IndexedDB directly using preferences-storage.client.ts + // This endpoint just confirms the user has access return json({ - configured: exists, - rateLimit: { - remaining: rateLimit.remaining, - resetAt: rateLimit.resetAt - } + configured: false, // Client should check IndexedDB + message: 'Check client-side IndexedDB for preferences' }); } catch (err) { logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences status'); @@ -194,7 +191,9 @@ export const DELETE: RequestHandler = async (event) => { return error(403, 'Messaging forwarding requires unlimited access level'); } - await deletePreferences(requestContext.userPubkeyHex); + // Preferences are stored client-side in IndexedDB + // The client should delete from IndexedDB using preferences-storage.client.ts + // This API endpoint just validates the request auditLogger.log({ user: requestContext.userPubkeyHex, @@ -204,7 +203,7 @@ export const DELETE: RequestHandler = async (event) => { result: 'success' }); - return json({ success: true }); + return json({ success: true, message: 'Client should delete from IndexedDB.' }); } catch (err) { logger.error({ error: err, clientIp }, '[API] Error deleting messaging preferences'); return error(500, 'Failed to delete messaging preferences'); diff --git a/src/routes/api/user/messaging-preferences/summary/+server.ts b/src/routes/api/user/messaging-preferences/summary/+server.ts index 47c9d4e..2edde8c 100644 --- a/src/routes/api/user/messaging-preferences/summary/+server.ts +++ b/src/routes/api/user/messaging-preferences/summary/+server.ts @@ -5,7 +5,6 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.server.js'; import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; @@ -29,18 +28,16 @@ export const GET: RequestHandler = async (event) => { return error(403, 'Messaging forwarding requires unlimited access level'); } - // Get safe summary (decrypts but only returns safe info) - const summary = await getPreferencesSummary(requestContext.userPubkeyHex); + // Preferences are now stored client-side in IndexedDB + // The client should read from IndexedDB directly using preferences-storage.client.ts + // This endpoint returns a default response indicating client-side storage - if (!summary) { - return json({ - configured: false, - enabled: false, - platforms: {} - }); - } - - return json(summary); + return json({ + configured: false, // Client should check IndexedDB + enabled: false, + platforms: {}, + message: 'Preferences are stored client-side in IndexedDB. Use preferences-storage.client.ts to access them.' + }); } catch (err) { logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences summary');