first commit
This commit is contained in:
619
app/admin/page.tsx
Normal file
619
app/admin/page.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
role: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AdminApiKeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPreview: string;
|
||||
expiresAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
remainingLabel: string;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const router = useRouter();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentUserRole, setCurrentUserRole] = useState<string>("");
|
||||
const [keysModalUser, setKeysModalUser] = useState<User | null>(null);
|
||||
const [userKeys, setUserKeys] = useState<AdminApiKeyRow[]>([]);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
const [keysPatching, setKeysPatching] = useState<string | null>(null);
|
||||
const [expiryDraft, setExpiryDraft] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const session: Session = await res.json();
|
||||
|
||||
if (!session.user || session.user.role !== "admin") {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Erişim Engellendi",
|
||||
text: "Bu sayfaya erişim yetkiniz yok. Sadece adminler görebilir.",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUserRole(session.user.role || "user");
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Yetki Hatası",
|
||||
text: "Bu sayfaya erişim yetkiniz yok.",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("Kullanıcılar yüklenemedi");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setUsers(data.data.users);
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = async (userId: string, newRole: string, currentRole: string) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Rol Değiştir",
|
||||
text: `Bu kullanıcının rolünü "${currentRole}" → "${newRole}" olarak değiştirmek istediğinizden emin misiniz?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Evet, değiştir",
|
||||
cancelButtonText: "İptal",
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
await fetchUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}/role`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Rol güncellenemedi");
|
||||
}
|
||||
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Başarılı!",
|
||||
text: "Kullanıcı rolü güncellendi",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
await fetchUsers();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmailVerification = async (userId: string, currentStatus: boolean, email: string) => {
|
||||
const newStatus = !currentStatus;
|
||||
const result = await Swal.fire({
|
||||
title: "Email Doğrulama",
|
||||
text: `${email} için email doğrulamasını ${newStatus ? "aktif" : "pasif"} yapmak istiyor musunuz?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Evet, değiştir",
|
||||
cancelButtonText: "İptal",
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}/verification`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ emailVerified: newStatus }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Doğrulama güncellenemedi");
|
||||
}
|
||||
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Başarılı!",
|
||||
text: `Email doğrulama ${newStatus ? "aktif edildi" : "pasif edildi"}`,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserKeysList = async (userId: string) => {
|
||||
setKeysLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/users/${userId}/api-keys`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Anahtarlar yüklenemedi");
|
||||
}
|
||||
setUserKeys(data.data?.keys || []);
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openApiKeysModal = async (u: User) => {
|
||||
setKeysModalUser(u);
|
||||
setUserKeys([]);
|
||||
setExpiryDraft({});
|
||||
try {
|
||||
await fetchUserKeysList(u.id);
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
setKeysModalUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const patchKeyExpiry = async (userId: string, keyId: string, draft: string) => {
|
||||
let expiresInDays: number | null;
|
||||
const t = draft.trim();
|
||||
if (t === "") {
|
||||
expiresInDays = null;
|
||||
} else {
|
||||
const n = parseInt(t, 10);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
await Swal.fire({
|
||||
icon: "warning",
|
||||
title: "Geçersiz",
|
||||
text: "Gün sayısı boş (süresiz) veya 0 veya pozitif tam sayı olmalı.",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
return;
|
||||
}
|
||||
expiresInDays = n === 0 ? null : n;
|
||||
}
|
||||
setKeysPatching(keyId);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/admin/users/${userId}/api-keys/${keyId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expiresInDays }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Güncellenemedi");
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Güncellendi",
|
||||
text: "API anahtarı süresi kaydedildi.",
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
setExpiryDraft((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[keyId];
|
||||
return next;
|
||||
});
|
||||
await fetchUserKeysList(userId);
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
} finally {
|
||||
setKeysPatching(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: string, email: string) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Kullanıcıyı Sil",
|
||||
html: `<b>${email}</b> kullanıcısını silmek istediğinizden emin misiniz?<br><br>
|
||||
<span style="color: #ef4444; font-weight: 600;">⚠️ Bu işlem geri alınamaz!</span><br>
|
||||
<small>Kullanıcının tüm resimleri ve verileri silinecek.</small>`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Evet, sil!",
|
||||
cancelButtonText: "İptal",
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Kullanıcı silinemedi");
|
||||
}
|
||||
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Silindi!",
|
||||
text: "Kullanıcı başarıyla silindi",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Hata",
|
||||
text: err.message,
|
||||
confirmButtonColor: "#3b82f6",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
case "moderator":
|
||||
return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Yükleniyor...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Admin Panel</h1>
|
||||
<p className="text-gray-600">Kullanıcı yönetimi ve rol atama</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Kullanıcı</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Email</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Rol</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Doğrulama</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Kayıt Tarihi</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">
|
||||
{user.name || "İsimsiz"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{user.id.substring(0, 8)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-700">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole(user.id, e.target.value, user.role)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium border ${getRoleBadgeColor(
|
||||
user.role
|
||||
)} focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer transition-all hover:shadow-md`}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => toggleEmailVerification(user.id, user.emailVerified, user.email)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all cursor-pointer hover:shadow-md ${
|
||||
user.emailVerified
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-yellow-100 text-yellow-700 hover:bg-yellow-200"
|
||||
}`}
|
||||
>
|
||||
{user.emailVerified ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Doğrulandı
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Beklemede
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(user.createdAt).toLocaleDateString("tr-TR")}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openApiKeysModal(user)}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
API Keys
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteUser(user.id, user.email)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Sil
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keysModalUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">API anahtarları</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{keysModalUser.email} — süre: bugünden itibaren gün sayısı (boş veya 0 =
|
||||
süresiz)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeysModalUser(null)}
|
||||
className="rounded-lg px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Kapat
|
||||
</button>
|
||||
</div>
|
||||
{keysLoading ? (
|
||||
<p className="text-gray-600">Yükleniyor…</p>
|
||||
) : userKeys.length === 0 ? (
|
||||
<p className="text-gray-600">Bu kullanıcının henüz API anahtarı yok.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="py-2 pr-3">İsim</th>
|
||||
<th className="py-2 pr-3">Önizleme</th>
|
||||
<th className="py-2 pr-3">Mevcut bitiş</th>
|
||||
<th className="py-2 pr-3">Kalan süre</th>
|
||||
<th className="py-2 pr-3">Yeni süre (gün)</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userKeys.map((k) => (
|
||||
<tr key={k.id} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-3 font-medium">{k.name}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs">{k.keyPreview}</td>
|
||||
<td className="py-2 pr-3 text-xs">
|
||||
{k.expiresAt
|
||||
? new Date(k.expiresAt).toLocaleString("tr-TR")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span
|
||||
className={
|
||||
k.remainingLabel === "Süresi doldu"
|
||||
? "font-medium text-red-600"
|
||||
: k.remainingLabel === "Süresiz"
|
||||
? "text-gray-600"
|
||||
: "font-medium text-emerald-700"
|
||||
}
|
||||
>
|
||||
{k.remainingLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0=süresiz"
|
||||
value={expiryDraft[k.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setExpiryDraft((prev) => ({
|
||||
...prev,
|
||||
[k.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-28 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={keysPatching === k.id}
|
||||
onClick={() =>
|
||||
patchKeyExpiry(
|
||||
keysModalUser.id,
|
||||
k.id,
|
||||
expiryDraft[k.id] ?? ""
|
||||
)
|
||||
}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{keysPatching === k.id ? "…" : "Kaydet"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-2">Toplam Kullanıcı</h3>
|
||||
<p className="text-3xl font-bold text-gray-900">{users.length}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-2">Admin Sayısı</h3>
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
{users.filter((u) => u.role === "admin").length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-2">Moderatör Sayısı</h3>
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{users.filter((u) => u.role === "moderator").length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-2">Doğrulananlar</h3>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{users.filter((u) => u.emailVerified).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
app/api-docs/page.tsx
Normal file
318
app/api-docs/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-zinc-900 dark:via-black dark:to-zinc-900">
|
||||
<div className="mx-auto max-w-5xl px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-5xl font-bold text-transparent dark:from-blue-400 dark:to-purple-400">
|
||||
API Dokümantasyonu
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300">
|
||||
Image Manipulation API - REST API Kullanım Kılavuzu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-8">
|
||||
{/* Base URL */}
|
||||
<section className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Base URL
|
||||
</h2>
|
||||
<div className="rounded-lg bg-gray-100 p-4 dark:bg-zinc-900">
|
||||
<code className="text-blue-600 dark:text-blue-400">
|
||||
https://v2.beyhano.com.tr
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Authentication */}
|
||||
<section className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Authentication
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||
İki yöntem desteklenir: <strong>JWT</strong> (login/register ile alınan token) veya
|
||||
hesabınız için oluşturduğunuz <strong>API anahtarı</strong> (
|
||||
<code className="text-sm">img_</code> ile başlar). İkisi de aynı header ile
|
||||
gönderilir:
|
||||
</p>
|
||||
<div className="rounded-lg bg-gray-100 p-4 dark:bg-zinc-900">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200">
|
||||
Authorization: Bearer <jwt_token_veya_img_..._api_key>
|
||||
</code>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-300">
|
||||
API anahtarını web arayüzünde profil sayfasından oluşturabilirsiniz; isteğe bağlı
|
||||
gün sınırı koyabilir veya süresiz bırakabilirsiniz. Admin, kullanıcı anahtarlarının
|
||||
süresini panelden güncelleyebilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Endpoints */}
|
||||
<section className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<h2 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Endpoints
|
||||
</h2>
|
||||
|
||||
{/* Register */}
|
||||
<div className="mb-8 border-l-4 border-green-500 pl-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-semibold text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
POST
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/auth/register
|
||||
</code>
|
||||
</div>
|
||||
<p className="mb-3 text-gray-600 dark:text-gray-400">
|
||||
Yeni kullanıcı kaydı oluşturur ve JWT token döner.
|
||||
</p>
|
||||
<div className="rounded-lg bg-gray-100 p-4 dark:bg-zinc-900">
|
||||
<pre className="text-sm text-gray-800 dark:text-gray-200">
|
||||
{`{
|
||||
"email": "user@example.com",
|
||||
"password": "minimum8karakter",
|
||||
"name": "Kullanıcı Adı"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login */}
|
||||
<div className="mb-8 border-l-4 border-blue-500 pl-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
POST
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/auth/login
|
||||
</code>
|
||||
</div>
|
||||
<p className="mb-3 text-gray-600 dark:text-gray-400">
|
||||
Mevcut kullanıcı ile giriş yapar ve JWT token döner.
|
||||
</p>
|
||||
<div className="rounded-lg bg-gray-100 p-4 dark:bg-zinc-900">
|
||||
<pre className="text-sm text-gray-800 dark:text-gray-200">
|
||||
{`{
|
||||
"email": "user@example.com",
|
||||
"password": "minimum8karakter"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="mb-8 border-l-4 border-teal-500 pl-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-teal-100 px-2 py-1 text-xs font-semibold text-teal-800 dark:bg-teal-900/30 dark:text-teal-400">
|
||||
GET
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/api-keys
|
||||
</code>
|
||||
</div>
|
||||
<p className="mb-2 text-gray-600 dark:text-gray-400">
|
||||
Oturum veya Bearer ile: kendi API anahtarlarınızı listeler (tam değer dönmez).
|
||||
</p>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-teal-100 px-2 py-1 text-xs font-semibold text-teal-800 dark:bg-teal-900/30 dark:text-teal-400">
|
||||
POST
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/api-keys
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Body:{" "}
|
||||
<code className="text-xs">
|
||||
{`{ "name": "Etiket", "expiresInDays": 30 }`}
|
||||
</code>{" "}
|
||||
— <code className="text-xs">expiresInDays</code> yok/null/0 ise süresiz.
|
||||
Yanıtta tam anahtar yalnızca bir kez gelir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Image */}
|
||||
<div className="mb-8 border-l-4 border-purple-500 pl-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-semibold text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
POST
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/images/upload
|
||||
</code>
|
||||
</div>
|
||||
<p className="mb-3 text-gray-600 dark:text-gray-400">
|
||||
Resim yükler, belirtilen boyut/kalite/formatta işler ve kaydeder. (multipart/form-data)
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div><strong>file</strong> (required): Resim dosyası (max 10MB)</div>
|
||||
<div><strong>width</strong> (optional): Genişlik (px), default: 800</div>
|
||||
<div><strong>height</strong> (optional): Yükseklik (px), default: 600</div>
|
||||
<div><strong>quality</strong> (optional): Kalite (1-100), default: 90</div>
|
||||
<div><strong>format</strong> (optional): jpeg, png, webp, avif</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Images */}
|
||||
<div className="mb-8 border-l-4 border-yellow-500 pl-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-yellow-100 px-2 py-1 text-xs font-semibold text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
GET
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/images
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Kullanıcının tüm resimlerini listeler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Image */}
|
||||
<div className="border-l-4 border-red-500 pl-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
DELETE
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
/api/v1/images/:id
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Belirtilen ID'ye sahip resmi siler.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Example Code */}
|
||||
<section className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Örnek Kullanım (JavaScript)
|
||||
</h2>
|
||||
<div className="rounded-lg bg-gray-100 p-4 dark:bg-zinc-900">
|
||||
<pre className="overflow-x-auto text-sm text-gray-800 dark:text-gray-200">
|
||||
{`// 1. Kayıt ol
|
||||
const registerResponse = await fetch(
|
||||
'https://image.beyhano.com.tr/api/v1/auth/register',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'securepassword123',
|
||||
name: 'Kullanıcı'
|
||||
})
|
||||
}
|
||||
);
|
||||
const { data } = await registerResponse.json();
|
||||
const token = data.accessToken;
|
||||
|
||||
// 2. Resim yükle
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('width', '1920');
|
||||
formData.append('quality', '85');
|
||||
formData.append('format', 'webp');
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
'https://image.beyhano.com.tr/api/v1/images/upload',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': \`Bearer \${token}\` },
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
const uploadData = await uploadResponse.json();
|
||||
console.log('URL:', uploadData.data.image.url);`}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<h2 className="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Özellikler ve Limitler
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
✅ Desteklenen Formatlar
|
||||
</h3>
|
||||
<ul className="space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<li>• JPEG / JPG</li>
|
||||
<li>• PNG</li>
|
||||
<li>• WebP</li>
|
||||
<li>• AVIF</li>
|
||||
<li>• GIF</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
⚙️ Limitler
|
||||
</h3>
|
||||
<ul className="space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<li>• Max dosya: 10MB</li>
|
||||
<li>• Max boyut: 10000x10000 px</li>
|
||||
<li>• Token süresi: 7 gün</li>
|
||||
<li>• HTTPS zorunlu (production)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Download Full Docs */}
|
||||
<section className="rounded-xl bg-gradient-to-r from-blue-600 to-purple-600 p-6 text-center text-white shadow-lg">
|
||||
<h3 className="mb-2 text-xl font-bold">Detaylı Dokümantasyon</h3>
|
||||
<p className="mb-4">
|
||||
Tüm endpoint'ler, hata kodları ve örnekler için tam dokümantasyonu indirin.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/yourusername/image-api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white px-6 py-3 font-semibold text-blue-600 transition-transform hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
GitHub'da Görüntüle
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
app/api/admin/users/[id]/role/route.ts
Normal file
70
app/api/admin/users/[id]/role/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { isAdmin, UserRole, updateUserRole } from "@/app/lib/permissions";
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/[id]/role
|
||||
* Kullanıcının rolünü değiştir (Sadece admin - Web Session)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Giriş yapmalısınız" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
const userRole = (session.user as any).role || "user";
|
||||
if (!isAdmin(userRole)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler rol değiştirebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
// Role validasyonu
|
||||
const validRoles: UserRole[] = ["user", "admin", "moderator"];
|
||||
if (!role || !validRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçersiz rol. Geçerli roller: user, admin, moderator" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Kendi rolünü değiştirmeyi engelle
|
||||
if (userId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kendi rolünüzü değiştiremezsiniz" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rolü güncelle
|
||||
await updateUserRole(userId, role);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kullanıcı rolü başarıyla güncellendi",
|
||||
data: {
|
||||
userId,
|
||||
newRole: role,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Rol güncelleme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Rol güncellenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/api/admin/users/[id]/route.ts
Normal file
75
app/api/admin/users/[id]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { hasPermission, PERMISSIONS } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user, images, apiKeys } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/[id]
|
||||
* Kullanıcıyı sil (Sadece admin - Web Session)
|
||||
* Kullanıcının tüm resimleri ve API anahtarları da silinir
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Giriş yapmalısınız" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Permission kontrolü
|
||||
const userRole = (session.user as any).role || "user";
|
||||
if (!hasPermission(userRole, PERMISSIONS.USER_DELETE)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler kullanıcı silebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
|
||||
// Kendi hesabını silmeyi engelle
|
||||
if (userId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kendi hesabınızı silemezsiniz" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Kullanıcının var olup olmadığını kontrol et
|
||||
const targetUser = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return NextResponse.json({ error: "Kullanıcı bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Kullanıcının resimlerini sil
|
||||
await db.delete(images).where(eq(images.userId, userId));
|
||||
|
||||
// Kullanıcının API anahtarlarını sil
|
||||
await db.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
|
||||
// Kullanıcıyı sil
|
||||
await db.delete(user).where(eq(user.id, userId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kullanıcı başarıyla silindi",
|
||||
data: {
|
||||
deletedUserId: userId,
|
||||
deletedUser: targetUser[0].email,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Kullanıcı silme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Kullanıcı silinemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
app/api/admin/users/[id]/verification/route.ts
Normal file
72
app/api/admin/users/[id]/verification/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/[id]/verification
|
||||
* Kullanıcının email doğrulamasını değiştir (Sadece admin - Web Session)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Giriş yapmalısınız" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
const userRole = (session.user as any).role || "user";
|
||||
if (!isAdmin(userRole)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler doğrulama değiştirebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
const body = await request.json();
|
||||
const { emailVerified } = body;
|
||||
|
||||
// Boolean validasyonu
|
||||
if (typeof emailVerified !== "boolean") {
|
||||
return NextResponse.json(
|
||||
{ error: "emailVerified boolean olmalıdır" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email doğrulama durumunu güncelle
|
||||
const result = await db
|
||||
.update(user)
|
||||
.set({ emailVerified })
|
||||
.where(eq(user.id, userId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: "Kullanıcı bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Email doğrulama ${emailVerified ? "aktif edildi" : "pasif edildi"}`,
|
||||
data: {
|
||||
userId,
|
||||
emailVerified,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Email doğrulama güncelleme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Email doğrulama güncellenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
57
app/api/admin/users/route.ts
Normal file
57
app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* Tüm kullanıcıları listele (Sadece admin - Web Session)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Giriş yapmalısınız" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
const userRole = (session.user as any).role || "user";
|
||||
if (!isAdmin(userRole)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler kullanıcıları görüntüleyebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
emailVerified: user.emailVerified,
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(desc(user.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
total: users.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Kullanıcı listesi hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Kullanıcılar yüklenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
7
app/api/config/route.ts
Normal file
7
app/api/config/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
registerEnabled: process.env.REGISTER_ENABLE === "true",
|
||||
});
|
||||
}
|
||||
80
app/api/images/[id]/route.ts
Normal file
80
app/api/images/[id]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { deleteFromR2 } from "@/app/lib/r2-storage";
|
||||
|
||||
async function getUserId(request: NextRequest): Promise<string | null> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
return session?.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
||||
) {
|
||||
try {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Yetkisiz erişim" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Next.js 15'te params async olabilir
|
||||
const resolvedParams = await Promise.resolve(params);
|
||||
const imageId = resolvedParams.id;
|
||||
|
||||
// Input validation
|
||||
if (!imageId || typeof imageId !== "string" || imageId.length > 255) {
|
||||
return NextResponse.json(
|
||||
{ message: "Geçersiz resim ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resmi veritabanından bul
|
||||
const image = await db
|
||||
.select()
|
||||
.from(images)
|
||||
.where(and(eq(images.id, imageId), eq(images.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (image.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Resim bulunamadı veya yetkiniz yok" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const imageData = image[0];
|
||||
|
||||
// R2'den dosyayı sil
|
||||
try {
|
||||
await deleteFromR2(imageData.fileName);
|
||||
} catch (error) {
|
||||
console.error("R2'den silme hatası:", error);
|
||||
// Hata olsa bile devam et, veritabanından sil
|
||||
}
|
||||
|
||||
// Veritabanından sil
|
||||
await db.delete(images).where(eq(images.id, imageId));
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Resim başarıyla silindi",
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "Silme işlemi başarısız" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/images/route.ts
Normal file
53
app/api/images/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
|
||||
async function getUserId(request: NextRequest): Promise<string | null> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
return session?.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Yetkisiz erişim" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userImages = await db
|
||||
.select()
|
||||
.from(images)
|
||||
.where(eq(images.userId, userId))
|
||||
.orderBy(desc(images.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
images: userImages.map((img) => ({
|
||||
id: img.id,
|
||||
originalName: img.originalName,
|
||||
url: img.url, // R2 URL'leri zaten tam URL olarak kaydedildi
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
quality: img.quality,
|
||||
format: img.format,
|
||||
fileSize: img.fileSize,
|
||||
createdAt: img.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "Resimler yüklenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
154
app/api/images/upload/route.ts
Normal file
154
app/api/images/upload/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { uploadToR2, getContentType } from "@/app/lib/r2-storage";
|
||||
|
||||
async function getUserId(request: NextRequest): Promise<string | null> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
return session?.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Yetkisiz erişim" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ message: "Dosya bulunamadı" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// File size validation (max 10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ message: "Dosya boyutu çok büyük. Maksimum 10MB olmalıdır." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// File type validation
|
||||
const allowedMimeTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/avif"];
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Geçersiz dosya tipi. Sadece resim dosyaları kabul edilir." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Input validation
|
||||
const widthInput = formData.get("width") as string;
|
||||
const heightInput = formData.get("height") as string;
|
||||
const qualityInput = formData.get("quality") as string;
|
||||
const formatInput = (formData.get("format") as string) || "jpeg";
|
||||
|
||||
const width = Math.max(1, Math.min(10000, parseInt(widthInput) || 800));
|
||||
const height = Math.max(1, Math.min(10000, parseInt(heightInput) || 600));
|
||||
const quality = Math.max(1, Math.min(100, parseInt(qualityInput) || 90));
|
||||
const allowedFormats = ["jpeg", "jpg", "png", "webp", "avif"];
|
||||
const format = allowedFormats.includes(formatInput) ? formatInput : "jpeg";
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Resim manipülasyonu - Tam istenen boyuta getir (crop ile, bozmadan)
|
||||
// fit: "cover" kullanarak resmi tam boyuta getiriyoruz
|
||||
// Aspect ratio korunur, fazla kısımlar ortadan kesilir (crop)
|
||||
let processedBuffer = sharp(buffer).resize(width, height, {
|
||||
fit: "cover", // Tam boyuta getir, aspect ratio koru, fazla kısımları kes
|
||||
position: "center", // Ortadan crop yap
|
||||
withoutEnlargement: false, // Gerekirse büyüt de tam boyuta getir
|
||||
});
|
||||
|
||||
// Format ve kalite ayarları
|
||||
const normalizedFormat = format === "jpg" ? "jpeg" : format;
|
||||
if (normalizedFormat === "jpeg") {
|
||||
processedBuffer = processedBuffer.jpeg({ quality });
|
||||
} else if (normalizedFormat === "png") {
|
||||
processedBuffer = processedBuffer.png({ quality });
|
||||
} else if (normalizedFormat === "webp") {
|
||||
processedBuffer = processedBuffer.webp({ quality });
|
||||
} else if (normalizedFormat === "avif") {
|
||||
processedBuffer = processedBuffer.avif({ quality });
|
||||
}
|
||||
|
||||
const processedImage = await processedBuffer.toBuffer();
|
||||
const metadata = await sharp(processedImage).metadata();
|
||||
|
||||
// Dosya adı oluştur
|
||||
const fileId = nanoid();
|
||||
const originalName = file.name;
|
||||
const fileExtension = normalizedFormat === "jpeg" ? "jpg" : normalizedFormat;
|
||||
const fileName = `${fileId}.${fileExtension}`;
|
||||
|
||||
// R2'ye yükle
|
||||
const contentType = getContentType(fileExtension);
|
||||
const r2Url = await uploadToR2({
|
||||
buffer: processedImage,
|
||||
fileName,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// Veritabanına kaydet
|
||||
const imageId = nanoid();
|
||||
|
||||
await db.insert(images).values({
|
||||
id: imageId,
|
||||
userId,
|
||||
originalName,
|
||||
fileName,
|
||||
filePath: fileName, // R2'de sadece fileName yeterli
|
||||
url: r2Url, // R2'nin tam URL'si
|
||||
width: metadata.width || null,
|
||||
height: metadata.height || null,
|
||||
quality,
|
||||
format: normalizedFormat,
|
||||
fileSize: processedImage.length,
|
||||
});
|
||||
|
||||
const fullImageUrl = r2Url;
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Resim başarıyla yüklendi",
|
||||
image: {
|
||||
id: imageId,
|
||||
url: fullImageUrl,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Upload hatası:", error);
|
||||
console.error("Error stack:", error?.stack);
|
||||
console.error("Error message:", error?.message);
|
||||
|
||||
// Production'da detaylı hata mesajı döndür (debug için)
|
||||
const errorMessage = process.env.NODE_ENV === "production"
|
||||
? `Yükleme başarısız: ${error?.message || "Bilinmeyen hata"}`
|
||||
: "Yükleme başarısız";
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/api/v1/admin/users/[id]/api-keys/[keyId]/route.ts
Normal file
87
app/api/v1/admin/users/[id]/api-keys/[keyId]/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { authenticateWebOrAPIRequest } from "@/app/lib/api-auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { maskApiKey } from "@/app/lib/jwt";
|
||||
import {
|
||||
expiresAtFromDays,
|
||||
getDaysRemaining,
|
||||
getExpiryRemainingLabel,
|
||||
parseExpiresInDaysOptional,
|
||||
} from "@/app/lib/api-key-utils";
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/users/[id]/api-keys/[keyId]
|
||||
*
|
||||
* Body: { "expiresInDays": number | null }
|
||||
* — null veya 0: süresiz; 1–3650: bugünden itibaren o kadar gün sonra sona erer
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string; keyId: string }> }
|
||||
) {
|
||||
const auth = await authenticateWebOrAPIRequest(request);
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error ?? "Yetkisiz" }, { status: 401 });
|
||||
}
|
||||
if (!isAdmin(auth.role!)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için admin yetkisi gerekir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: userId, keyId } = await context.params;
|
||||
|
||||
let body: { expiresInDays?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Geçersiz JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseExpiresInDaysOptional(body.expiresInDays);
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
parsed.value === null ? null : expiresAtFromDays(parsed.value);
|
||||
|
||||
const updated = await db
|
||||
.update(apiKeys)
|
||||
.set({ expiresAt, updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
|
||||
.returning({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
key: apiKeys.key,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
isActive: apiKeys.isActive,
|
||||
});
|
||||
|
||||
if (updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Anahtar bulunamadı veya bu kullanıcıya ait değil." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const r = updated[0];
|
||||
const exp = r.expiresAt ?? null;
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "API anahtarı süresi güncellendi.",
|
||||
data: {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
keyPreview: maskApiKey(r.key),
|
||||
expiresAt: exp?.toISOString() ?? null,
|
||||
daysRemaining: getDaysRemaining(exp),
|
||||
remainingLabel: getExpiryRemainingLabel(exp),
|
||||
isActive: r.isActive,
|
||||
},
|
||||
});
|
||||
}
|
||||
63
app/api/v1/admin/users/[id]/api-keys/route.ts
Normal file
63
app/api/v1/admin/users/[id]/api-keys/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { authenticateWebOrAPIRequest } from "@/app/lib/api-auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { maskApiKey } from "@/app/lib/jwt";
|
||||
import { getDaysRemaining, getExpiryRemainingLabel } from "@/app/lib/api-key-utils";
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/users/[id]/api-keys — Admin: seçilen kullanıcının API anahtarları
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateWebOrAPIRequest(request);
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error ?? "Yetkisiz" }, { status: 401 });
|
||||
}
|
||||
if (!isAdmin(auth.role!)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için admin yetkisi gerekir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: userId } = await context.params;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
key: apiKeys.key,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId))
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
keys: rows.map((r) => {
|
||||
const exp = r.expiresAt ?? null;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
keyPreview: maskApiKey(r.key),
|
||||
expiresAt: exp?.toISOString() ?? null,
|
||||
daysRemaining: getDaysRemaining(exp),
|
||||
remainingLabel: getExpiryRemainingLabel(exp),
|
||||
lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
67
app/api/v1/admin/users/[id]/role/route.ts
Normal file
67
app/api/v1/admin/users/[id]/role/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { isAdmin, UserRole, updateUserRole } from "@/app/lib/permissions";
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/users/[id]/role
|
||||
* Kullanıcının rolünü değiştir (Sadece admin)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
if (!isAdmin(auth.role!)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler rol değiştirebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
// Role validasyonu
|
||||
const validRoles: UserRole[] = ["user", "admin", "moderator"];
|
||||
if (!role || !validRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçersiz rol. Geçerli roller: user, admin, moderator" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Kendi rolünü değiştirmeyi engelle
|
||||
if (userId === auth.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kendi rolünüzü değiştiremezsiniz" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rolü güncelle
|
||||
await updateUserRole(userId, role);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kullanıcı rolü başarıyla güncellendi",
|
||||
data: {
|
||||
userId,
|
||||
newRole: role,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Rol güncelleme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Rol güncellenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
app/api/v1/admin/users/[id]/route.ts
Normal file
72
app/api/v1/admin/users/[id]/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { hasPermission, PERMISSIONS } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user, images, apiKeys } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/users/[id]
|
||||
* Kullanıcıyı sil (Sadece admin)
|
||||
* Kullanıcının tüm resimleri ve API anahtarları da silinir
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Permission kontrolü
|
||||
if (!hasPermission(auth.role!, PERMISSIONS.USER_DELETE)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler kullanıcı silebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
|
||||
// Kendi hesabını silmeyi engelle
|
||||
if (userId === auth.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kendi hesabınızı silemezsiniz" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Kullanıcının var olup olmadığını kontrol et
|
||||
const targetUser = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return NextResponse.json({ error: "Kullanıcı bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Kullanıcının resimlerini sil
|
||||
const deletedImages = await db.delete(images).where(eq(images.userId, userId));
|
||||
|
||||
// Kullanıcının API anahtarlarını sil
|
||||
await db.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
|
||||
// Kullanıcıyı sil
|
||||
await db.delete(user).where(eq(user.id, userId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kullanıcı başarıyla silindi",
|
||||
data: {
|
||||
deletedUserId: userId,
|
||||
deletedUser: targetUser[0].email,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Kullanıcı silme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Kullanıcı silinemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/api/v1/admin/users/[id]/verification/route.ts
Normal file
69
app/api/v1/admin/users/[id]/verification/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/users/[id]/verification
|
||||
* Kullanıcının email doğrulamasını değiştir (Sadece admin - JWT)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
if (!isAdmin(auth.role!)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler doğrulama değiştirebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: userId } = await params;
|
||||
const body = await request.json();
|
||||
const { emailVerified } = body;
|
||||
|
||||
// Boolean validasyonu
|
||||
if (typeof emailVerified !== "boolean") {
|
||||
return NextResponse.json(
|
||||
{ error: "emailVerified boolean olmalıdır" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email doğrulama durumunu güncelle
|
||||
const result = await db
|
||||
.update(user)
|
||||
.set({ emailVerified })
|
||||
.where(eq(user.id, userId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json({ error: "Kullanıcı bulunamadı" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Email doğrulama ${emailVerified ? "aktif edildi" : "pasif edildi"}`,
|
||||
data: {
|
||||
userId,
|
||||
emailVerified,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Email doğrulama güncelleme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Email doğrulama güncellenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/api/v1/admin/users/route.ts
Normal file
54
app/api/v1/admin/users/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { isAdmin } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/users
|
||||
* Tüm kullanıcıları listele (Sadece admin)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Admin kontrolü
|
||||
if (!isAdmin(auth.role!)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu işlem için yetkiniz yok. Sadece adminler kullanıcıları görüntüleyebilir." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
emailVerified: user.emailVerified,
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(desc(user.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
total: users.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Kullanıcı listesi hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Kullanıcılar yüklenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/v1/api-keys/[id]/route.ts
Normal file
38
app/api/v1/api-keys/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { authenticateWebOrAPIRequest } from "@/app/lib/api-auth";
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/api-keys/[id] — Kendi anahtarını iptal et (isActive: false)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateWebOrAPIRequest(request);
|
||||
if (!auth.authenticated || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error ?? "Yetkisiz" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
const updated = await db
|
||||
.update(apiKeys)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, auth.userId)))
|
||||
.returning({ id: apiKeys.id });
|
||||
|
||||
if (updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Anahtar bulunamadı veya size ait değil." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "API anahtarı iptal edildi.",
|
||||
});
|
||||
}
|
||||
121
app/api/v1/api-keys/route.ts
Normal file
121
app/api/v1/api-keys/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { authenticateWebOrAPIRequest } from "@/app/lib/api-auth";
|
||||
import { generateAPIKey, maskApiKey } from "@/app/lib/jwt";
|
||||
import {
|
||||
MAX_API_KEY_NAME_LEN,
|
||||
expiresAtFromDays,
|
||||
getDaysRemaining,
|
||||
getExpiryRemainingLabel,
|
||||
parseExpiresInDaysOptional,
|
||||
} from "@/app/lib/api-key-utils";
|
||||
|
||||
/**
|
||||
* GET /api/v1/api-keys — Oturum veya Bearer ile: kendi API anahtarlarını listele (tam key dönmez)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await authenticateWebOrAPIRequest(request);
|
||||
if (!auth.authenticated || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error ?? "Yetkisiz" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
key: apiKeys.key,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, auth.userId))
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
keys: rows.map((r) => {
|
||||
const exp = r.expiresAt ?? null;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
keyPreview: maskApiKey(r.key),
|
||||
expiresAt: exp?.toISOString() ?? null,
|
||||
daysRemaining: getDaysRemaining(exp),
|
||||
remainingLabel: getExpiryRemainingLabel(exp),
|
||||
lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/api-keys — Yeni API anahtarı oluştur (tam key yalnızca bu yanıtta bir kez)
|
||||
*
|
||||
* Body: { "name": string, "expiresInDays"?: number | null }
|
||||
* — expiresInDays yok/null/0: süresiz; 1–3650: bugünden itibaren o kadar gün
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await authenticateWebOrAPIRequest(request);
|
||||
if (!auth.authenticated || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error ?? "Yetkisiz" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { name?: unknown; expiresInDays?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Geçersiz JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name || name.length > MAX_API_KEY_NAME_LEN) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `name zorunludur ve en fazla ${MAX_API_KEY_NAME_LEN} karakter olabilir.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseExpiresInDaysOptional(body.expiresInDays);
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
parsed.value === null ? null : expiresAtFromDays(parsed.value);
|
||||
const plainKey = generateAPIKey();
|
||||
const id = nanoid();
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
id,
|
||||
userId: auth.userId,
|
||||
name,
|
||||
key: plainKey,
|
||||
expiresAt,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
"API anahtarı oluşturuldu. Tam değeri yalnızca bu yanıtta saklayın; bir daha gösterilmez.",
|
||||
data: {
|
||||
id,
|
||||
name,
|
||||
key: plainKey,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
daysRemaining: getDaysRemaining(expiresAt),
|
||||
remainingLabel: getExpiryRemainingLabel(expiresAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
73
app/api/v1/auth/login/route.ts
Normal file
73
app/api/v1/auth/login/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { signJWT } from "@/app/lib/jwt";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
// Validasyon
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email ve password gereklidir" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Better Auth ile giriş yap
|
||||
try {
|
||||
const signInResponse = await auth.api.signInEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signInResponse || !signInResponse.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçersiz email veya şifre" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = signInResponse.user;
|
||||
|
||||
// JWT token oluştur
|
||||
const accessToken = signJWT(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
type: "access",
|
||||
},
|
||||
"7d"
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Giriş başarılı",
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
accessToken,
|
||||
},
|
||||
});
|
||||
} catch (authError: any) {
|
||||
// Better Auth hatası - muhtemelen geçersiz credentials
|
||||
console.error("Better Auth login hatası:", authError);
|
||||
return NextResponse.json(
|
||||
{ error: "Geçersiz email veya şifre" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Login API hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Giriş sırasında bir hata oluştu" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/api/v1/auth/register/route.ts
Normal file
80
app/api/v1/auth/register/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
import { signJWT } from "@/app/lib/jwt";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, name } = body;
|
||||
|
||||
// Validasyon
|
||||
if (!email || !password || !name) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email, password ve name gereklidir" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Şifre en az 8 karakter olmalıdır" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Better Auth ile kullanıcı oluştur
|
||||
try {
|
||||
const signUpResponse = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signUpResponse || !signUpResponse.user) {
|
||||
throw new Error("Kullanıcı oluşturulamadı");
|
||||
}
|
||||
|
||||
const user = signUpResponse.user;
|
||||
|
||||
// JWT token oluştur
|
||||
const accessToken = signJWT(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
type: "access",
|
||||
},
|
||||
"7d"
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kayıt başarılı",
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
accessToken,
|
||||
},
|
||||
});
|
||||
} catch (authError: any) {
|
||||
// Better Auth hatası - muhtemelen email zaten kullanımda
|
||||
if (authError.message?.includes("exists") || authError.message?.includes("duplicate")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu email adresi zaten kullanımda" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Register API hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Kayıt sırasında bir hata oluştu" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/api/v1/images/[id]/route.ts
Normal file
80
app/api/v1/images/[id]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { hasPermission, PERMISSIONS } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { deleteFromR2 } from "@/app/lib/r2-storage";
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/images/[id]
|
||||
* Resim sil
|
||||
* Kullanıcılar sadece kendi resimlerini silebilir
|
||||
* Moderator ve adminler herhangi bir resmi silebilir
|
||||
*
|
||||
* Headers:
|
||||
* - Authorization: Bearer <jwt_token>
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Permission kontrolü - moderator ve admin herhangi bir resmi silebilir
|
||||
const canDeleteAny = hasPermission(auth.role!, PERMISSIONS.IMAGE_DELETE_ANY);
|
||||
|
||||
// Resmi bul
|
||||
const imageRecords = await db
|
||||
.select()
|
||||
.from(images)
|
||||
.where(eq(images.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (imageRecords.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Resim bulunamadı" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const image = imageRecords[0];
|
||||
|
||||
// Yetki kontrolü - kendi resmi değilse ve delete any yetkisi yoksa reddedilir
|
||||
if (!canDeleteAny && image.userId !== auth.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bu resmi silme yetkiniz yok" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// R2'den dosyayı sil
|
||||
try {
|
||||
await deleteFromR2(image.fileName);
|
||||
} catch (fileError) {
|
||||
console.error("R2'den silme hatası:", fileError);
|
||||
// Devam et, veritabanından sil
|
||||
}
|
||||
|
||||
// Veritabanından sil
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Resim başarıyla silindi",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("API - Resim silme hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Resim silinemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
app/api/v1/images/route.ts
Normal file
67
app/api/v1/images/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import { hasPermission, PERMISSIONS } from "@/app/lib/permissions";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/v1/images
|
||||
* Kullanıcının tüm resimlerini listele
|
||||
* Moderator ve adminler tüm resimleri görebilir
|
||||
*
|
||||
* Headers:
|
||||
* - Authorization: Bearer <jwt_token>
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Permission kontrolü - admin ve moderator tüm resimleri görebilir
|
||||
const canViewAll = hasPermission(auth.role!, PERMISSIONS.IMAGE_VIEW_ANY);
|
||||
|
||||
let userImages;
|
||||
if (canViewAll) {
|
||||
// Tüm resimleri listele
|
||||
userImages = await db
|
||||
.select()
|
||||
.from(images)
|
||||
.orderBy(desc(images.createdAt));
|
||||
} else {
|
||||
// Sadece kendi resimlerini listele
|
||||
userImages = await db
|
||||
.select()
|
||||
.from(images)
|
||||
.where(eq(images.userId, auth.userId!))
|
||||
.orderBy(desc(images.createdAt));
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
images: userImages.map((img) => ({
|
||||
id: img.id,
|
||||
originalName: img.originalName,
|
||||
url: img.url, // R2 URL'leri zaten tam URL olarak kaydedildi
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
quality: img.quality,
|
||||
format: img.format,
|
||||
fileSize: img.fileSize,
|
||||
createdAt: img.createdAt.toISOString(),
|
||||
})),
|
||||
total: userImages.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("API - Resim listesi hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Resimler yüklenemedi" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
app/api/v1/images/upload/route.ts
Normal file
151
app/api/v1/images/upload/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateAPIRequest } from "@/app/lib/api-auth";
|
||||
import sharp from "sharp";
|
||||
import { db } from "@/db";
|
||||
import { images } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { uploadToR2, getContentType } from "@/app/lib/r2-storage";
|
||||
|
||||
/**
|
||||
* POST /api/v1/images/upload
|
||||
* Resim yükle ve manipüle et
|
||||
*
|
||||
* Headers:
|
||||
* - Authorization: Bearer <jwt_token>
|
||||
* - Content-Type: multipart/form-data
|
||||
*
|
||||
* Body (FormData):
|
||||
* - file: Resim dosyası
|
||||
* - width: Genişlik (px) - opsiyonel, default: 800
|
||||
* - height: Yükseklik (px) - opsiyonel, default: 600
|
||||
* - quality: Kalite (1-100) - opsiyonel, default: 90
|
||||
* - format: Format (jpeg, png, webp, avif) - opsiyonel, default: jpeg
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await authenticateAPIRequest(request);
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return NextResponse.json({ error: auth.error }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dosya bulunamadı" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Dosya boyutu kontrolü (max 10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dosya boyutu çok büyük. Maksimum 10MB olmalıdır." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Dosya tipi kontrolü
|
||||
const allowedMimeTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/avif"];
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçersiz dosya tipi. Sadece resim dosyaları kabul edilir." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parametreleri al
|
||||
const widthInput = formData.get("width") as string;
|
||||
const heightInput = formData.get("height") as string;
|
||||
const qualityInput = formData.get("quality") as string;
|
||||
const formatInput = (formData.get("format") as string) || "jpeg";
|
||||
|
||||
const width = Math.max(1, Math.min(10000, parseInt(widthInput) || 800));
|
||||
const height = Math.max(1, Math.min(10000, parseInt(heightInput) || 600));
|
||||
const quality = Math.max(1, Math.min(100, parseInt(qualityInput) || 90));
|
||||
const allowedFormats = ["jpeg", "jpg", "png", "webp", "avif"];
|
||||
const format = allowedFormats.includes(formatInput) ? formatInput : "jpeg";
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Resim manipülasyonu
|
||||
let processedBuffer = sharp(buffer).resize(width, height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
withoutEnlargement: false,
|
||||
});
|
||||
|
||||
// Format ve kalite ayarları
|
||||
const normalizedFormat = format === "jpg" ? "jpeg" : format;
|
||||
if (normalizedFormat === "jpeg") {
|
||||
processedBuffer = processedBuffer.jpeg({ quality });
|
||||
} else if (normalizedFormat === "png") {
|
||||
processedBuffer = processedBuffer.png({ quality });
|
||||
} else if (normalizedFormat === "webp") {
|
||||
processedBuffer = processedBuffer.webp({ quality });
|
||||
} else if (normalizedFormat === "avif") {
|
||||
processedBuffer = processedBuffer.avif({ quality });
|
||||
}
|
||||
|
||||
const processedImage = await processedBuffer.toBuffer();
|
||||
const metadata = await sharp(processedImage).metadata();
|
||||
|
||||
// Dosya kaydet
|
||||
const fileId = nanoid();
|
||||
const originalName = file.name;
|
||||
const fileExtension = normalizedFormat === "jpeg" ? "jpg" : normalizedFormat;
|
||||
const fileName = `${fileId}.${fileExtension}`;
|
||||
|
||||
// R2'ye yükle
|
||||
const contentType = getContentType(fileExtension);
|
||||
const r2Url = await uploadToR2({
|
||||
buffer: processedImage,
|
||||
fileName,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// Veritabanına kaydet
|
||||
const imageId = nanoid();
|
||||
|
||||
await db.insert(images).values({
|
||||
id: imageId,
|
||||
userId: auth.userId!,
|
||||
originalName,
|
||||
fileName,
|
||||
filePath: fileName, // R2'de sadece fileName yeterli
|
||||
url: r2Url, // R2'nin tam URL'si
|
||||
width: metadata.width || null,
|
||||
height: metadata.height || null,
|
||||
quality,
|
||||
format: normalizedFormat,
|
||||
fileSize: processedImage.length,
|
||||
});
|
||||
|
||||
const fullImageUrl = r2Url;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Resim başarıyla yüklendi",
|
||||
data: {
|
||||
image: {
|
||||
id: imageId,
|
||||
url: fullImageUrl,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
format: normalizedFormat,
|
||||
fileSize: processedImage.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("API - Upload hatası:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Yükleme başarısız" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
214
app/lib/api-auth.ts
Normal file
214
app/lib/api-auth.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys, user } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { verifyJWT, isValidAPIKeyFormat } from "./jwt";
|
||||
import { UserRole } from "./permissions";
|
||||
import { auth } from "@/app/lib/auth";
|
||||
|
||||
export interface AuthenticatedRequest extends NextRequest {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
authenticated: boolean;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API isteklerini doğrula (JWT token veya API key ile)
|
||||
*
|
||||
* Kullanım:
|
||||
* const authResult = await authenticateAPIRequest(request);
|
||||
* if (!authResult.authenticated) {
|
||||
* return NextResponse.json({ error: authResult.error }, { status: 401 });
|
||||
* }
|
||||
* const userId = authResult.userId;
|
||||
*/
|
||||
/**
|
||||
* Cookie oturumu (Better Auth) veya Bearer (JWT / API key) ile doğrula.
|
||||
* Web arayüzünden yapılan isteklerde session; script/istemci için Authorization kullanılır.
|
||||
*/
|
||||
export async function authenticateWebOrAPIRequest(
|
||||
request: NextRequest
|
||||
): Promise<AuthResult> {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (session?.user) {
|
||||
const u = session.user as { id: string };
|
||||
try {
|
||||
const users = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, u.id))
|
||||
.limit(1);
|
||||
|
||||
if (users.length === 0) {
|
||||
return { authenticated: false, error: "Kullanıcı bulunamadı." };
|
||||
}
|
||||
|
||||
const userData = users[0];
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: u.id,
|
||||
email: userData.email,
|
||||
role: (userData.role as UserRole) || "user",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Session user lookup error:", error);
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Kimlik doğrulama sırasında bir hata oluştu.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return authenticateAPIRequest(request);
|
||||
}
|
||||
|
||||
export async function authenticateAPIRequest(request: NextRequest): Promise<AuthResult> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (!authHeader) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Authorization header eksik. Bearer token veya API key gerekli.",
|
||||
};
|
||||
}
|
||||
|
||||
// Bearer token kontrolü
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// JWT token mu yoksa API key mi?
|
||||
if (isValidAPIKeyFormat(token)) {
|
||||
// API Key doğrulama
|
||||
return await validateAPIKey(token);
|
||||
} else {
|
||||
// JWT token doğrulama
|
||||
return await validateJWTToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Geçersiz authorization formatı. 'Bearer <token>' formatında olmalı.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token doğrula ve kullanıcı bilgilerini getir
|
||||
*/
|
||||
async function validateJWTToken(token: string): Promise<AuthResult> {
|
||||
const payload = verifyJWT(token);
|
||||
|
||||
if (!payload) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Geçersiz veya süresi dolmuş token.",
|
||||
};
|
||||
}
|
||||
|
||||
// Kullanıcı bilgilerini DB'den al (role için)
|
||||
try {
|
||||
const users = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, payload.userId))
|
||||
.limit(1);
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Kullanıcı bulunamadı.",
|
||||
};
|
||||
}
|
||||
|
||||
const userData = users[0];
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: (userData.role as UserRole) || "user",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("User lookup error:", error);
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Kimlik doğrulama sırasında bir hata oluştu.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API key doğrula (veritabanından kontrol)
|
||||
*/
|
||||
async function validateAPIKey(key: string): Promise<AuthResult> {
|
||||
try {
|
||||
const apiKey = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.key, key), eq(apiKeys.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Geçersiz API key.",
|
||||
};
|
||||
}
|
||||
|
||||
const keyData = apiKey[0];
|
||||
|
||||
// Süre kontrolü
|
||||
if (keyData.expiresAt && keyData.expiresAt < new Date()) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "API key süresi dolmuş.",
|
||||
};
|
||||
}
|
||||
|
||||
// Kullanıcı bilgilerini al
|
||||
const users = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, keyData.userId))
|
||||
.limit(1);
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Kullanıcı bulunamadı.",
|
||||
};
|
||||
}
|
||||
|
||||
const userData = users[0];
|
||||
|
||||
// Son kullanım tarihini güncelle (opsiyonel)
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, keyData.id));
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: keyData.userId,
|
||||
email: userData.email,
|
||||
role: (userData.role as UserRole) || "user",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("API key doğrulama hatası:", error);
|
||||
return {
|
||||
authenticated: false,
|
||||
error: "Kimlik doğrulama sırasında bir hata oluştu.",
|
||||
};
|
||||
}
|
||||
}
|
||||
51
app/lib/api-key-utils.ts
Normal file
51
app/lib/api-key-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Kullanıcı/admin API key oluşturma ve güncelleme için ortak süre kuralları */
|
||||
|
||||
export const MAX_API_KEY_NAME_LEN = 120;
|
||||
export const MAX_EXPIRES_DAYS = 3650;
|
||||
|
||||
export function expiresAtFromDays(days: number): Date {
|
||||
return new Date(Date.now() + days * 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body'den süre çıkarır: yok/null/0 = süresiz; 1..MAX_EXPIRES_DAYS = o kadar gün.
|
||||
* Geçersiz sayıda null döner (çağıran 400 verebilir).
|
||||
*/
|
||||
export function parseExpiresInDaysOptional(
|
||||
raw: unknown
|
||||
): { ok: true; value: number | null } | { ok: false; error: string } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return { ok: false, error: "expiresInDays sayı olmalıdır." };
|
||||
}
|
||||
const d = Math.floor(raw);
|
||||
if (d === 0) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
if (d < 1 || d > MAX_EXPIRES_DAYS) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `expiresInDays 0 (süresiz) veya 1–${MAX_EXPIRES_DAYS} arası olmalıdır.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value: d };
|
||||
}
|
||||
|
||||
/** Süresiz: daysRemaining null. Süreli: kalan tam gün sayısı (bitiş anına kadar; dolmuşsa 0). */
|
||||
export function getDaysRemaining(expiresAt: Date | null | undefined): number | null {
|
||||
if (expiresAt == null) return null;
|
||||
const ms = expiresAt.getTime() - Date.now();
|
||||
if (ms <= 0) return 0;
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
/** Kullanıcı ve admin arayüzleri için kısa Türkçe ibare */
|
||||
export function getExpiryRemainingLabel(expiresAt: Date | null | undefined): string {
|
||||
if (expiresAt == null) return "Süresiz";
|
||||
const d = getDaysRemaining(expiresAt);
|
||||
if (d === 0) return "Süresi doldu";
|
||||
if (d === 1) return "1 gün kaldı";
|
||||
return `${d} gün kaldı`;
|
||||
}
|
||||
37
app/lib/auth.ts
Normal file
37
app/lib/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "@/db";
|
||||
import * as schema from "@/db/schema";
|
||||
|
||||
// Validate BETTER_AUTH_SECRET at runtime (not during build)
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret && process.env.NODE_ENV === "production") {
|
||||
console.warn("WARNING: BETTER_AUTH_SECRET is not set. Authentication will not work properly.");
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: schema.user,
|
||||
session: schema.session,
|
||||
account: schema.account,
|
||||
verification: schema.verification,
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
secret: secret || "build-time-secret-key-minimum-32-characters-long-temp",
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
required: false,
|
||||
input: false, // Don't allow setting role on signup
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
54
app/lib/jwt.ts
Normal file
54
app/lib/jwt.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET || "fallback-secret-key";
|
||||
const API_KEY_PREFIX = "img_";
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
type: "access" | "refresh";
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token oluştur
|
||||
* @param payload - Token içeriği
|
||||
* @param expiresIn - Geçerlilik süresi (örn: "7d", "1h")
|
||||
*/
|
||||
export function signJWT(payload: JWTPayload, expiresIn: string | number = "7d"): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn } as SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token doğrula
|
||||
* @param token - Doğrulanacak token
|
||||
*/
|
||||
export function verifyJWT(token: string): JWTPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API key oluştur
|
||||
* Formad: img_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
*/
|
||||
export function generateAPIKey(): string {
|
||||
return `${API_KEY_PREFIX}${nanoid(32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API key validasyonu
|
||||
*/
|
||||
export function isValidAPIKeyFormat(key: string): boolean {
|
||||
return key.startsWith(API_KEY_PREFIX) && key.length === 36; // img_ + 32 chars
|
||||
}
|
||||
|
||||
/** Liste/detay için tam anahtarı göstermez (img_xxxx…yyyy) */
|
||||
export function maskApiKey(key: string): string {
|
||||
if (key.length < 12) return "img_••••";
|
||||
return `${key.slice(0, 7)}…${key.slice(-4)}`;
|
||||
}
|
||||
15
app/lib/next js beter auth yuklu ve drizze orm y.txt
Normal file
15
app/lib/next js beter auth yuklu ve drizze orm y.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
next js beter auth yuklu ve drizze orm yuklu posgrsql veritabanı ile entegre edilecek.
|
||||
drizzle orm ile veritabanına bağlanılacak.
|
||||
beter auth ile register yapılacak.
|
||||
beter auth ile giriş yapılacak.
|
||||
giriş yapıldıktan sonra kullanıcının bilgileri veritabanından alınacak.
|
||||
kullanıcının bilgileri veritabanından alındıktan sonra kullanıcının bilgileri sayfada görüntülenecek.
|
||||
birkaç yapilandirma eklendi ama duzgun olmayabilir sen kotrol et ve duzelt
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
sadece login olmus userlerin giris yapabilecegi bir sayfa olacak. ve sayfada resim dosyalri yuklenecek en boy kalite format vs kullnacini verdigi bilgilere gore
|
||||
resim manipule edilecek ve database ye drizzle orm ile kaydedilecek.
|
||||
resim url si çıkartilarak download edilebilir ve bir buton ile resmin url si kopyalanabilir. olacak ve bu url ile resim indirilebilir.
|
||||
93
app/lib/permissions.ts
Normal file
93
app/lib/permissions.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type UserRole = "user" | "admin" | "moderator";
|
||||
|
||||
// Permission tanımları
|
||||
export const PERMISSIONS = {
|
||||
// Image permissions
|
||||
IMAGE_UPLOAD: "image:upload",
|
||||
IMAGE_DELETE_OWN: "image:delete:own",
|
||||
IMAGE_DELETE_ANY: "image:delete:any",
|
||||
IMAGE_VIEW_OWN: "image:view:own",
|
||||
IMAGE_VIEW_ANY: "image:view:any",
|
||||
|
||||
// User permissions
|
||||
USER_VIEW: "user:view",
|
||||
USER_EDIT: "user:edit",
|
||||
USER_DELETE: "user:delete",
|
||||
USER_MANAGE_ROLES: "user:manage:roles",
|
||||
} as const;
|
||||
|
||||
// Role'lere göre izinler
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
|
||||
user: [
|
||||
PERMISSIONS.IMAGE_UPLOAD,
|
||||
PERMISSIONS.IMAGE_DELETE_OWN,
|
||||
PERMISSIONS.IMAGE_VIEW_OWN,
|
||||
],
|
||||
moderator: [
|
||||
PERMISSIONS.IMAGE_UPLOAD,
|
||||
PERMISSIONS.IMAGE_DELETE_OWN,
|
||||
PERMISSIONS.IMAGE_VIEW_OWN,
|
||||
PERMISSIONS.IMAGE_VIEW_ANY,
|
||||
PERMISSIONS.USER_VIEW,
|
||||
],
|
||||
admin: Object.values(PERMISSIONS), // Tüm izinler
|
||||
};
|
||||
|
||||
/**
|
||||
* Kullanıcının belirli bir role sahip olup olmadığını kontrol eder
|
||||
*/
|
||||
export function hasRole(userRole: UserRole, requiredRole: UserRole | UserRole[]): boolean {
|
||||
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
|
||||
return roles.includes(userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının belirli bir izne sahip olup olmadığını kontrol eder
|
||||
*/
|
||||
export function hasPermission(userRole: UserRole, permission: string): boolean {
|
||||
const rolePermissions = ROLE_PERMISSIONS[userRole] || [];
|
||||
return rolePermissions.includes(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının birden fazla izne sahip olup olmadığını kontrol eder
|
||||
*/
|
||||
export function hasPermissions(userRole: UserRole, permissions: string[]): boolean {
|
||||
return permissions.every(permission => hasPermission(userRole, permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının en az bir izne sahip olup olmadığını kontrol eder
|
||||
*/
|
||||
export function hasAnyPermission(userRole: UserRole, permissions: string[]): boolean {
|
||||
return permissions.some(permission => hasPermission(userRole, permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının admin olup olmadığını kontrol eder
|
||||
*/
|
||||
export function isAdmin(userRole: UserRole): boolean {
|
||||
return userRole === "admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcı bilgilerini userId'den alır
|
||||
*/
|
||||
export async function getUserById(userId: string) {
|
||||
const users = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||
return users[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının rolünü günceller (sadece admin yapabilir)
|
||||
*/
|
||||
export async function updateUserRole(userId: string, newRole: UserRole) {
|
||||
await db.update(user).set({
|
||||
role: newRole,
|
||||
updatedAt: new Date()
|
||||
}).where(eq(user.id, userId));
|
||||
}
|
||||
81
app/lib/r2-storage.ts
Normal file
81
app/lib/r2-storage.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
// R2 configuration
|
||||
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || "";
|
||||
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || "";
|
||||
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || "";
|
||||
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME || "";
|
||||
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || "";
|
||||
|
||||
// S3 client configuration for Cloudflare R2
|
||||
const s3Client = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${R2_ACCOUNT_ID}.eu.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export interface UploadOptions {
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to R2
|
||||
*/
|
||||
export async function uploadToR2(options: UploadOptions): Promise<string> {
|
||||
const { buffer, fileName, contentType } = options;
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: fileName,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
||||
// Return the public URL
|
||||
return `${R2_PUBLIC_URL}/${fileName}`;
|
||||
} catch (error) {
|
||||
console.error("R2 upload error:", error);
|
||||
throw new Error(`R2'ye yükleme başarısız: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from R2
|
||||
*/
|
||||
export async function deleteFromR2(fileName: string): Promise<void> {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: fileName,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
} catch (error) {
|
||||
console.error("R2 delete error:", error);
|
||||
throw new Error(`R2'den silme başarısız: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
*/
|
||||
export function getContentType(format: string): string {
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
};
|
||||
|
||||
return contentTypeMap[format] || "application/octet-stream";
|
||||
}
|
||||
142
app/login/page.tsx
Normal file
142
app/login/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registerEnabled, setRegisterEnabled] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkRegisterEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/config");
|
||||
const data = await response.json();
|
||||
setRegisterEnabled(data.registerEnabled);
|
||||
} catch (error) {
|
||||
console.error("Config kontrolü başarısız:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkRegisterEnabled();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Giriş başarısız");
|
||||
}
|
||||
|
||||
router.push("/profile");
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Bir hata oluştu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-8 shadow-lg dark:bg-zinc-900">
|
||||
<div>
|
||||
<h2 className="text-center text-3xl font-bold text-black dark:text-zinc-50">
|
||||
Giriş Yap
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
E-posta
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="ornek@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Şifre
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{registerEnabled && (
|
||||
<div className="text-center text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Hesabınız yok mu?{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
app/page.tsx
Normal file
293
app/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [registerEnabled, setRegisterEnabled] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const [authResponse, configResponse] = await Promise.all([
|
||||
fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
}),
|
||||
fetch("/api/config"),
|
||||
]);
|
||||
|
||||
const authData = await authResponse.json();
|
||||
const configData = await configResponse.json();
|
||||
|
||||
setIsAuthenticated(!!authData.user);
|
||||
setRegisterEnabled(configData.registerEnabled);
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
setRegisterEnabled(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-zinc-900 dark:via-black dark:to-zinc-900">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-blue-200 border-t-blue-600"></div>
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Yükleniyor...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-zinc-900 dark:via-black dark:to-zinc-900">
|
||||
{/* Hero Section */}
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-6xl">
|
||||
{/* Main Content */}
|
||||
<div className="text-center">
|
||||
{/* Icon/Logo */}
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-blue-600 to-purple-600 p-6 shadow-2xl">
|
||||
<svg
|
||||
className="h-16 w-16 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-6 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-5xl font-bold text-transparent dark:from-blue-400 dark:to-purple-400 sm:text-6xl md:text-7xl">
|
||||
Image Manipulation API
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mx-auto mb-4 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
||||
Resimlerinizi yükleyin, boyutlandırın, formatını değiştirin ve
|
||||
istediğiniz kalitede kaydedin.
|
||||
</p>
|
||||
<p className="mx-auto mb-12 max-w-2xl text-lg text-gray-500 dark:text-gray-400">
|
||||
JWT API desteği ile dış uygulamalarınızdan da kullanabilirsiniz.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mx-auto mb-12 grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="rounded-full bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
Hızlı İşlem
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Saniyeler içinde resim manipülasyonu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="rounded-full bg-purple-100 p-3 dark:bg-purple-900/30">
|
||||
<svg
|
||||
className="h-6 w-6 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
Çoklu Format
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
JPEG, PNG, WebP, AVIF desteği
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-6 shadow-lg dark:bg-zinc-800/50">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
Güvenli API
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
JWT token ile korumalı erişim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="group flex h-14 w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 px-8 text-lg font-semibold text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
Resim Yükle
|
||||
</Link>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex h-14 w-full items-center justify-center gap-2 rounded-full border-2 border-gray-300 bg-white px-8 text-lg font-semibold text-gray-700 transition-all hover:border-gray-400 hover:bg-gray-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:hover:border-zinc-600 dark:hover:bg-zinc-700 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Profilim
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex h-14 w-full items-center justify-center gap-2 rounded-full border-2 border-red-300 bg-white px-8 text-lg font-semibold text-red-700 transition-all hover:border-red-400 hover:bg-red-50 dark:border-red-700 dark:bg-zinc-800 dark:text-red-400 dark:hover:border-red-600 dark:hover:bg-red-900/20 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
Admin Panel
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="group flex h-14 w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 px-8 text-lg font-semibold text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl sm:w-auto"
|
||||
>
|
||||
Giriş Yap
|
||||
<svg
|
||||
className="h-5 w-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
{registerEnabled && (
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex h-14 w-full items-center justify-center gap-2 rounded-full border-2 border-gray-300 bg-white px-8 text-lg font-semibold text-gray-700 transition-all hover:border-gray-400 hover:bg-gray-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:hover:border-zinc-600 dark:hover:bg-zinc-700 sm:w-auto"
|
||||
>
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Link */}
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
href="/api-docs"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
API Dokümantasyonu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
391
app/profile/page.tsx
Normal file
391
app/profile/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ApiKeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPreview: string;
|
||||
expiresAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
remainingLabel: string;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKeyDays, setNewKeyDays] = useState("");
|
||||
const [createBusy, setCreateBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const loadApiKeys = useCallback(async () => {
|
||||
setKeysLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/api-keys", { credentials: "include" });
|
||||
const json = await res.json();
|
||||
if (res.ok && json.data?.keys) {
|
||||
setApiKeys(json.data.keys);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
} catch (error) {
|
||||
console.error("Kullanıcı bilgileri alınamadı:", error);
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadApiKeys();
|
||||
}
|
||||
}, [user, loadApiKeys]);
|
||||
|
||||
const createApiKey = async () => {
|
||||
const name = newKeyName.trim();
|
||||
if (!name) return;
|
||||
setCreateBusy(true);
|
||||
try {
|
||||
const body: { name: string; expiresInDays?: number | null } = { name };
|
||||
const d = newKeyDays.trim();
|
||||
if (d !== "") {
|
||||
const n = parseInt(d, 10);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
alert("Geçerli bir gün sayısı girin veya boş bırakın (süresiz).");
|
||||
setCreateBusy(false);
|
||||
return;
|
||||
}
|
||||
body.expiresInDays = n === 0 ? null : n;
|
||||
}
|
||||
const res = await fetch("/api/v1/api-keys", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
alert(json.error || "Oluşturulamadı");
|
||||
return;
|
||||
}
|
||||
const raw = json.data?.key as string | undefined;
|
||||
const rem = json.data?.remainingLabel as string | undefined;
|
||||
if (raw) {
|
||||
await navigator.clipboard.writeText(raw).catch(() => {});
|
||||
const extra = rem ? ` Süre: ${rem}.` : "";
|
||||
alert(
|
||||
`API anahtarı oluşturuldu ve panoya kopyalandı. Bu tam değeri yalnızca bir kez görebilirsiniz.${extra}`
|
||||
);
|
||||
}
|
||||
setNewKeyName("");
|
||||
setNewKeyDays("");
|
||||
await loadApiKeys();
|
||||
} finally {
|
||||
setCreateBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeKey = async (id: string) => {
|
||||
if (!confirm("Bu API anahtarını iptal etmek istediğinize emin misiniz?")) return;
|
||||
const res = await fetch(`/api/v1/api-keys/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json();
|
||||
alert(j.error || "İptal edilemedi");
|
||||
return;
|
||||
}
|
||||
await loadApiKeys();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Çıkış yapılamadı:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Yükleniyor...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg dark:bg-zinc-900">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-black dark:text-zinc-50">
|
||||
Kullanıcı Bilgileri
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/upload"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
Resim Yükle
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md bg-gray-200 px-4 py-2 text-gray-700 hover:bg-gray-300 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md bg-red-600 px-4 py-2 text-white hover:bg-red-700"
|
||||
>
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border border-gray-200 p-6 dark:border-zinc-700">
|
||||
<h2 className="mb-4 text-xl font-semibold text-black dark:text-zinc-50">
|
||||
Profil Bilgileri
|
||||
</h2>
|
||||
<dl className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Ad Soyad
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg text-black dark:text-zinc-50">
|
||||
{user.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
E-posta
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg text-black dark:text-zinc-50">
|
||||
{user.email}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
E-posta Doğrulandı mı?
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg text-black dark:text-zinc-50">
|
||||
{user.emailVerified ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Evet
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
Hayır
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Kullanıcı ID
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{user.id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Kayıt Tarihi
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg text-black dark:text-zinc-50">
|
||||
{new Date(user.createdAt).toLocaleString("tr-TR")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Son Güncelleme
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg text-black dark:text-zinc-50">
|
||||
{new Date(user.updatedAt).toLocaleString("tr-TR")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 p-6 dark:border-zinc-700">
|
||||
<h2 className="mb-2 text-xl font-semibold text-black dark:text-zinc-50">
|
||||
API anahtarları
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Resim API'sine JWT yerine{" "}
|
||||
<code className="rounded bg-gray-100 px-1 dark:bg-zinc-800">
|
||||
Authorization: Bearer img_…
|
||||
</code>{" "}
|
||||
ile erişin. Anahtarı burada oluşturun; süre kısıtı opsiyoneldir (boş
|
||||
= süresiz). Admin gerekirse süreyi değiştirebilir.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-end gap-3 rounded-lg bg-gray-50 p-4 dark:bg-zinc-800/50">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
İsim
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="örn. Mobil uygulama"
|
||||
className="w-full rounded border border-gray-300 bg-white px-3 py-2 text-black dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Geçerlilik (gün)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Süresiz"
|
||||
value={newKeyDays}
|
||||
onChange={(e) => setNewKeyDays(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 bg-white px-3 py-2 text-black dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createBusy || !newKeyName.trim()}
|
||||
onClick={createApiKey}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{createBusy ? "…" : "Anahtar oluştur"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keysLoading ? (
|
||||
<p className="text-sm text-gray-500">Anahtarlar yükleniyor…</p>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Henüz API anahtarı yok.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-zinc-600">
|
||||
<th className="py-2 pr-4 font-medium">İsim</th>
|
||||
<th className="py-2 pr-4 font-medium">Önizleme</th>
|
||||
<th className="py-2 pr-4 font-medium">Bitiş tarihi</th>
|
||||
<th className="py-2 pr-4 font-medium">Kalan süre</th>
|
||||
<th className="py-2 pr-4 font-medium">Durum</th>
|
||||
<th className="py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map((k) => (
|
||||
<tr
|
||||
key={k.id}
|
||||
className="border-b border-gray-100 dark:border-zinc-800"
|
||||
>
|
||||
<td className="py-2 pr-4">{k.name}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{k.keyPreview}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{k.expiresAt
|
||||
? new Date(k.expiresAt).toLocaleString("tr-TR")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={
|
||||
k.remainingLabel === "Süresi doldu"
|
||||
? "font-medium text-red-600 dark:text-red-400"
|
||||
: k.remainingLabel === "Süresiz"
|
||||
? "text-gray-600 dark:text-gray-400"
|
||||
: "font-medium text-emerald-700 dark:text-emerald-400"
|
||||
}
|
||||
>
|
||||
{k.remainingLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{k.isActive ? (
|
||||
<span className="text-green-600 dark:text-green-400">Aktif</span>
|
||||
) : (
|
||||
<span className="text-gray-500">İptal</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{k.isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeKey(k.id)}
|
||||
className="text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
İptal et
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
app/register/page.tsx
Normal file
211
app/register/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registerEnabled, setRegisterEnabled] = useState(true);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkRegisterEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/config");
|
||||
const data = await response.json();
|
||||
setRegisterEnabled(data.registerEnabled);
|
||||
if (!data.registerEnabled) {
|
||||
router.push("/login");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Config kontrolü başarısız:", error);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkRegisterEnabled();
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Şifre kontrolü
|
||||
if (password !== confirmPassword) {
|
||||
setError("Şifreler eşleşmiyor");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Şifre en az 6 karakter olmalıdır");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Kayıt başarısız");
|
||||
}
|
||||
|
||||
router.push("/profile");
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Bir hata oluştu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Yükleniyor...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!registerEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-8 shadow-lg dark:bg-zinc-900">
|
||||
<div>
|
||||
<h2 className="text-center text-3xl font-bold text-black dark:text-zinc-50">
|
||||
Kayıt Ol
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Ad Soyad
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="Adınız Soyadınız"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
E-posta
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="ornek@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Şifre
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Şifre Tekrar
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Kayıt yapılıyor..." : "Kayıt Ol"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Zaten hesabınız var mı?{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
app/upload/page.tsx
Normal file
415
app/upload/page.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Image {
|
||||
id: string;
|
||||
originalName: string;
|
||||
url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
quality: number | null;
|
||||
format: string;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [width, setWidth] = useState<number>(800);
|
||||
const [height, setHeight] = useState<number>(600);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
|
||||
// Resmin boyutlarını al
|
||||
const img = new Image();
|
||||
const objectUrl = URL.createObjectURL(selectedFile);
|
||||
|
||||
img.onload = () => {
|
||||
setWidth(img.naturalWidth);
|
||||
setHeight(img.naturalHeight);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
img.src = objectUrl;
|
||||
}
|
||||
};
|
||||
const [quality, setQuality] = useState<number>(90);
|
||||
const [format, setFormat] = useState<string>("avif");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [images, setImages] = useState<Image[]>([]);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticated(true);
|
||||
loadImages();
|
||||
} catch (error) {
|
||||
console.error("Auth kontrolü başarısız:", error);
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setCheckingAuth(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
const loadImages = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/images", {
|
||||
credentials: "include",
|
||||
cache: 'no-store', // Cache'i devre dışı bırak
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log("Yüklenen resimler:", data.images?.length || 0);
|
||||
setImages(data.images || []);
|
||||
} else {
|
||||
console.error("Resimler yüklenemedi, status:", response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Resimler yüklenemedi:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
if (!file) {
|
||||
setError("Lütfen bir dosya seçin");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("width", width.toString());
|
||||
formData.append("height", height.toString());
|
||||
formData.append("quality", quality.toString());
|
||||
formData.append("format", format);
|
||||
|
||||
const response = await fetch("/api/images/upload", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Yükleme başarısız");
|
||||
}
|
||||
|
||||
// Formu tamamen resetle
|
||||
setSuccess("Resim başarıyla yüklendi!");
|
||||
setFile(null);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
// Resimleri yeniden yükle
|
||||
await loadImages();
|
||||
|
||||
// Success mesajını kısa süre sonra temizle
|
||||
setTimeout(() => setSuccess(""), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Bir hata oluştu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setSuccess("URL kopyalandı!");
|
||||
setTimeout(() => setSuccess(""), 2000);
|
||||
};
|
||||
|
||||
const downloadImage = (url: string, originalName: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = originalName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleDelete = async (imageId: string, originalName: string) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Emin misiniz?",
|
||||
text: `${originalName} adlı resmi silmek istediğinize emin misiniz?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonText: "Evet, Sil!",
|
||||
cancelButtonText: "İptal",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
console.log("Silme isteği gönderiliyor, imageId:", imageId);
|
||||
console.log("Image objesi:", images.find(img => img.id === imageId));
|
||||
const response = await fetch(`/api/images/${encodeURIComponent(imageId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Silme hatası:", data);
|
||||
throw new Error(data.message || "Silme işlemi başarısız");
|
||||
}
|
||||
|
||||
Swal.fire("Silindi!", "Resim başarıyla silindi.", "success");
|
||||
loadImages();
|
||||
} catch (error: any) {
|
||||
console.error("Silme hatası:", error);
|
||||
Swal.fire("Hata!", error.message || "Resim silinirken bir hata oluştu.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (checkingAuth) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Yükleniyor...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-black dark:text-zinc-50">
|
||||
Resim Yükle ve Manipüle Et
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="rounded-md bg-gray-200 px-4 py-2 text-gray-700 hover:bg-gray-300 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Profil
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Upload Form */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-lg dark:bg-zinc-900 lg:col-span-1">
|
||||
<h2 className="mb-4 text-xl font-semibold text-black dark:text-zinc-50">
|
||||
Resim Yükle
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Resim Dosyası
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Genişlik (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(parseInt(e.target.value) || 800)}
|
||||
min="1"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Yükseklik (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(parseInt(e.target.value) || 600)}
|
||||
min="1"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Kalite (1-100)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(parseInt(e.target.value) || 90)}
|
||||
min="1"
|
||||
max="100"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-zinc-800 dark:text-white"
|
||||
>
|
||||
<option value="avif">AVIF</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="png">PNG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Yükleniyor..." : "Resmi Yükle ve İşle"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Images List */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-lg dark:bg-zinc-900 lg:col-span-2">
|
||||
<h2 className="mb-4 text-xl font-semibold text-black dark:text-zinc-50">
|
||||
Yüklenen Resimler ({images.length})
|
||||
</h2>
|
||||
{images.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 dark:border-zinc-700">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Henüz resim yüklenmedi
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md transition-shadow hover:shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.originalName}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="mb-2 truncate text-sm font-semibold text-gray-900 dark:text-zinc-50">
|
||||
{image.originalName}
|
||||
</h3>
|
||||
<div className="mb-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
<span className="font-medium">Boyut:</span> {image.width} × {image.height} px
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Format:</span> {image.format.toUpperCase()}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Dosya:</span> {Math.round(image.fileSize / 1024)} KB
|
||||
</p>
|
||||
{image.quality && (
|
||||
<p>
|
||||
<span className="font-medium">Kalite:</span> {image.quality}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(image.url)}
|
||||
className="rounded-md bg-green-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-green-700"
|
||||
title="URL'yi Kopyala"
|
||||
>
|
||||
Kopyala
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadImage(image.url, image.originalName)}
|
||||
className="rounded-md bg-blue-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-blue-700"
|
||||
title="İndir"
|
||||
>
|
||||
İndir
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(image.id, image.originalName)}
|
||||
className="mt-2 w-full rounded-md bg-red-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
title="Sil"
|
||||
>
|
||||
Sil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/uploads/[...path]/route.ts
Normal file
43
app/uploads/[...path]/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path } = await params;
|
||||
const filePath = join(process.cwd(), "public", "uploads", ...path);
|
||||
const fileBuffer = await readFile(filePath);
|
||||
|
||||
// Dosya uzantısına göre content-type belirle
|
||||
const fileName = path[path.length - 1];
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'avif': 'image/avif',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const contentType = contentTypeMap[ext || ''] || 'application/octet-stream';
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dosya okuma hatası:', error);
|
||||
return NextResponse.json(
|
||||
{ message: "Dosya bulunamadı" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user