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:
Yusuf İpek
2025-12-24 15:26:55 +03:00
parent 1626106a8b
commit 70d246bc17
10 changed files with 2078 additions and 14 deletions
+15 -6
View File
@@ -1,16 +1,25 @@
# Active Context - Dead Man Switch 2.0 # Active Context - Dead Man Switch 2.0
## Şu Anki Durum ## Ş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 ## Aktif Kararlar
- Next.js 16 + App Router kullanılacak - Next.js 15 + App Router kullanılıyor
- Client-side encryption zorunlu değil, opsiyonel - shadcn/ui + Tailwind dark mode
- İlk fazda temel MVP hedefleniyor - Client-side encryption opsiyonel
## Sonraki Adım ## Sonraki Adım
Faz 1: Proje kurulumu ve Auth sistemi Faz 3: Cloudflare R2 ile dosya yükleme
## Notlar ## 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 - Şifreleme UX'i kritik - kullanıcı şifreyi kaybederse veri kurtarılamaz
- Check-in reminder email'leri düşünülmeli
+13 -8
View File
@@ -9,17 +9,19 @@
- [x] NextAuth.js entegrasyonu - [x] NextAuth.js entegrasyonu
- [x] User tablosu ve auth akışı - [x] User tablosu ve auth akışı
- [x] Temel layout ve routing - [x] Temel layout ve routing
- [x] Dark mode aktifleştirildi
### Faz 2: Mesaj CRUD 🔲 ### Faz 2: Mesaj CRUD
- [ ] Message, Recipient, Attachment şemaları - [x] Message, Recipient, Attachment şemaları
- [ ] Mesaj oluşturma formu - [x] Mesaj oluşturma formu
- [ ] Mesaj listeleme/detay sayfaları - [x] Mesaj listeleme/detay sayfaları
- [ ] Mesaj düzenleme/silme - [x] Mesaj düzenleme/silme
- [x] IDOR güvenlik açığı düzeltildi
### Faz 3: Dosya Yükleme (R2) 🔲 ### Faz 3: Dosya Yükleme (R2) 🔲
- [ ] Cloudflare R2 entegrasyonu - [x] Cloudflare R2 entegrasyonu
- [ ] Dosya upload/download API - [x] Dosya upload/download API
- [ ] Dosya listeleme UI - [x] Dosya listeleme UI
### Faz 4: Client-side Encryption 🔲 ### Faz 4: Client-side Encryption 🔲
- [ ] Web Crypto API wrapper - [ ] Web Crypto API wrapper
@@ -56,6 +58,9 @@
- ✅ Teknoloji stack seçimi - ✅ Teknoloji stack seçimi
- ✅ Memory bank oluşturma - ✅ Memory bank oluşturma
- ✅ Faz planlaması - ✅ Faz planlaması
- ✅ Faz 1: Auth sistemi
- ✅ Faz 2: Mesaj CRUD
- ✅ Faz 3: Dosya Yükleme (R2)
## Bilinen Sorunlar ## Bilinen Sorunlar
(henüz yok) (henüz yok)
+1702
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@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/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-label": "^2.1.8", "@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 });
}
}
+42
View File
@@ -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: { include: {
recipients: true, recipients: true,
attachments: true,
}, },
}); });
@@ -36,6 +37,7 @@ export default async function EditMessagePage({
content: message.content || "", content: message.content || "",
checkInterval: message.checkInterval, checkInterval: message.checkInterval,
recipients: message.recipients.map((r) => r.email), recipients: message.recipients.map((r) => r.email),
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
}; };
return ( return (
+116
View File
@@ -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>
);
}
+83
View File
@@ -8,6 +8,12 @@ import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
interface Attachment {
id: string;
fileName: string;
}
interface MessageFormProps { interface MessageFormProps {
initialData?: { initialData?: {
@@ -16,6 +22,7 @@ interface MessageFormProps {
content: string; content: string;
recipients: string[]; recipients: string[];
checkInterval: number; checkInterval: number;
attachments?: Attachment[];
}; };
} }
@@ -24,6 +31,8 @@ export default function MessageForm({ initialData }: MessageFormProps) {
const [content, setContent] = useState(initialData?.content || ""); const [content, setContent] = useState(initialData?.content || "");
const [recipients, setRecipients] = useState(initialData?.recipients || [""]); const [recipients, setRecipients] = useState(initialData?.recipients || [""]);
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24); 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 [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -73,6 +82,28 @@ export default function MessageForm({ initialData }: MessageFormProps) {
}); });
if (response.ok) { 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.push("/dashboard");
router.refresh(); router.refresh();
} }
@@ -165,6 +196,58 @@ export default function MessageForm({ initialData }: MessageFormProps) {
required required
/> />
</div> </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> </CardContent>
<CardFooter> <CardFooter>
<Button type="submit" disabled={loading} className="w-full"> <Button type="submit" disabled={loading} className="w-full">
+38
View File
@@ -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);
}