first commit
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
README.md
|
||||||
|
SETUP.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.log
|
||||||
|
drizzle
|
||||||
|
.env*
|
||||||
20
.env
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#DATABASE_URL=postgres://cloud:gg7678290@10.80.80.70/image_api
|
||||||
|
#DATABASE_URL=postgresql://cloud:gg7678290@10.80.80.70:5432/image_api?search_path=public
|
||||||
|
DATABASE_URL=postgresql://cloud:gg7678290@188.132.232.119:5455/image_apiv2?search_path=public
|
||||||
|
BETTER_AUTH_SECRET=dB89kiKf56igxrB783yb3UyQToQIPZ93cRKADyq1yQEJ8EU4JRw5GlxBmGvQMu8e
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
JWT_SECRET=dB89kiKf56igxrB783yb3UyQToQIPZ93cRKADyq1yQEJ8EU4JRw5GlxBmGvQMu8e
|
||||||
|
|
||||||
|
# https://4f3dc7a1aa54f4ba52803e952d6cf6be.r2.cloudflarestorage.com/image-api
|
||||||
|
|
||||||
|
|
||||||
|
# local devop
|
||||||
|
# https://pub-79ea55a4c5a943c48147a7f1460049f2.r2.dev
|
||||||
|
# Cloudflare R2 Storage (Europa Server)
|
||||||
|
R2_ACCOUNT_ID=4f3dc7a1aa54f4ba52803e952d6cf6be
|
||||||
|
R2_ACCESS_KEY_ID=177784973745076ce943e02b267cf139
|
||||||
|
R2_SECRET_ACCESS_KEY=9fcf11d84de480e0bee89f23703c655582c0b0035bcaee828ef0fe187b0d4b63
|
||||||
|
R2_BUCKET_NAME=resimlerim
|
||||||
|
R2_PUBLIC_URL=https://flare.beyhano.com.tr
|
||||||
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/image_api
|
||||||
|
|
||||||
|
# Better Auth
|
||||||
|
BETTER_AUTH_SECRET=your-secret-key-here-min-32-characters
|
||||||
|
BETTER_AUTH_URL=https://image.beyhano.com.tr
|
||||||
|
|
||||||
|
# App Settings
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# App URLs (production)
|
||||||
|
NEXT_PUBLIC_APP_URL=https://image.beyhano.com.tr
|
||||||
|
APP_URL=https://flare.beyhano.com.tr
|
||||||
|
|
||||||
|
# JWT Secret
|
||||||
|
JWT_SECRET=your-jwt-secret-key-min-32-characters-long
|
||||||
|
# Cloudflare R2 Storage
|
||||||
|
R2_ACCOUNT_ID=your-cloudflare-account-id
|
||||||
|
R2_ACCESS_KEY_ID=your-r2-access-key
|
||||||
|
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
|
||||||
|
R2_BUCKET_NAME=your-bucket-name
|
||||||
|
R2_PUBLIC_URL=https://your-public-domain.com
|
||||||
23
.env.local
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#DATABASE_URL=postgres://cloud:gg7678290@10.80.80.70/image_api
|
||||||
|
#DATABASE_URL=postgresql://cloud:gg7678290@10.80.80.70:5432/image_api?search_path=public
|
||||||
|
DATABASE_URL=postgresql://cloud:gg7678290@188.132.232.119:5455/image_apiv2?search_path=public
|
||||||
|
BETTER_AUTH_SECRET=dB89kiKf56igxrB783yb3UyQToQIPZ93cRKADyq1yQEJ8EU4JRw5GlxBmGvQMu8e
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
JWT_SECRET=dB89kiKf56igxrB783yb3UyQToQIPZ93cRKADyq1yQEJ8EU4JRw5GlxBmGvQMu8e
|
||||||
|
|
||||||
|
# https://4f3dc7a1aa54f4ba52803e952d6cf6be.r2.cloudflarestorage.com/image-api
|
||||||
|
|
||||||
|
|
||||||
|
# local devop
|
||||||
|
# https://pub-79ea55a4c5a943c48147a7f1460049f2.r2.dev
|
||||||
|
# Cloudflare R2 Storage (Europa Server)
|
||||||
|
R2_ACCOUNT_ID=4f3dc7a1aa54f4ba52803e952d6cf6be
|
||||||
|
R2_ACCESS_KEY_ID=177784973745076ce943e02b267cf139
|
||||||
|
R2_SECRET_ACCESS_KEY=9fcf11d84de480e0bee89f23703c655582c0b0035bcaee828ef0fe187b0d4b63
|
||||||
|
R2_BUCKET_NAME=resimlerim
|
||||||
|
R2_PUBLIC_URL=https://flare.beyhano.com.tr
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/image-apiv2.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/image-apiv2.iml" filepath="$PROJECT_DIR$/.idea/image-apiv2.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
313
ADMIN_PANEL.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# Admin Panel & Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
## Genel Bakış
|
||||||
|
|
||||||
|
Bu proje artık kapsamlı bir rol tabanlı erişim kontrol (RBAC) sistemine sahiptir. Kullanıcılar rollere atanabilir ve bu roller belirli izinler sağlar.
|
||||||
|
|
||||||
|
## Roller
|
||||||
|
|
||||||
|
### 1. **User** (Varsayılan)
|
||||||
|
- Sadece kendi resimlerini yükleyebilir
|
||||||
|
- Sadece kendi resimlerini görebilir
|
||||||
|
- Sadece kendi resimlerini silebilir
|
||||||
|
|
||||||
|
### 2. **Moderator**
|
||||||
|
- Kendi resimlerini yükleyebilir
|
||||||
|
- **TÜM** kullanıcıların resimlerini görebilir
|
||||||
|
- **TÜM** resimleri silebilir
|
||||||
|
- Moderasyon yetkisi
|
||||||
|
|
||||||
|
### 3. **Admin**
|
||||||
|
- **TÜM** moderator izinlerine sahiptir
|
||||||
|
- Kullanıcıları yönetebilir (listeleme, silme)
|
||||||
|
- Kullanıcı rollerini değiştirebilir
|
||||||
|
- Tam sistem kontrolü
|
||||||
|
|
||||||
|
## İzinler
|
||||||
|
|
||||||
|
| İzin | User | Moderator | Admin |
|
||||||
|
|------|------|-----------|-------|
|
||||||
|
| `IMAGE_UPLOAD` | ✅ | ✅ | ✅ |
|
||||||
|
| `IMAGE_VIEW_OWN` | ✅ | ✅ | ✅ |
|
||||||
|
| `IMAGE_VIEW_ANY` | ❌ | ✅ | ✅ |
|
||||||
|
| `IMAGE_DELETE_OWN` | ✅ | ✅ | ✅ |
|
||||||
|
| `IMAGE_DELETE_ANY` | ❌ | ✅ | ✅ |
|
||||||
|
| `USER_VIEW` | ❌ | ❌ | ✅ |
|
||||||
|
| `USER_DELETE` | ❌ | ❌ | ✅ |
|
||||||
|
| `USER_MANAGE_ROLES` | ❌ | ❌ | ✅ |
|
||||||
|
| `USER_MANAGE_PERMISSIONS` | ❌ | ❌ | ✅ |
|
||||||
|
| `USER_MANAGE_API_KEYS` | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
|
||||||
|
Admin paneline erişim: **http://localhost:3000/admin**
|
||||||
|
|
||||||
|
### Özellikler
|
||||||
|
|
||||||
|
1. **Kullanıcı Listesi**
|
||||||
|
- Tüm kullanıcıları görüntüleme
|
||||||
|
- Kullanıcı detayları (email, rol, doğrulama durumu, kayıt tarihi)
|
||||||
|
|
||||||
|
2. **Rol Yönetimi**
|
||||||
|
- Dropdown menüden rol seçerek anında güncelleme
|
||||||
|
- Kendi rolünü değiştirememe koruması
|
||||||
|
|
||||||
|
3. **Kullanıcı Silme**
|
||||||
|
- Kullanıcıyı ve tüm verilerini silme
|
||||||
|
- Kullanıcının resimleri ve API anahtarları da silinir
|
||||||
|
- Kendi hesabını silememe koruması
|
||||||
|
|
||||||
|
4. **İstatistikler**
|
||||||
|
- Toplam kullanıcı sayısı
|
||||||
|
- Admin sayısı
|
||||||
|
- Moderatör sayısı
|
||||||
|
|
||||||
|
## API Endpoint'leri
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
#### 1. Kullanıcıları Listele
|
||||||
|
```http
|
||||||
|
GET /api/v1/admin/users
|
||||||
|
Authorization: Bearer <admin_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "user-id",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"role": "user",
|
||||||
|
"emailVerified": true,
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Kullanıcı Rolünü Değiştir
|
||||||
|
```http
|
||||||
|
PATCH /api/v1/admin/users/:id/role
|
||||||
|
Authorization: Bearer <admin_jwt_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"role": "moderator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Kullanıcı rolü başarıyla güncellendi",
|
||||||
|
"data": {
|
||||||
|
"userId": "user-id",
|
||||||
|
"newRole": "moderator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Koşullar:**
|
||||||
|
- Sadece admin yetkisi
|
||||||
|
- Kendi rolünü değiştiremez
|
||||||
|
- Geçerli roller: `user`, `moderator`, `admin`
|
||||||
|
|
||||||
|
#### 3. Kullanıcı Sil
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/admin/users/:id
|
||||||
|
Authorization: Bearer <admin_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Kullanıcı başarıyla silindi",
|
||||||
|
"data": {
|
||||||
|
"deletedUserId": "user-id",
|
||||||
|
"deletedUser": "user@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Koşullar:**
|
||||||
|
- Sadece admin yetkisi
|
||||||
|
- Kendi hesabını silemez
|
||||||
|
- Kullanıcının tüm resimleri ve API anahtarları da silinir
|
||||||
|
|
||||||
|
### Güncellenmiş Resim API'leri
|
||||||
|
|
||||||
|
#### 1. Resimleri Listele
|
||||||
|
```http
|
||||||
|
GET /api/v1/images
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Davranış:**
|
||||||
|
- **User**: Sadece kendi resimlerini görür
|
||||||
|
- **Moderator/Admin**: TÜM resimleri görür
|
||||||
|
|
||||||
|
#### 2. Resim Sil
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/images/:id
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Davranış:**
|
||||||
|
- **User**: Sadece kendi resimlerini silebilir
|
||||||
|
- **Moderator/Admin**: Herhangi bir resmi silebilir
|
||||||
|
|
||||||
|
## İlk Admin Oluşturma
|
||||||
|
|
||||||
|
Veritabanında manuel olarak ilk admin kullanıcısını oluşturun:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE "user"
|
||||||
|
SET role = 'admin'
|
||||||
|
WHERE email = 'your-email@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
Veya direkt database üzerinden:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Drizzle Studio veya migration ile
|
||||||
|
import { db } from "./db";
|
||||||
|
import { user } from "./db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
await db.update(user)
|
||||||
|
.set({ role: "admin" })
|
||||||
|
.where(eq(user.email, "your-email@example.com"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Güvenlik Özellikleri
|
||||||
|
|
||||||
|
1. **Kendi Hesap Koruması**
|
||||||
|
- Admin kendi rolünü değiştiremez
|
||||||
|
- Admin kendi hesabını silemez
|
||||||
|
|
||||||
|
2. **Yetkilendirme Kontrolleri**
|
||||||
|
- Her endpoint permission kontrolü yapar
|
||||||
|
- 401 (Unauthorized): Giriş yapmamış
|
||||||
|
- 403 (Forbidden): Yetkisi yok
|
||||||
|
|
||||||
|
3. **Cascade Silme**
|
||||||
|
- Kullanıcı silindiğinde tüm resimleri ve API anahtarları da silinir
|
||||||
|
|
||||||
|
4. **Role Validasyonu**
|
||||||
|
- Sadece geçerli roller kabul edilir
|
||||||
|
- Geçersiz rol atama engellenir
|
||||||
|
|
||||||
|
## Kullanım Örnekleri
|
||||||
|
|
||||||
|
### JavaScript/TypeScript (Web)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Admin paneline giriş (token localStorage'da saklanıyor)
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
// Kullanıcıları listele
|
||||||
|
const response = await fetch("/api/v1/admin/users", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await response.json();
|
||||||
|
console.log(data.users);
|
||||||
|
|
||||||
|
// Rol değiştir
|
||||||
|
await fetch("/api/v1/admin/users/user-id/role", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ role: "moderator" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kullanıcı sil
|
||||||
|
await fetch("/api/v1/admin/users/user-id", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kullanıcıları listele
|
||||||
|
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
||||||
|
http://localhost:3000/api/v1/admin/users
|
||||||
|
|
||||||
|
# Rol değiştir
|
||||||
|
curl -X PATCH \
|
||||||
|
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"role":"moderator"}' \
|
||||||
|
http://localhost:3000/api/v1/admin/users/USER_ID/role
|
||||||
|
|
||||||
|
# Kullanıcı sil
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
||||||
|
http://localhost:3000/api/v1/admin/users/USER_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Role field'ını veritabanına eklemek için:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Schema'dan migration oluştur
|
||||||
|
yarn db:generate
|
||||||
|
|
||||||
|
# Migration'ı çalıştır
|
||||||
|
yarn db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Veya manuel olarak SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dosya Yapısı
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── lib/
|
||||||
|
│ ├── permissions.ts # RBAC sistem tanımları
|
||||||
|
│ └── api-auth.ts # Auth middleware (role içerir)
|
||||||
|
├── api/
|
||||||
|
│ └── v1/
|
||||||
|
│ └── admin/
|
||||||
|
│ └── users/
|
||||||
|
│ ├── route.ts # GET (list)
|
||||||
|
│ └── [id]/
|
||||||
|
│ ├── route.ts # DELETE
|
||||||
|
│ └── role/
|
||||||
|
│ └── route.ts # PATCH
|
||||||
|
└── admin/
|
||||||
|
└── page.tsx # Admin panel UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sonraki Adımlar
|
||||||
|
|
||||||
|
1. ✅ Role sistemi eklendi
|
||||||
|
2. ✅ Permission kontrolleri eklendi
|
||||||
|
3. ✅ Admin panel oluşturuldu
|
||||||
|
4. ✅ Kullanıcı yönetimi API'leri eklendi
|
||||||
|
5. ⏳ Email bildirimleri (rol değişikliği, hesap silme)
|
||||||
|
6. ⏳ Audit log sistemi (kim ne yaptı?)
|
||||||
|
7. ⏳ Gelişmiş filtreleme (role göre, tarihe göre)
|
||||||
351
API_README.md
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
# Image API - Resim Manipülasyon API Dokümantasyonu
|
||||||
|
|
||||||
|
Bu API, dış uygulamaların resim yükleme, manipülasyon ve yönetim işlemlerini yapmasına olanak tanır.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
```
|
||||||
|
https://image.beyhano.com.tr
|
||||||
|
# veya development için
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
API, JWT token tabanlı kimlik doğrulama kullanır. Her istekte `Authorization` header'ında Bearer token gönderilmelidir.
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. Kayıt Ol (Register)
|
||||||
|
|
||||||
|
Yeni kullanıcı kaydı oluşturur ve JWT token döner.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/auth/register`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "minimum8karakter",
|
||||||
|
"name": "Kullanıcı Adı"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Kayıt başarılı",
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id": "user_123abc",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "Kullanıcı Adı"
|
||||||
|
},
|
||||||
|
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hata Kodları:**
|
||||||
|
- `400` - Gerekli alanlar eksik veya geçersiz
|
||||||
|
- `409` - Email adresi zaten kullanımda
|
||||||
|
- `500` - Sunucu hatası
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Giriş Yap (Login)
|
||||||
|
|
||||||
|
Mevcut kullanıcı ile giriş yapar ve JWT token döner.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "minimum8karakter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Giriş başarılı",
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id": "user_123abc",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "Kullanıcı Adı"
|
||||||
|
},
|
||||||
|
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hata Kodları:**
|
||||||
|
- `400` - Email veya şifre eksik
|
||||||
|
- `401` - Geçersiz email veya şifre
|
||||||
|
- `500` - Sunucu hatası
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Resim Yükle ve Manipüle Et
|
||||||
|
|
||||||
|
Resim yükler, belirtilen boyut/kalite/formatta işler ve kaydeder.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/images/upload`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body (FormData):**
|
||||||
|
- `file` (required): Resim dosyası (max 10MB)
|
||||||
|
- `width` (optional): Genişlik (px), default: 800, max: 10000
|
||||||
|
- `height` (optional): Yükseklik (px), default: 600, max: 10000
|
||||||
|
- `quality` (optional): Kalite (1-100), default: 90
|
||||||
|
- `format` (optional): Format (jpeg, png, webp, avif), default: jpeg
|
||||||
|
|
||||||
|
**cURL Örneği:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://image.beyhano.com.tr/api/v1/images/upload \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-F "file=@/path/to/image.jpg" \
|
||||||
|
-F "width=1920" \
|
||||||
|
-F "height=1080" \
|
||||||
|
-F "quality=85" \
|
||||||
|
-F "format=webp"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Resim başarıyla yüklendi",
|
||||||
|
"data": {
|
||||||
|
"image": {
|
||||||
|
"id": "img_xyz789",
|
||||||
|
"url": "https://image.beyhano.com.tr/uploads/xyz789.webp",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"format": "webp",
|
||||||
|
"fileSize": 245678
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hata Kodları:**
|
||||||
|
- `400` - Dosya eksik, boyut çok büyük veya geçersiz tip
|
||||||
|
- `401` - Geçersiz veya eksik token
|
||||||
|
- `500` - Sunucu hatası
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Resimleri Listele
|
||||||
|
|
||||||
|
Kullanıcının tüm resimlerini listeler.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/images`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": "img_xyz789",
|
||||||
|
"originalName": "photo.jpg",
|
||||||
|
"url": "https://image.beyhano.com.tr/uploads/xyz789.webp",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"quality": 85,
|
||||||
|
"format": "webp",
|
||||||
|
"fileSize": 245678,
|
||||||
|
"createdAt": "2026-01-06T02:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hata Kodları:**
|
||||||
|
- `401` - Geçersiz veya eksik token
|
||||||
|
- `500` - Sunucu hatası
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Resim Sil
|
||||||
|
|
||||||
|
Belirtilen ID'ye sahip resmi siler.
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/v1/images/{id}`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**cURL Örneği:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE https://image.beyhano.com.tr/api/v1/images/img_xyz789 \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Resim başarıyla silindi"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hata Kodları:**
|
||||||
|
- `401` - Geçersiz veya eksik token
|
||||||
|
- `404` - Resim bulunamadı veya size ait değil
|
||||||
|
- `500` - Sunucu hatası
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Örnek Kullanım (JavaScript/Node.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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('height', '1080');
|
||||||
|
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('Yüklenen resim:', uploadData.data.image.url);
|
||||||
|
|
||||||
|
// 3. Resimleri listele
|
||||||
|
const imagesResponse = await fetch('https://image.beyhano.com.tr/api/v1/images', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const imagesData = await imagesResponse.json();
|
||||||
|
console.log('Toplam resim:', imagesData.data.total);
|
||||||
|
|
||||||
|
// 4. Resim sil
|
||||||
|
const deleteResponse = await fetch(`https://image.beyhano.com.tr/api/v1/images/${imageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const deleteData = await deleteResponse.json();
|
||||||
|
console.log(deleteData.message);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Örnek Kullanım (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# 1. Kayıt ol
|
||||||
|
register_response = requests.post(
|
||||||
|
'https://image.beyhano.com.tr/api/v1/auth/register',
|
||||||
|
json={
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'password': 'securepassword123',
|
||||||
|
'name': 'Kullanıcı'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
token = register_response.json()['data']['accessToken']
|
||||||
|
|
||||||
|
# 2. Resim yükle
|
||||||
|
with open('image.jpg', 'rb') as f:
|
||||||
|
upload_response = requests.post(
|
||||||
|
'https://image.beyhano.com.tr/api/v1/images/upload',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
files={'file': f},
|
||||||
|
data={
|
||||||
|
'width': '1920',
|
||||||
|
'height': '1080',
|
||||||
|
'quality': '85',
|
||||||
|
'format': 'webp'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
image_url = upload_response.json()['data']['image']['url']
|
||||||
|
print(f'Yüklenen resim: {image_url}')
|
||||||
|
|
||||||
|
# 3. Resimleri listele
|
||||||
|
images_response = requests.get(
|
||||||
|
'https://image.beyhano.com.tr/api/v1/images',
|
||||||
|
headers={'Authorization': f'Bearer {token}'}
|
||||||
|
)
|
||||||
|
images = images_response.json()['data']['images']
|
||||||
|
print(f'Toplam resim: {len(images)}')
|
||||||
|
|
||||||
|
# 4. Resim sil
|
||||||
|
delete_response = requests.delete(
|
||||||
|
f'https://image.beyhano.com.tr/api/v1/images/{image_id}',
|
||||||
|
headers={'Authorization': f'Bearer {token}'}
|
||||||
|
)
|
||||||
|
print(delete_response.json()['message'])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Güvenlik
|
||||||
|
|
||||||
|
- JWT token'lar 7 gün geçerlidir
|
||||||
|
- Şifreler bcrypt ile hashlenmiş olarak saklanır
|
||||||
|
- Token'ları güvenli bir şekilde saklayın
|
||||||
|
- HTTPS kullanın (production'da)
|
||||||
|
- Rate limiting uygulanabilir
|
||||||
|
|
||||||
|
## Limitler
|
||||||
|
|
||||||
|
- Maximum dosya boyutu: 10MB
|
||||||
|
- Maximum resim boyutu: 10000x10000 px
|
||||||
|
- Desteklenen formatlar: JPEG, PNG, WebP, AVIF, GIF
|
||||||
|
- JWT token geçerlilik süresi: 7 gün
|
||||||
|
|
||||||
|
## Hata Yönetimi
|
||||||
|
|
||||||
|
Tüm hata yanıtları şu formatta döner:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Hata mesajı burada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP status kodları:
|
||||||
|
- `200` - Başarılı
|
||||||
|
- `400` - Kötü istek (geçersiz parametreler)
|
||||||
|
- `401` - Kimlik doğrulama hatası
|
||||||
|
- `404` - Bulunamadı
|
||||||
|
- `409` - Çakışma (örn: email zaten kullanımda)
|
||||||
|
- `500` - Sunucu hatası
|
||||||
84
API_TEST.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Image API - Test Senaryoları
|
||||||
|
|
||||||
|
## API Test Komutları (cURL)
|
||||||
|
|
||||||
|
### 1. Kayıt Ol
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "test12345",
|
||||||
|
"name": "Test User"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Login
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "test12345"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Yanıttan `accessToken` değerini alın ve aşağıdaki komutlarda kullanın:
|
||||||
|
```bash
|
||||||
|
export TOKEN="buraya_token_yapistirin"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Resim Yükle
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/images/upload \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "file=@/path/to/image.jpg" \
|
||||||
|
-F "width=800" \
|
||||||
|
-F "height=600" \
|
||||||
|
-F "quality=90" \
|
||||||
|
-F "format=webp"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Resimleri Listele
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3000/api/v1/images \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Resim Sil
|
||||||
|
```bash
|
||||||
|
export IMAGE_ID="buraya_image_id_yapistirin"
|
||||||
|
curl -X DELETE http://localhost:3000/api/v1/images/$IMAGE_ID \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Flow
|
||||||
|
|
||||||
|
1. Önce register olun ve token alın
|
||||||
|
2. Token ile resim yükleyin
|
||||||
|
3. Yüklenen resimleri listeleyin
|
||||||
|
4. Bir resmi silin
|
||||||
|
5. Tekrar listeleyin ve silindiğini doğrulayın
|
||||||
|
|
||||||
|
## Hata Testleri
|
||||||
|
|
||||||
|
### Geçersiz Token
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3000/api/v1/images \
|
||||||
|
-H "Authorization: Bearer invalid_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Olmadan
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:3000/api/v1/images
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geçersiz Login
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "yanlis_sifre"
|
||||||
|
}'
|
||||||
|
```
|
||||||
60
DOCKER.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
Bu proje Node.js v24.12.0 ve Yarn 1.22.22 ile Dockerize edilmiştir.
|
||||||
|
|
||||||
|
## Gereksinimler
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Hızlı Başlangıç
|
||||||
|
|
||||||
|
1. `.env` dosyası oluşturun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
||||||
|
BETTER_AUTH_SECRET=your-secret-key-here-min-32-characters-long
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not:** `DATABASE_URL` mevcut PostgreSQL sunucunuzun bağlantı bilgilerini içermelidir. Eğer PostgreSQL Docker dışında çalışıyorsa, host IP adresini veya `host.docker.internal` (Mac/Windows) kullanabilirsiniz.
|
||||||
|
|
||||||
|
2. Docker Compose ile başlatın:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Veritabanı migration'larını çalıştırın (ilk kurulumda):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec image-api yarn db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Uygulama `http://localhost:3000` adresinde çalışacaktır.
|
||||||
|
|
||||||
|
## Manuel Docker Build
|
||||||
|
|
||||||
|
Sadece uygulamayı build etmek için:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t image-api .
|
||||||
|
docker run -p 3000:3000 --env-file .env image-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Notları
|
||||||
|
|
||||||
|
- `.env` dosyasında `BETTER_AUTH_SECRET` mutlaka güçlü bir secret olmalıdır (en az 32 karakter)
|
||||||
|
- Production'da `BETTER_AUTH_URL` gerçek domain'inizi içermelidir
|
||||||
|
- PostgreSQL veritabanı dış bir sunucuda çalışmaktadır (docker-compose'da dahil değildir)
|
||||||
|
- `DATABASE_URL` mevcut PostgreSQL sunucunuzun erişilebilir adresini içermelidir (örn: `postgresql://user:pass@10.80.80.70:5432/dbname`)
|
||||||
|
- Upload klasörü volume olarak mount edilmiştir, böylece veriler kalıcı olur
|
||||||
|
- Container, host'un network'ündeki PostgreSQL sunucusuna erişebilir
|
||||||
|
|
||||||
|
## Güvenlik
|
||||||
|
|
||||||
|
- Tüm güvenlik header'ları yapılandırılmıştır
|
||||||
|
- File upload validasyonları eklenmiştir
|
||||||
|
- Debug bilgileri production'dan kaldırılmıştır
|
||||||
|
- Input validasyonları eklenmiştir
|
||||||
107
DOKPLOY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Dokploy Troubleshooting Guide
|
||||||
|
|
||||||
|
## PostgreSQL Bağlantı Sorunları
|
||||||
|
|
||||||
|
### Hata: `ECONNREFUSED` veya `Failed query`
|
||||||
|
|
||||||
|
Bu hata, uygulamanın PostgreSQL sunucusuna bağlanamadığını gösterir.
|
||||||
|
|
||||||
|
### Kontrol Listesi
|
||||||
|
|
||||||
|
1. **DATABASE_URL Environment Variable Kontrolü**
|
||||||
|
|
||||||
|
Dokploy'da environment variables bölümünde `DATABASE_URL` değerini kontrol edin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Doğru format:
|
||||||
|
DATABASE_URL=postgresql://user:password@host:port/database?search_path=public
|
||||||
|
|
||||||
|
# Örnek:
|
||||||
|
DATABASE_URL=postgresql://cloud:gg7678290@10.80.80.70:5432/image_api?search_path=public
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Network Erişimi**
|
||||||
|
|
||||||
|
- Dokploy container'ından PostgreSQL sunucusuna erişilebilir olmalı
|
||||||
|
- Eğer PostgreSQL farklı bir network'teyse, network yapılandırmasını kontrol edin
|
||||||
|
- Firewall kurallarını kontrol edin (port 5432 açık olmalı)
|
||||||
|
|
||||||
|
3. **IP Adresi ve Port**
|
||||||
|
|
||||||
|
- Hata mesajında görünen IP/port ile DATABASE_URL'deki IP/port eşleşmeli
|
||||||
|
- Eğer farklıysa, Dokploy'da environment variable'ı güncelleyin
|
||||||
|
|
||||||
|
4. **Connection String Formatı**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ✅ Doğru
|
||||||
|
postgresql://user:password@10.80.80.70:5432/dbname
|
||||||
|
|
||||||
|
# ❌ Yanlış
|
||||||
|
postgres://user:password@10.80.80.70:5432/dbname # postgres yerine postgresql kullanın
|
||||||
|
postgresql://user:password@10.80.80.70/dbname # Port eksik
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Schema Belirtme**
|
||||||
|
|
||||||
|
Eğer `public` dışında bir schema kullanıyorsanız:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://user:password@host:port/dbname?search_path=your_schema
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dokploy'da Environment Variables Ayarlama
|
||||||
|
|
||||||
|
1. Dokploy dashboard'a giriş yapın
|
||||||
|
2. Projenizi seçin
|
||||||
|
3. "Environment Variables" bölümüne gidin
|
||||||
|
4. `DATABASE_URL` değerini kontrol edin/güncelleyin
|
||||||
|
5. Değişikliklerden sonra container'ı yeniden başlatın
|
||||||
|
|
||||||
|
### Test Komutları
|
||||||
|
|
||||||
|
Container içinden bağlantıyı test etmek için:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container'a bağlan
|
||||||
|
docker exec -it image-api-app sh
|
||||||
|
|
||||||
|
# PostgreSQL bağlantısını test et
|
||||||
|
psql $DATABASE_URL -c "SELECT version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yaygın Hatalar ve Çözümleri
|
||||||
|
|
||||||
|
**Hata:** `ECONNREFUSED 10.0.1.215:5455`
|
||||||
|
- **Sebep:** DATABASE_URL yanlış IP/port içeriyor
|
||||||
|
- **Çözüm:** Dokploy'da DATABASE_URL'i doğru IP ve port ile güncelleyin
|
||||||
|
|
||||||
|
**Hata:** `timeout expired`
|
||||||
|
- **Sebep:** Network erişim sorunu veya firewall
|
||||||
|
- **Çözüm:** Network yapılandırmasını ve firewall kurallarını kontrol edin
|
||||||
|
|
||||||
|
**Hata:** `password authentication failed`
|
||||||
|
- **Sebep:** Yanlış kullanıcı adı/şifre
|
||||||
|
- **Çözüm:** DATABASE_URL'deki credentials'ları kontrol edin
|
||||||
|
|
||||||
|
### Örnek Doğru DATABASE_URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local PostgreSQL
|
||||||
|
DATABASE_URL=postgresql://postgres:password@localhost:5432/image_api?search_path=public
|
||||||
|
|
||||||
|
# Remote PostgreSQL
|
||||||
|
DATABASE_URL=postgresql://cloud:gg7678290@10.80.80.70:5432/image_api?search_path=public
|
||||||
|
|
||||||
|
# SSL ile
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Modu
|
||||||
|
|
||||||
|
Daha detaylı hata mesajları için:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# db.ts dosyasında pool.on('error') event handler'ı logları gösterir
|
||||||
|
# Container loglarını kontrol edin:
|
||||||
|
docker logs image-api-app
|
||||||
|
```
|
||||||
56
Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Node.js v24.12.0 kullan
|
||||||
|
FROM node:24.12.0-alpine AS base
|
||||||
|
|
||||||
|
# Yarn için corepack'i etkinleştir
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Dependencies stage - sadece gerekli bağımlılıkları yükle
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Package dosyalarını kopyala
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Builder stage - uygulamayı build et
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Node modules'ü kopyala
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build için gerekli environment variables
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Build-time için dummy değerler (runtime'da gerçek değerler kullanılacak)
|
||||||
|
ENV DATABASE_URL="postgresql://cloud:gg7678290@database-postgist-5pcspx:5432/image_api?search_path=public"
|
||||||
|
ENV BETTER_AUTH_SECRET="dB89kiKf56igxrB783yb3UyQToQIPZ93cRKADyq1yQEJ8EU4JRw5GlxBmGvQMu8e"
|
||||||
|
ENV BETTER_AUTH_URL="https://image.beyhano.com.tr"
|
||||||
|
ENV REGISTER_ENABLE=false
|
||||||
|
# Next.js build
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Runner stage - production için minimal image
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Güvenlik için non-root user oluştur
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Standalone build'den dosyaları kopyala
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
115
MemoryBank.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Memory Bank
|
||||||
|
|
||||||
|
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
||||||
|
|
||||||
|
## Memory Bank Structure
|
||||||
|
|
||||||
|
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
||||||
|
|
||||||
|
flowchart TD
|
||||||
|
PB[projectbrief.md] --> PC[productContext.md]
|
||||||
|
PB --> SP[systemPatterns.md]
|
||||||
|
PB --> TC[techContext.md]
|
||||||
|
|
||||||
|
PC --> AC[activeContext.md]
|
||||||
|
SP --> AC
|
||||||
|
TC --> AC
|
||||||
|
|
||||||
|
AC --> P[progress.md]
|
||||||
|
|
||||||
|
### Core Files (Required)
|
||||||
|
1. `projectbrief.md`
|
||||||
|
- Foundation document that shapes all other files
|
||||||
|
- Created at project start if it doesn't exist
|
||||||
|
- Defines core requirements and goals
|
||||||
|
- Source of truth for project scope
|
||||||
|
|
||||||
|
2. `productContext.md`
|
||||||
|
- Why this project exists
|
||||||
|
- Problems it solves
|
||||||
|
- How it should work
|
||||||
|
- User experience goals
|
||||||
|
|
||||||
|
3. `activeContext.md`
|
||||||
|
- Current work focus
|
||||||
|
- Recent changes
|
||||||
|
- Next steps
|
||||||
|
- Active decisions and considerations
|
||||||
|
- Important patterns and preferences
|
||||||
|
- Learnings and project insights
|
||||||
|
|
||||||
|
4. `systemPatterns.md`
|
||||||
|
- System architecture
|
||||||
|
- Key technical decisions
|
||||||
|
- Design patterns in use
|
||||||
|
- Component relationships
|
||||||
|
- Critical implementation paths
|
||||||
|
|
||||||
|
5. `techContext.md`
|
||||||
|
- Technologies used
|
||||||
|
- Development setup
|
||||||
|
- Technical constraints
|
||||||
|
- Dependencies
|
||||||
|
- Tool usage patterns
|
||||||
|
|
||||||
|
6. `progress.md`
|
||||||
|
- What works
|
||||||
|
- What's left to build
|
||||||
|
- Current status
|
||||||
|
- Known issues
|
||||||
|
- Evolution of project decisions
|
||||||
|
|
||||||
|
### Additional Context
|
||||||
|
Create additional files/folders within memory-bank/ when they help organize:
|
||||||
|
- Complex feature documentation
|
||||||
|
- Integration specifications
|
||||||
|
- API documentation
|
||||||
|
- Testing strategies
|
||||||
|
- Deployment procedures
|
||||||
|
|
||||||
|
## Core Workflows
|
||||||
|
|
||||||
|
### Plan Mode
|
||||||
|
flowchart TD
|
||||||
|
Start[Start] --> ReadFiles[Read Memory Bank]
|
||||||
|
ReadFiles --> CheckFiles{Files Complete?}
|
||||||
|
|
||||||
|
CheckFiles -->|No| Plan[Create Plan]
|
||||||
|
Plan --> Document[Document in Chat]
|
||||||
|
|
||||||
|
CheckFiles -->|Yes| Verify[Verify Context]
|
||||||
|
Verify --> Strategy[Develop Strategy]
|
||||||
|
Strategy --> Present[Present Approach]
|
||||||
|
|
||||||
|
### Act Mode
|
||||||
|
flowchart TD
|
||||||
|
Start[Start] --> Context[Check Memory Bank]
|
||||||
|
Context --> Update[Update Documentation]
|
||||||
|
Update --> Execute[Execute Task]
|
||||||
|
Execute --> Document[Document Changes]
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
Memory Bank updates occur when:
|
||||||
|
1. Discovering new project patterns
|
||||||
|
2. After implementing significant changes
|
||||||
|
3. When user requests with **update memory bank** (MUST review ALL files)
|
||||||
|
4. When context needs clarification
|
||||||
|
|
||||||
|
flowchart TD
|
||||||
|
Start[Update Process]
|
||||||
|
|
||||||
|
subgraph Process
|
||||||
|
P1[Review ALL Files]
|
||||||
|
P2[Document Current State]
|
||||||
|
P3[Clarify Next Steps]
|
||||||
|
P4[Document Insights & Patterns]
|
||||||
|
|
||||||
|
P1 --> P2 --> P3 --> P4
|
||||||
|
end
|
||||||
|
|
||||||
|
Start --> Process
|
||||||
|
|
||||||
|
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
|
||||||
|
|
||||||
|
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||||
10
R2 ClousFlare.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Toke Value
|
||||||
|
0jbuP8PiWCbXglAfFAkfFC_l5bVk8w69WCJT6MtG
|
||||||
|
Access Key
|
||||||
|
177784973745076ce943e02b267cf139
|
||||||
|
Server Access Key
|
||||||
|
9fcf11d84de480e0bee89f23703c655582c0b0035bcaee828ef0fe187b0d4b63
|
||||||
|
Default
|
||||||
|
https://4f3dc7a1aa54f4ba52803e952d6cf6be.r2.cloudflarestorage.com
|
||||||
|
Europa
|
||||||
|
https://4f3dc7a1aa54f4ba52803e952d6cf6be.eu.r2.cloudflarestorage.com
|
||||||
142
R2_MIGRATION.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Cloudflare R2 Storage Entegrasyonu
|
||||||
|
|
||||||
|
Bu proje, resim depolamasını yerel dosya sisteminden Cloudflare R2'ye taşımak için güncellenmiştir.
|
||||||
|
|
||||||
|
## Yapılan Değişiklikler
|
||||||
|
|
||||||
|
### 1. Yeni Kütüphane
|
||||||
|
- **@aws-sdk/client-s3** eklendi (Cloudflare R2, S3-uyumlu API kullanır)
|
||||||
|
|
||||||
|
### 2. Yeni Dosyalar
|
||||||
|
- **app/lib/r2-storage.ts**: R2 storage işlemleri için yardımcı fonksiyonlar
|
||||||
|
- `uploadToR2()`: Resim yükleme
|
||||||
|
- `deleteFromR2()`: Resim silme
|
||||||
|
- `getContentType()`: Dosya türü belirleme
|
||||||
|
|
||||||
|
### 3. Güncellenen Dosyalar
|
||||||
|
|
||||||
|
#### API Routes (Upload)
|
||||||
|
- `app/api/images/upload/route.ts`
|
||||||
|
- `app/api/v1/images/upload/route.ts`
|
||||||
|
|
||||||
|
**Değişiklikler:**
|
||||||
|
- Yerel dosya sistemi yerine R2'ye yükleme
|
||||||
|
- `writeFile` ve `mkdir` kaldırıldı
|
||||||
|
- `uploadToR2()` kullanılıyor
|
||||||
|
- URL'ler artık tam R2 URL'leri olarak kaydediliyor
|
||||||
|
|
||||||
|
#### API Routes (List)
|
||||||
|
- `app/api/images/route.ts`
|
||||||
|
- `app/api/v1/images/route.ts`
|
||||||
|
|
||||||
|
**Değişiklikler:**
|
||||||
|
- Base URL ekleme kaldırıldı (R2 URL'leri zaten tam URL)
|
||||||
|
- Direkt olarak veritabanından gelen URL döndürülüyor
|
||||||
|
|
||||||
|
#### API Routes (Delete)
|
||||||
|
- `app/api/images/[id]/route.ts`
|
||||||
|
- `app/api/v1/images/[id]/route.ts`
|
||||||
|
|
||||||
|
**Değişiklikler:**
|
||||||
|
- Yerel dosya silme yerine R2'den silme
|
||||||
|
- `unlink` yerine `deleteFromR2()` kullanılıyor
|
||||||
|
|
||||||
|
### 4. Environment Variables
|
||||||
|
|
||||||
|
`.env`, `.env.local` ve `.env.example` dosyalarına eklenen değişkenler:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Cloudflare R2 Storage (Europa Server)
|
||||||
|
R2_ACCOUNT_ID=4f3dc7a1aa54f4ba52803e952d6cf6be
|
||||||
|
R2_ACCESS_KEY_ID=177784973745076ce943e02b267cf139
|
||||||
|
R2_SECRET_ACCESS_KEY=9fcf11d84de480e0bee89f23703c655582c0b0035bcaee828ef0fe187b0d4b63
|
||||||
|
R2_BUCKET_NAME=resimlerim
|
||||||
|
R2_PUBLIC_URL=https://images.beyhano.com.tr
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not:** Europa server endpoint'i kullanılıyor: `https://{ACCOUNT_ID}.eu.r2.cloudflarestorage.com`
|
||||||
|
|
||||||
|
## R2 Bucket Yapılandırması
|
||||||
|
|
||||||
|
### Gerekli Ayarlar
|
||||||
|
|
||||||
|
1. **Bucket Oluşturma:**
|
||||||
|
- Cloudflare Dashboard > R2 > Create Bucket
|
||||||
|
- Bucket adı: `images` (veya .env'de belirtilen)
|
||||||
|
- Location: Europa
|
||||||
|
|
||||||
|
2. **Public Access:**
|
||||||
|
- Bucket Settings > Public Access
|
||||||
|
- Custom Domain ekleyin: `images.beyhano.com.tr`
|
||||||
|
- CORS ayarlarını yapılandırın (gerekirse)
|
||||||
|
|
||||||
|
3. **API Token:**
|
||||||
|
- R2 > Manage R2 API Tokens
|
||||||
|
- Create API Token
|
||||||
|
- Permissions: Object Read & Write
|
||||||
|
- Token bilgilerini .env'e ekleyin
|
||||||
|
|
||||||
|
## Avantajlar
|
||||||
|
|
||||||
|
1. **Ölçeklenebilirlik:** Sınırsız depolama kapasitesi
|
||||||
|
2. **Performans:** CDN entegrasyonu ile hızlı erişim
|
||||||
|
3. **Maliyet:** Egress ücreti yok (Cloudflare CDN üzerinden)
|
||||||
|
4. **Güvenilirlik:** Otomatik yedekleme ve dayanıklılık
|
||||||
|
5. **Bakım:** Sunucu disk alanı yönetimi gerekmez
|
||||||
|
|
||||||
|
## Geriye Dönük Uyumluluk
|
||||||
|
|
||||||
|
Mevcut sistemde `public/uploads/` klasöründe bulunan resimler için:
|
||||||
|
|
||||||
|
1. **Manuel Migrasyon:** Mevcut resimleri R2'ye yükleyin
|
||||||
|
2. **Veritabanı Güncelleme:** `images` tablosundaki `url` ve `filePath` alanlarını güncelleyin
|
||||||
|
3. **Eski Dosyalar:** Migrasyon sonrası eski dosyaları silebilirsiniz
|
||||||
|
|
||||||
|
## Test Etme
|
||||||
|
|
||||||
|
### 1. Resim Yükleme Testi
|
||||||
|
```bash
|
||||||
|
curl -X POST https://image.beyhano.com.tr/api/images/upload \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "file=@test-image.jpg" \
|
||||||
|
-F "width=800" \
|
||||||
|
-F "height=600" \
|
||||||
|
-F "quality=90" \
|
||||||
|
-F "format=jpeg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Resim Listeleme Testi
|
||||||
|
```bash
|
||||||
|
curl -X GET https://image.beyhano.com.tr/api/images \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Resim Silme Testi
|
||||||
|
```bash
|
||||||
|
curl -X DELETE https://image.beyhano.com.tr/api/images/IMAGE_ID \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sorun Giderme
|
||||||
|
|
||||||
|
### R2 Bağlantı Hatası
|
||||||
|
- Environment variables'ların doğru olduğundan emin olun
|
||||||
|
- R2 API Token'ın aktif olduğunu kontrol edin
|
||||||
|
- Bucket adının doğru olduğunu kontrol edin
|
||||||
|
|
||||||
|
### Upload Hatası
|
||||||
|
- Dosya boyutunun 10MB'ın altında olduğundan emin olun
|
||||||
|
- Content-Type'ın doğru ayarlandığını kontrol edin
|
||||||
|
- R2 bucket'ın write permission'a sahip olduğunu kontrol edin
|
||||||
|
|
||||||
|
### URL Erişim Hatası
|
||||||
|
- Custom domain'in doğru yapılandırıldığından emin olun
|
||||||
|
- Public access ayarlarını kontrol edin
|
||||||
|
- DNS kayıtlarının doğru olduğunu kontrol edin
|
||||||
|
|
||||||
|
## Önemli Notlar
|
||||||
|
|
||||||
|
1. **Güvenlik:** API credentials'ları asla commit etmeyin
|
||||||
|
2. **Backup:** Kritik resimlerin yedeğini alın
|
||||||
|
3. **Monitoring:** R2 kullanım metriklerini takip edin
|
||||||
|
4. **Rate Limiting:** R2 API rate limitlerini göz önünde bulundurun
|
||||||
190
R2_SETUP_CHECKLIST.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Cloudflare R2 Kurulum Kontrol Listesi
|
||||||
|
|
||||||
|
## ✅ Tamamlanan Adımlar
|
||||||
|
|
||||||
|
### 1. Kod Değişiklikleri
|
||||||
|
- [x] AWS SDK (@aws-sdk/client-s3) kuruldu
|
||||||
|
- [x] R2 storage servisi oluşturuldu (app/lib/r2-storage.ts)
|
||||||
|
- [x] Upload endpoint'leri güncellendi (hem /api hem /api/v1)
|
||||||
|
- [x] List endpoint'leri güncellendi (R2 URL'leri kullanıyor)
|
||||||
|
- [x] Delete endpoint'leri güncellendi (R2'den silme)
|
||||||
|
- [x] Environment variables eklendi (.env, .env.local, .env.example)
|
||||||
|
- [x] Yerel dosya sistemi referansları kaldırıldı
|
||||||
|
- [x] Linter hataları temizlendi
|
||||||
|
|
||||||
|
### 2. Yapılandırma
|
||||||
|
- [x] Europa server endpoint yapılandırıldı
|
||||||
|
- [x] R2 credentials .env dosyalarına eklendi
|
||||||
|
- [x] Bucket name: `resimlerim`
|
||||||
|
- [x] Public URL: `https://images.beyhano.com.tr`
|
||||||
|
|
||||||
|
## 📋 Yapılması Gerekenler (Cloudflare Dashboard)
|
||||||
|
|
||||||
|
### 1. R2 Bucket Kontrolü
|
||||||
|
- [ ] Cloudflare Dashboard'a giriş yapın
|
||||||
|
- [ ] R2 > Buckets bölümüne gidin
|
||||||
|
- [ ] `resimlerim` adında bucket olduğunu kontrol edin
|
||||||
|
- [ ] Bucket location'ın Europa (EEUR) olduğunu doğrulayın
|
||||||
|
|
||||||
|
### 2. Public Access Ayarları
|
||||||
|
- [ ] Bucket > Settings > Public Access
|
||||||
|
- [ ] Custom Domain ekleyin: `images.beyhano.com.tr`
|
||||||
|
- [ ] DNS kayıtlarını yapılandırın (CNAME veya A record)
|
||||||
|
- [ ] SSL/TLS sertifikasının aktif olduğunu kontrol edin
|
||||||
|
|
||||||
|
### 3. CORS Ayarları (Gerekirse)
|
||||||
|
Eğer frontend'den direkt R2'ye erişim gerekiyorsa:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["https://image.beyhano.com.tr"],
|
||||||
|
"AllowedMethods": ["GET", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API Token Kontrolü
|
||||||
|
- [ ] R2 > Manage R2 API Tokens
|
||||||
|
- [ ] Token'ın aktif olduğunu kontrol edin
|
||||||
|
- [ ] Permissions: Object Read & Write
|
||||||
|
- [ ] Token bilgilerinin .env'de doğru olduğunu doğrulayın
|
||||||
|
|
||||||
|
## 🧪 Test Adımları
|
||||||
|
|
||||||
|
### 1. Yerel Test (Development)
|
||||||
|
```bash
|
||||||
|
# Sunucuyu başlat
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Test resmi yükle
|
||||||
|
curl -X POST http://localhost:3000/api/images/upload \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "file=@test-image.jpg" \
|
||||||
|
-F "width=800" \
|
||||||
|
-F "height=600"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Production Test
|
||||||
|
```bash
|
||||||
|
# Test resmi yükle
|
||||||
|
curl -X POST https://image.beyhano.com.tr/api/images/upload \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-F "file=@test-image.jpg"
|
||||||
|
|
||||||
|
# Resimleri listele
|
||||||
|
curl -X GET https://image.beyhano.com.tr/api/images \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Resmi tarayıcıda aç (dönen URL'yi kullan)
|
||||||
|
# Örnek: https://images.beyhano.com.tr/abc123.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Kontrol Edilecekler
|
||||||
|
- [ ] Resim başarıyla yükleniyor
|
||||||
|
- [ ] R2 bucket'ta resim görünüyor
|
||||||
|
- [ ] Public URL üzerinden resme erişilebiliyor
|
||||||
|
- [ ] Resim listesi doğru URL'leri döndürüyor
|
||||||
|
- [ ] Resim silme işlemi çalışıyor
|
||||||
|
- [ ] Resim manipülasyonu (resize, format) çalışıyor
|
||||||
|
|
||||||
|
## 🔄 Mevcut Resimlerin Migrasyonu (Opsiyonel)
|
||||||
|
|
||||||
|
Eğer `public/uploads/` klasöründe mevcut resimler varsa:
|
||||||
|
|
||||||
|
### 1. Resimleri R2'ye Yükle
|
||||||
|
```bash
|
||||||
|
# AWS CLI veya rclone kullanarak toplu yükleme
|
||||||
|
# Örnek: rclone sync public/uploads/ r2:resimlerim/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Veritabanını Güncelle
|
||||||
|
```sql
|
||||||
|
-- Eski URL'leri yeni R2 URL'leriyle değiştir
|
||||||
|
UPDATE images
|
||||||
|
SET url = CONCAT('https://images.beyhano.com.tr/', fileName),
|
||||||
|
filePath = fileName
|
||||||
|
WHERE url LIKE '/uploads/%';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Eski Dosyaları Temizle
|
||||||
|
```bash
|
||||||
|
# Migrasyon başarılı olduktan sonra
|
||||||
|
rm -rf public/uploads/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Sorun Giderme
|
||||||
|
|
||||||
|
### R2 Bağlantı Hatası
|
||||||
|
```
|
||||||
|
Error: R2'ye yükleme başarısız
|
||||||
|
```
|
||||||
|
**Çözüm:**
|
||||||
|
- Environment variables'ları kontrol edin
|
||||||
|
- R2 API Token'ın aktif olduğunu doğrulayın
|
||||||
|
- Account ID'nin doğru olduğunu kontrol edin
|
||||||
|
|
||||||
|
### Public URL Erişim Hatası
|
||||||
|
```
|
||||||
|
403 Forbidden veya 404 Not Found
|
||||||
|
```
|
||||||
|
**Çözüm:**
|
||||||
|
- Custom domain yapılandırmasını kontrol edin
|
||||||
|
- Public access ayarlarını kontrol edin
|
||||||
|
- DNS propagation'ı bekleyin (24 saat)
|
||||||
|
|
||||||
|
### Upload Başarılı Ama Resim Görünmüyor
|
||||||
|
**Çözüm:**
|
||||||
|
- Browser cache'i temizleyin
|
||||||
|
- R2 bucket'ta dosyanın var olduğunu kontrol edin
|
||||||
|
- Content-Type header'ının doğru olduğunu kontrol edin
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Cloudflare Dashboard
|
||||||
|
- R2 > Analytics
|
||||||
|
- Storage kullanımı
|
||||||
|
- Request sayısı
|
||||||
|
- Bandwidth kullanımı
|
||||||
|
|
||||||
|
### Önemli Metrikler
|
||||||
|
- Upload success rate
|
||||||
|
- Average response time
|
||||||
|
- Error rate
|
||||||
|
- Storage growth
|
||||||
|
|
||||||
|
## 🔒 Güvenlik Notları
|
||||||
|
|
||||||
|
1. **API Credentials:**
|
||||||
|
- Asla commit etmeyin
|
||||||
|
- Production'da environment variables kullanın
|
||||||
|
- Düzenli olarak rotate edin
|
||||||
|
|
||||||
|
2. **Public Access:**
|
||||||
|
- Sadece gerekli dosyalar public olsun
|
||||||
|
- Rate limiting uygulayın
|
||||||
|
- Hotlink protection düşünün
|
||||||
|
|
||||||
|
3. **Backup:**
|
||||||
|
- Kritik resimlerin yedeğini alın
|
||||||
|
- Versioning'i etkinleştirin (R2 settings)
|
||||||
|
- Disaster recovery planı oluşturun
|
||||||
|
|
||||||
|
## 📝 Notlar
|
||||||
|
|
||||||
|
- R2 egress ücreti yoktur (Cloudflare CDN üzerinden)
|
||||||
|
- Storage maliyeti: ~$0.015/GB/ay
|
||||||
|
- Class A operations: $4.50/million (PUT, LIST)
|
||||||
|
- Class B operations: $0.36/million (GET, HEAD)
|
||||||
|
- Europa server kullanıldığı için GDPR uyumlu
|
||||||
|
|
||||||
|
## ✅ Son Kontrol
|
||||||
|
|
||||||
|
Deployment öncesi:
|
||||||
|
- [ ] Tüm environment variables production'da ayarlandı
|
||||||
|
- [ ] R2 bucket ve custom domain yapılandırıldı
|
||||||
|
- [ ] Test upload/download/delete çalışıyor
|
||||||
|
- [ ] Monitoring ve alerting kuruldu
|
||||||
|
- [ ] Backup stratejisi belirlendi
|
||||||
|
- [ ] Documentation güncellendi
|
||||||
220
README.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Image Manipulation API
|
||||||
|
|
||||||
|
Modern, güvenli ve ölçeklenebilir resim yönetim ve manipülasyon platformu. Next.js 16, Better Auth ve Drizzle ORM ile geliştirilmiştir.
|
||||||
|
|
||||||
|
## ✨ Özellikler
|
||||||
|
|
||||||
|
### 🔐 Güvenlik & Kimlik Doğrulama
|
||||||
|
- **Better Auth** ile session tabanlı kimlik doğrulama
|
||||||
|
- **JWT** destekli REST API (dış uygulamalar için)
|
||||||
|
- **Role-Based Access Control (RBAC)** - 3 farklı rol (User, Moderator, Admin)
|
||||||
|
- API Key yönetimi
|
||||||
|
- Şifre hashleme (bcrypt)
|
||||||
|
|
||||||
|
### 🎨 Resim İşleme
|
||||||
|
- **Sharp** kütüphanesi ile hızlı resim manipülasyonu
|
||||||
|
- Boyutlandırma (width/height)
|
||||||
|
- Format dönüştürme (JPEG, PNG, WebP, AVIF)
|
||||||
|
- Kalite ayarı (1-100)
|
||||||
|
- Otomatik dosya boyutu optimizasyonu
|
||||||
|
|
||||||
|
### 👥 Kullanıcı Yönetimi
|
||||||
|
- **Admin Panel** - Kullanıcıları yönetme, rol atama, silme
|
||||||
|
- Kullanıcı profili
|
||||||
|
- Email doğrulama desteği
|
||||||
|
- Kayıt açma/kapama kontrolü
|
||||||
|
|
||||||
|
### 📊 Rol ve İzinler
|
||||||
|
|
||||||
|
| Rol | İzinler |
|
||||||
|
|-----|---------|
|
||||||
|
| **User** | Kendi resimlerini yükleyebilir, görüntüleyebilir ve silebilir |
|
||||||
|
| **Moderator** | Tüm resimleri görüntüleyebilir ve silebilir |
|
||||||
|
| **Admin** | Tüm moderator izinleri + Kullanıcı yönetimi |
|
||||||
|
|
||||||
|
### 🚀 API Özellikleri
|
||||||
|
- RESTful API tasarımı
|
||||||
|
- JWT token authentication
|
||||||
|
- API Key authentication
|
||||||
|
- Rate limiting (opsiyonel)
|
||||||
|
- Swagger-style dokümantasyon
|
||||||
|
|
||||||
|
## 📦 Teknolojiler
|
||||||
|
|
||||||
|
- **Framework**: Next.js 16.1.1 (App Router, Turbopack)
|
||||||
|
- **Auth**: better-auth v1.4.10
|
||||||
|
- **Database**: PostgreSQL + Drizzle ORM v0.45.1
|
||||||
|
- **Image Processing**: Sharp v0.34.5
|
||||||
|
- **Styling**: Tailwind CSS v4
|
||||||
|
- **Container**: Docker & Docker Compose
|
||||||
|
- **Runtime**: Node.js v24.12.0
|
||||||
|
|
||||||
|
## 🛠️ Kurulum
|
||||||
|
|
||||||
|
### 1. Proje Klonlama
|
||||||
|
\`\`\`bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd image-api
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 2. Ortam Değişkenleri
|
||||||
|
\`.env\` dosyası oluşturun:
|
||||||
|
\`\`\`env
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/image_api
|
||||||
|
BETTER_AUTH_SECRET=your-super-secret-key-min-32-chars
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
JWT_SECRET=another-super-secret-key-min-32-characters
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 3. Docker ile Çalıştırma
|
||||||
|
\`\`\`bash
|
||||||
|
# PostgreSQL başlat
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
# Bağımlılıkları yükle
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Database migration
|
||||||
|
yarn db:push
|
||||||
|
|
||||||
|
# Development server
|
||||||
|
yarn dev
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 4. İlk Admin Kullanıcısı
|
||||||
|
\`\`\`bash
|
||||||
|
# Kayıt ol
|
||||||
|
# Sonra admin yap:
|
||||||
|
npx tsx make-admin.ts your-email@example.com
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 📚 API Kullanımı
|
||||||
|
|
||||||
|
### Kayıt ve Login
|
||||||
|
\`\`\`bash
|
||||||
|
# Kayıt
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/register \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"email":"user@example.com","password":"password123","name":"John Doe"}'
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:3000/api/v1/auth/login \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"email":"user@example.com","password":"password123"}'
|
||||||
|
|
||||||
|
# Response: {"success":true,"token":"jwt-token-here"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Resim Yükleme
|
||||||
|
\`\`\`bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/images/upload \\
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \\
|
||||||
|
-F "file=@photo.jpg" \\
|
||||||
|
-F "width=800" \\
|
||||||
|
-F "quality=90" \\
|
||||||
|
-F "format=webp"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Resim Listeleme
|
||||||
|
\`\`\`bash
|
||||||
|
# User: Sadece kendi resimleri
|
||||||
|
# Moderator/Admin: Tüm resimler
|
||||||
|
curl http://localhost:3000/api/v1/images \\
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Admin - Kullanıcıları Listeleme
|
||||||
|
\`\`\`bash
|
||||||
|
curl http://localhost:3000/api/v1/admin/users \\
|
||||||
|
-H "Authorization: Bearer ADMIN_JWT_TOKEN"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Admin - Rol Değiştirme
|
||||||
|
\`\`\`bash
|
||||||
|
curl -X PATCH http://localhost:3000/api/v1/admin/users/USER_ID/role \\
|
||||||
|
-H "Authorization: Bearer ADMIN_JWT_TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"role":"moderator"}'
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Detaylı API dokümantasyonu: **http://localhost:3000/api-docs**
|
||||||
|
|
||||||
|
## 🎯 Sayfalar
|
||||||
|
|
||||||
|
- \`/\` - Anasayfa
|
||||||
|
- \`/login\` - Giriş yap
|
||||||
|
- \`/register\` - Kayıt ol (REGISTER_ENABLE=true ise)
|
||||||
|
- \`/upload\` - Resim yükle
|
||||||
|
- \`/profile\` - Profil ve resimlerim
|
||||||
|
- \`/admin\` - Admin panel (sadece adminler)
|
||||||
|
- \`/api-docs\` - API dokümantasyonu
|
||||||
|
|
||||||
|
## 🔒 Güvenlik Özellikleri
|
||||||
|
|
||||||
|
1. **Password Hashing**: bcrypt ile şifreler güvenle saklanır
|
||||||
|
2. **JWT Tokens**: 7 günlük geçerlilik süresi
|
||||||
|
3. **Role-Based Access**: Endpoint bazında yetkilendirme
|
||||||
|
4. **Session Management**: Better Auth ile güvenli session yönetimi
|
||||||
|
5. **CORS**: Yapılandırılabilir CORS desteği
|
||||||
|
6. **Environment Variables**: Hassas bilgiler .env'de
|
||||||
|
|
||||||
|
## 📊 Database Schema
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
- \`user\` - Kullanıcı bilgileri (email, password_hash, role)
|
||||||
|
- \`session\` - Aktif oturumlar
|
||||||
|
- \`account\` - OAuth provider hesapları
|
||||||
|
- \`verification\` - Email doğrulama
|
||||||
|
- \`images\` - Yüklenen resimler
|
||||||
|
- \`apiKeys\` - API anahtarları
|
||||||
|
|
||||||
|
## 🐳 Docker Production Build
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Build image
|
||||||
|
docker build -t image-api .
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose logs -f image-api
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 📝 Scripts
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
yarn dev # Development server
|
||||||
|
yarn build # Production build
|
||||||
|
yarn start # Production server
|
||||||
|
yarn lint # ESLint
|
||||||
|
yarn db:generate # Generate migrations
|
||||||
|
yarn db:push # Push schema to DB
|
||||||
|
yarn db:studio # Drizzle Studio
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 🤝 Katkıda Bulunma
|
||||||
|
|
||||||
|
1. Fork edin
|
||||||
|
2. Feature branch oluşturun (\`git checkout -b feature/amazing\`)
|
||||||
|
3. Commit edin (\`git commit -m 'Add amazing feature'\`)
|
||||||
|
4. Push edin (\`git push origin feature/amazing\`)
|
||||||
|
5. Pull Request açın
|
||||||
|
|
||||||
|
## 📄 Lisans
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## 👨💻 Geliştirici
|
||||||
|
|
||||||
|
Beyhan Oğur - [beyhan@beyhan.dev](mailto:beyhan@beyhan.dev)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Daha fazla bilgi için:**
|
||||||
|
- [API Documentation](./API_README.md)
|
||||||
|
- [Admin Panel Guide](./ADMIN_PANEL.md)
|
||||||
|
- [Docker Setup](./DOCKER.md)
|
||||||
|
- [Security Guide](./SECURITY.md)
|
||||||
61
SECURITY.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Güvenlik İyileştirmeleri
|
||||||
|
|
||||||
|
Bu dokümanda production'a hazırlık için yapılan güvenlik iyileştirmeleri listelenmektedir.
|
||||||
|
|
||||||
|
## Yapılan İyileştirmeler
|
||||||
|
|
||||||
|
### 1. Debug Bilgilerinin Kaldırılması
|
||||||
|
- ✅ Production console.log ifadeleri kaldırıldı
|
||||||
|
- ✅ API response'lardan debug bilgileri çıkarıldı
|
||||||
|
- ✅ Hata mesajlarında hassas bilgi sızıntısı önlendi
|
||||||
|
|
||||||
|
### 2. File Upload Güvenliği
|
||||||
|
- ✅ Dosya boyutu limiti eklendi (maksimum 10MB)
|
||||||
|
- ✅ MIME type validasyonu eklendi
|
||||||
|
- ✅ Sadece resim dosyaları kabul ediliyor (jpeg, jpg, png, gif, webp, avif)
|
||||||
|
- ✅ Path traversal koruması (nanoid kullanımı)
|
||||||
|
|
||||||
|
### 3. Input Validasyonu
|
||||||
|
- ✅ Width/Height validasyonu (1-10000px arası)
|
||||||
|
- ✅ Quality validasyonu (1-100 arası)
|
||||||
|
- ✅ Format validasyonu (sadece izin verilen formatlar)
|
||||||
|
- ✅ Image ID validasyonu (uzunluk ve tip kontrolü)
|
||||||
|
|
||||||
|
### 4. Security Headers
|
||||||
|
- ✅ Strict-Transport-Security (HSTS)
|
||||||
|
- ✅ X-Frame-Options
|
||||||
|
- ✅ X-Content-Type-Options
|
||||||
|
- ✅ X-XSS-Protection
|
||||||
|
- ✅ Referrer-Policy
|
||||||
|
- ✅ Permissions-Policy
|
||||||
|
|
||||||
|
### 5. Authentication & Authorization
|
||||||
|
- ✅ Tüm API endpoint'lerinde authentication kontrolü
|
||||||
|
- ✅ User-based authorization (kullanıcılar sadece kendi resimlerini görebilir/silebilir)
|
||||||
|
- ✅ Better Auth kullanımı (güvenli session yönetimi)
|
||||||
|
|
||||||
|
### 6. Database Security
|
||||||
|
- ✅ Drizzle ORM kullanımı (SQL injection koruması)
|
||||||
|
- ✅ Parameterized queries
|
||||||
|
- ✅ Foreign key constraints
|
||||||
|
|
||||||
|
### 7. Environment Variables
|
||||||
|
- ✅ Hassas bilgiler environment variable'larda
|
||||||
|
- ✅ .env.example dosyası oluşturuldu
|
||||||
|
- ✅ .gitignore'da .env dosyaları ignore ediliyor
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] `BETTER_AUTH_SECRET` güçlü bir secret olarak ayarlanmalı (min 32 karakter)
|
||||||
|
- [ ] `BETTER_AUTH_URL` production domain'i ile güncellenmeli
|
||||||
|
- [ ] `DATABASE_URL` production veritabanı bağlantısı ile güncellenmeli
|
||||||
|
- [ ] HTTPS kullanılmalı (production'da)
|
||||||
|
- [ ] Rate limiting eklenmeli (opsiyonel, yüksek trafik için)
|
||||||
|
- [ ] Monitoring ve logging kurulumu yapılmalı
|
||||||
|
- [ ] Regular backup stratejisi oluşturulmalı
|
||||||
|
|
||||||
|
## Notlar
|
||||||
|
|
||||||
|
- File upload limiti 10MB olarak ayarlanmıştır. Gerekirse artırılabilir.
|
||||||
|
- Tüm hata mesajları generic olarak döndürülmektedir (hassas bilgi sızıntısını önlemek için).
|
||||||
|
- Console.log ifadeleri production'dan kaldırılmıştır, ancak geliştirme ortamında gerekirse eklenebilir.
|
||||||
76
SETUP.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Kurulum Talimatları
|
||||||
|
|
||||||
|
Bu proje Next.js, Better Auth ve Drizzle ORM ile PostgreSQL entegrasyonu içerir.
|
||||||
|
|
||||||
|
## Gereksinimler
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL veritabanı
|
||||||
|
- npm veya yarn
|
||||||
|
|
||||||
|
## Kurulum Adımları
|
||||||
|
|
||||||
|
### 1. Bağımlılıkları Yükleyin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# veya
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ortam Değişkenlerini Ayarlayın
|
||||||
|
|
||||||
|
Proje kök dizininde `.env` dosyası oluşturun:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||||
|
BETTER_AUTH_SECRET=your-secret-key-here-minimum-32-characters
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
REGISTER_ENABLE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Önemli:**
|
||||||
|
- `BETTER_AUTH_SECRET` en az 32 karakter uzunluğunda güvenli bir rastgele string olmalıdır.
|
||||||
|
- `REGISTER_ENABLE` kayıt sayfasını açıp kapatmak için kullanılır. `true` veya `false` değeri alabilir. Varsayılan olarak `true`'dur.
|
||||||
|
|
||||||
|
### 3. Veritabanı Şemasını Oluşturun
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:push
|
||||||
|
# veya
|
||||||
|
yarn db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Bu komut, `db/schema.ts` dosyasındaki şemaya göre veritabanı tablolarını oluşturacaktır.
|
||||||
|
|
||||||
|
### 4. Geliştirme Sunucusunu Başlatın
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# veya
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Tarayıcınızda [http://localhost:3000](http://localhost:3000) adresine gidin.
|
||||||
|
|
||||||
|
## Kullanım
|
||||||
|
|
||||||
|
1. **Kayıt Ol:** `/register` sayfasından yeni bir hesap oluşturun
|
||||||
|
2. **Giriş Yap:** `/login` sayfasından giriş yapın
|
||||||
|
3. **Profil:** Giriş yaptıktan sonra `/profile` sayfasında kullanıcı bilgilerinizi görüntüleyin
|
||||||
|
|
||||||
|
## Veritabanı Komutları
|
||||||
|
|
||||||
|
- `npm run db:generate` - Migration dosyalarını oluşturur
|
||||||
|
- `npm run db:push` - Şemayı veritabanına uygular
|
||||||
|
- `npm run db:studio` - Drizzle Studio'yu açar (veritabanı görüntüleme aracı)
|
||||||
|
|
||||||
|
## Dosya Yapısı
|
||||||
|
|
||||||
|
- `db.ts` - Drizzle veritabanı bağlantısı
|
||||||
|
- `db/schema.ts` - Veritabanı şema tanımları
|
||||||
|
- `app/lib/auth.ts` - Better Auth yapılandırması
|
||||||
|
- `app/api/auth/[...all]/route.ts` - Better Auth API route handler
|
||||||
|
- `app/login/page.tsx` - Giriş sayfası
|
||||||
|
- `app/register/page.tsx` - Kayıt sayfası
|
||||||
|
- `app/profile/page.tsx` - Kullanıcı profil sayfası
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 25 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
db.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Create pool - DATABASE_URL check will happen at runtime when pool is actually used
|
||||||
|
// During build time, this won't fail even if DATABASE_URL is not set
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || "postgresql://localhost:5432/temp",
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection on startup
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
console.error("Unexpected error on idle client", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate DATABASE_URL at runtime (not during build)
|
||||||
|
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) {
|
||||||
|
console.warn("WARNING: DATABASE_URL is not set. Database operations will fail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = drizzle(pool);
|
||||||
90
db/schema.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// Roles enum
|
||||||
|
export const roleEnum = ["user", "admin", "moderator"] as const;
|
||||||
|
export type UserRole = typeof roleEnum[number];
|
||||||
|
|
||||||
|
// Better-auth requires these tables
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||||
|
image: text("image"),
|
||||||
|
role: text("role").notNull().default("user"), // user, admin, moderator
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const session = pgTable("session", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
ipAddress: text("ipAddress"),
|
||||||
|
userAgent: text("userAgent"),
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const account = pgTable("account", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("accountId").notNull(),
|
||||||
|
providerId: text("providerId").notNull(),
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("accessToken"),
|
||||||
|
refreshToken: text("refreshToken"),
|
||||||
|
idToken: text("idToken"),
|
||||||
|
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
|
||||||
|
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verification = pgTable("verification", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: timestamp("expiresAt").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const images = pgTable("images", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
originalName: text("originalName").notNull(),
|
||||||
|
fileName: text("fileName").notNull(),
|
||||||
|
filePath: text("filePath").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
width: integer("width"),
|
||||||
|
height: integer("height"),
|
||||||
|
quality: integer("quality"),
|
||||||
|
format: text("format").notNull(),
|
||||||
|
fileSize: integer("fileSize").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Keys for external applications
|
||||||
|
export const apiKeys = pgTable("apiKeys", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(), // API key ismi (örn: "Mobile App", "Dashboard")
|
||||||
|
key: text("key").notNull().unique(), // API key
|
||||||
|
lastUsedAt: timestamp("lastUsedAt"),
|
||||||
|
expiresAt: timestamp("expiresAt"), // null = süresiz
|
||||||
|
isActive: boolean("isActive").notNull().default(true),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
image-apiv2:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: image-apiv2
|
||||||
|
user: "1001:1001" # nextjs user (uid:gid)
|
||||||
|
ports:
|
||||||
|
- "3151:3000"
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
|
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
- REGISTER_ENABLE=${REGISTER_ENABLE:-true}
|
||||||
|
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
|
||||||
|
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||||
|
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||||
|
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
|
||||||
|
- R2_PUBLIC_URL=${R2_PUBLIC_URL}
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
# Uploads klasörünü persist et
|
||||||
|
- uploads_data:/app/public/uploads
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
uploads_data:
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
# Opsiyonel: PostgreSQL ile birlikte kullanmak için
|
||||||
|
# depends_on:
|
||||||
|
# - postgres
|
||||||
|
|
||||||
|
# Opsiyonel: PostgreSQL veritabanı
|
||||||
|
# postgres:
|
||||||
|
# image: postgres:16-alpine
|
||||||
|
# container_name: image-api-db
|
||||||
|
# environment:
|
||||||
|
# - POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
|
||||||
|
# - POSTGRES_DB=${POSTGRES_DB:-image_api}
|
||||||
|
# volumes:
|
||||||
|
# - postgres_data:/var/lib/postgresql/data
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
# volumes:
|
||||||
|
# postgres_data:
|
||||||
9
docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Uploads klasörünü oluştur ve izinleri ayarla (volume mount için)
|
||||||
|
mkdir -p /app/public/uploads
|
||||||
|
chmod -R 775 /app/public/uploads
|
||||||
|
|
||||||
|
# Uygulamayı başlat
|
||||||
|
exec node server.js
|
||||||
13
drizzle.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
81
drizzle/0000_perpetual_alice.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"accountId" text NOT NULL,
|
||||||
|
"providerId" text NOT NULL,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
"accessToken" text,
|
||||||
|
"refreshToken" text,
|
||||||
|
"idToken" text,
|
||||||
|
"accessTokenExpiresAt" timestamp,
|
||||||
|
"refreshTokenExpiresAt" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "apiKeys" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"lastUsedAt" timestamp,
|
||||||
|
"expiresAt" timestamp,
|
||||||
|
"isActive" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "apiKeys_key_unique" UNIQUE("key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "images" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
"originalName" text NOT NULL,
|
||||||
|
"fileName" text NOT NULL,
|
||||||
|
"filePath" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"quality" integer,
|
||||||
|
"format" text NOT NULL,
|
||||||
|
"fileSize" integer NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"userAgent" text,
|
||||||
|
"userId" text NOT NULL,
|
||||||
|
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"emailVerified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "apiKeys" ADD CONSTRAINT "apiKeys_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "images" ADD CONSTRAINT "images_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1
drizzle/0001_harsh_morbius.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;
|
||||||
527
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
{
|
||||||
|
"id": "e57a66f4-9087-4f42-843d-86044c7d703b",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"accountId": {
|
||||||
|
"name": "accountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"providerId": {
|
||||||
|
"name": "providerId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"name": "accessToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refreshToken": {
|
||||||
|
"name": "refreshToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"idToken": {
|
||||||
|
"name": "idToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"accessTokenExpiresAt": {
|
||||||
|
"name": "accessTokenExpiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refreshTokenExpiresAt": {
|
||||||
|
"name": "refreshTokenExpiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.apiKeys": {
|
||||||
|
"name": "apiKeys",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"name": "lastUsedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"name": "isActive",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"apiKeys_userId_user_id_fk": {
|
||||||
|
"name": "apiKeys_userId_user_id_fk",
|
||||||
|
"tableFrom": "apiKeys",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"apiKeys_key_unique": {
|
||||||
|
"name": "apiKeys_key_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.images": {
|
||||||
|
"name": "images",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"originalName": {
|
||||||
|
"name": "originalName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"name": "fileName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"filePath": {
|
||||||
|
"name": "filePath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"name": "width",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"name": "height",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"name": "quality",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"name": "format",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fileSize": {
|
||||||
|
"name": "fileSize",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"images_userId_user_id_fk": {
|
||||||
|
"name": "images_userId_user_id_fk",
|
||||||
|
"tableFrom": "images",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"ipAddress": {
|
||||||
|
"name": "ipAddress",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"userAgent": {
|
||||||
|
"name": "userAgent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
534
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
{
|
||||||
|
"id": "fc4f3c3d-f7b9-4e17-b7dc-88e2944f9303",
|
||||||
|
"prevId": "e57a66f4-9087-4f42-843d-86044c7d703b",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"accountId": {
|
||||||
|
"name": "accountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"providerId": {
|
||||||
|
"name": "providerId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"name": "accessToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refreshToken": {
|
||||||
|
"name": "refreshToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"idToken": {
|
||||||
|
"name": "idToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"accessTokenExpiresAt": {
|
||||||
|
"name": "accessTokenExpiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refreshTokenExpiresAt": {
|
||||||
|
"name": "refreshTokenExpiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.apiKeys": {
|
||||||
|
"name": "apiKeys",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"name": "lastUsedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"name": "isActive",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"apiKeys_userId_user_id_fk": {
|
||||||
|
"name": "apiKeys_userId_user_id_fk",
|
||||||
|
"tableFrom": "apiKeys",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"apiKeys_key_unique": {
|
||||||
|
"name": "apiKeys_key_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.images": {
|
||||||
|
"name": "images",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"originalName": {
|
||||||
|
"name": "originalName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"name": "fileName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"filePath": {
|
||||||
|
"name": "filePath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"name": "width",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"name": "height",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"name": "quality",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"name": "format",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fileSize": {
|
||||||
|
"name": "fileSize",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"images_userId_user_id_fk": {
|
||||||
|
"name": "images_userId_user_id_fk",
|
||||||
|
"tableFrom": "images",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"ipAddress": {
|
||||||
|
"name": "ipAddress",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"userAgent": {
|
||||||
|
"name": "userAgent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"name": "expiresAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767680035227,
|
||||||
|
"tag": "0000_perpetual_alice",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767682768043,
|
||||||
|
"tag": "0001_harsh_morbius",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
BIN
image_api.sql
Normal file
35
make-admin.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Admin kullanıcı oluşturma scripti
|
||||||
|
import { db } from "./db";
|
||||||
|
import { user } from "./db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
async function makeAdmin() {
|
||||||
|
const email = process.argv[2];
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.error("Kullanım: tsx make-admin.mjs email@example.com");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.update(user)
|
||||||
|
.set({ role: "admin" })
|
||||||
|
.where(eq(user.email, email))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.error(`❌ ${email} bulunamadı`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${email} artık admin!`);
|
||||||
|
console.log(result[0]);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Hata:", error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeAdmin();
|
||||||
115
nemoriebank.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Cline's Memory Bank
|
||||||
|
|
||||||
|
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
||||||
|
|
||||||
|
## Memory Bank Structure
|
||||||
|
|
||||||
|
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
||||||
|
|
||||||
|
flowchart TD
|
||||||
|
PB[projectbrief.md] --> PC[productContext.md]
|
||||||
|
PB --> SP[systemPatterns.md]
|
||||||
|
PB --> TC[techContext.md]
|
||||||
|
|
||||||
|
PC --> AC[activeContext.md]
|
||||||
|
SP --> AC
|
||||||
|
TC --> AC
|
||||||
|
|
||||||
|
AC --> P[progress.md]
|
||||||
|
|
||||||
|
### Core Files (Required)
|
||||||
|
1. `projectbrief.md`
|
||||||
|
- Foundation document that shapes all other files
|
||||||
|
- Created at project start if it doesn't exist
|
||||||
|
- Defines core requirements and goals
|
||||||
|
- Source of truth for project scope
|
||||||
|
|
||||||
|
2. `productContext.md`
|
||||||
|
- Why this project exists
|
||||||
|
- Problems it solves
|
||||||
|
- How it should work
|
||||||
|
- User experience goals
|
||||||
|
|
||||||
|
3. `activeContext.md`
|
||||||
|
- Current work focus
|
||||||
|
- Recent changes
|
||||||
|
- Next steps
|
||||||
|
- Active decisions and considerations
|
||||||
|
- Important patterns and preferences
|
||||||
|
- Learnings and project insights
|
||||||
|
|
||||||
|
4. `systemPatterns.md`
|
||||||
|
- System architecture
|
||||||
|
- Key technical decisions
|
||||||
|
- Design patterns in use
|
||||||
|
- Component relationships
|
||||||
|
- Critical implementation paths
|
||||||
|
|
||||||
|
5. `techContext.md`
|
||||||
|
- Technologies used
|
||||||
|
- Development setup
|
||||||
|
- Technical constraints
|
||||||
|
- Dependencies
|
||||||
|
- Tool usage patterns
|
||||||
|
|
||||||
|
6. `progress.md`
|
||||||
|
- What works
|
||||||
|
- What's left to build
|
||||||
|
- Current status
|
||||||
|
- Known issues
|
||||||
|
- Evolution of project decisions
|
||||||
|
|
||||||
|
### Additional Context
|
||||||
|
Create additional files/folders within memory-bank/ when they help organize:
|
||||||
|
- Complex feature documentation
|
||||||
|
- Integration specifications
|
||||||
|
- API documentation
|
||||||
|
- Testing strategies
|
||||||
|
- Deployment procedures
|
||||||
|
|
||||||
|
## Core Workflows
|
||||||
|
|
||||||
|
### Plan Mode
|
||||||
|
flowchart TD
|
||||||
|
Start[Start] --> ReadFiles[Read Memory Bank]
|
||||||
|
ReadFiles --> CheckFiles{Files Complete?}
|
||||||
|
|
||||||
|
CheckFiles -->|No| Plan[Create Plan]
|
||||||
|
Plan --> Document[Document in Chat]
|
||||||
|
|
||||||
|
CheckFiles -->|Yes| Verify[Verify Context]
|
||||||
|
Verify --> Strategy[Develop Strategy]
|
||||||
|
Strategy --> Present[Present Approach]
|
||||||
|
|
||||||
|
### Act Mode
|
||||||
|
flowchart TD
|
||||||
|
Start[Start] --> Context[Check Memory Bank]
|
||||||
|
Context --> Update[Update Documentation]
|
||||||
|
Update --> Execute[Execute Task]
|
||||||
|
Execute --> Document[Document Changes]
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
Memory Bank updates occur when:
|
||||||
|
1. Discovering new project patterns
|
||||||
|
2. After implementing significant changes
|
||||||
|
3. When user requests with **update memory bank** (MUST review ALL files)
|
||||||
|
4. When context needs clarification
|
||||||
|
|
||||||
|
flowchart TD
|
||||||
|
Start[Update Process]
|
||||||
|
|
||||||
|
subgraph Process
|
||||||
|
P1[Review ALL Files]
|
||||||
|
P2[Document Current State]
|
||||||
|
P3[Clarify Next Steps]
|
||||||
|
P4[Document Insights & Patterns]
|
||||||
|
|
||||||
|
P1 --> P2 --> P3 --> P4
|
||||||
|
end
|
||||||
|
|
||||||
|
Start --> Process
|
||||||
|
|
||||||
|
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
|
||||||
|
|
||||||
|
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||||
44
next.config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-DNS-Prefetch-Control",
|
||||||
|
value: "on",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Strict-Transport-Security",
|
||||||
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "SAMEORIGIN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-XSS-Protection",
|
||||||
|
value: "1; mode=block",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=()",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "image-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.965.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-auth": "^1.4.10",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"next": "16.1.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"sweetalert2": "^11.26.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/uploads/G2IZKMVncbgNhi9rU1bon.avif
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/uploads/UTwr4wJNmDaIHgmovLogH.avif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/_u1OsfM__88agO_goDnRz.jpg
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/uploads/diQ-g_qzoYCI4GuOiNny6.avif
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
public/uploads/kGGOVikvdoUO7F7ci0MLx.avif
Normal file
|
After Width: | Height: | Size: 43 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
9
r2-cors.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
3
sadece login olmus userlerin giris yapab.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
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.
|
||||||
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||