mirror of
https://github.com/yusufipk/dead-man-switch-2.0.git
synced 2026-09-11 09:26:07 +00:00
feat(faz3): implement file upload with Cloudflare R2
- Add AWS SDK for S3-compatible R2 storage - Create R2 client with presigned URL support - Add attachment upload/download/delete API routes - Create FileUpload and AttachmentList components - Integrate file upload in message form (new & edit) - Add .env.example with R2 configuration
This commit is contained in:
@@ -1,16 +1,25 @@
|
||||
# Active Context - Dead Man Switch 2.0
|
||||
|
||||
## Şu Anki Durum
|
||||
Proje planlaması tamamlandı, teknoloji stack'i belirlendi.
|
||||
Faz 1 ve Faz 2 tamamlandı. Mesaj CRUD sistemi çalışıyor, dark mode aktif.
|
||||
|
||||
## Son Yapılan Değişiklikler
|
||||
- Message, Recipient modelleri ve Prisma migration eklendi
|
||||
- Mesaj API route'ları (GET, POST, PATCH, DELETE)
|
||||
- IDOR güvenlik açığı düzeltildi (PATCH'te ownership doğrulaması)
|
||||
- Dashboard ve mesaj sayfaları (liste, yeni, detay)
|
||||
- UI bileşenleri (card, input, label, textarea)
|
||||
- Dark mode aktifleştirildi
|
||||
- NextAuth session'a user id typing eklendi
|
||||
|
||||
## Aktif Kararlar
|
||||
- Next.js 16 + App Router kullanılacak
|
||||
- Client-side encryption zorunlu değil, opsiyonel
|
||||
- İlk fazda temel MVP hedefleniyor
|
||||
- Next.js 15 + App Router kullanılıyor
|
||||
- shadcn/ui + Tailwind dark mode
|
||||
- Client-side encryption opsiyonel
|
||||
|
||||
## Sonraki Adım
|
||||
Faz 1: Proje kurulumu ve Auth sistemi
|
||||
Faz 3: Cloudflare R2 ile dosya yükleme
|
||||
|
||||
## Notlar
|
||||
- IDOR fix: recipient silme işleminden önce mesaj sahipliği doğrulanıyor
|
||||
- Şifreleme UX'i kritik - kullanıcı şifreyi kaybederse veri kurtarılamaz
|
||||
- Check-in reminder email'leri düşünülmeli
|
||||
|
||||
+13
-8
@@ -9,17 +9,19 @@
|
||||
- [x] NextAuth.js entegrasyonu
|
||||
- [x] User tablosu ve auth akışı
|
||||
- [x] Temel layout ve routing
|
||||
- [x] Dark mode aktifleştirildi
|
||||
|
||||
### Faz 2: Mesaj CRUD 🔲
|
||||
- [ ] Message, Recipient, Attachment şemaları
|
||||
- [ ] Mesaj oluşturma formu
|
||||
- [ ] Mesaj listeleme/detay sayfaları
|
||||
- [ ] Mesaj düzenleme/silme
|
||||
### Faz 2: Mesaj CRUD ✅
|
||||
- [x] Message, Recipient, Attachment şemaları
|
||||
- [x] Mesaj oluşturma formu
|
||||
- [x] Mesaj listeleme/detay sayfaları
|
||||
- [x] Mesaj düzenleme/silme
|
||||
- [x] IDOR güvenlik açığı düzeltildi
|
||||
|
||||
### Faz 3: Dosya Yükleme (R2) 🔲
|
||||
- [ ] Cloudflare R2 entegrasyonu
|
||||
- [ ] Dosya upload/download API
|
||||
- [ ] Dosya listeleme UI
|
||||
- [x] Cloudflare R2 entegrasyonu
|
||||
- [x] Dosya upload/download API
|
||||
- [x] Dosya listeleme UI
|
||||
|
||||
### Faz 4: Client-side Encryption 🔲
|
||||
- [ ] Web Crypto API wrapper
|
||||
@@ -56,6 +58,9 @@
|
||||
- ✅ Teknoloji stack seçimi
|
||||
- ✅ Memory bank oluşturma
|
||||
- ✅ Faz planlaması
|
||||
- ✅ Faz 1: Auth sistemi
|
||||
- ✅ Faz 2: Mesaj CRUD
|
||||
- ✅ Faz 3: Dosya Yükleme (R2)
|
||||
|
||||
## Bilinen Sorunlar
|
||||
(henüz yok)
|
||||
|
||||
Generated
+1702
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@aws-sdk/client-s3": "^3.958.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.958.0",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/options";
|
||||
import { getDownloadUrl, deleteFile } from "@/lib/r2";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ attachmentId: string }> }
|
||||
) {
|
||||
const { attachmentId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
include: { message: true },
|
||||
});
|
||||
|
||||
if (!attachment || attachment.message.userId !== session.user.id) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const downloadUrl = await getDownloadUrl(attachment.fileUrl);
|
||||
return NextResponse.json({ downloadUrl });
|
||||
} catch (error) {
|
||||
console.error("DOWNLOAD_URL_ERROR", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ attachmentId: string }> }
|
||||
) {
|
||||
const { attachmentId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
include: { message: true },
|
||||
});
|
||||
|
||||
if (!attachment || attachment.message.userId !== session.user.id) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
await deleteFile(attachment.fileUrl);
|
||||
await prisma.attachment.delete({ where: { id: attachmentId } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("DELETE_ATTACHMENT_ERROR", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/options";
|
||||
import { getUploadUrl } from "@/lib/r2";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { fileName, contentType, messageId } = await req.json();
|
||||
|
||||
const message = await prisma.message.findUnique({
|
||||
where: { id: messageId, userId: session.user.id },
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return new NextResponse("Message not found", { status: 404 });
|
||||
}
|
||||
|
||||
const key = `${session.user.id}/${messageId}/${randomUUID()}-${fileName}`;
|
||||
const uploadUrl = await getUploadUrl(key, contentType);
|
||||
|
||||
const attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
messageId,
|
||||
fileName,
|
||||
fileUrl: key,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ uploadUrl, attachment });
|
||||
} catch (error) {
|
||||
console.error("UPLOAD_URL_ERROR", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export default async function EditMessagePage({
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
attachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +37,7 @@ export default async function EditMessagePage({
|
||||
content: message.content || "",
|
||||
checkInterval: message.checkInterval,
|
||||
recipients: message.recipients.map((r) => r.email),
|
||||
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FileUploadProps {
|
||||
messageId: string;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function FileUpload({ messageId, onUploadComplete }: FileUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await fetch("/api/attachments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
messageId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to get upload URL");
|
||||
|
||||
const { uploadUrl } = await res.json();
|
||||
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
|
||||
onUploadComplete?.();
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button asChild variant="outline" disabled={uploading}>
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
{uploading ? "Yükleniyor..." : "Dosya Ekle"}
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: Attachment[];
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AttachmentList({ attachments, onDelete }: AttachmentListProps) {
|
||||
const handleDownload = async (id: string, fileName: string) => {
|
||||
const res = await fetch(`/api/attachments/${id}`);
|
||||
const { downloadUrl } = await res.json();
|
||||
window.open(downloadUrl, "_blank");
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
onDelete?.(id);
|
||||
};
|
||||
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{attachments.map((att) => (
|
||||
<li key={att.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{att.fileName}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(att.id, att.fileName)}
|
||||
>
|
||||
İndir
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(att.id)}
|
||||
>
|
||||
Sil
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface MessageFormProps {
|
||||
initialData?: {
|
||||
@@ -16,6 +22,7 @@ interface MessageFormProps {
|
||||
content: string;
|
||||
recipients: string[];
|
||||
checkInterval: number;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +31,8 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
||||
const [content, setContent] = useState(initialData?.content || "");
|
||||
const [recipients, setRecipients] = useState(initialData?.recipients || [""]);
|
||||
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -73,6 +82,28 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const message = await response.json();
|
||||
|
||||
for (const file of pendingFiles) {
|
||||
const uploadRes = await fetch("/api/attachments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
messageId: message.id,
|
||||
}),
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const { uploadUrl } = await uploadRes.json();
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
@@ -165,6 +196,58 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Dosyalar</Label>
|
||||
{initialData?.id && (
|
||||
<AttachmentList
|
||||
attachments={attachments}
|
||||
onDelete={(id) => setAttachments(attachments.filter((a) => a.id !== id))}
|
||||
/>
|
||||
)}
|
||||
{pendingFiles.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{pendingFiles.map((file, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{file.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingFiles(pendingFiles.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
Kaldır
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{initialData?.id ? (
|
||||
<FileUpload
|
||||
messageId={initialData.id}
|
||||
onUploadComplete={() => router.refresh()}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="pending-file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingFiles([...pendingFiles, file]);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button asChild variant="outline">
|
||||
<label htmlFor="pending-file" className="cursor-pointer">
|
||||
Dosya Ekle
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export const r2Client = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT!,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export const R2_BUCKET = process.env.R2_BUCKET!;
|
||||
|
||||
export async function getUploadUrl(key: string, contentType: string) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
return getSignedUrl(r2Client, command, { expiresIn: 3600 });
|
||||
}
|
||||
|
||||
export async function getDownloadUrl(key: string) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(r2Client, command, { expiresIn: 3600 });
|
||||
}
|
||||
|
||||
export async function deleteFile(key: string) {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
return r2Client.send(command);
|
||||
}
|
||||
Reference in New Issue
Block a user