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
|
# 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
@@ -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)
|
||||||
|
|||||||
Generated
+1702
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 { 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">
|
||||||
|
|||||||
@@ -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