first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:09:32 +03:00
commit 71eff2d979
78 changed files with 10173 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*

14
.env Normal file
View File

@@ -0,0 +1,14 @@
#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

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# 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://image.beyhano.com.tr
# JWT Secret
JWT_SECRET=your-jwt-secret-key-min-32-characters-long

14
.env.local Normal file
View File

@@ -0,0 +1,14 @@
#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

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

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

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ı

401
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,401 @@
"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 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>("");
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 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">
<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>
</td>
</tr>
))}
</tbody>
</table>
</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>
);
}

279
app/api-docs/page.tsx Normal file
View File

@@ -0,0 +1,279 @@
"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">
API, JWT token tabanlı kimlik doğrulama kullanır. Her istekte Authorization header'ında Bearer token gönderilmelidir:
</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;your_jwt_token&gt;
</code>
</div>
</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>
{/* 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 { unlink } from "fs/promises";
import { join } from "path";
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];
// Dosyayı sil
try {
await unlink(imageData.filePath);
} catch (error) {
// Dosya bulunamazsa devam et (log production'da kaldırıldı)
}
// 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 }
);
}
}

77
app/api/images/route.ts Normal file
View File

@@ -0,0 +1,77 @@
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;
}
}
function getBaseUrl(request: NextRequest): string {
// First, check environment variables (production should set this)
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (process.env.APP_URL) {
return process.env.APP_URL;
}
// Check for reverse proxy headers (X-Forwarded-Host, X-Forwarded-Proto)
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
// Fallback to request origin
return request.nextUrl.origin;
}
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));
// Get base URL
const baseUrl = getBaseUrl(request);
return NextResponse.json({
images: userImages.map((img) => ({
id: img.id,
originalName: img.originalName,
url: `${baseUrl}${img.url}`,
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,196 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import sharp from "sharp";
import { db } from "@/db";
import { images } from "@/db/schema";
import { nanoid } from "nanoid";
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;
}
}
function getBaseUrl(request: NextRequest): string {
// First, check environment variables (production should set this)
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (process.env.APP_URL) {
return process.env.APP_URL;
}
// Check for reverse proxy headers (X-Forwarded-Host, X-Forwarded-Proto)
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
// Fallback to request origin
return request.nextUrl.origin;
}
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}`;
// Uploads klasörünü oluştur
// Docker volume mount: /app/public/uploads
// Standalone build'de process.cwd() = /app olmalı
const uploadsDir = join(process.cwd(), "public", "uploads");
try {
await mkdir(uploadsDir, { recursive: true });
} catch (mkdirError: any) {
console.error("Uploads klasörü oluşturulamadı:", mkdirError);
console.error("Klasör yolu:", uploadsDir);
console.error("Current working directory:", process.cwd());
throw new Error(`Uploads klasörü oluşturulamadı: ${mkdirError.message}`);
}
// Dosyayı kaydet
const filePath = join(uploadsDir, fileName);
try {
await writeFile(filePath, processedImage);
} catch (writeError: any) {
console.error("Dosya yazılamadı:", writeError);
console.error("Dosya yolu:", filePath);
console.error("Dosya boyutu:", processedImage.length);
throw new Error(`Dosya yazılamadı: ${writeError.message}`);
}
// Veritabanına kaydet
const imageUrl = `/uploads/${fileName}`;
const imageId = nanoid();
await db.insert(images).values({
id: imageId,
userId,
originalName,
fileName,
filePath: filePath,
url: imageUrl,
width: metadata.width || null,
height: metadata.height || null,
quality,
format: normalizedFormat,
fileSize: processedImage.length,
});
// Get base URL
const baseUrl = getBaseUrl(request);
const fullImageUrl = `${baseUrl}${imageUrl}`;
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,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,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 { unlink } from "fs/promises";
/**
* 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 }
);
}
// Dosyayı sil
try {
await unlink(image.filePath);
} catch (fileError) {
console.error("Dosya silinemedi:", 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,88 @@
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));
}
// Base URL'i al
const baseUrl = getBaseUrl(request);
return NextResponse.json({
success: true,
data: {
images: userImages.map((img) => ({
id: img.id,
originalName: img.originalName,
url: `${baseUrl}${img.url}`,
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 }
);
}
}
function getBaseUrl(request: NextRequest): string {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (process.env.APP_URL) {
return process.env.APP_URL;
}
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return request.nextUrl.origin;
}

View File

@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import { authenticateAPIRequest } from "@/app/lib/api-auth";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import sharp from "sharp";
import { db } from "@/db";
import { images } from "@/db/schema";
import { nanoid } from "nanoid";
/**
* 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}`;
const uploadsDir = join(process.cwd(), "public", "uploads");
try {
await mkdir(uploadsDir, { recursive: true });
} catch (mkdirError: any) {
console.error("Uploads klasörü oluşturulamadı:", mkdirError);
throw new Error(`Uploads klasörü oluşturulamadı: ${mkdirError.message}`);
}
const filePath = join(uploadsDir, fileName);
try {
await writeFile(filePath, processedImage);
} catch (writeError: any) {
console.error("Dosya yazılamadı:", writeError);
throw new Error(`Dosya yazılamadı: ${writeError.message}`);
}
// Veritabanına kaydet
const imageUrl = `/uploads/${fileName}`;
const imageId = nanoid();
await db.insert(images).values({
id: imageId,
userId: auth.userId!,
originalName,
fileName,
filePath: filePath,
url: imageUrl,
width: metadata.width || null,
height: metadata.height || null,
quality,
format: normalizedFormat,
fileSize: processedImage.length,
});
// Base URL
const baseUrl = getBaseUrl(request);
const fullImageUrl = `${baseUrl}${imageUrl}`;
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 }
);
}
}
function getBaseUrl(request: NextRequest): string {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (process.env.APP_URL) {
return process.env.APP_URL;
}
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return request.nextUrl.origin;
}

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

170
app/lib/api-auth.ts Normal file
View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } 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";
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;
*/
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.",
};
}
}

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

48
app/lib/jwt.ts Normal file
View File

@@ -0,0 +1,48 @@
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
}

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

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

177
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } 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;
}
export default function ProfilePage() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
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]);
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>
</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(),
});

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
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}
- 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;

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"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": {
"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

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

3299
yarn.lock Normal file

File diff suppressed because it is too large Load Diff