19 changed files with 2928 additions and 41 deletions
@ -0,0 +1,396 @@
@@ -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=<bot-token> |
||||
TELEGRAM_ENABLED=true |
||||
|
||||
# Email SMTP Configuration |
||||
SMTP_HOST=<smtp-host> |
||||
SMTP_PORT=587 |
||||
SMTP_USER=<smtp-username> |
||||
SMTP_PASSWORD=<smtp-password> |
||||
SMTP_FROM_ADDRESS=<from-email> |
||||
SMTP_FROM_NAME=GitRepublic |
||||
EMAIL_ENABLED=true |
||||
|
||||
# OR use SMTP API (alternative to direct SMTP) |
||||
SMTP_API_URL=<smtp-api-url> |
||||
SMTP_API_KEY=<api-key> |
||||
|
||||
# SimpleX API Configuration |
||||
SIMPLEX_API_URL=<simplex-api-url> |
||||
SIMPLEX_API_KEY=<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 |
||||
@ -0,0 +1,635 @@
@@ -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<string, string>; |
||||
} |
||||
|
||||
interface EventContent { |
||||
title: string; |
||||
body: string; |
||||
fullBody: string; |
||||
} |
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const KIND_NAMES: Record<number, string> = { |
||||
1621: 'Issue', |
||||
1618: 'Pull Request', |
||||
9802: 'Highlight', |
||||
30617: 'Repository Announcement', |
||||
1641: 'Ownership Transfer', |
||||
24: 'Public Message' |
||||
}; |
||||
|
||||
const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> = { |
||||
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<string, string> = { |
||||
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<string, unknown> { |
||||
const title = prefix ? `${prefix}${content.title}` : content.title; |
||||
const payload: Record<string, unknown> = { 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<string, unknown> { |
||||
const payload: Record<string, unknown> = { |
||||
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<string, string>, |
||||
payload: Record<string, unknown>, |
||||
platform: string |
||||
): Promise<void> { |
||||
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<void> { |
||||
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<void> { |
||||
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<void> { |
||||
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<void> { |
||||
if (!MESSAGING_CONFIG.gitPlatforms?.enabled) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
const config = getGitPlatformConfig(platform, customApiUrl); |
||||
const content = extractEventContent(event); |
||||
|
||||
const authHeader = buildAuthHeader(config, token); |
||||
const headers: Record<string, string> = { |
||||
'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<void> { |
||||
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<void>[] = []; |
||||
|
||||
// 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'); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,383 @@
@@ -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<string, DecryptionAttempt>(); |
||||
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<string, string>(); |
||||
|
||||
/** |
||||
* 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<void> { |
||||
// 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<MessagingPreferences | null> { |
||||
// 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<void> { |
||||
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<boolean> { |
||||
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 |
||||
}; |
||||
} |
||||
@ -0,0 +1,215 @@
@@ -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<PublicMessage[]> { |
||||
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<PublicMessage[]> { |
||||
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<PublicMessage[]> { |
||||
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<string, PublicMessage>(); |
||||
[...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<PublicMessage> { |
||||
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<PublicMessage, 'id' | 'sig'> = { |
||||
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<string>(); |
||||
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; |
||||
} |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -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 |
||||
@ -0,0 +1,211 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,58 @@
@@ -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'); |
||||
} |
||||
}; |
||||
Loading…
Reference in new issue