mirror of
https://github.com/yusufipk/dead-man-switch-2.0.git
synced 2026-09-11 09:26:07 +00:00
feat: phase 6: email sending implemented.
This commit is contained in:
@@ -37,11 +37,11 @@
|
|||||||
- [ ] Deadline kontrolü
|
- [ ] Deadline kontrolü
|
||||||
- [ ] Status güncellemeleri
|
- [ ] Status güncellemeleri
|
||||||
|
|
||||||
### Faz 6: Email Gönderimi 🔲
|
### Faz 6: Email Gönderimi ✅
|
||||||
- [ ] Nodemailer setup
|
- [x] Nodemailer setup
|
||||||
- [ ] Email templates
|
- [x] Email templates
|
||||||
- [ ] Deadline triggered email
|
- [x] Deadline triggered email
|
||||||
- [ ] Check-in reminder emails
|
- [x] Check-in reminder emails
|
||||||
|
|
||||||
### Faz 7: Admin Panel 🔲
|
### Faz 7: Admin Panel 🔲
|
||||||
- [ ] Admin role ve guard
|
- [ ] Admin role ve guard
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
- ✅ Faz 2: Mesaj CRUD
|
- ✅ Faz 2: Mesaj CRUD
|
||||||
- ✅ Faz 3: Dosya Yükleme (R2)
|
- ✅ Faz 3: Dosya Yükleme (R2)
|
||||||
- ✅ Faz 4: Client-side Encryption
|
- ✅ Faz 4: Client-side Encryption
|
||||||
|
- ✅ Faz 6: Email Gönderimi
|
||||||
|
|
||||||
## Bilinen Sorunlar
|
## Bilinen Sorunlar
|
||||||
(henüz yok)
|
(henüz yok)
|
||||||
|
|||||||
Generated
+74
@@ -20,6 +20,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -391,6 +393,58 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/client-sesv2": {
|
||||||
|
"version": "3.958.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.958.0.tgz",
|
||||||
|
"integrity": "sha512-3x3n8IIxIMAkdpt9wy9zS7MO2lqTcJwQTdHMn6BlD7YUohb+r5Q4KCOEQ2uHWd4WIJv2tlbXnfypHaXReO/WXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
|
"@aws-sdk/core": "3.957.0",
|
||||||
|
"@aws-sdk/credential-provider-node": "3.958.0",
|
||||||
|
"@aws-sdk/middleware-host-header": "3.957.0",
|
||||||
|
"@aws-sdk/middleware-logger": "3.957.0",
|
||||||
|
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
||||||
|
"@aws-sdk/middleware-user-agent": "3.957.0",
|
||||||
|
"@aws-sdk/region-config-resolver": "3.957.0",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "3.957.0",
|
||||||
|
"@aws-sdk/types": "3.957.0",
|
||||||
|
"@aws-sdk/util-endpoints": "3.957.0",
|
||||||
|
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
||||||
|
"@aws-sdk/util-user-agent-node": "3.957.0",
|
||||||
|
"@smithy/config-resolver": "^4.4.5",
|
||||||
|
"@smithy/core": "^3.20.0",
|
||||||
|
"@smithy/fetch-http-handler": "^5.3.8",
|
||||||
|
"@smithy/hash-node": "^4.2.7",
|
||||||
|
"@smithy/invalid-dependency": "^4.2.7",
|
||||||
|
"@smithy/middleware-content-length": "^4.2.7",
|
||||||
|
"@smithy/middleware-endpoint": "^4.4.1",
|
||||||
|
"@smithy/middleware-retry": "^4.4.17",
|
||||||
|
"@smithy/middleware-serde": "^4.2.8",
|
||||||
|
"@smithy/middleware-stack": "^4.2.7",
|
||||||
|
"@smithy/node-config-provider": "^4.3.7",
|
||||||
|
"@smithy/node-http-handler": "^4.4.7",
|
||||||
|
"@smithy/protocol-http": "^5.3.7",
|
||||||
|
"@smithy/smithy-client": "^4.10.2",
|
||||||
|
"@smithy/types": "^4.11.0",
|
||||||
|
"@smithy/url-parser": "^4.2.7",
|
||||||
|
"@smithy/util-base64": "^4.3.0",
|
||||||
|
"@smithy/util-body-length-browser": "^4.2.0",
|
||||||
|
"@smithy/util-body-length-node": "^4.2.1",
|
||||||
|
"@smithy/util-defaults-mode-browser": "^4.3.16",
|
||||||
|
"@smithy/util-defaults-mode-node": "^4.2.19",
|
||||||
|
"@smithy/util-endpoints": "^3.2.7",
|
||||||
|
"@smithy/util-middleware": "^4.2.7",
|
||||||
|
"@smithy/util-retry": "^4.2.7",
|
||||||
|
"@smithy/util-utf8": "^4.2.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/client-sso": {
|
"node_modules/@aws-sdk/client-sso": {
|
||||||
"version": "3.958.0",
|
"version": "3.958.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz",
|
||||||
@@ -3740,6 +3794,17 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-sesv2": "^3.839.0",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||||
@@ -7757,6 +7822,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz",
|
||||||
|
"integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { sendEmail, emailTemplates } from '@/lib/email';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const expiredMessages = await prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
lastPing: {
|
||||||
|
lt: new Date(now.getTime() - 1000 * 60 * 60 * 24), // 24 saat geçmiş
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const message of expiredMessages) {
|
||||||
|
const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (now > deadlineTime) {
|
||||||
|
for (const recipient of message.recipients) {
|
||||||
|
const template = emailTemplates.deadlineTriggered(
|
||||||
|
recipient.email,
|
||||||
|
message.user.name || 'Bilinmeyen',
|
||||||
|
message.id
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: recipient.email,
|
||||||
|
subject: template.subject,
|
||||||
|
html: template.html,
|
||||||
|
});
|
||||||
|
results.push({ messageId: message.id, recipient: recipient.email, status: 'sent' });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ messageId: message.id, recipient: recipient.email, status: 'failed', error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.message.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: { status: 'TRIGGERED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ checked: expiredMessages.length, results });
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { sendEmail, emailTemplates } from '@/lib/email';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const reminderHours = [24, 12, 6, 1];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
const activeMessages = await prisma.message.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const message of activeMessages) {
|
||||||
|
const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000);
|
||||||
|
const hoursLeft = (deadlineTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
for (const reminderHour of reminderHours) {
|
||||||
|
if (hoursLeft <= reminderHour && hoursLeft > reminderHour - 1) {
|
||||||
|
if (message.user.email) {
|
||||||
|
const template = emailTemplates.checkInReminder(
|
||||||
|
message.user.name || 'Kullanıcı',
|
||||||
|
Math.round(hoursLeft),
|
||||||
|
message.title
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: message.user.email,
|
||||||
|
subject: template.subject,
|
||||||
|
html: template.html,
|
||||||
|
});
|
||||||
|
results.push({ messageId: message.id, hoursLeft: Math.round(hoursLeft), status: 'sent' });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ messageId: message.id, status: 'failed', error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ checked: activeMessages.length, reminders: results });
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Lock, Unlock } from "lucide-react";
|
import { Lock, Unlock } from "lucide-react";
|
||||||
import { deriveKeyFromPassword, decrypt, decryptFile } from "@/lib/crypto";
|
import { decrypt, decryptFile } from "@/lib/crypto";
|
||||||
|
|
||||||
export default function DecryptPage() {
|
export default function DecryptPage() {
|
||||||
const [encryptedContent, setEncryptedContent] = useState("");
|
const [encryptedContent, setEncryptedContent] = useState("");
|
||||||
@@ -20,8 +20,7 @@ export default function DecryptPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const key = await deriveKeyFromPassword(password);
|
const decrypted = await decrypt(encryptedContent, password);
|
||||||
const decrypted = await decrypt(encryptedContent, key);
|
|
||||||
setDecryptedContent(decrypted);
|
setDecryptedContent(decrypted);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Şifre çözme başarısız. Şifre veya içerik hatalı olabilir.");
|
setError("Şifre çözme başarısız. Şifre veya içerik hatalı olabilir.");
|
||||||
@@ -37,14 +36,13 @@ export default function DecryptPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const key = await deriveKeyFromPassword(password);
|
const decryptedData = await decryptFile(file, password);
|
||||||
const decryptedData = await decryptFile(file, key);
|
|
||||||
|
|
||||||
let originalName = file.name;
|
let originalName = file.name;
|
||||||
if (file.name.endsWith(".enc")) {
|
if (file.name.endsWith(".enc")) {
|
||||||
const encryptedName = file.name.slice(0, -4);
|
const encryptedName = file.name.slice(0, -4);
|
||||||
try {
|
try {
|
||||||
originalName = await decrypt(encryptedName, key);
|
originalName = await decrypt(encryptedName, password);
|
||||||
} catch {
|
} catch {
|
||||||
originalName = "decrypted_file";
|
originalName = "decrypted_file";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { deriveKeyFromPassword, encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto";
|
import { encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto";
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -31,9 +31,8 @@ export function FileUpload({ messageId, isEncrypted, password, onUploadComplete
|
|||||||
let contentType = file.type;
|
let contentType = file.type;
|
||||||
|
|
||||||
if (isEncrypted && password) {
|
if (isEncrypted && password) {
|
||||||
const key = await deriveKeyFromPassword(password);
|
fileToUpload = await encryptFile(file, password);
|
||||||
fileToUpload = await encryptFile(file, key);
|
fileName = (await encrypt(file.name, password)) + ".enc";
|
||||||
fileName = (await encrypt(file.name, key)) + ".enc";
|
|
||||||
contentType = "application/octet-stream";
|
contentType = "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +102,12 @@ export function AttachmentList({ attachments, onDelete, isEncrypted, password }:
|
|||||||
if (isEncrypted && password && fileName.endsWith(".enc")) {
|
if (isEncrypted && password && fileName.endsWith(".enc")) {
|
||||||
const fileRes = await fetch(downloadUrl);
|
const fileRes = await fetch(downloadUrl);
|
||||||
const encryptedBlob = await fileRes.blob();
|
const encryptedBlob = await fileRes.blob();
|
||||||
const key = await deriveKeyFromPassword(password);
|
const decryptedData = await decryptFile(encryptedBlob, password);
|
||||||
const decryptedData = await decryptFile(encryptedBlob, key);
|
|
||||||
|
|
||||||
let originalName = "decrypted_file";
|
let originalName = "decrypted_file";
|
||||||
try {
|
try {
|
||||||
const encryptedName = fileName.slice(0, -4);
|
const encryptedName = fileName.slice(0, -4);
|
||||||
originalName = await decrypt(encryptedName, key);
|
originalName = await decrypt(encryptedName, password);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const blob = new Blob([decryptedData]);
|
const blob = new Blob([decryptedData]);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/componen
|
|||||||
import { Plus, Trash2, Lock, Unlock } from "lucide-react";
|
import { Plus, Trash2, Lock, Unlock } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
||||||
import { deriveKeyFromPassword, encrypt, decrypt, encryptFile } from "@/lib/crypto";
|
import { encrypt, decrypt, encryptFile } from "@/lib/crypto";
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -75,8 +75,7 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
setDecryptError(null);
|
setDecryptError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const key = await deriveKeyFromPassword(password);
|
const decryptedContent = await decrypt(content, password);
|
||||||
const decryptedContent = await decrypt(content, key);
|
|
||||||
setContent(decryptedContent);
|
setContent(decryptedContent);
|
||||||
setIsDecrypted(true);
|
setIsDecrypted(true);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -97,12 +96,10 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let encryptionKey: string | null = null;
|
|
||||||
let finalContent = content;
|
let finalContent = content;
|
||||||
|
|
||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
encryptionKey = await deriveKeyFromPassword(password);
|
finalContent = await encrypt(content, password);
|
||||||
finalContent = await encrypt(content, encryptionKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = initialData?.id
|
const url = initialData?.id
|
||||||
@@ -130,9 +127,9 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
let fileToUpload: Blob = file;
|
let fileToUpload: Blob = file;
|
||||||
let fileName = file.name;
|
let fileName = file.name;
|
||||||
|
|
||||||
if (isEncrypted && encryptionKey) {
|
if (isEncrypted) {
|
||||||
fileToUpload = await encryptFile(file, encryptionKey);
|
fileToUpload = await encryptFile(file, password);
|
||||||
const encryptedFileName = await encrypt(file.name, encryptionKey);
|
const encryptedFileName = await encrypt(file.name, password);
|
||||||
fileName = encryptedFileName + ".enc";
|
fileName = encryptedFileName + ".enc";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-59
@@ -1,4 +1,4 @@
|
|||||||
export async function deriveKeyFromPassword(password: string): Promise<string> {
|
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
@@ -7,90 +7,61 @@ export async function deriveKeyFromPassword(password: string): Promise<string> {
|
|||||||
false,
|
false,
|
||||||
["deriveBits", "deriveKey"]
|
["deriveBits", "deriveKey"]
|
||||||
);
|
);
|
||||||
const key = await crypto.subtle.deriveKey(
|
return crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: "PBKDF2",
|
name: "PBKDF2",
|
||||||
salt: encoder.encode("dead-man-switch-salt"),
|
salt: salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer,
|
||||||
iterations: 100000,
|
iterations: 100000,
|
||||||
hash: "SHA-256",
|
hash: "SHA-256",
|
||||||
},
|
},
|
||||||
keyMaterial,
|
keyMaterial,
|
||||||
{ name: "AES-GCM", length: 256 },
|
{ name: "AES-GCM", length: 256 },
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
);
|
|
||||||
const exported = await crypto.subtle.exportKey("raw", key);
|
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(exported)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateKey(): Promise<string> {
|
|
||||||
const key = await crypto.subtle.generateKey(
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
);
|
|
||||||
const exported = await crypto.subtle.exportKey("raw", key);
|
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(exported)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importKey(keyString: string): Promise<CryptoKey> {
|
|
||||||
const keyData = Uint8Array.from(atob(keyString), (c) => c.charCodeAt(0));
|
|
||||||
return crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
keyData,
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
false,
|
false,
|
||||||
["encrypt", "decrypt"]
|
["encrypt", "decrypt"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encrypt(data: string, keyString: string): Promise<string> {
|
export async function encrypt(data: string, password: string): Promise<string> {
|
||||||
const key = await importKey(keyString);
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const key = await deriveKey(password, salt);
|
||||||
const encoded = new TextEncoder().encode(data);
|
const encoded = new TextEncoder().encode(data);
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
||||||
{ name: "AES-GCM", iv },
|
const combined = new Uint8Array(16 + 12 + encrypted.byteLength);
|
||||||
key,
|
combined.set(salt);
|
||||||
encoded
|
combined.set(iv, 16);
|
||||||
);
|
combined.set(new Uint8Array(encrypted), 28);
|
||||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
||||||
combined.set(iv);
|
|
||||||
combined.set(new Uint8Array(encrypted), iv.length);
|
|
||||||
return btoa(String.fromCharCode(...combined));
|
return btoa(String.fromCharCode(...combined));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(encryptedData: string, keyString: string): Promise<string> {
|
export async function decrypt(encryptedData: string, password: string): Promise<string> {
|
||||||
const key = await importKey(keyString);
|
|
||||||
const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0));
|
const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0));
|
||||||
const iv = combined.slice(0, 12);
|
const salt = combined.slice(0, 16);
|
||||||
const data = combined.slice(12);
|
const iv = combined.slice(16, 28);
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
const data = combined.slice(28);
|
||||||
{ name: "AES-GCM", iv },
|
const key = await deriveKey(password, salt);
|
||||||
key,
|
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
|
||||||
data
|
|
||||||
);
|
|
||||||
return new TextDecoder().decode(decrypted);
|
return new TextDecoder().decode(decrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptFile(file: File, keyString: string): Promise<Blob> {
|
export async function encryptFile(file: File, password: string): Promise<Blob> {
|
||||||
const key = await importKey(keyString);
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const key = await deriveKey(password, salt);
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, arrayBuffer);
|
||||||
{ name: "AES-GCM", iv },
|
const combined = new Uint8Array(16 + 12 + encrypted.byteLength);
|
||||||
key,
|
combined.set(salt);
|
||||||
arrayBuffer
|
combined.set(iv, 16);
|
||||||
);
|
combined.set(new Uint8Array(encrypted), 28);
|
||||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
||||||
combined.set(iv);
|
|
||||||
combined.set(new Uint8Array(encrypted), iv.length);
|
|
||||||
return new Blob([combined], { type: "application/octet-stream" });
|
return new Blob([combined], { type: "application/octet-stream" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptFile(encryptedBlob: Blob, keyString: string): Promise<ArrayBuffer> {
|
export async function decryptFile(encryptedBlob: Blob, password: string): Promise<ArrayBuffer> {
|
||||||
const key = await importKey(keyString);
|
|
||||||
const combined = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const combined = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const iv = combined.slice(0, 12);
|
const salt = combined.slice(0, 16);
|
||||||
const data = combined.slice(12);
|
const iv = combined.slice(16, 28);
|
||||||
|
const data = combined.slice(28);
|
||||||
|
const key = await deriveKey(password, salt);
|
||||||
return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
|
return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(unsafe: string): string {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailOptions {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail({ to, subject, html }: EmailOptions) {
|
||||||
|
return transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'Dead Man Switch <[email protected]>',
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailTemplates = {
|
||||||
|
deadlineTriggered: (recipientEmail: string, senderName: string, messageId: string) => ({
|
||||||
|
subject: `Önemli Mesaj: ${escapeHtml(senderName)} tarafından gönderildi`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #dc2626; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
|
||||||
|
.button { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 15px; }
|
||||||
|
.footer { text-align: center; padding: 15px; color: #6b7280; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Dead Man's Switch Tetiklendi</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Merhaba,</p>
|
||||||
|
<p><strong>${escapeHtml(senderName)}</strong> tarafından size önceden hazırlanmış bir mesaj bırakıldı.</p>
|
||||||
|
<p>Bu mesaj, belirlenen süre içinde check-in yapılmadığı için otomatik olarak gönderilmiştir.</p>
|
||||||
|
<p>Mesajı görüntülemek için aşağıdaki bağlantıya tıklayın:</p>
|
||||||
|
<a href="${process.env.NEXT_PUBLIC_APP_URL}/decrypt/${messageId}" class="button">Mesajı Görüntüle</a>
|
||||||
|
<p style="margin-top: 20px; font-size: 14px; color: #6b7280;">Mesaj şifreli ise, gönderen tarafından size iletilen şifreyi girmeniz gerekecektir.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Dead Man's Switch Sistemi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkInReminder: (userName: string, hoursLeft: number, switchTitle: string) => ({
|
||||||
|
subject: `Hatırlatma: ${escapeHtml(switchTitle)} için check-in gerekli`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
|
||||||
|
.warning { background: #fef3c7; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #f59e0b; }
|
||||||
|
.button { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 15px; }
|
||||||
|
.footer { text-align: center; padding: 15px; color: #6b7280; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Check-in Hatırlatması</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Merhaba ${escapeHtml(userName)},</p>
|
||||||
|
<div class="warning">
|
||||||
|
<strong>"${escapeHtml(switchTitle)}"</strong> için son <strong>${hoursLeft} saat</strong> kaldı!
|
||||||
|
</div>
|
||||||
|
<p>Mesajlarınızın gönderilmemesi için lütfen check-in yapın.</p>
|
||||||
|
<a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard" class="button">Şimdi Check-in Yap</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Dead Man's Switch Sistemi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
welcomeEmail: (userName: string) => ({
|
||||||
|
subject: 'Dead Man\'s Switch\'e Hoş Geldiniz',
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #3b82f6; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
|
||||||
|
.button { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 15px; }
|
||||||
|
.footer { text-align: center; padding: 15px; color: #6b7280; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Hoş Geldiniz!</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Merhaba ${escapeHtml(userName)},</p>
|
||||||
|
<p>Dead Man's Switch sistemine kayıt olduğunuz için teşekkür ederiz.</p>
|
||||||
|
<p>Artık önemli mesajlarınızı güvenle saklayabilir ve belirlediğiniz kişilere iletilmesini sağlayabilirsiniz.</p>
|
||||||
|
<a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard" class="button">Dashboard'a Git</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Dead Man's Switch Sistemi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user