first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:11:03 +03:00
commit 031582ea2c
98 changed files with 13281 additions and 0 deletions

22
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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ıı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
View 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
View 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 &lt;jwt_token_veya_img_..._api_key&gt;
</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>
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
registerEnabled: process.env.REGISTER_ENABLE === "true",
});
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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; 13650: 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,
},
});
}

View 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(),
};
}),
},
});
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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.",
});
}

View 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; 13650: 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),
},
});
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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)}`;
}

View 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
View 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
View 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
View 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
View 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
View 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&apos;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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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!,
},
});

View 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;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;

View 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": {}
}
}

View 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": {}
}
}

View 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
View 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

Binary file not shown.

35
make-admin.ts Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

1
public/vercel.svg Normal file
View 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
View 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
View File

@@ -0,0 +1,9 @@
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]

View 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
View 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"]
}

5008
yarn.lock Normal file

File diff suppressed because it is too large Load Diff