Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
596e5fffe6 | ||
|
|
15160b9e86 | ||
|
|
515bef4fc9 | ||
|
|
b95cc7dd54 | ||
|
|
d8568849b4 | ||
|
|
99efd4be55 |
No files matched your search
@@ -128,13 +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
|
||||||
|
|
||||||
|
# Pull new images first
|
||||||
if docker compose version > /dev/null 2>&1; then
|
if docker compose version > /dev/null 2>&1; then
|
||||||
docker compose pull backend frontend
|
COMPOSE="docker compose"
|
||||||
docker compose up -d --no-build backend frontend
|
|
||||||
else
|
else
|
||||||
docker-compose pull backend frontend
|
COMPOSE="docker-compose"
|
||||||
docker-compose up -d --no-build backend frontend
|
|
||||||
fi
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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">
|
||||||
<Outlet />
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & drop or click Upload</p>
|
<p>No files yet — drag & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
<Shield className="w-12 h-12 text-accent mb-3" />
|
{isReauth ? (
|
||||||
|
<Lock 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>
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-400 mt-4">
|
{!isReauth && (
|
||||||
No account?{' '}
|
<p className="text-center text-sm text-slate-400 mt-4">
|
||||||
<Link to="/register" className="text-accent hover:underline">Register</Link>
|
No account?{' '}
|
||||||
</p>
|
<Link to="/register" className="text-accent hover:underline">Register</Link>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
/>
|
||||||
<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'}
|
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
|
||||||
</p>
|
mine ? 'bg-accent text-white rounded-br-sm' : 'bg-surface-1 text-slate-100 rounded-bl-sm'
|
||||||
</div>
|
}`}>
|
||||||
|
<p>{parsed.text}</p>
|
||||||
|
<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'}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+3
-15
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user