From df1cff8fd49f85842a6811ddbae156b7529efdc3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 11:41:51 +0100 Subject: [PATCH] message forwarding --- README.md | 2 +- docs/MESSAGING_FORWARDING.md | 396 +++++++++++ package-lock.json | 10 + package.json | 2 + src/lib/components/ForwardingConfig.svelte | 348 ++++++++++ src/lib/config.ts | 13 +- src/lib/services/messaging/event-forwarder.ts | 635 ++++++++++++++++++ src/lib/services/messaging/nodemailer.d.ts | 31 + .../services/messaging/preferences-storage.ts | 383 +++++++++++ .../services/nostr/public-messages-service.ts | 215 ++++++ src/lib/services/security/security-audit.md | 112 +++ src/lib/types/nostr.ts | 1 + .../repos/[npub]/[repo]/highlights/+server.ts | 12 + .../api/repos/[npub]/[repo]/issues/+server.ts | 10 + .../api/repos/[npub]/[repo]/prs/+server.ts | 10 + .../api/user/messaging-preferences/+server.ts | 211 ++++++ .../messaging-preferences/summary/+server.ts | 58 ++ src/routes/repos/[npub]/[repo]/+page.svelte | 4 + src/routes/users/[npub]/+page.svelte | 516 +++++++++++++- 19 files changed, 2928 insertions(+), 41 deletions(-) create mode 100644 docs/MESSAGING_FORWARDING.md create mode 100644 src/lib/components/ForwardingConfig.svelte create mode 100644 src/lib/services/messaging/event-forwarder.ts create mode 100644 src/lib/services/messaging/nodemailer.d.ts create mode 100644 src/lib/services/messaging/preferences-storage.ts create mode 100644 src/lib/services/nostr/public-messages-service.ts create mode 100644 src/lib/services/security/security-audit.md create mode 100644 src/routes/api/user/messaging-preferences/+server.ts create mode 100644 src/routes/api/user/messaging-preferences/summary/+server.ts diff --git a/README.md b/README.md index 1b44f5c..cfe2ccb 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform - `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional) - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) -- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) +- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`) - `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations. - `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost. diff --git a/docs/MESSAGING_FORWARDING.md b/docs/MESSAGING_FORWARDING.md new file mode 100644 index 0000000..84d5f43 --- /dev/null +++ b/docs/MESSAGING_FORWARDING.md @@ -0,0 +1,396 @@ +# Messaging Forwarding Feature + +This feature allows users with **unlimited access** to forward Nostr events to messaging platforms (Telegram, SimpleX, Email) and Git hosting platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo) when they publish events. + +## Security Architecture + +### Multi-Layer Security + +1. **Encrypted Salt Storage**: Each user's salt is encrypted with a separate key (`MESSAGING_SALT_ENCRYPTION_KEY`) +2. **HMAC Lookup Keys**: User pubkeys are hashed with HMAC before being used as database keys +3. **Rate Limiting**: Decryption attempts are rate-limited (10 attempts per 15 minutes) +4. **Per-User Key Derivation**: Encryption keys derived from master key + pubkey + salt +5. **AES-256-GCM**: Authenticated encryption with random IV per encryption + +### Threat Mitigation + +- **Database Compromise**: All data encrypted at rest with per-user keys +- **Key Leakage**: Per-user key derivation means master key leak doesn't expose all users +- **Brute Force**: Rate limiting prevents rapid decryption attempts +- **Lookup Attacks**: HMAC hashing prevents pubkey enumeration + +## Environment Variables + +### Required for Encryption + +```bash +# Master encryption key (256-bit hex, generate with: openssl rand -hex 32) +MESSAGING_PREFS_ENCRYPTION_KEY=<256-bit-hex> + +# Salt encryption key (256-bit hex, generate with: openssl rand -hex 32) +MESSAGING_SALT_ENCRYPTION_KEY=<256-bit-hex> + +# HMAC secret for lookup key hashing (256-bit hex, generate with: openssl rand -hex 32) +MESSAGING_LOOKUP_SECRET=<256-bit-hex> +``` + +### Optional for Messaging Platforms + +```bash +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN= +TELEGRAM_ENABLED=true + +# Email SMTP Configuration +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM_ADDRESS= +SMTP_FROM_NAME=GitRepublic +EMAIL_ENABLED=true + +# OR use SMTP API (alternative to direct SMTP) +SMTP_API_URL= +SMTP_API_KEY= + +# SimpleX API Configuration +SIMPLEX_API_URL= +SIMPLEX_API_KEY= +SIMPLEX_ENABLED=true + +# Git Platforms Forwarding Configuration +GIT_PLATFORMS_ENABLED=true +``` + +## Setup + +### 1. Generate Encryption Keys + +```bash +# Generate all three required keys +openssl rand -hex 32 # For MESSAGING_PREFS_ENCRYPTION_KEY +openssl rand -hex 32 # For MESSAGING_SALT_ENCRYPTION_KEY +openssl rand -hex 32 # For MESSAGING_LOOKUP_SECRET +``` + +### 2. Store Keys Securely + +**Development:** +- Store in `.env` file (never commit to git) + +**Production:** +- Use secret management service (AWS Secrets Manager, HashiCorp Vault, etc.) +- Or environment variables in deployment platform +- Consider using Hardware Security Modules (HSM) for maximum security + +### 3. Configure Messaging Platforms + +#### Telegram +1. Create a bot with [@BotFather](https://t.me/botfather) +2. Get bot token +3. Users will provide their chat ID when configuring preferences + +#### Email +1. Configure SMTP settings (host, port, credentials) +2. Or use an SMTP API service +3. Users will provide their email addresses (to/cc) + +#### SimpleX +1. Set up SimpleX Chat API +2. Configure API URL and key +3. Users will provide their contact ID + +#### Git Platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo) +1. Users create Personal Access Tokens with appropriate scopes +2. Users provide platform, username/org, repository name, and token +3. Events will be forwarded as issues or PRs on the selected platform +4. Supports self-hosted instances via custom API URL + +## User Flow + +### 1. User Saves Preferences (Client-Side) + +```typescript +// User encrypts preferences to self on Nostr (kind 30078) +const encryptedContent = await window.nostr.nip44.encrypt( + userPubkey, + JSON.stringify(preferences) +); + +// Publish to Nostr (backup/sync) +const event = { + kind: 30078, + pubkey: userPubkey, + tags: [['d', 'gitrepublic-messaging'], ['enabled', 'true']], + content: encryptedContent +}; + +// Sign and publish +const signedEvent = await signEventWithNIP07(event); +await nostrClient.publishEvent(signedEvent); + +// Send decrypted copy to server (over HTTPS) +await fetch('/api/user/messaging-preferences', { + method: 'POST', + body: JSON.stringify({ + preferences, + proofEvent: signedEvent + }) +}); +``` + +### 2. Server Stores Securely + +- Verifies proof event signature +- Checks user has unlimited access +- Generates random salt +- Encrypts salt with `MESSAGING_SALT_ENCRYPTION_KEY` +- Derives per-user encryption key +- Encrypts preferences with AES-256-GCM +- Stores using HMAC lookup key + +### 3. Event Forwarding + +When user publishes an event (issue, PR, highlight): +1. Server checks user has unlimited access +2. Retrieves encrypted preferences +3. Decrypts (with rate limiting) +4. Checks if forwarding enabled and event kind matches +5. Forwards to configured platforms + +## API Endpoints + +### POST `/api/user/messaging-preferences` + +Save messaging preferences. + +**Request:** +```json +{ + "preferences": { + "telegram": "@username", + "simplex": "contact-id", + "email": { + "to": ["user@example.com"], + "cc": ["cc@example.com"] + }, + "gitPlatforms": [ + { + "platform": "github", + "owner": "username", + "repo": "repository-name", + "token": "ghp_xxxxxxxxxxxx" + }, + { + "platform": "gitlab", + "owner": "username", + "repo": "repository-name", + "token": "glpat-xxxxxxxxxxxx" + }, + { + "platform": "codeberg", + "owner": "username", + "repo": "repository-name", + "token": "xxxxxxxxxxxx" + }, + { + "platform": "onedev", + "owner": "project-path", + "repo": "repository-name", + "token": "xxxxxxxxxxxx", + "apiUrl": "https://your-onedev-instance.com" + }, + { + "platform": "custom", + "owner": "username", + "repo": "repository-name", + "token": "xxxxxxxxxxxx", + "apiUrl": "https://your-git-instance.com/api/v1" + } + ], + "enabled": true, + "notifyOn": ["1621", "1618"] + }, + "proofEvent": { /* Signed Nostr event (kind 30078) */ } +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +### GET `/api/user/messaging-preferences` + +Get preferences status (without decrypting). + +**Response:** +```json +{ + "configured": true, + "rateLimit": { + "remaining": 10, + "resetAt": null + } +} +``` + +### DELETE `/api/user/messaging-preferences` + +Delete messaging preferences. + +**Response:** +```json +{ + "success": true +} +``` + +## Security Best Practices + +1. **Key Management** + - Never commit keys to git + - Rotate keys periodically + - Use secret management services in production + - Consider HSM for maximum security + +2. **Monitoring** + - Monitor rate limit violations + - Alert on decryption failures + - Audit log all preference changes + +3. **Access Control** + - Only users with unlimited access can use this feature + - Requires valid signed Nostr event proof + - Server verifies all inputs + +4. **Data Protection** + - All data encrypted at rest + - Per-user key derivation + - HMAC lookup keys + - Rate limiting on decryption + +## Troubleshooting + +### "Decryption rate limit exceeded" + +User has exceeded 10 decryption attempts in 15 minutes. Wait for the window to reset. + +### "Messaging forwarding requires unlimited access" + +User must have relay write access (unlimited level) to use this feature. + +### "Failed to forward event" + +Check: +- Messaging platform API credentials +- Network connectivity +- Platform-specific error logs + +## Email Setup + +### Option 1: Direct SMTP + +Install nodemailer: +```bash +npm install nodemailer +``` + +Configure environment variables: +```bash +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM_ADDRESS=noreply@yourdomain.com +SMTP_FROM_NAME=GitRepublic +EMAIL_ENABLED=true +``` + +### Option 2: SMTP API + +Use an SMTP API service (e.g., SendGrid, Mailgun, AWS SES): +```bash +SMTP_API_URL=https://api.sendgrid.com/v3/mail/send +SMTP_API_KEY=your-api-key +EMAIL_ENABLED=true +``` + +## Git Platforms Setup + +### Supported Platforms + +- **GitHub** (`github`) - github.com +- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted) +- **Gitea** (`gitea`) - Self-hosted instances +- **Codeberg** (`codeberg`) - codeberg.org +- **Forgejo** (`forgejo`) - Self-hosted instances +- **OneDev** (`onedev`) - Self-hosted instances (requires apiUrl) +- **Custom** (`custom`) - Any Gitea-compatible API with custom URL + +### Creating Personal Access Tokens + +#### GitHub +1. Go to Settings → Developer settings → Personal access tokens → Tokens (classic) +2. Click "Generate new token (classic)" +3. Select `repo` scope +4. Generate and copy token + +#### GitLab +1. Go to Settings → Access Tokens +2. Create token with `api` scope +3. Generate and copy token + +#### Gitea/Codeberg/Forgejo +1. Go to Settings → Applications → Generate New Token +2. Select `repo` scope +3. Generate and copy token + +#### OneDev +1. Go to User Settings → Access Tokens +2. Create a new access token +3. Select appropriate scopes (typically `write:issue` and `write:pull-request`) +4. Generate and copy token +5. **Note**: OneDev is self-hosted, so you must provide the `apiUrl` (e.g., `https://your-onedev-instance.com`) + +### User Configuration + +Users provide: +- **Platform**: One of `github`, `gitlab`, `gitea`, `codeberg`, `forgejo`, `onedev`, or `custom` +- **Owner**: Username or organization name (project path for OneDev) +- **Repo**: Repository name (project name for OneDev) +- **Token**: Personal access token (stored encrypted) +- **API URL** (required for OneDev, optional for custom/self-hosted): Base URL of the instance (e.g., `https://your-onedev-instance.com`) + +### Event Mapping + +- **Nostr Issues (kind 1621)** → Platform Issues +- **Nostr PRs (kind 1618)** → Platform Pull Requests/Merge Requests (if branch info available) or Issues with PR label +- **Other events** → Platform Issues with event kind label + +### Platform-Specific Notes + +- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs +- **OneDev**: Uses `description` field and `source_branch`/`target_branch` for PRs. Requires `apiUrl` (self-hosted). API endpoints: `/api/projects/{owner}/{repo}/issues` and `/api/projects/{owner}/{repo}/pull-requests` +- **GitHub**: Uses `body` field and `head`/`base` for PRs +- **Gitea/Codeberg/Forgejo**: Compatible with GitHub API format +- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API + +### Security Note + +All tokens are stored encrypted in the database. Users should: +- Use tokens with minimal required scopes +- Rotate tokens periodically +- Revoke tokens if compromised + +## Future Enhancements + +- [ ] Support for more messaging platforms +- [ ] User-configurable message templates +- [ ] Webhook support +- [ ] Encrypted preferences sync across devices +- [ ] Per-repository forwarding rules +- [ ] HTML email templates \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7916b6b..b7eb8f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "codemirror-asciidoc": "^2.0.1", "highlight.js": "^11.10.0", "markdown-it": "^14.1.0", + "nodemailer": "^8.0.1", "nostr-tools": "^2.22.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -3673,6 +3674,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index c5c8af8..f0f1f60 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "codemirror-asciidoc": "^2.0.1", "highlight.js": "^11.10.0", "markdown-it": "^14.1.0", + "nodemailer": "^8.0.1", "nostr-tools": "^2.22.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -35,6 +36,7 @@ "@sveltejs/adapter-node": "^5.0.0", "@types/markdown-it": "^14.1.2", "@types/node": "^20.0.0", + "@types/nodemailer": "^6.4.14", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", diff --git a/src/lib/components/ForwardingConfig.svelte b/src/lib/components/ForwardingConfig.svelte new file mode 100644 index 0000000..ed37822 --- /dev/null +++ b/src/lib/components/ForwardingConfig.svelte @@ -0,0 +1,348 @@ + + +{#if loading} +
+ {#if showTitle} +

Event Forwarding

+ {/if} +

Loading...

+
+{:else if error} +
+ {#if showTitle} +

Event Forwarding

+ {/if} +

{error}

+
+{:else if !summary || !summary.configured} +
+ {#if showTitle} +

Event Forwarding

+ {/if} +

+ {compact ? 'Not configured' : 'No forwarding configured. Events will not be forwarded to external platforms.'} +

+
+{:else if !summary.enabled} +
+ {#if showTitle} +

Event Forwarding

+ {/if} +

Forwarding is disabled

+
+{:else} +
+ {#if showTitle} +

Event Forwarding

+ {/if} + +
+ {#if summary.platforms.telegram} +
+ 📱 + Telegram +
+ {/if} + + {#if summary.platforms.simplex} +
+ 💬 + SimpleX +
+ {/if} + + {#if summary.platforms.email} +
+ 📧 + Email +
+ {/if} + + {#if summary.platforms.gitPlatforms && summary.platforms.gitPlatforms.length > 0} + {#each summary.platforms.gitPlatforms as gitPlatform} +
+ {getPlatformIcon(gitPlatform.platform)} + + {getPlatformName(gitPlatform.platform)} + {#if !compact} + + ({gitPlatform.owner}/{gitPlatform.repo} + {#if gitPlatform.apiUrl} + * + {/if}) + + {/if} + +
+ {/each} + {/if} +
+ + {#if summary.notifyOn && summary.notifyOn.length > 0 && !compact} +
+ Forwarding events: + + {#each summary.notifyOn as kind} + {KIND_NAMES[kind] || `Kind ${kind}`} + {/each} + +
+ {/if} +
+{/if} + + diff --git a/src/lib/config.ts b/src/lib/config.ts index 52a371a..58fb547 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -21,7 +21,6 @@ export const DEFAULT_NOSTR_RELAYS = ? process.env.NOSTR_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ 'wss://theforest.nostr1.com', - 'wss://nostr.land', ]; /** @@ -32,12 +31,12 @@ export const DEFAULT_NOSTR_SEARCH_RELAYS = typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ - 'wss://relay.damus.io', - 'wss://thecitadel.nostr1.com', - 'wss://nostr21.com', - 'wss://profiles.nostr1.com', - "wss://relay.primal.net", - ...DEFAULT_NOSTR_RELAYS, + 'wss://nostr.land', + 'wss://relay.damus.io', + 'wss://thecitadel.nostr1.com', + 'wss://nostr21.com', + 'wss://profiles.nostr1.com', + "wss://relay.primal.net", ]; /** diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts new file mode 100644 index 0000000..771bbed --- /dev/null +++ b/src/lib/services/messaging/event-forwarder.ts @@ -0,0 +1,635 @@ +/** + * Event forwarding service + * Forwards Nostr events to messaging platforms (Telegram, SimpleX, Email, Git platforms) + * Only for users with unlimited access and configured preferences + */ + +import logger from '../logger.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { getPreferences } from './preferences-storage.js'; +import { getCachedUserLevel } from '../security/user-level-cache.js'; +import { KIND } from '../../types/nostr.js'; + +// ============================================================================ +// Types & Interfaces +// ============================================================================ + +export interface MessagingConfig { + telegram?: { + botToken: string; + enabled: boolean; + }; + simplex?: { + apiUrl: string; + apiKey: string; + enabled: boolean; + }; + email?: { + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPassword: string; + fromAddress: string; + fromName: string; + enabled: boolean; + }; + gitPlatforms?: { + enabled: boolean; + }; +} + +type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; + +interface GitPlatformConfig { + baseUrl: string; + issuesPath: string; + pullsPath: string; + authHeader: 'Bearer' | 'token'; + usesDescription: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc + usesSourceTargetBranch: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc + customHeaders?: Record; +} + +interface EventContent { + title: string; + body: string; + fullBody: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const KIND_NAMES: Record = { + 1621: 'Issue', + 1618: 'Pull Request', + 9802: 'Highlight', + 30617: 'Repository Announcement', + 1641: 'Ownership Transfer', + 24: 'Public Message' +}; + +const GIT_PLATFORM_CONFIGS: Record> = { + github: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + authHeader: 'Bearer', + usesDescription: false, + usesSourceTargetBranch: false, + customHeaders: { 'Accept': 'application/vnd.github.v3+json' } + }, + gitlab: { + issuesPath: '/projects/{owner}%2F{repo}/issues', + pullsPath: '/projects/{owner}%2F{repo}/merge_requests', + authHeader: 'Bearer', + usesDescription: true, + usesSourceTargetBranch: true + }, + gitea: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + authHeader: 'token', + usesDescription: false, + usesSourceTargetBranch: false + }, + codeberg: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + authHeader: 'token', + usesDescription: false, + usesSourceTargetBranch: false + }, + forgejo: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + authHeader: 'token', + usesDescription: false, + usesSourceTargetBranch: false + }, + onedev: { + issuesPath: '/api/projects/{owner}/{repo}/issues', + pullsPath: '/api/projects/{owner}/{repo}/pull-requests', + authHeader: 'Bearer', + usesDescription: true, + usesSourceTargetBranch: true + } +}; + +const MESSAGING_CONFIG: MessagingConfig = { + telegram: { + botToken: process.env.TELEGRAM_BOT_TOKEN || '', + enabled: process.env.TELEGRAM_ENABLED === 'true' + }, + simplex: { + apiUrl: process.env.SIMPLEX_API_URL || '', + apiKey: process.env.SIMPLEX_API_KEY || '', + enabled: process.env.SIMPLEX_ENABLED === 'true' + }, + email: { + smtpHost: process.env.SMTP_HOST || '', + smtpPort: parseInt(process.env.SMTP_PORT || '587', 10), + smtpUser: process.env.SMTP_USER || '', + smtpPassword: process.env.SMTP_PASSWORD || '', + fromAddress: process.env.SMTP_FROM_ADDRESS || '', + fromName: process.env.SMTP_FROM_NAME || 'GitRepublic', + enabled: process.env.EMAIL_ENABLED === 'true' + }, + gitPlatforms: { + enabled: process.env.GIT_PLATFORMS_ENABLED === 'true' + } +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function formatEventMessage(event: NostrEvent, userPubkeyHex: string): string { + const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`; + const userShort = userPubkeyHex.slice(0, 16) + '...'; + + // Special formatting for public messages (kind 24) + if (event.kind === KIND.PUBLIC_MESSAGE) { + const recipients = event.tags + .filter(tag => tag[0] === 'p' && tag[1]) + .map(tag => tag[1].slice(0, 16) + '...'); + + let message = `💬 Public Message from ${userShort}`; + if (recipients.length > 0) { + message += ` to ${recipients.join(', ')}`; + } + message += '\n\n'; + message += event.content || 'No content'; + return message; + } + + let message = `🔔 ${kindName} published by ${userShort}\n\n`; + + if (event.content) { + const content = event.content.length > 500 + ? event.content.slice(0, 500) + '...' + : event.content; + message += content; + } else { + message += 'No content'; + } + + return message; +} + +function extractEventContent(event: NostrEvent): EventContent { + const titleTag = event.tags.find(t => t[0] === 'title' || t[0] === 'subject'); + const title = titleTag?.[1] || (event.content ? event.content.split('\n')[0].slice(0, 100) : 'Untitled'); + const body = event.content || ''; + const metadata = `\n\n---\n*Forwarded from GitRepublic (Nostr)*\n*Event ID: ${event.id}*\n*Kind: ${event.kind}*`; + const fullBody = body + metadata; + + return { title, body, fullBody }; +} + +function getGitPlatformConfig( + platform: GitPlatform, + customApiUrl?: string +): GitPlatformConfig { + if (platform === 'onedev') { + if (!customApiUrl) { + throw new Error('OneDev requires apiUrl to be provided (self-hosted instance)'); + } + return { + ...GIT_PLATFORM_CONFIGS.onedev, + baseUrl: customApiUrl + }; + } + + if (customApiUrl) { + // Custom platform - assume Gitea-compatible format + return { + baseUrl: customApiUrl, + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + authHeader: 'Bearer', + usesDescription: false, + usesSourceTargetBranch: false + }; + } + + const config = GIT_PLATFORM_CONFIGS[platform]; + if (!config) { + throw new Error(`Unsupported Git platform: ${platform}`); + } + + // Get baseUrl from platform config or use default + const baseUrls: Record = { + github: 'https://api.github.com', + gitlab: 'https://gitlab.com/api/v4', + gitea: 'https://codeberg.org/api/v1', + codeberg: 'https://codeberg.org/api/v1', + forgejo: 'https://forgejo.org/api/v1' + }; + + return { + ...config, + baseUrl: baseUrls[platform] || '' + }; +} + +function buildGitPlatformUrl( + config: GitPlatformConfig, + owner: string, + repo: string, + pathType: 'issues' | 'pulls', + platform: GitPlatform +): string { + const path = pathType === 'issues' ? config.issuesPath : config.pullsPath; + + if (platform === 'onedev') { + const projectPath = repo ? `${owner}/${repo}` : owner; + const endpoint = pathType === 'issues' ? 'issues' : 'pull-requests'; + return `${config.baseUrl}/api/projects/${encodeURIComponent(projectPath)}/${endpoint}`; + } + + const urlPath = path + .replace('{owner}', encodeURIComponent(owner)) + .replace('{repo}', encodeURIComponent(repo)); + + return `${config.baseUrl}${urlPath}`; +} + +function buildAuthHeader(config: GitPlatformConfig, token: string): string { + return config.authHeader === 'Bearer' ? `Bearer ${token}` : `token ${token}`; +} + +function buildIssuePayload( + content: EventContent, + config: GitPlatformConfig, + prefix?: string +): Record { + const title = prefix ? `${prefix}${content.title}` : content.title; + const payload: Record = { title }; + + if (config.usesDescription) { + payload.description = content.fullBody; + } else { + payload.body = content.fullBody; + } + + return payload; +} + +function buildPullRequestPayload( + content: EventContent, + config: GitPlatformConfig, + headBranch: string, + baseBranch: string +): Record { + const payload: Record = { + title: content.title + }; + + if (config.usesDescription) { + payload.description = content.fullBody; + } else { + payload.body = content.fullBody; + } + + if (config.usesSourceTargetBranch) { + payload.source_branch = headBranch; + payload.target_branch = baseBranch; + } else { + payload.head = headBranch; + payload.base = baseBranch; + } + + return payload; +} + +async function makeGitPlatformRequest( + url: string, + method: string, + headers: Record, + payload: Record, + platform: string +): Promise { + const response = await fetch(url, { + method, + headers, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(`${platform} API error: ${error.message || response.statusText}`); + } +} + +// ============================================================================ +// Platform Forwarding Functions +// ============================================================================ + +async function sendToTelegram(message: string, chatId: string): Promise { + if (!MESSAGING_CONFIG.telegram?.enabled || !MESSAGING_CONFIG.telegram?.botToken) { + return; + } + + try { + const url = `https://api.telegram.org/bot${MESSAGING_CONFIG.telegram.botToken}/sendMessage`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + parse_mode: 'Markdown', + disable_web_page_preview: true + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Telegram API error: ${error.description || response.statusText}`); + } + } catch (error) { + logger.error({ error, chatId: chatId.slice(0, 10) + '...' }, 'Failed to send to Telegram'); + throw error; + } +} + +async function sendToSimpleX(message: string, contactId: string): Promise { + if (!MESSAGING_CONFIG.simplex?.enabled || !MESSAGING_CONFIG.simplex?.apiUrl) { + return; + } + + try { + const response = await fetch(`${MESSAGING_CONFIG.simplex.apiUrl}/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${MESSAGING_CONFIG.simplex.apiKey}` + }, + body: JSON.stringify({ + contact_id: contactId, + message: message + }) + }); + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error'); + throw new Error(`SimpleX API error: ${error}`); + } + } catch (error) { + logger.error({ error, contactId: contactId.slice(0, 10) + '...' }, 'Failed to send to SimpleX'); + throw error; + } +} + +async function sendEmail( + subject: string, + message: string, + to: string[], + cc?: string[] +): Promise { + if (!MESSAGING_CONFIG.email?.enabled || !MESSAGING_CONFIG.email?.smtpHost) { + return; + } + + try { + const smtpUrl = process.env.SMTP_API_URL; + + if (smtpUrl) { + // Use SMTP API if provided + const response = await fetch(smtpUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}` + }, + body: JSON.stringify({ + from: MESSAGING_CONFIG.email.fromAddress, + to, + cc: cc || [], + subject, + text: message + }) + }); + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error'); + throw new Error(`Email API error: ${error}`); + } + } else { + // Direct SMTP using nodemailer + try { + const nodemailer = await import('nodemailer'); + const { createTransport } = nodemailer; + + const transporter = createTransport({ + host: MESSAGING_CONFIG.email.smtpHost, + port: MESSAGING_CONFIG.email.smtpPort, + secure: MESSAGING_CONFIG.email.smtpPort === 465, + auth: { + user: MESSAGING_CONFIG.email.smtpUser, + pass: MESSAGING_CONFIG.email.smtpPassword + } + }); + + await transporter.sendMail({ + from: `"${MESSAGING_CONFIG.email.fromName}" <${MESSAGING_CONFIG.email.fromAddress}>`, + to: to.join(', '), + cc: cc && cc.length > 0 ? cc.join(', ') : undefined, + subject, + text: message + }); + } catch (importError) { + throw new Error( + 'Email sending requires either SMTP_API_URL or nodemailer package. ' + + 'Install with: npm install nodemailer' + ); + } + } + } catch (error) { + logger.error({ + error, + toCount: to.length, + ccCount: cc?.length || 0 + }, 'Failed to send email'); + throw error; + } +} + +async function forwardToGitPlatform( + event: NostrEvent, + platform: GitPlatform, + owner: string, + repo: string, + token: string, + customApiUrl?: string +): Promise { + if (!MESSAGING_CONFIG.gitPlatforms?.enabled) { + return; + } + + try { + const config = getGitPlatformConfig(platform, customApiUrl); + const content = extractEventContent(event); + + const authHeader = buildAuthHeader(config, token); + const headers: Record = { + 'Authorization': authHeader, + 'Content-Type': 'application/json', + 'User-Agent': 'GitRepublic', + ...(config.customHeaders || {}) + }; + + // Handle different event kinds + if (event.kind === KIND.ISSUE) { + const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); + const payload = buildIssuePayload(content, config); + await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); + + } else if (event.kind === KIND.PULL_REQUEST) { + const headTag = event.tags.find(t => t[0] === 'head'); + const baseTag = event.tags.find(t => t[0] === 'base'); + + if (headTag?.[1] && baseTag?.[1]) { + // Create actual PR with branch info + const pullsUrl = buildGitPlatformUrl(config, owner, repo, 'pulls', platform); + const payload = buildPullRequestPayload(content, config, headTag[1], baseTag[1]); + await makeGitPlatformRequest(pullsUrl, 'POST', headers, payload, platform); + } else { + // No branch info, create issue with PR label + const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); + const payload = buildIssuePayload(content, config, '[PR] '); + if (Array.isArray(payload.labels)) { + payload.labels.push('pull-request'); + } else { + payload.labels = ['pull-request']; + } + await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); + } + + } else { + // Other event types: create issue with event kind label + const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform); + const payload = buildIssuePayload(content, config, `[Event ${event.kind}] `); + payload.labels = [`nostr-kind-${event.kind}`]; + await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform); + } + + } catch (error) { + logger.error({ + error, + platform, + owner, + repo: repo.slice(0, 10) + '...', + eventKind: event.kind + }, `Failed to forward to ${platform}`); + throw error; + } +} + +// ============================================================================ +// Main Export +// ============================================================================ + +/** + * Forward event to configured messaging platforms + * Only forwards if: + * - User has unlimited access + * - User has preferences configured and enabled + * - Event kind is in notifyOn list (if specified) + */ +export async function forwardEventIfEnabled( + event: NostrEvent, + userPubkeyHex: string +): Promise { + try { + // Early returns for eligibility checks + const cached = getCachedUserLevel(userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + return; + } + + const preferences = await getPreferences(userPubkeyHex); + if (!preferences || !preferences.enabled) { + return; + } + + if (preferences.notifyOn && preferences.notifyOn.length > 0) { + if (!preferences.notifyOn.includes(event.kind.toString())) { + return; + } + } + + // Prepare message content + const message = formatEventMessage(event, userPubkeyHex); + const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`; + const subject = `GitRepublic: ${kindName} Notification`; + + // Collect all forwarding promises + const promises: Promise[] = []; + + // Messaging platforms + if (preferences.telegram) { + promises.push( + sendToTelegram(message, preferences.telegram) + .catch(err => logger.warn({ error: err }, 'Telegram forwarding failed')) + ); + } + + if (preferences.simplex) { + promises.push( + sendToSimpleX(message, preferences.simplex) + .catch(err => logger.warn({ error: err }, 'SimpleX forwarding failed')) + ); + } + + if (preferences.email?.to && preferences.email.to.length > 0) { + promises.push( + sendEmail(subject, message, preferences.email.to, preferences.email.cc) + .catch(err => logger.warn({ error: err }, 'Email forwarding failed')) + ); + } + + // Git platforms + if (preferences.gitPlatforms && preferences.gitPlatforms.length > 0) { + for (const gitPlatform of preferences.gitPlatforms) { + if (!gitPlatform.owner || !gitPlatform.repo || !gitPlatform.token) { + continue; + } + + if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) { + logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided'); + continue; + } + + promises.push( + forwardToGitPlatform( + event, + gitPlatform.platform, + gitPlatform.owner, + gitPlatform.repo, + gitPlatform.token, + gitPlatform.apiUrl + ) + .catch(err => logger.warn({ error: err, platform: gitPlatform.platform }, 'Git platform forwarding failed')) + ); + } + } + + // Execute all forwarding in parallel + await Promise.allSettled(promises); + + logger.debug({ + eventId: event.id.slice(0, 16) + '...', + userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', + platforms: Object.keys(preferences).filter(k => k !== 'enabled' && k !== 'notifyOn' && preferences[k as keyof typeof preferences]) + }, 'Forwarded event to messaging platforms'); + + } catch (error) { + // Log but don't throw - forwarding failures shouldn't break event publishing + logger.error({ + error, + eventId: event.id?.slice(0, 16) + '...', + userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' + }, 'Failed to forward event to messaging platforms'); + } +} diff --git a/src/lib/services/messaging/nodemailer.d.ts b/src/lib/services/messaging/nodemailer.d.ts new file mode 100644 index 0000000..39be14c --- /dev/null +++ b/src/lib/services/messaging/nodemailer.d.ts @@ -0,0 +1,31 @@ +/** + * Type declarations for nodemailer + * This file ensures TypeScript recognizes nodemailer types + */ + +declare module 'nodemailer' { + export interface TransportOptions { + host?: string; + port?: number; + secure?: boolean; + auth?: { + user: string; + pass: string; + }; + } + + export interface MailOptions { + from?: string; + to?: string; + cc?: string; + subject?: string; + text?: string; + html?: string; + } + + export interface Transporter { + sendMail(options: MailOptions): Promise<{ messageId: string }>; + } + + export function createTransport(options: TransportOptions): Transporter; +} diff --git a/src/lib/services/messaging/preferences-storage.ts b/src/lib/services/messaging/preferences-storage.ts new file mode 100644 index 0000000..2401202 --- /dev/null +++ b/src/lib/services/messaging/preferences-storage.ts @@ -0,0 +1,383 @@ +/** + * Secure messaging preferences storage + * + * SECURITY FEATURES: + * - Encrypted salt storage (separate key) + * - HMAC-based lookup keys (pubkey never directly used as DB key) + * - Rate limiting on decryption attempts + * - Per-user key derivation (master key + pubkey + salt) + * - AES-256-GCM authenticated encryption + * - Random IV per encryption + */ + +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, + createHash, + createHmac +} from 'crypto'; +import logger from '../logger.js'; +import { getCachedUserLevel } from '../security/user-level-cache.js'; + +// Encryption keys from environment (NEVER commit these!) +const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY; +const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY; +const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET; + +if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) { + throw new Error( + 'Missing required environment variables: ' + + 'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET' + ); +} + +export interface MessagingPreferences { + telegram?: string; // Chat ID or username + simplex?: string; // Contact ID + email?: { + to: string[]; // To: email addresses + cc?: string[]; // CC: email addresses (optional) + }; + gitPlatforms?: Array<{ + platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; + owner: string; // Repository owner (username or org) + repo: string; // Repository name + token: string; // Personal access token (encrypted) + apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms) + }>; + enabled: boolean; + notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618']) +} + +interface StoredPreferences { + encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY + encrypted: string; // Preferences encrypted with derived key +} + +// Rate limiting: track decryption attempts per pubkey +interface DecryptionAttempt { + count: number; + resetAt: number; +} + +const decryptionAttempts = new Map(); +const MAX_DECRYPTION_ATTEMPTS = 10; // Max attempts per window +const DECRYPTION_WINDOW_MS = 15 * 60 * 1000; // 15 minutes + +// Cleanup expired rate limit entries +setInterval(() => { + const now = Date.now(); + for (const [key, attempt] of decryptionAttempts.entries()) { + if (attempt.resetAt < now) { + decryptionAttempts.delete(key); + } + } +}, 60 * 1000); // Cleanup every minute + +/** + * Generate HMAC-based lookup key from pubkey + * Prevents pubkey from being directly used as database key + */ +function getLookupKey(userPubkeyHex: string): string { + if (!LOOKUP_SECRET) { + throw new Error('LOOKUP_SECRET not configured'); + } + return createHmac('sha256', LOOKUP_SECRET) + .update(userPubkeyHex) + .digest('hex'); +} + +/** + * Check and enforce rate limiting on decryption attempts + */ +function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } { + const lookupKey = getLookupKey(userPubkeyHex); + const now = Date.now(); + + const attempt = decryptionAttempts.get(lookupKey); + + if (!attempt || attempt.resetAt < now) { + // New window or expired + decryptionAttempts.set(lookupKey, { + count: 1, + resetAt: now + DECRYPTION_WINDOW_MS + }); + return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - 1 }; + } + + if (attempt.count >= MAX_DECRYPTION_ATTEMPTS) { + logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'Decryption rate limit exceeded'); + return { allowed: false, remaining: 0 }; + } + + attempt.count++; + return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count }; +} + +/** + * Encrypt data with AES-256-GCM + */ +function encryptAES256GCM(key: Buffer, plaintext: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-gcm', key, iv); + + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + return JSON.stringify({ + iv: iv.toString('hex'), + tag: authTag.toString('hex'), + data: encrypted + }); +} + +/** + * Decrypt data with AES-256-GCM + */ +function decryptAES256GCM(key: Buffer, encryptedData: string): string { + const { iv, tag, data } = JSON.parse(encryptedData); + + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')); + decipher.setAuthTag(Buffer.from(tag, 'hex')); + + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +/** + * Encrypt salt with SALT_ENCRYPTION_KEY + */ +function encryptSalt(salt: string): string { + if (!SALT_ENCRYPTION_KEY) { + throw new Error('SALT_ENCRYPTION_KEY not configured'); + } + const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest(); + return encryptAES256GCM(key, salt); +} + +/** + * Decrypt salt with SALT_ENCRYPTION_KEY + */ +function decryptSalt(encryptedSalt: string): string { + if (!SALT_ENCRYPTION_KEY) { + throw new Error('SALT_ENCRYPTION_KEY not configured'); + } + const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest(); + return decryptAES256GCM(key, encryptedSalt); +} + +/** + * Derive per-user encryption key + * Uses: master key + user pubkey + salt + */ +function deriveUserKey(userPubkeyHex: string, salt: string): Buffer { + if (!ENCRYPTION_KEY) { + throw new Error('ENCRYPTION_KEY not configured'); + } + // Combine pubkey and salt for key derivation + // Attacker needs: master key + pubkey + salt + const combinedSalt = `${userPubkeyHex}:${salt}`; + return scryptSync( + ENCRYPTION_KEY, + combinedSalt, + 32, // 256-bit key + { N: 16384, r: 8, p: 1 } // scrypt parameters + ); +} + +/** + * In-memory storage (in production, use Redis or database) + * Key: HMAC(pubkey), Value: {encryptedSalt, encrypted} + */ +const preferencesStore = new Map(); + +/** + * Store user messaging preferences securely + * + * @param userPubkeyHex - User's public key (hex) + * @param preferences - Messaging preferences to store + * @throws Error if user doesn't have unlimited access + */ +export async function storePreferences( + userPubkeyHex: string, + preferences: MessagingPreferences +): Promise { + // Verify user has unlimited access + const cached = getCachedUserLevel(userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + throw new Error('Messaging forwarding requires unlimited access'); + } + + // Generate random salt (unique per user, per save) + const salt = randomBytes(32).toString('hex'); + + // Encrypt salt with separate key + const encryptedSalt = encryptSalt(salt); + + // Derive user-specific encryption key + const userKey = deriveUserKey(userPubkeyHex, salt); + + // 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 + }; + + preferencesStore.set(lookupKey, JSON.stringify(stored)); + + logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'Stored messaging preferences'); +} + +/** + * Retrieve and decrypt user messaging preferences + * + * @param userPubkeyHex - User's public key (hex) + * @returns Decrypted preferences or null if not found + * @throws Error if rate limit exceeded or decryption fails + */ +export async function getPreferences( + userPubkeyHex: string +): Promise { + // Check rate limit + const rateLimit = checkRateLimit(userPubkeyHex); + if (!rateLimit.allowed) { + throw new Error( + `Decryption rate limit exceeded. Try again in ${Math.ceil( + (decryptionAttempts.get(getLookupKey(userPubkeyHex))!.resetAt - Date.now()) / 1000 / 60 + )} minutes.` + ); + } + + // Get stored data using HMAC lookup key + const lookupKey = getLookupKey(userPubkeyHex); + const storedJson = preferencesStore.get(lookupKey); + + if (!storedJson) { + 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.'); + } +} + +/** + * Delete user messaging preferences + */ +export async function deletePreferences(userPubkeyHex: string): Promise { + const lookupKey = getLookupKey(userPubkeyHex); + preferencesStore.delete(lookupKey); + decryptionAttempts.delete(lookupKey); + + logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'Deleted messaging preferences'); +} + +/** + * Check if user has preferences configured + */ +export async function hasPreferences(userPubkeyHex: string): Promise { + const lookupKey = getLookupKey(userPubkeyHex); + return preferencesStore.has(lookupKey); +} + +/** + * Get rate limit status for a user + */ +export function getRateLimitStatus(userPubkeyHex: string): { + remaining: number; + resetAt: number | null; +} { + const lookupKey = getLookupKey(userPubkeyHex); + const attempt = decryptionAttempts.get(lookupKey); + + if (!attempt) { + return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null }; + } + + if (attempt.resetAt < Date.now()) { + return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null }; + } + + return { + remaining: Math.max(0, MAX_DECRYPTION_ATTEMPTS - attempt.count), + resetAt: attempt.resetAt + }; +} + +/** + * Get a safe summary of user preferences (without sensitive tokens) + * This decrypts preferences but only returns safe information + */ +export async function getPreferencesSummary(userPubkeyHex: string): Promise<{ + configured: boolean; + enabled: boolean; + platforms: { + telegram?: boolean; + simplex?: boolean; + email?: boolean; + gitPlatforms?: Array<{ + platform: string; + owner: string; + repo: string; + apiUrl?: string; + }>; + }; + notifyOn?: string[]; +} | null> { + const preferences = await getPreferences(userPubkeyHex); + + if (!preferences) { + return null; + } + + return { + configured: true, + enabled: preferences.enabled, + platforms: { + telegram: !!preferences.telegram, + simplex: !!preferences.simplex, + email: !!preferences.email, + gitPlatforms: preferences.gitPlatforms?.map(gp => ({ + platform: gp.platform, + owner: gp.owner, + repo: gp.repo, + apiUrl: gp.apiUrl + // token is intentionally omitted + })) + }, + notifyOn: preferences.notifyOn + }; +} diff --git a/src/lib/services/nostr/public-messages-service.ts b/src/lib/services/nostr/public-messages-service.ts new file mode 100644 index 0000000..0c0f8bb --- /dev/null +++ b/src/lib/services/nostr/public-messages-service.ts @@ -0,0 +1,215 @@ +/** + * Service for handling NIP-24 public messages (kind 24) + * Public messages are direct messages that can be seen by anyone + */ + +import { NostrClient } from './nostr-client.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { KIND } from '../../types/nostr.js'; +import { getUserRelays } from './user-relays.js'; +import { combineRelays } from '../../config.js'; +import logger from '../logger.js'; +import { verifyEvent } from 'nostr-tools'; + +export interface PublicMessage extends NostrEvent { + kind: typeof KIND.PUBLIC_MESSAGE; +} + +export class PublicMessagesService { + private nostrClient: NostrClient; + + constructor(relays: string[]) { + this.nostrClient = new NostrClient(relays); + } + + /** + * Fetch public messages sent to a user (where user is in p tags) + */ + async getMessagesToUser( + userPubkey: string, + limit: number = 50 + ): Promise { + try { + const events = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.PUBLIC_MESSAGE], + '#p': [userPubkey], // Messages where user is a recipient + limit + } + ]); + + // Verify events and filter to only valid kind 24 + const validMessages = events + .filter((e): e is PublicMessage => { + if (e.kind !== KIND.PUBLIC_MESSAGE) return false; + if (!verifyEvent(e)) { + logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); + return false; + } + return true; + }) + .sort((a, b) => b.created_at - a.created_at); // Newest first + + return validMessages; + } catch (error) { + logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages to user'); + throw error; + } + } + + /** + * Fetch public messages sent by a user + */ + async getMessagesFromUser( + userPubkey: string, + limit: number = 50 + ): Promise { + try { + const events = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.PUBLIC_MESSAGE], + authors: [userPubkey], + limit + } + ]); + + // Verify events + const validMessages = events + .filter((e): e is PublicMessage => { + if (e.kind !== KIND.PUBLIC_MESSAGE) return false; + if (!verifyEvent(e)) { + logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); + return false; + } + return true; + }) + .sort((a, b) => b.created_at - a.created_at); // Newest first + + return validMessages; + } catch (error) { + logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages from user'); + throw error; + } + } + + /** + * Fetch all public messages involving a user (sent to or from) + */ + async getAllMessagesForUser( + userPubkey: string, + limit: number = 50 + ): Promise { + try { + const [toUser, fromUser] = await Promise.all([ + this.getMessagesToUser(userPubkey, limit), + this.getMessagesFromUser(userPubkey, limit) + ]); + + // Combine and deduplicate by event ID + const messageMap = new Map(); + [...toUser, ...fromUser].forEach(msg => { + messageMap.set(msg.id, msg); + }); + + // Sort by created_at descending + return Array.from(messageMap.values()) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, limit); + } catch (error) { + logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch all public messages for user'); + throw error; + } + } + + /** + * Create and publish a public message + * Messages are sent to inbox relays of recipients and outbox relay of sender + */ + async sendPublicMessage( + senderPubkey: string, + content: string, + recipients: Array<{ pubkey: string; relay?: string }>, + senderRelays?: string[] + ): Promise { + if (!content.trim()) { + throw new Error('Message content cannot be empty'); + } + + if (recipients.length === 0) { + throw new Error('At least one recipient is required'); + } + + // Build p tags for recipients + const pTags: string[][] = recipients.map(recipient => { + const tag: string[] = ['p', recipient.pubkey]; + if (recipient.relay) { + tag.push(recipient.relay); + } + return tag; + }); + + // Create the event (will be signed by client) + const messageEvent: Omit = { + pubkey: senderPubkey, + kind: KIND.PUBLIC_MESSAGE, + created_at: Math.floor(Date.now() / 1000), + tags: pTags, + content: content.trim() + }; + + // Get sender's outbox relays if not provided + let outboxRelays: string[] = senderRelays || []; + if (outboxRelays.length === 0) { + const { outbox } = await getUserRelays(senderPubkey, this.nostrClient); + outboxRelays = outbox; + } + + // Get inbox relays for each recipient + const recipientInboxes = new Set(); + for (const recipient of recipients) { + if (recipient.relay) { + recipientInboxes.add(recipient.relay); + } else { + // Fetch recipient's inbox relays + try { + const { inbox } = await getUserRelays(recipient.pubkey, this.nostrClient); + inbox.forEach(relay => recipientInboxes.add(relay)); + } catch (error) { + logger.warn({ error, recipient: recipient.pubkey.slice(0, 16) + '...' }, 'Failed to fetch recipient inbox relays'); + } + } + } + + // Combine all relays: sender's outbox + all recipient inboxes + const allRelays = combineRelays([...outboxRelays, ...Array.from(recipientInboxes)]); + + // Return the event (client will sign and publish) + return messageEvent as PublicMessage; + } + + /** + * Get recipients from a public message + */ + getRecipients(message: PublicMessage): Array<{ pubkey: string; relay?: string }> { + return message.tags + .filter(tag => tag[0] === 'p' && tag[1]) + .map(tag => ({ + pubkey: tag[1], + relay: tag[2] || undefined + })); + } + + /** + * Check if a message is sent to a specific user + */ + isMessageToUser(message: PublicMessage, userPubkey: string): boolean { + return message.tags.some(tag => tag[0] === 'p' && tag[1] === userPubkey); + } + + /** + * Check if a message is from a specific user + */ + isMessageFromUser(message: PublicMessage, userPubkey: string): boolean { + return message.pubkey === userPubkey; + } +} diff --git a/src/lib/services/security/security-audit.md b/src/lib/services/security/security-audit.md new file mode 100644 index 0000000..091f23d --- /dev/null +++ b/src/lib/services/security/security-audit.md @@ -0,0 +1,112 @@ +# Security Audit Report + +## Overview +This document outlines the security measures implemented in the GitRepublic application. + +## Security Measures Implemented + +### 1. Server-Side Rate Limiting +- **Location**: `src/lib/services/security/rate-limiter.ts` +- **Implementation**: Server-side rate limiting in `hooks.server.ts` +- **Features**: + - Per-IP and per-user rate limiting + - Different limits for authenticated vs anonymous users + - Configurable via environment variables + - Automatic cleanup of expired entries +- **Security**: Client-side rate limiting removed (was insecure) + +### 2. User Level Verification +- **Location**: `src/routes/api/user/level/+server.ts` +- **Implementation**: Server-side verification of relay write access +- **Features**: + - Verifies NIP-98 proof events server-side + - Cannot be bypassed by client manipulation + - Three-tier access levels: unlimited, rate_limited, strictly_rate_limited +- **Security**: Client-side checks are UI-only, actual enforcement is server-side + +### 3. Input Validation +- **Location**: `src/lib/utils/input-validation.ts` +- **Features**: + - Repository name validation + - File path validation (prevents path traversal) + - Pubkey format validation + - Commit message validation + - Branch name validation + - String sanitization (XSS prevention) + +### 4. Security Headers +- **Location**: `src/hooks.server.ts` +- **Headers Added**: + - `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing + - `X-Frame-Options: DENY` - Prevents clickjacking + - `X-XSS-Protection: 1; mode=block` - XSS protection + - `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information + - `Permissions-Policy` - Restricts browser features + - `Content-Security-Policy` - Restricts resource loading + +### 5. Audit Logging +- **Location**: `src/lib/services/security/audit-logger.ts` +- **Features**: + - Comprehensive logging of security events + - Automatic log rotation + - Configurable retention period + - Sanitization of sensitive data + - Pubkey truncation for privacy + +### 6. Session Management +- **Location**: `src/lib/services/activity-tracker.ts` +- **Features**: + - 24-hour session timeout + - Activity tracking (ONLY timestamp, no activity details) + - Automatic logout on expiry + - Secure storage in localStorage (with XSS protections) +- **Privacy**: Only stores timestamp of last activity. No information about what the user did is stored. + +### 7. Authentication +- **NIP-98 Authentication**: Server-side verification of HTTP auth events +- **NIP-07 Integration**: Client-side key management (keys never leave browser) +- **Relay Write Proof**: Verifies users can write to Nostr relays + +## Security Best Practices + +### Client-Side Security +1. **No Sensitive Logic**: All security-critical operations are server-side +2. **Input Validation**: All user inputs are validated and sanitized +3. **XSS Prevention**: Content sanitization and CSP headers +4. **Session Management**: Secure session tracking with automatic expiry + +### Server-Side Security +1. **Rate Limiting**: Prevents abuse and DoS attacks +2. **Input Validation**: Server-side validation of all inputs +3. **Path Traversal Prevention**: Strict path validation +4. **Audit Logging**: Comprehensive security event logging +5. **Error Handling**: Secure error messages that don't leak information + +## Known Limitations + +1. **localStorage Security**: Activity tracking uses localStorage which is vulnerable to XSS + - **Mitigation**: CSP headers and input sanitization reduce XSS risk + - **Future**: Consider httpOnly cookies for session management + +2. **In-Memory Rate Limiting**: Current implementation uses in-memory storage + - **Mitigation**: Works for single-instance deployments + - **Future**: Use Redis for distributed rate limiting + +3. **Client-Side User Level**: User level is determined client-side for UI + - **Mitigation**: Actual enforcement is server-side via rate limiting + - **Future**: Consider server-side session management + +## Recommendations + +1. **Implement CSRF Protection**: Add CSRF tokens for state-changing operations +2. **Add Request Signing**: Sign all API requests to prevent replay attacks +3. **Implement Rate Limiting Per Endpoint**: More granular rate limiting +4. **Add IP Reputation Checking**: Block known malicious IPs +5. **Implement WAF**: Web Application Firewall for additional protection +6. **Regular Security Audits**: Periodic security reviews and penetration testing + +## Compliance + +- **GDPR**: User data is minimal (pubkeys only), no personal information stored +- **Security Logging**: All security events are logged for compliance +- **Data Retention**: Configurable log retention periods diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 2548c6c..92506a7 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -47,6 +47,7 @@ export const KIND = { RELAY_LIST: 10002, // NIP-65: Relay list metadata NIP98_AUTH: 27235, // NIP-98: HTTP authentication event HIGHLIGHT: 9802, // NIP-84: Highlight event + PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) } as const; export interface Issue extends NostrEvent { diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts index 9415563..426f633 100644 --- a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -13,6 +13,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; import { decodeNpubToHex } from '$lib/utils/npub-utils.js'; +import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; /** * GET - Get highlights for a pull request @@ -85,6 +86,17 @@ export const POST: RequestHandler = withRepoValidation( throw handleApiError(new Error('Failed to publish to all relays'), { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish to all relays'); } + // Forward to messaging platforms if user has unlimited access and preferences configured + // Decode userPubkey if it's an npub + const userPubkeyHex = requestContext.userPubkeyHex || (userPubkey ? decodeNpubToHex(userPubkey) : null); + if (userPubkeyHex && result.success.length > 0) { + forwardEventIfEnabled(highlightEvent as NostrEvent, userPubkeyHex) + .catch(err => { + // Log but don't fail the request - forwarding is optional + console.error('Failed to forward event to messaging platforms:', err); + }); + } + return json({ success: true, event: highlightEvent, published: result }); }, { operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index cb6f32b..84918d9 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -9,6 +9,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { @@ -39,6 +40,15 @@ export const POST: RequestHandler = withRepoValidation( throw handleApiError(new Error('Failed to publish issue to all relays'), { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish issue to all relays'); } + // Forward to messaging platforms if user has unlimited access and preferences configured + if (requestContext.userPubkeyHex && result.success.length > 0) { + forwardEventIfEnabled(issueEvent, requestContext.userPubkeyHex) + .catch(err => { + // Log but don't fail the request - forwarding is optional + console.error('Failed to forward event to messaging platforms:', err); + }); + } + return json({ success: true, event: issueEvent, published: result }); }, { operation: 'createIssue', requireRepoAccess: false } // Issues can be created by anyone with access diff --git a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts index 510e4b8..88be3f7 100644 --- a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts @@ -10,6 +10,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { @@ -40,6 +41,15 @@ export const POST: RequestHandler = withRepoValidation( throw handleApiError(new Error('Failed to publish pull request to all relays'), { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish pull request to all relays'); } + // Forward to messaging platforms if user has unlimited access and preferences configured + if (requestContext.userPubkeyHex && result.success.length > 0) { + forwardEventIfEnabled(prEvent, requestContext.userPubkeyHex) + .catch(err => { + // Log but don't fail the request - forwarding is optional + console.error('Failed to forward event to messaging platforms:', err); + }); + } + return json({ success: true, event: prEvent, published: result }); }, { operation: 'createPR', requireRepoAccess: false } // PRs can be created by anyone with access diff --git a/src/routes/api/user/messaging-preferences/+server.ts b/src/routes/api/user/messaging-preferences/+server.ts new file mode 100644 index 0000000..ab188fb --- /dev/null +++ b/src/routes/api/user/messaging-preferences/+server.ts @@ -0,0 +1,211 @@ +/** + * API endpoint for managing messaging preferences + * + * SECURITY: + * - Requires unlimited user access level + * - Verifies signed Nostr event proof + * - Stores encrypted preferences with multiple security layers + */ + +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.js'; +import { getCachedUserLevel } from '$lib/services/security/user-level-cache.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.js'; + +/** + * POST - Save messaging preferences + * Requires: + * - Signed Nostr event (kind 30078) as proof + * - User must have unlimited access level + */ +export const POST: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + const clientIp = requestContext.clientIp || 'unknown'; + + try { + const body = await event.request.json(); + const { preferences, proofEvent } = body; + + // Validate input + if (!preferences || !proofEvent) { + auditLogger.log({ + user: requestContext.userPubkeyHex || undefined, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: 'Missing preferences or proof event' + }); + return error(400, 'Missing required fields: preferences and proofEvent'); + } + + // Verify proof event signature + if (!verifyEvent(proofEvent)) { + auditLogger.log({ + user: requestContext.userPubkeyHex || undefined, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: 'Invalid proof event signature' + }); + return error(400, 'Invalid proof event signature'); + } + + const userPubkeyHex = proofEvent.pubkey; + + // Verify it's a valid preferences event (kind 30078, d tag = 'gitrepublic-messaging') + if (proofEvent.kind !== 30078) { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: 'Invalid event kind' + }); + return error(400, 'Proof event must be kind 30078'); + } + + const dTag = proofEvent.tags.find(t => t[0] === 'd'); + if (dTag?.[1] !== 'gitrepublic-messaging') { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: 'Invalid event d tag' + }); + return error(400, 'Proof event must have d tag "gitrepublic-messaging"'); + } + + // Verify user has unlimited access + const cached = getCachedUserLevel(userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: 'Requires unlimited access' + }); + return error(403, 'Messaging forwarding requires unlimited access level'); + } + + // Validate preferences structure + if (typeof preferences.enabled !== 'boolean') { + return error(400, 'Invalid preferences: enabled must be boolean'); + } + + // Store preferences (will encrypt and store securely) + await storePreferences(userPubkeyHex, preferences as MessagingPreferences); + + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'success' + }); + + return json({ success: true }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error({ error: err, clientIp }, '[API] Error saving messaging preferences'); + + auditLogger.log({ + user: requestContext.userPubkeyHex || undefined, + ip: clientIp, + action: 'api.saveMessagingPreferences', + resource: 'messaging_preferences', + result: 'failure', + error: errorMessage + }); + + if (errorMessage.includes('rate limit')) { + return error(429, errorMessage); + } + + return error(500, 'Failed to save messaging preferences'); + } +}; + +/** + * GET - Retrieve messaging preferences + * Returns encrypted status only (for UI), not decrypted data + */ +export const GET: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + const clientIp = requestContext.clientIp || 'unknown'; + + try { + if (!requestContext.userPubkeyHex) { + return error(401, 'Authentication required'); + } + + // Verify user has unlimited access + const cached = getCachedUserLevel(requestContext.userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + 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); + + return json({ + configured: exists, + rateLimit: { + remaining: rateLimit.remaining, + resetAt: rateLimit.resetAt + } + }); + } catch (err) { + logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences status'); + return error(500, 'Failed to get messaging preferences status'); + } +}; + +/** + * DELETE - Remove messaging preferences + */ +export const DELETE: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + const clientIp = requestContext.clientIp || 'unknown'; + + try { + if (!requestContext.userPubkeyHex) { + return error(401, 'Authentication required'); + } + + // Verify user has unlimited access + const cached = getCachedUserLevel(requestContext.userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + return error(403, 'Messaging forwarding requires unlimited access level'); + } + + await deletePreferences(requestContext.userPubkeyHex); + + auditLogger.log({ + user: requestContext.userPubkeyHex, + ip: clientIp, + action: 'api.deleteMessagingPreferences', + resource: 'messaging_preferences', + result: 'success' + }); + + return json({ success: true }); + } 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 new file mode 100644 index 0000000..ef275bc --- /dev/null +++ b/src/routes/api/user/messaging-preferences/summary/+server.ts @@ -0,0 +1,58 @@ +/** + * API endpoint for getting messaging preferences summary + * Returns safe summary without sensitive tokens + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.js'; +import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import logger from '$lib/services/logger.js'; + +/** + * GET - Get messaging preferences summary (safe, no tokens) + */ +export const GET: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + const clientIp = requestContext.clientIp || 'unknown'; + + try { + if (!requestContext.userPubkeyHex) { + return error(401, 'Authentication required'); + } + + // Verify user has unlimited access + const cached = getCachedUserLevel(requestContext.userPubkeyHex); + if (!cached || cached.level !== 'unlimited') { + return error(403, 'Messaging forwarding requires unlimited access level'); + } + + // Get safe summary (decrypts but only returns safe info) + const summary = await getPreferencesSummary(requestContext.userPubkeyHex); + + if (!summary) { + return json({ + configured: false, + enabled: false, + platforms: {} + }); + } + + return json(summary); + } catch (err) { + logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences summary'); + + // If rate limit exceeded, return configured but no details + if (err instanceof Error && err.message.includes('rate limit')) { + return json({ + configured: true, + enabled: false, + platforms: {}, + error: 'Rate limit exceeded' + }); + } + + return error(500, 'Failed to get messaging preferences summary'); + } +}; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 35bf82c..e94415e 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -5,6 +5,7 @@ import CodeEditor from '$lib/components/CodeEditor.svelte'; import PRDetail from '$lib/components/PRDetail.svelte'; import UserBadge from '$lib/components/UserBadge.svelte'; + import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; @@ -1457,6 +1458,9 @@ {/if} {/if} + {#if pageData.repoOwnerPubkey && userPubkey === pageData.repoOwnerPubkey} + + {/if}
{#if userPubkey}
+ {#if getForwardingPubkey()} + + {/if}
@@ -147,41 +283,155 @@ {#if loading}
Loading profile...
{:else} -
-

Repositories ({repos.length})

- {#if repos.length === 0} -
No repositories found
- {:else} -
- {#each repos as event} -
goto(`/repos/${npub}/${getRepoId(event)}`)} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - goto(`/repos/${npub}/${getRepoId(event)}`); - } - }} - style="cursor: pointer;"> -

{getRepoName(event)}

- {#if getRepoDescription(event)} -

{getRepoDescription(event)}

- {/if} -
- - {new Date(event.created_at * 1000).toLocaleDateString()} - + +
+ + +
+ + + {#if activeTab === 'repos'} +
+

Repositories ({repos.length})

+ {#if repos.length === 0} +
No repositories found
+ {:else} +
+ {#each repos as event} +
goto(`/repos/${npub}/${getRepoId(event)}`)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/repos/${npub}/${getRepoId(event)}`); + } + }} + style="cursor: pointer;"> +

{getRepoName(event)}

+ {#if getRepoDescription(event)} +

{getRepoDescription(event)}

+ {/if} +
+ + {new Date(event.created_at * 1000).toLocaleDateString()} + +
-
- {/each} + {/each} +
+ {/if} +
+ {/if} + + + {#if activeTab === 'messages'} +
+
+

Public Messages

+ {#if viewerPubkeyHex && viewerPubkeyHex !== userPubkey} + + {/if}
- {/if} -
+ + {#if loadingMessages} +
Loading messages...
+ {:else if messages.length === 0} +
No messages found
+ {:else} +
+ {#each messages as message} + {@const isFromViewer = viewerPubkeyHex !== null && message.pubkey === viewerPubkeyHex} + {@const isToViewer = viewerPubkeyHex !== null && getMessageRecipients(message).includes(viewerPubkeyHex)} + {@const isFromUser = userPubkey !== null && message.pubkey === userPubkey} + {@const isToUser = userPubkey !== null && getMessageRecipients(message).includes(userPubkey)} +
+
+ + {formatMessageTime(message.created_at)} +
+
+ {#if getMessageRecipients(message).length > 0} + To: + {#each getMessageRecipients(message) as recipientPubkey} + + {/each} + {/if} +
+
{message.content}
+
+ {/each} +
+ {/if} +
+ {/if} {/if}
+ + + {#if showSendMessageDialog} + + + + {/if}