9 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 4.6 596e5fffe6 fix: deploy step stops containers before recreating to avoid ContainerConfig bug
Build & Release / Docker / backend (push) Successful in 1m6s
Build & Release / Docker / frontend (push) Successful in 1m21s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m49s
Build & Release / Deploy to server (push) Has been skipped
Build & Release / Create release (push) Failing after 29s
docker-compose 1.29.x fails with KeyError: 'ContainerConfig' when trying
to recreate containers built from newer image manifests. Explicitly stopping
and removing before 'up -d' avoids the inspection of old container state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-31 11:59:06 -04:00
nmemmertandClaude Sonnet 4.6 15160b9e86 feat: image/file sharing in chat with drive grouping by contact
Build & Release / Docker / backend (push) Successful in 2m10s
Build & Release / Docker / frontend (push) Successful in 2m30s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m15s
Build & Release / Deploy to server (push) Successful in 34s
Build & Release / Create release (push) Has been skipped
- Attach button in chat sends encrypted files via conversation key
- Images render inline; other files show a download button
- Shared files stored on server with conversation_id for access control
- Drive groups files by conversation partner name (sender view)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-31 09:46:08 -04:00
nmemmertandClaude Sonnet 4.6 515bef4fc9 feat: password unlock screen on refresh instead of full logout
Build & Release / Docker / backend (push) Successful in 1m3s
Build & Release / Docker / frontend (push) Successful in 2m47s
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled
On page reload, tokens persist but crypto keys are lost (by design).
Now shows a password-only unlock screen instead of the full login form,
so users don't need to re-enter their email after a refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-31 09:42:04 -04:00
nmemmertandClaude Sonnet 4.6 b95cc7dd54 feat: mobile-responsive layout and chat navigation
Build & Release / Docker / backend (push) Successful in 1m32s
Build & Release / Docker / frontend (push) Successful in 3m2s
Build & Release / Desktop / Linux & Windows (push) Successful in 16m58s
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
- Bottom tab bar on mobile instead of side icon strip
- Conversation list and chat view are mutually exclusive on mobile
- Back button in chat header returns to conversation list on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-31 09:39:36 -04:00
nmemmertandClaude Sonnet 4.6 d8568849b4 chore: switch nginx to HTTP-only for Cloudflare Flexible SSL
Build & Release / Docker / backend (push) Successful in 1m19s
Build & Release / Docker / frontend (push) Successful in 1m22s
Build & Release / Desktop / Linux & Windows (push) Successful in 16m58s
Build & Release / Deploy to server (push) Successful in 25s
Build & Release / Create release (push) Has been skipped
Cloudflare terminates TLS at the edge; origin only needs port 80.
Removes SSL cert requirement, drops port 443 from compose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-31 08:46:17 -04:00
nmemmertandClaude Sonnet 4.6 99efd4be55 docs: update README for P-256 ECDH, first admin setup, release filenames
Build & Release / Docker / backend (push) Successful in 1m32s
Build & Release / Docker / frontend (push) Successful in 1m32s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m38s
Build & Release / Deploy to server (push) Successful in 2m49s
Build & Release / Create release (push) Has been skipped
- Replace X25519 references with ECDH P-256 throughout encryption model
- Expand first admin user section: register normally, promote via SQL,
  then manage further admins through the UI
- Update desktop install table with actual DMG filenames and Mac entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-28 11:44:03 -04:00
nmemmertandClaude Sonnet 4.6 7351f9048a fix: replace X25519 with ECDH P-256 for Electron compatibility
Build & Release / Docker / backend (push) Has been cancelled
Build & Release / Docker / frontend (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
X25519 Web Crypto support is not available in Electron's Chromium build
despite being in Chrome 113+. P-256 ECDH is universally supported across
all browsers and Electron versions with equivalent security.

- Replace { name: 'X25519' } with { name: 'ECDH', namedCurve: 'P-256' }
  throughout the keypair generation, sealing, and opening functions
- Wire format updated: ephemeral pub key grows from 32 to 65 bytes
  (uncompressed P-256 point: 0x04 || x || y)
- Existing accounts with X25519 keys must re-register

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-28 11:41:11 -04:00
nmemmertandClaude Sonnet 4.6 84ff218a30 fix: support both docker compose (v2) and docker-compose (v1) in deploy
Build & Release / Docker / backend (push) Successful in 1m7s
Build & Release / Docker / frontend (push) Successful in 1m14s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m15s
Build & Release / Deploy to server (push) Successful in 18s
Build & Release / Create release (push) Has been cancelled
The target server has Docker Compose V1 (standalone binary) not the V2
plugin, so 'docker compose' fails. Detect which is available at runtime
and use the appropriate command.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-28 11:27:03 -04:00
nmemmertandClaude Sonnet 4.6 3e89775e60 fix: downgrade upload/download-artifact to v3 for Gitea Actions compatibility
Build & Release / Docker / backend (push) Has been cancelled
Build & Release / Docker / frontend (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
actions/upload-artifact@v4 uses a new artifact API that requires
specific runner-side configuration not present in all act_runner setups.
v3 uses the simpler API that works reliably with self-hosted Gitea runners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-28 11:24:55 -04:00
12 changed files with 412 additions and 180 deletions

No files matched your search

+18 -4
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: desktop working-directory: desktop
run: npx electron-builder --win nsis --x64 --publish never run: npx electron-builder --win nsis --x64 --publish never
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v3
with: with:
name: desktop-linux-windows-${{ github.sha }} name: desktop-linux-windows-${{ github.sha }}
path: | path: |
@@ -128,8 +128,22 @@ jobs:
script: | script: |
cd ${{ secrets.DEPLOY_PATH }} cd ${{ secrets.DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.necloud.us -u "${{ secrets.REGISTRY_USER }}" --password-stdin echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.necloud.us -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose pull backend frontend
docker compose up -d --no-build backend frontend # Pull new images first
if docker compose version > /dev/null 2>&1; then
COMPOSE="docker compose"
else
COMPOSE="docker-compose"
fi
$COMPOSE pull backend frontend
# Stop and remove old containers so docker-compose up creates fresh ones
# (works around ContainerConfig bug in docker-compose 1.29.x)
$COMPOSE stop backend frontend || true
$COMPOSE rm -f backend frontend || true
$COMPOSE up -d backend frontend
docker logout git.necloud.us docker logout git.necloud.us
docker image prune -f docker image prune -f
@@ -141,7 +155,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Download desktop artifacts - name: Download desktop artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: desktop-linux-windows-${{ github.sha }} name: desktop-linux-windows-${{ github.sha }}
path: ./release-assets path: ./release-assets
+11 -8
View File
@@ -49,15 +49,15 @@ All encryption and decryption happens exclusively in the browser/client. The bac
|---|---|---| |---|---|---|
| Password-derived key | PBKDF2-SHA256 (600 000 iterations) | In-memory only, derived at login | | Password-derived key | PBKDF2-SHA256 (600 000 iterations) | In-memory only, derived at login |
| Master key | AES-256-GCM | Encrypted with derived key, stored on server | | Master key | AES-256-GCM | Encrypted with derived key, stored on server |
| X25519 keypair | X25519 ECDH | Private key encrypted with master key, stored on server; public key stored plaintext | | ECDH keypair | P-256 ECDH | Private key encrypted with master key, stored on server; public key stored plaintext |
| Conversation key | AES-256-GCM | Per-conversation symmetric key, encrypted with each member's X25519 public key | | Conversation key | AES-256-GCM | Per-conversation symmetric key, sealed with each member's P-256 public key |
| File key | AES-256-GCM | Per-file symmetric key, encrypted with owner's master key | | File key | AES-256-GCM | Per-file symmetric key, encrypted with owner's master key |
### Message flow ### Message flow
1. Alice opens a conversation with Bob 1. Alice opens a conversation with Bob
2. Client generates a random AES-256-GCM conversation key 2. Client generates a random AES-256-GCM conversation key
3. Key is sealed with Bob's X25519 public key (ephemeral ECDH + HKDF + AES-GCM) 3. Key is sealed with Bob's P-256 public key (ephemeral ECDH + HKDF-SHA256 + AES-GCM)
4. Ciphertext of the key is stored in `conversation_members.encrypted_key` 4. Ciphertext of the key is stored in `conversation_members.encrypted_key`
5. Each message is encrypted with the conversation key 5. Each message is encrypted with the conversation key
6. Server stores and relays only ciphertext — it never sees plaintext 6. Server stores and relays only ciphertext — it never sees plaintext
@@ -201,14 +201,16 @@ openssl rand -hex 32
### First admin user ### First admin user
After the stack is up, register a normal account, then promote it: There are no default credentials. Register a normal account via the UI, then promote it to admin with one SQL command:
```bash ```bash
docker exec -i cloudlink-postgres-1 psql -U cloudlink -d cloudlink \ docker exec -i cloudlink-postgres-1 psql -U cloudlink -d cloudlink \
-c "UPDATE users SET is_admin = true WHERE email = 'you@example.com';" -c "UPDATE users SET is_admin = true WHERE email = 'you@example.com';"
``` ```
Subsequent admins can be toggled from the Admin portal in the UI. After that, the **Admin** link appears in the sidebar. From there you can promote or demote any other user without touching SQL again.
> **Tip:** The container name may vary. Run `docker ps --format '{{.Names}}'` to confirm it.
--- ---
@@ -220,9 +222,10 @@ Download the installer for your OS from [Releases](https://git.necloud.us/nate/c
| OS | File | Install | | OS | File | Install |
|---|---|---| |---|---|---|
| Windows | `CloudLink Setup.exe` | Run the installer, follow wizard | | Mac (Apple Silicon) | `CloudLink-*-arm64.dmg` | Open DMG, drag to Applications, right-click → Open |
| Linux | `CloudLink.AppImage` | `chmod +x CloudLink.AppImage && ./CloudLink.AppImage` | | Mac (Intel) | `CloudLink-*.dmg` | Open DMG, drag to Applications, right-click → Open |
| Mac | `CloudLink.dmg` | Open DMG, drag to Applications *(built locally — see below)* | | Windows | `CloudLink Setup *.exe` | Run installer, follow wizard |
| Linux | `CloudLink-*.AppImage` | `chmod +x CloudLink-*.AppImage && ./CloudLink-*.AppImage` |
On first launch the app prompts for your CloudLink server URL (e.g. `https://cloudlink.example.com`). This is saved and used for all future launches. On first launch the app prompts for your CloudLink server URL (e.g. `https://cloudlink.example.com`). This is saved and used for all future launches.
+9 -2
View File
@@ -57,8 +57,15 @@ router.post('/upload', upload.single('file'), async (req: AuthRequest, res) => {
router.get('/files', async (req: AuthRequest, res) => { router.get('/files', async (req: AuthRequest, res) => {
const result = await db.query( const result = await db.query(
`SELECT id, encrypted_name, mime_type, size_bytes, encrypted_file_key, uploaded_at `SELECT f.id, f.encrypted_name, f.mime_type, f.size_bytes, f.encrypted_file_key,
FROM files WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY uploaded_at DESC`, f.uploaded_at, f.conversation_id,
u.username AS shared_with_username
FROM files f
LEFT JOIN conversation_members cm
ON cm.conversation_id = f.conversation_id AND cm.user_id != $1
LEFT JOIN users u ON u.id = cm.user_id
WHERE f.owner_id = $1 AND f.deleted_at IS NULL
ORDER BY f.uploaded_at DESC`,
[req.userId] [req.userId]
); );
res.json(result.rows); res.json(result.rows);
-2
View File
@@ -53,11 +53,9 @@ services:
image: nginx:alpine image: nginx:alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${HOST_PORT:-443}:443"
- "80:80" - "80:80"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
+34 -7
View File
@@ -20,11 +20,11 @@ export default function Layout() {
return ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar */} {/* Desktop sidebar */}
<aside className="w-16 md:w-56 flex flex-col bg-surface-1 border-r border-slate-700 shrink-0"> <aside className="hidden md:flex w-56 flex-col bg-surface-1 border-r border-slate-700 shrink-0">
<div className="flex items-center gap-2 px-4 py-5 border-b border-slate-700"> <div className="flex items-center gap-2 px-4 py-5 border-b border-slate-700">
<Shield className="text-accent w-6 h-6 shrink-0" /> <Shield className="text-accent w-6 h-6 shrink-0" />
<span className="hidden md:block font-bold text-lg tracking-tight">CloudLink</span> <span className="font-bold text-lg tracking-tight">CloudLink</span>
</div> </div>
<nav className="flex-1 py-4 space-y-1 px-2"> <nav className="flex-1 py-4 space-y-1 px-2">
@@ -41,7 +41,7 @@ export default function Layout() {
} }
> >
<Icon className="w-5 h-5 shrink-0" /> <Icon className="w-5 h-5 shrink-0" />
<span className="hidden md:block">{label}</span> <span>{label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
@@ -51,21 +51,48 @@ export default function Layout() {
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0"> <div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{username?.[0]?.toUpperCase() ?? '?'} {username?.[0]?.toUpperCase() ?? '?'}
</div> </div>
<span className="hidden md:block text-sm text-slate-300 truncate">{username}</span> <span className="text-sm text-slate-300 truncate">{username}</span>
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-surface-2 transition-colors" className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-surface-2 transition-colors"
> >
<LogOut className="w-4 h-4 shrink-0" /> <LogOut className="w-4 h-4 shrink-0" />
<span className="hidden md:block">Sign out</span> <span>Sign out</span>
</button> </button>
</div> </div>
</aside> </aside>
{/* Main */} {/* Main */}
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden">
<Outlet /> <Outlet />
</div>
{/* Mobile bottom nav */}
<nav className="md:hidden flex border-t border-slate-700 bg-surface-1 shrink-0">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex-1 flex flex-col items-center py-2 gap-0.5 text-xs font-medium transition-colors ${
isActive ? 'text-accent' : 'text-slate-500'
}`
}
>
<Icon className="w-5 h-5" />
<span>{label}</span>
</NavLink>
))}
<button
onClick={handleLogout}
className="flex-1 flex flex-col items-center py-2 gap-0.5 text-xs font-medium text-slate-500"
>
<LogOut className="w-5 h-5" />
<span>Sign out</span>
</button>
</nav>
</main> </main>
</div> </div>
); );
+22 -37
View File
@@ -50,24 +50,23 @@ export async function decryptMasterKey(encB64: string, derived: CryptoKey): Prom
]); ]);
} }
// ── X25519 ECDH keypair ────────────────────────────────────────────────────── // ── ECDH P-256 keypair ───────────────────────────────────────────────────────
// P-256 is universally supported across all browsers and Electron versions.
// Raw public key is 65 bytes (uncompressed point: 0x04 || x || y).
const ECDH_ALGO = { name: 'ECDH', namedCurve: 'P-256' } as const;
const PUB_KEY_BYTES = 65; // uncompressed P-256 point
export async function generateX25519Keypair(): Promise<{ export async function generateX25519Keypair(): Promise<{
publicKeyB64: string; publicKeyB64: string;
privateKey: CryptoKey; privateKey: CryptoKey;
pkcs8Bytes: Uint8Array<ArrayBuffer>; pkcs8Bytes: Uint8Array<ArrayBuffer>;
}> { }> {
const kp = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, ['deriveBits']); const kp = await crypto.subtle.generateKey(ECDH_ALGO, true, ['deriveBits']);
const publicRaw = await crypto.subtle.exportKey('raw', (kp as CryptoKeyPair).publicKey); const publicRaw = await crypto.subtle.exportKey('raw', (kp as CryptoKeyPair).publicKey);
const pkcs8 = await crypto.subtle.exportKey('pkcs8', (kp as CryptoKeyPair).privateKey); const pkcs8 = await crypto.subtle.exportKey('pkcs8', (kp as CryptoKeyPair).privateKey);
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, ECDH_ALGO, false, ['deriveBits']);
'pkcs8',
pkcs8,
{ name: 'X25519' } as any,
false,
['deriveBits']
);
return { return {
publicKeyB64: bufToB64(publicRaw), publicKeyB64: bufToB64(publicRaw),
@@ -85,12 +84,12 @@ export async function decryptPrivateKeyToCryptoKey(
master: CryptoKey master: CryptoKey
): Promise<CryptoKey> { ): Promise<CryptoKey> {
const pkcs8 = await aesDecryptRaw(encB64, master); const pkcs8 = await aesDecryptRaw(encB64, master);
return crypto.subtle.importKey('pkcs8', pkcs8, { name: 'X25519' } as any, false, ['deriveBits']); return crypto.subtle.importKey('pkcs8', pkcs8, ECDH_ALGO, false, ['deriveBits']);
} }
// ── Conversation key exchange (ephemeral X25519 ECDH sealed box) ───────────── // ── Conversation key exchange (ephemeral ECDH P-256 sealed box) ───────────────
// //
// Wire format: ephemeral_pub_key(32) | nonce(12) | ciphertext // Wire format: ephemeral_pub_key(65) | nonce(12) | ciphertext
export function generateConvKeyRaw(): Uint8Array<ArrayBuffer> { export function generateConvKeyRaw(): Uint8Array<ArrayBuffer> {
return crypto.getRandomValues(new Uint8Array(32)); return crypto.getRandomValues(new Uint8Array(32));
@@ -100,23 +99,15 @@ export async function sealConvKey(
keyRaw: Uint8Array<ArrayBuffer>, keyRaw: Uint8Array<ArrayBuffer>,
recipientPublicKeyB64: string recipientPublicKeyB64: string
): Promise<string> { ): Promise<string> {
const recipientPubKey = await crypto.subtle.importKey( const recipientPubKey = await crypto.subtle.importKey('raw', b64ToBuf(recipientPublicKeyB64), ECDH_ALGO, false, []);
'raw',
b64ToBuf(recipientPublicKeyB64),
{ name: 'X25519' } as any,
false,
[]
);
const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, [ const ephemeral = await crypto.subtle.generateKey(ECDH_ALGO, true, ['deriveBits']);
'deriveBits',
]);
const ephemeralPubRaw = new Uint8Array( const ephemeralPubRaw = new Uint8Array(
await crypto.subtle.exportKey('raw', (ephemeral as CryptoKeyPair).publicKey) await crypto.subtle.exportKey('raw', (ephemeral as CryptoKeyPair).publicKey)
); );
const sharedBits = await crypto.subtle.deriveBits( const sharedBits = await crypto.subtle.deriveBits(
{ name: 'X25519', public: recipientPubKey } as any, { name: 'ECDH', public: recipientPubKey },
(ephemeral as CryptoKeyPair).privateKey, (ephemeral as CryptoKeyPair).privateKey,
256 256
); );
@@ -126,10 +117,10 @@ export async function sealConvKey(
const nonce = crypto.getRandomValues(new Uint8Array(12)); const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, keyRaw); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, keyRaw);
const out = new Uint8Array(32 + 12 + ct.byteLength); const out = new Uint8Array(PUB_KEY_BYTES + 12 + ct.byteLength);
out.set(ephemeralPubRaw, 0); out.set(ephemeralPubRaw, 0);
out.set(nonce, 32); out.set(nonce, PUB_KEY_BYTES);
out.set(new Uint8Array(ct), 44); out.set(new Uint8Array(ct), PUB_KEY_BYTES + 12);
return bufToB64(out); return bufToB64(out);
} }
@@ -138,20 +129,14 @@ export async function openSealedConvKey(
myPrivateKey: CryptoKey myPrivateKey: CryptoKey
): Promise<Uint8Array<ArrayBuffer>> { ): Promise<Uint8Array<ArrayBuffer>> {
const data = b64ToBuf(sealedB64); const data = b64ToBuf(sealedB64);
const ephemeralPubRaw = u8(data, 0, 32); const ephemeralPubRaw = u8(data, 0, PUB_KEY_BYTES);
const nonce = u8(data, 32, 44); const nonce = u8(data, PUB_KEY_BYTES, PUB_KEY_BYTES + 12);
const ct = u8(data, 44); const ct = u8(data, PUB_KEY_BYTES + 12);
const ephemeralPubKey = await crypto.subtle.importKey( const ephemeralPubKey = await crypto.subtle.importKey('raw', ephemeralPubRaw, ECDH_ALGO, false, []);
'raw',
ephemeralPubRaw,
{ name: 'X25519' } as any,
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits( const sharedBits = await crypto.subtle.deriveBits(
{ name: 'X25519', public: ephemeralPubKey } as any, { name: 'ECDH', public: ephemeralPubKey },
myPrivateKey, myPrivateKey,
256 256
); );
+9 -5
View File
@@ -6,12 +6,13 @@ interface AuthState {
refreshToken: string | null; refreshToken: string | null;
userId: string | null; userId: string | null;
username: string | null; username: string | null;
email: string | null;
isAdmin: boolean; isAdmin: boolean;
// Crypto keys — in-memory only, never persisted // Crypto keys — in-memory only, never persisted
masterKey: CryptoKey | null; masterKey: CryptoKey | null;
privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey) privateKey: CryptoKey | null;
publicKey: string | null; // X25519 public key (base64) publicKey: string | null;
setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean }) => void; setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean; email?: string }) => void;
setTokens: (access: string, refresh: string) => void; setTokens: (access: string, refresh: string) => void;
setMasterKey: (key: CryptoKey) => void; setMasterKey: (key: CryptoKey) => void;
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void; setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
@@ -26,12 +27,13 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null, refreshToken: null,
userId: null, userId: null,
username: null, username: null,
email: null,
isAdmin: false, isAdmin: false,
masterKey: null, masterKey: null,
privateKey: null as CryptoKey | null, privateKey: null as CryptoKey | null,
publicKey: null, publicKey: null,
setAuth: ({ accessToken, refreshToken, userId, isAdmin }) => setAuth: ({ accessToken, refreshToken, userId, isAdmin, email }) =>
set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false }), set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false, ...(email ? { email } : {}) }),
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }), setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
setMasterKey: (masterKey) => set({ masterKey }), setMasterKey: (masterKey) => set({ masterKey }),
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }), setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
@@ -41,6 +43,7 @@ export const useAuthStore = create<AuthState>()(
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
userId: null, userId: null,
email: null,
masterKey: null, masterKey: null,
privateKey: null, privateKey: null,
publicKey: null, publicKey: null,
@@ -54,6 +57,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: s.refreshToken, refreshToken: s.refreshToken,
userId: s.userId, userId: s.userId,
username: s.username, username: s.username,
email: s.email,
isAdmin: s.isAdmin, isAdmin: s.isAdmin,
}), }),
} }
+81 -34
View File
@@ -1,6 +1,6 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Upload, File, Trash2, Download, Lock } from 'lucide-react'; import { Upload, File, Trash2, Download, Lock, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import api from '../lib/api'; import api from '../lib/api';
import { useAuthStore } from '../lib/store'; import { useAuthStore } from '../lib/store';
@@ -21,6 +21,8 @@ interface DriveFile {
size_bytes: number; size_bytes: number;
encrypted_file_key: string; encrypted_file_key: string;
uploaded_at: string; uploaded_at: string;
conversation_id: string | null;
shared_with_username: string | null;
} }
function formatBytes(bytes: number) { function formatBytes(bytes: number) {
@@ -157,41 +159,86 @@ export default function DrivePage() {
<p>No files yet — drag &amp; drop or click Upload</p> <p>No files yet — drag &amp; drop or click Upload</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-2"> <FileGroups files={files} decryptedNames={decryptedNames}
{files.map((f) => ( onDownload={handleDownload} onDelete={handleDelete} />
<div
key={f.id}
className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700"
>
<File className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{decryptedNames[f.id] ?? '…'}
</p>
<p className="text-xs text-slate-500">
{formatBytes(f.size_bytes)} ·{' '}
{formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
</p>
</div>
<button
onClick={() => handleDownload(f)}
className="p-1.5 text-slate-400 hover:text-white transition-colors"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(f.id)}
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)} )}
</div> </div>
</div> </div>
); );
} }
function FileRow({ f, name, onDownload, onDelete }: {
f: DriveFile; name: string;
onDownload: (f: DriveFile) => void; onDelete: (id: string) => void;
}) {
return (
<div className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700">
<File className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{name}</p>
<p className="text-xs text-slate-500">
{formatBytes(f.size_bytes)} · {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
</p>
</div>
<button onClick={() => onDownload(f)} className="p-1.5 text-slate-400 hover:text-white transition-colors" title="Download">
<Download className="w-4 h-4" />
</button>
<button onClick={() => onDelete(f.id)} className="p-1.5 text-slate-400 hover:text-red-400 transition-colors" title="Delete">
<Trash2 className="w-4 h-4" />
</button>
</div>
);
}
function FileGroups({ files, decryptedNames, onDownload, onDelete }: {
files: DriveFile[];
decryptedNames: Record<string, string>;
onDownload: (f: DriveFile) => void;
onDelete: (id: string) => void;
}) {
const personal = files.filter((f) => !f.conversation_id);
const byPerson = files
.filter((f) => f.conversation_id && f.shared_with_username)
.reduce<Record<string, DriveFile[]>>((acc, f) => {
const name = f.shared_with_username!;
if (!acc[name]) acc[name] = [];
acc[name].push(f);
return acc;
}, {});
return (
<div className="space-y-6">
{Object.entries(byPerson).map(([person, personFiles]) => (
<div key={person}>
<div className="flex items-center gap-2 mb-2">
<MessageSquare className="w-4 h-4 text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400">{person}</h2>
</div>
<div className="grid grid-cols-1 gap-2">
{personFiles.map((f) => (
<FileRow key={f.id} f={f} name={decryptedNames[f.id] ?? '…'}
onDownload={onDownload} onDelete={onDelete} />
))}
</div>
</div>
))}
{personal.length > 0 && (
<div>
{Object.keys(byPerson).length > 0 && (
<div className="flex items-center gap-2 mb-2">
<File className="w-4 h-4 text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400">Personal</h2>
</div>
)}
<div className="grid grid-cols-1 gap-2">
{personal.map((f) => (
<FileRow key={f.id} f={f} name={decryptedNames[f.id] ?? '…'}
onDownload={onDownload} onDelete={onDelete} />
))}
</div>
</div>
)}
</div>
);
}
+41 -16
View File
@@ -1,6 +1,6 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Shield } from 'lucide-react'; import { Shield, Lock } from 'lucide-react';
import api from '../lib/api'; import api from '../lib/api';
import { useAuthStore } from '../lib/store'; import { useAuthStore } from '../lib/store';
import { deriveKeyFromPassword, decryptMasterKey, decryptPrivateKeyToCryptoKey } from '../crypto'; import { deriveKeyFromPassword, decryptMasterKey, decryptPrivateKeyToCryptoKey } from '../crypto';
@@ -8,7 +8,12 @@ import { socket } from '../lib/ws';
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore(); const location = useLocation();
const { setAuth, setMasterKey, setKeyPair, setUsername, username, email: storedEmail } = useAuthStore();
// Reauth: tokens still valid but crypto keys lost on refresh
const isReauth = location.state?.reauth === true && !!storedEmail;
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
@@ -23,7 +28,7 @@ export default function LoginPage() {
try { try {
const res = await api.post('/auth/login', { const res = await api.post('/auth/login', {
email, email: isReauth ? storedEmail : email,
password, password,
...(totpCode ? { totpCode } : {}), ...(totpCode ? { totpCode } : {}),
}); });
@@ -34,18 +39,15 @@ export default function LoginPage() {
return; return;
} }
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey, isAdmin } = res.data; const { accessToken, refreshToken, userId, username: uname, kdfSalt, encryptedMasterKey, isAdmin } = res.data;
// Re-derive master key from password — never leaves the browser
const derived = await deriveKeyFromPassword(password, kdfSalt); const derived = await deriveKeyFromPassword(password, kdfSalt);
const masterKey = await decryptMasterKey(encryptedMasterKey, derived); const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
// Set tokens first so the next API call has a Bearer token setAuth({ accessToken, refreshToken, userId, isAdmin, email: isReauth ? storedEmail! : email });
setAuth({ accessToken, refreshToken, userId, isAdmin });
setMasterKey(masterKey); setMasterKey(masterKey);
setUsername(username); setUsername(uname);
// Fetch and decrypt the X25519 private key (stored encrypted on server for cross-device support)
const keyRes = await api.get('/auth/my-key'); const keyRes = await api.get('/auth/my-key');
const privateKey = await decryptPrivateKeyToCryptoKey(keyRes.data.encrypted_private_key, masterKey); const privateKey = await decryptPrivateKeyToCryptoKey(keyRes.data.encrypted_private_key, masterKey);
setKeyPair(keyRes.data.public_key, privateKey); setKeyPair(keyRes.data.public_key, privateKey);
@@ -63,17 +65,27 @@ export default function LoginPage() {
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
{isReauth ? (
<Lock className="w-12 h-12 text-accent mb-3" />
) : (
<Shield className="w-12 h-12 text-accent mb-3" /> <Shield className="w-12 h-12 text-accent mb-3" />
)}
<h1 className="text-2xl font-bold">CloudLink</h1> <h1 className="text-2xl font-bold">CloudLink</h1>
<p className="text-slate-400 text-sm mt-1">End-to-end encrypted</p> <p className="text-slate-400 text-sm mt-1">
{isReauth ? `Welcome back, ${username}` : 'End-to-end encrypted'}
</p>
</div> </div>
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700"> <form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
<h2 className="font-semibold text-lg">Sign in</h2> <h2 className="font-semibold text-lg">
{isReauth ? 'Enter your password to unlock' : 'Sign in'}
</h2>
{error && <p className="text-red-400 text-sm">{error}</p>} {error && <p className="text-red-400 text-sm">{error}</p>}
{!require2fa ? ( {isReauth ? (
<Field label="Password" type="password" value={password} onChange={setPassword} autoFocus />
) : !require2fa ? (
<> <>
<Field label="Email" type="email" value={email} onChange={setEmail} /> <Field label="Email" type="email" value={email} onChange={setEmail} />
<Field label="Password" type="password" value={password} onChange={setPassword} /> <Field label="Password" type="password" value={password} onChange={setPassword} />
@@ -95,21 +107,33 @@ export default function LoginPage() {
)} )}
<button type="submit" disabled={loading} className="btn-primary w-full"> <button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'} {loading ? (isReauth ? 'Unlocking…' : 'Signing in…') : isReauth ? 'Unlock' : require2fa ? 'Verify' : 'Sign in'}
</button> </button>
{isReauth && (
<button
type="button"
onClick={() => { useAuthStore.getState().logout(); navigate('/login'); }}
className="w-full text-center text-sm text-slate-500 hover:text-slate-300"
>
Sign in as a different user
</button>
)}
</form> </form>
{!isReauth && (
<p className="text-center text-sm text-slate-400 mt-4"> <p className="text-center text-sm text-slate-400 mt-4">
No account?{' '} No account?{' '}
<Link to="/register" className="text-accent hover:underline">Register</Link> <Link to="/register" className="text-accent hover:underline">Register</Link>
</p> </p>
)}
</div> </div>
</div> </div>
); );
} }
function Field({ label, type, value, onChange }: { function Field({ label, type, value, onChange, autoFocus }: {
label: string; type: string; value: string; onChange: (v: string) => void; label: string; type: string; value: string; onChange: (v: string) => void; autoFocus?: boolean;
}) { }) {
return ( return (
<div> <div>
@@ -119,6 +143,7 @@ function Field({ label, type, value, onChange }: {
type={type} type={type}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
autoFocus={autoFocus}
required required
/> />
</div> </div>
+182 -48
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Send, Plus, Search } from 'lucide-react'; import { Send, Plus, Search, ArrowLeft, Paperclip, Download, ImageIcon } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import api from '../lib/api'; import api from '../lib/api';
import { useAuthStore, useCryptoStore } from '../lib/store'; import { useAuthStore, useCryptoStore } from '../lib/store';
@@ -13,6 +13,12 @@ import {
sealConvKey, sealConvKey,
openSealedConvKey, openSealedConvKey,
importConvKey, importConvKey,
encryptFile,
decryptFile,
generateFileKey,
encryptFileKey,
bufToB64,
b64ToBuf,
} from '../crypto'; } from '../crypto';
interface Conversation { interface Conversation {
@@ -28,15 +34,98 @@ interface Message {
sender_username: string; sender_username: string;
ciphertext: string; ciphertext: string;
sent_at: string; sent_at: string;
file_id?: string;
plaintext?: string; plaintext?: string;
} }
interface ParsedMessage {
type: 'text' | 'file';
text?: string;
fileId?: string;
fileKeyB64?: string;
fileName?: string;
mimeType?: string;
}
interface UserResult { interface UserResult {
id: string; id: string;
username: string; username: string;
public_key: string; public_key: string;
} }
function parsePlaintext(plain: string): ParsedMessage {
try {
const obj = JSON.parse(plain);
if (obj.type === 'file' && obj.fileId) return obj as ParsedMessage;
} catch {}
return { type: 'text', text: plain };
}
function FileMessage({ msg, fileKeyB64, fileName, mimeType, isMine }: {
msg: Message; fileKeyB64: string; fileName: string; mimeType: string; isMine: boolean;
}) {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const isImage = mimeType.startsWith('image/');
useEffect(() => {
if (!isImage || !msg.file_id) return;
let url: string;
setLoading(true);
api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' })
.then(async (res) => {
const fileKey = await importConvKey(b64ToBuf(fileKeyB64));
const decrypted = await decryptFile(res.data, fileKey);
url = URL.createObjectURL(new Blob([decrypted], { type: mimeType }));
setSrc(url);
})
.catch(() => setSrc(null))
.finally(() => setLoading(false));
return () => { if (url) URL.revokeObjectURL(url); };
}, [msg.file_id, fileKeyB64, mimeType, isImage]);
async function download() {
if (!msg.file_id) return;
const res = await api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' });
const fileKey = await importConvKey(b64ToBuf(fileKeyB64));
const decrypted = await decryptFile(res.data, fileKey);
const url = URL.createObjectURL(new Blob([decrypted], { type: mimeType }));
const a = document.createElement('a');
a.href = url; a.download = fileName; a.click();
URL.revokeObjectURL(url);
}
return (
<div className={`max-w-xs lg:max-w-sm rounded-2xl overflow-hidden text-sm ${
isMine ? 'bg-accent text-white rounded-br-sm' : 'bg-surface-1 text-slate-100 rounded-bl-sm'
}`}>
{isImage ? (
loading ? (
<div className="w-48 h-32 flex items-center justify-center bg-slate-700">
<ImageIcon className="w-6 h-6 text-slate-500 animate-pulse" />
</div>
) : src ? (
<img src={src} alt={fileName} className="max-w-full max-h-64 object-contain cursor-pointer"
onClick={download} />
) : (
<div className="w-48 h-32 flex items-center justify-center bg-slate-700 text-slate-400 text-xs">
Failed to load
</div>
)
) : null}
<div className="px-3 py-2 flex items-center gap-2">
<button onClick={download} className="flex items-center gap-1.5 flex-1 min-w-0 hover:opacity-80">
<Download className="w-4 h-4 shrink-0" />
<span className="truncate text-xs">{fileName}</span>
</button>
</div>
<p className={`text-xs px-3 pb-2 ${isMine ? 'text-indigo-200' : 'text-slate-500'}`}>
{msg.sent_at ? formatDistanceToNow(new Date(msg.sent_at), { addSuffix: true }) : 'just now'}
</p>
</div>
);
}
export default function MessagesPage() { export default function MessagesPage() {
const { id: activeId } = useParams(); const { id: activeId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -47,24 +136,23 @@ export default function MessagesPage() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [newUserSearch, setNewUserSearch] = useState(''); const [newUserSearch, setNewUserSearch] = useState('');
const [searchResults, setSearchResults] = useState<UserResult[]>([]); const [searchResults, setSearchResults] = useState<UserResult[]>([]);
const [uploading, setUploading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: conversations = [] } = useQuery<Conversation[]>({ const { data: conversations = [] } = useQuery<Conversation[]>({
queryKey: ['conversations'], queryKey: ['conversations'],
queryFn: () => api.get('/conversations').then((r) => r.data), queryFn: () => api.get('/conversations').then((r) => r.data),
}); });
// Load + decrypt messages when active conversation changes
useEffect(() => { useEffect(() => {
if (!activeId || !publicKey || !privateKey) return; if (!activeId || !publicKey || !privateKey) return;
async function load() { async function load() {
const conv = conversations.find((c) => c.id === activeId); const conv = conversations.find((c) => c.id === activeId);
if (!conv) return; if (!conv) return;
let convKey = getConvKey(activeId!); let convKey = getConvKey(activeId!);
if (!convKey) { if (!convKey) {
// Decrypt the conversation key that was sealed for us with our X25519 public key
const raw = await openSealedConvKey(conv.encrypted_key, privateKey!); const raw = await openSealedConvKey(conv.encrypted_key, privateKey!);
convKey = await importConvKey(raw); convKey = await importConvKey(raw);
setConvKey(activeId!, convKey); setConvKey(activeId!, convKey);
@@ -79,16 +167,14 @@ export default function MessagesPage() {
); );
setMessages(decrypted); setMessages(decrypted);
} }
load(); load();
}, [activeId, conversations.length]); }, [activeId, conversations.length]);
// WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically)
useEffect(() => { useEffect(() => {
return socket.on(async (raw) => { return socket.on(async (raw) => {
const msg = raw as any; const msg = raw as any;
if (msg.type !== 'message' || msg.conversationId !== activeId) return; if (msg.type !== 'message' || msg.conversationId !== activeId) return;
if (msg.senderId === userId) return; // Own message already added optimistically if (msg.senderId === userId) return;
const convKey = getConvKey(activeId!); const convKey = getConvKey(activeId!);
if (!convKey) return; if (!convKey) return;
@@ -96,7 +182,13 @@ export default function MessagesPage() {
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]'); const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sentAt ?? new Date().toISOString() }, {
...msg,
sender_username: msg.senderUsername ?? msg.senderId,
plaintext,
file_id: msg.fileId ?? undefined,
sent_at: msg.sentAt ?? new Date().toISOString(),
},
]); ]);
}); });
}, [activeId, userId]); }, [activeId, userId]);
@@ -111,11 +203,9 @@ export default function MessagesPage() {
if (!convKey) return; if (!convKey) return;
const plaintext = text.trim(); const plaintext = text.trim();
setText(''); // Clear immediately for responsiveness setText('');
const ciphertext = await encryptMessage(plaintext, convKey); const ciphertext = await encryptMessage(plaintext, convKey);
// Optimistic update — show in UI before server confirms
setMessages((prev) => [...prev, { setMessages((prev) => [...prev, {
id: `pending-${Date.now()}`, id: `pending-${Date.now()}`,
sender_id: userId!, sender_id: userId!,
@@ -128,31 +218,64 @@ export default function MessagesPage() {
socket.send({ type: 'message', conversationId: activeId, ciphertext }); socket.send({ type: 'message', conversationId: activeId, ciphertext });
} }
const sendFile = useCallback(async (file: File) => {
if (!activeId) return;
const convKey = getConvKey(activeId);
if (!convKey) return;
setUploading(true);
try {
const fileKey = await generateFileKey();
const encryptedBlob = await encryptFile(file, fileKey);
const encryptedFileKey = await encryptFileKey(fileKey, useAuthStore.getState().masterKey!);
const encryptedName = await encryptMessage(file.name, fileKey);
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
const fileKeyB64 = bufToB64(rawFileKey);
const form = new FormData();
form.append('file', encryptedBlob, 'blob');
form.append('encryptedName', encryptedName);
form.append('encryptedFileKey', encryptedFileKey);
form.append('mimeType', file.type || 'application/octet-stream');
form.append('conversationId', activeId);
const uploadRes = await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
const fileId = uploadRes.data.id;
const payload = JSON.stringify({ type: 'file', fileId, fileKeyB64, fileName: file.name, mimeType: file.type });
const ciphertext = await encryptMessage(payload, convKey);
setMessages((prev) => [...prev, {
id: `pending-${Date.now()}`,
sender_id: userId!,
sender_username: useAuthStore.getState().username || userId!,
ciphertext,
plaintext: payload,
file_id: fileId,
sent_at: new Date().toISOString(),
}]);
socket.send({ type: 'message', conversationId: activeId, ciphertext, fileId });
qc.invalidateQueries({ queryKey: ['files'] });
} finally {
setUploading(false);
}
}, [activeId, getConvKey, userId, qc]);
async function startConversation(recipient: UserResult) { async function startConversation(recipient: UserResult) {
if (!publicKey || !privateKey) return; if (!publicKey || !privateKey) return;
// Generate random conversation key
const convKeyRaw = generateConvKeyRaw(); const convKeyRaw = generateConvKeyRaw();
// Seal a copy for each participant using their X25519 public key
// Each party can only open their own copy
const [myEncKey, recipientEncKey] = await Promise.all([ const [myEncKey, recipientEncKey] = await Promise.all([
sealConvKey(convKeyRaw, publicKey), sealConvKey(convKeyRaw, publicKey),
sealConvKey(convKeyRaw, recipient.public_key), sealConvKey(convKeyRaw, recipient.public_key),
]); ]);
const res = await api.post('/conversations', { const res = await api.post('/conversations', {
recipientId: recipient.id, recipientId: recipient.id,
encryptedKeys: { encryptedKeys: { [userId!]: myEncKey, [recipient.id]: recipientEncKey },
[userId!]: myEncKey,
[recipient.id]: recipientEncKey,
},
}); });
// Cache the decrypted key in memory for immediate use
const convKey = await importConvKey(convKeyRaw); const convKey = await importConvKey(convKeyRaw);
setConvKey(res.data.conversationId, convKey); setConvKey(res.data.conversationId, convKey);
qc.invalidateQueries({ queryKey: ['conversations'] }); qc.invalidateQueries({ queryKey: ['conversations'] });
setNewUserSearch(''); setNewUserSearch('');
setSearchResults([]); setSearchResults([]);
@@ -172,7 +295,7 @@ export default function MessagesPage() {
return ( return (
<div className="flex h-full"> <div className="flex h-full">
{/* Conversation list */} {/* Conversation list */}
<aside className="w-64 border-r border-slate-700 flex flex-col bg-surface-1 shrink-0"> <aside className={`${activeId ? 'hidden md:flex' : 'flex'} w-full md:w-64 border-r border-slate-700 flex-col bg-surface-1 shrink-0`}>
<div className="p-3 border-b border-slate-700"> <div className="p-3 border-b border-slate-700">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" /> <Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" />
@@ -186,11 +309,8 @@ export default function MessagesPage() {
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden"> <div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden">
{searchResults.map((u) => ( {searchResults.map((u) => (
<button <button key={u.id} onClick={() => startConversation(u)}
key={u.id} className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors">
onClick={() => startConversation(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
>
{u.username} {u.username}
</button> </button>
))} ))}
@@ -202,13 +322,10 @@ export default function MessagesPage() {
{conversations.map((conv) => { {conversations.map((conv) => {
const other = conv.members.find((m) => m.id !== userId); const other = conv.members.find((m) => m.id !== userId);
return ( return (
<Link <Link key={conv.id} to={`/messages/${conv.id}`}
key={conv.id}
to={`/messages/${conv.id}`}
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${ className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
activeId === conv.id ? 'bg-surface-2' : '' activeId === conv.id ? 'bg-surface-2' : ''
}`} }`}>
>
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0"> <div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{other?.username[0]?.toUpperCase() ?? '?'} {other?.username[0]?.toUpperCase() ?? '?'}
</div> </div>
@@ -227,11 +344,14 @@ export default function MessagesPage() {
</aside> </aside>
{/* Chat area */} {/* Chat area */}
<div className="flex-1 flex flex-col"> <div className={`${activeId ? 'flex' : 'hidden md:flex'} flex-1 flex-col`}>
{activeId ? ( {activeId ? (
<> <>
<header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3"> <header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold"> <button onClick={() => navigate('/messages')} className="md:hidden p-1 -ml-1 text-slate-400 hover:text-white">
<ArrowLeft className="w-5 h-5" />
</button>
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold shrink-0">
{otherMember?.username[0]?.toUpperCase() ?? '?'} {otherMember?.username[0]?.toUpperCase() ?? '?'}
</div> </div>
<span className="font-medium">{otherMember?.username ?? 'Conversation'}</span> <span className="font-medium">{otherMember?.username ?? 'Conversation'}</span>
@@ -244,20 +364,27 @@ export default function MessagesPage() {
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3"> <div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.map((m) => { {messages.map((m) => {
const mine = m.sender_id === userId; const mine = m.sender_id === userId;
const parsed = parsePlaintext(m.plaintext ?? '');
return ( return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}> <div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div {parsed.type === 'file' && parsed.fileId ? (
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${ <FileMessage
mine msg={{ ...m, file_id: parsed.fileId }}
? 'bg-accent text-white rounded-br-sm' fileKeyB64={parsed.fileKeyB64!}
: 'bg-surface-1 text-slate-100 rounded-bl-sm' fileName={parsed.fileName ?? 'file'}
}`} mimeType={parsed.mimeType ?? 'application/octet-stream'}
> isMine={mine}
<p>{m.plaintext}</p> />
) : (
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
mine ? 'bg-accent text-white rounded-br-sm' : 'bg-surface-1 text-slate-100 rounded-bl-sm'
}`}>
<p>{parsed.text}</p>
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}> <p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
{m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'} {m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'}
</p> </p>
</div> </div>
)}
</div> </div>
); );
})} })}
@@ -265,14 +392,21 @@ export default function MessagesPage() {
</div> </div>
<div className="px-4 py-3 border-t border-slate-700 flex gap-2"> <div className="px-4 py-3 border-t border-slate-700 flex gap-2">
<input ref={fileInputRef} type="file" accept="image/*,*/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) sendFile(f); e.target.value = ''; }} />
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
className="p-2 text-slate-400 hover:text-white transition-colors" title="Attach file">
<Paperclip className="w-5 h-5" />
</button>
<input <input
className="input flex-1" className="input flex-1"
placeholder="Message…" placeholder={uploading ? 'Uploading…' : 'Message…'}
value={text} value={text}
disabled={uploading}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()} onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
/> />
<button onClick={sendMessage} className="btn-primary px-4"> <button onClick={sendMessage} disabled={uploading} className="btn-primary px-4">
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</button> </button>
</div> </div>
+2 -2
View File
@@ -52,7 +52,7 @@ export default function RegisterPage() {
}); });
const { accessToken, refreshToken, userId } = res.data; const { accessToken, refreshToken, userId } = res.data;
setAuth({ accessToken, refreshToken, userId }); setAuth({ accessToken, refreshToken, userId, email });
setMasterKey(masterKey); setMasterKey(masterKey);
setKeyPair(publicKeyB64, privateKey); setKeyPair(publicKeyB64, privateKey);
setUsername(username); setUsername(username);
@@ -60,7 +60,7 @@ export default function RegisterPage() {
socket.connect(accessToken); socket.connect(accessToken);
navigate('/messages'); navigate('/messages');
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Registration failed'); setError(err.response?.data?.error || err.message || 'Registration failed');
} finally { } finally {
setLoading(false); setLoading(false);
} }
+3 -15
View File
@@ -7,26 +7,14 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 110m; client_max_body_size 110m;
# Redirect HTTP → HTTPS
server { server {
listen 80; listen 80;
return 301 https://$host$request_uri;
}
server { # Security headers (Cloudflare handles TLS; these still apply)
listen 443 ssl http2;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always; add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:;" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: https:;" always;
# API # API
location /api/ { location /api/ {
@@ -35,7 +23,7 @@ http {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
} }
# WebSocket # WebSocket