main
All checks were successful
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s

This commit is contained in:
2026-03-06 15:44:44 +03:00
parent 3aa07d096f
commit ce7702b1cb
43 changed files with 4279 additions and 78 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,5 +1,5 @@
name: UI Deploy (Next-Auth Support) 🎨
run-name: ${{ gitea.actor }} frontend güncelliyor...
name: UI Deploy - indir.bilgich.com 🎨
run-name: ${{ gitea.actor }} indir-fe güncelliyor...
on:
push:
@@ -19,19 +19,19 @@ jobs:
--build-arg NEXT_PUBLIC_API_URL='${{ secrets.NEXT_PUBLIC_API_URL }}' \
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}' \
--build-arg NEXT_PUBLIC_GOOGLE_API_KEY='${{ secrets.NEXT_PUBLIC_GOOGLE_API_KEY }}' \
-t frontend-proje:latest .
-t ui-indir:latest .
- name: Eski Konteyneri Sil
run: docker rm -f frontend-container || true
run: docker rm -f ui-indir-container || true
- name: Yeni Versiyonu Başlat
# Sunucu tarafında (Server-side/Auth) lazım olanları run anında veriyoruz
run: |
docker run -d \
--name frontend-container \
--name ui-indir-container \
--restart always \
--network gitea-server_gitea \
-p 1800:3000 \
-p 1507:3000 \
-e NEXTAUTH_SECRET='${{ secrets.NEXTAUTH_SECRET }}' \
-e NEXTAUTH_URL='${{ secrets.NEXTAUTH_URL }}' \
frontend-proje:latest
ui-indir:latest

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": [
"messages",
"src/i18n"
]
}

View File

@@ -0,0 +1,169 @@
# AI Session Log - 2026-03-05
## Overview
Bu oturumda sosyal medya video indirici özelliği tasarlandı ve implemente edildi.
## 1. Mimari Planlama
**İstek:** Sosyal medya platformlarından (YouTube, Instagram, TikTok, X, Facebook) video indirme özelliği.
**Kararlar:**
- Next.js API Routes kullanıldı (backend ayrı servis gereksinimi yok)
- ~~Cobalt API (public instance) entegrasyonu~~ → Cobalt API v7 Kasım 2024'te kapatıldı
- **ab-downloader npm paketi** entegre edildi (kullanıcının isteği üzerine)
- Auth sistemi gereksiz (basit, herkes kullanabilir)
- IP-based rate limiting (abuse önleme)
**Plan Dosyası:** `plans/social-media-downloader-plan.md`
## 2. Oluşturulan Dosyalar
### Types
- `src/types/download.ts` - TypeScript tip tanımları
- `src/types/ab-downloader.d.ts` - ab-downloader için type declarations
### Backend (API Routes)
- `src/app/api/download/route.ts` - Ana download endpoint
### Utilities
- `src/lib/download/platform-detector.ts` - URL'den platform tespiti
- `src/lib/download/downloader.ts` - ab-downloader wrapper (platform-specific fonksiyonlar)
- `src/lib/security/url-validator.ts` - URL validation ve sanitization
- `src/lib/security/rate-limiter.ts` - IP-based rate limiting
### Frontend Components
- `src/components/site/download/download-form.tsx` - URL input form
- `src/components/site/download/download-result.tsx` - Sonuç görüntüleme
- `src/app/[locale]/page.tsx` - Ana sayfa (download formu içeriyor)
### i18n
- `messages/en.json` - İngilizce çeviriler (download bölümü)
- `messages/tr.json` - Türkçe çeviriler (download bölümü)
### Navigation
- `src/config/navigation.ts` - Basitleştirildi (sadece home linki)
- `src/proxy.ts` - Auth gereksinimi kaldırıldı (herkes erişebilir)
## 3. ab-downloader Entegrasyonu
### Paket Bilgisi
- **Paket:** `ab-downloader`
- **Kaynak:** https://www.npmjs.com/package/ab-downloader
- **Versiyon:** 5.0.0
### Desteklenen Platformlar
| Platform | Fonksiyon | Notlar |
|----------|-----------|--------|
| Instagram | `igdl()` | Array döndürür |
| YouTube | `youtube()` | - |
| TikTok | `ttdl()` | - |
| Twitter/X | `twitter()` | - |
| Facebook | `fbdown()` | - |
| Generic | `aio()` | Auto-detect |
### Response Format
```typescript
interface DownloadResult {
url: string; // Direct download URL
title?: string; // Video title
thumbnail?: string; // Thumbnail URL
developer?: string; // "AbroCodes"
contactme?: string; // "Telegram: abrocodes"
}
```
### Örnek Kullanım
```typescript
import { igdl } from 'ab-downloader';
const result = await igdl('https://www.instagram.com/reel/xxx/');
// result bir array: [{ url, thumbnail, ... }]
const downloadUrl = result[0].url;
```
## 4. API Endpoint
### POST /api/download
**Request:**
```json
{
"url": "https://www.instagram.com/reel/xxx/"
}
```
**Response (Success):**
```json
{
"success": true,
"data": {
"downloadUrl": "https://d.rapidcdn.app/...",
"filename": "instagram_1772720934409.mp4",
"platform": "instagram",
"thumbnail": "https://d.rapidcdn.app/thumb?..."
}
}
```
**Response (Error):**
```json
{
"success": false,
"error": {
"code": "error.download.failed",
"message": "Video indirilemedi"
}
}
```
## 5. Güvenlik Önlemleri
- **URL Validation:** Sadece desteklenen platformlardan URL'lere izin verilir
- **Rate Limiting:** IP başına 10 istek/saat limiti
- **HTTPS Only:** Sadece HTTPS URL'ler kabul edilir
- **Request Timeout:** 30 saniye timeout
## 6. Test Sonuçları
### Instagram Test
```bash
curl -X POST http://localhost:3001/api/download \
-H "Content-Type: application/json" \
-d '{"url":"https://www.instagram.com/reel/DVejgkGkUPU/"}'
```
**Sonuç:** ✅ Başarılı - Download URL ve thumbnail döndü
## 7. Önemli Notlar
1. **Cobalt API Kapatıldı:** Cobalt API v7 Kasım 2024'te kapatıldığı için `ab-downloader` paketine geçildi
2. **Array Response:** `igdl()` fonksiyonu array döndürüyor, bu yüzden `getFirstResult()` helper fonksiyonu kullanılıyor
3. **Type Declarations:** Paket TypeScript support içermiyor, bu yüzden `src/types/ab-downloader.d.ts` oluşturuldu
## 8. Gelecek İyileştirmeler
1. Video kalite seçimi
2. Sadece ses indirme seçeneği
3. Download progress indicator
4. Video thumbnail gösterimi (önbellek ile)
5. Çoklu dil desteği genişletme
## Summary
Sosyal medya indirici özelliği başarıyla implemente edildi. Cobalt API'nin kapatılması nedeniyle `ab-downloader` npm paketi kullanıldı. Instagram videosu başarıyla indirildi ve test edildi. Tüm kod TypeScript ile strict modda yazıldı ve projenin mevcut mimarisine uygun şekilde geliştirildi.

View File

@@ -28,5 +28,28 @@
"name": "Name",
"low": "Low",
"medium": "Medium",
"high": "High"
}
"high": "High",
"download": {
"title": "Video Downloader",
"subtitle": "Download videos from YouTube, Instagram, TikTok, X, and Facebook",
"placeholder": "Paste video URL here...",
"download": "Download",
"downloading": "Downloading...",
"error": "Download Failed",
"unknownError": "An unknown error occurred",
"readyToDownload": "Ready to Download",
"downloadFile": "Download File",
"newDownload": "New Download",
"downloadHint": "Click the button to start downloading. The file will be saved to your downloads folder.",
"supportedPlatforms": "Supported Platforms",
"disclaimer": "Please respect copyright laws. Only download content you have permission to use.",
"errors": {
"urlRequired": "Please enter a URL",
"invalidUrl": "Please enter a valid URL",
"unsupportedPlatform": "This platform is not supported",
"downloadFailed": "Download Failed",
"networkError": "Network Error"
}
},
"predictions": "Predictions"
}

View File

@@ -28,5 +28,28 @@
"name": "İsim",
"low": "Düşük",
"medium": "Orta",
"high": "Yüksek"
}
"high": "Yüksek",
"download": {
"title": "Video İndirici",
"subtitle": "YouTube, Instagram, TikTok, X ve Facebook videolarını indirin",
"placeholder": "Video URL'sini buraya yapıştırın...",
"download": "İndir",
"downloading": "İndiriliyor...",
"error": "İndirme Başarısız",
"unknownError": "Bilinmeyen bir hata oluştu",
"readyToDownload": "İndirmeye Hazır",
"downloadFile": "Dosyayı İndir",
"newDownload": "Yeni İndirme",
"downloadHint": "İndirmeyi başlatmak için butona tıklayın. Dosya indirme klasörünüze kaydedilecek.",
"supportedPlatforms": "Desteklenen Platformlar",
"disclaimer": "Lütfen telif haklarına saygı duyulması gerektiğini unutmayın. Sadece izniniz olan içerikleri indirin.",
"errors": {
"urlRequired": "Lütfen bir URL girin",
"invalidUrl": "Lütfen geçerli bir URL girin",
"unsupportedPlatform": "Bu platform desteklenmiyor",
"downloadFailed": "İndirme Başarısız",
"networkError": "Ağ Hatası"
}
},
"predictions": "Tahminler"
}

View File

@@ -3,6 +3,7 @@ import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ["cheerio", "@vreden/youtube_scraper", "yt-search"],
experimental: {
optimizePackageImports: ["@chakra-ui/react"],
},

175
openclaw_setup_summary.json Normal file
View File

@@ -0,0 +1,175 @@
{
"project": "OpenClaw - Kişisel AI Asistanı",
"date": "2026-03-05",
"server": {
"hostname": "Raspberry Pi",
"ip": "95.70.252.214",
"ssh_port": 2222,
"ssh_user": "haruncan",
"ssh_command": "ssh -p 2222 haruncan@95.70.252.214"
},
"storage": {
"sd_card": {
"mount": "/",
"size": "57GB",
"used": "25GB",
"available": "30GB",
"usage_percent": "46%"
},
"external_disk": {
"mount": "/mnt/extreme",
"device": "/dev/sda1",
"filesystem": "ext4",
"size": "916GB",
"used": "15GB",
"available": "856GB",
"usage_percent": "2%"
}
},
"services": {
"openclaw": {
"name": "Open WebUI",
"container_name": "openclaw",
"image": "ghcr.io/open-webui/open-webui:main",
"port": 3001,
"status": "running",
"url_local": "http://127.0.0.1:3001",
"url_domain": "http://ai.bilgich.com",
"description": "ChatGPT benzeri web arayüzü"
},
"ollama": {
"port": 11434,
"api_url": "http://127.0.0.1:11434",
"models": [
"llama3.2:3b"
],
"models_path": "/mnt/extreme/ollama",
"status": "running"
},
"gitea": {
"url": "http://gitea.bilgich.com",
"port": 1224,
"ssh_port": 222,
"status": "running"
},
"portainer": {
"url": "http://95.70.252.214:9000",
"port": 9000,
"description": "Docker yönetim arayüzü"
},
"postgres": {
"gitea_db": "postgres:14",
"backend_db": "postgres:16-alpine"
},
"redis": {
"container": "apps_redis",
"image": "redis:alpine"
}
},
"nginx": {
"config_path": "/etc/nginx/sites-available/openclaw.conf",
"server_name": "ai.bilgich.com",
"proxy_pass": "http://127.0.0.1:3001",
"ssl": false,
"ssl_note": "DNS kaydı eksik olduğu için Let's Encrypt sertifikası alınamadı"
},
"docker_containers": [
{
"name": "openclaw",
"status": "Up 27 minutes (healthy)",
"ports": "3001:8080"
},
{
"name": "gitea",
"status": "Up 42 minutes",
"ports": "222:22, 1224:3000"
},
{
"name": "gitea_db",
"status": "Up 42 minutes",
"ports": "5432"
},
{
"name": "backend_db",
"status": "Up 42 minutes",
"ports": "5432"
},
{
"name": "apps_redis",
"status": "Up 42 minutes",
"ports": "6379"
},
{
"name": "portainer",
"status": "Up 42 minutes",
"ports": "9000:9000"
},
{
"name": "gitea_runner",
"status": "Up About an hour",
"ports": "-"
},
{
"name": "ui-skript-container",
"status": "Up About an hour",
"ports": "1506:3000"
},
{
"name": "backend-skript-container",
"status": "Up About an hour",
"ports": "1806:3000"
},
{
"name": "backend-digicraft-container",
"status": "Up About an hour",
"ports": "1805:3001"
},
{
"name": "ui-digicraft-container",
"status": "Up About an hour",
"ports": "1505:80"
},
{
"name": "frontend-container",
"status": "Up About an hour",
"ports": "1800:3000"
}
],
"data_paths": {
"docker_data": "/mnt/extreme/docker-data",
"docker_config": "/mnt/extreme/docker",
"projects": "/mnt/extreme/projects",
"ollama": "/mnt/extreme/ollama",
"openclaw_data": "/mnt/extreme/open-webui/data"
},
"pending_tasks": {
"dns_record": {
"description": "ai.bilgich.com için DNS A kaydı eklenmeli",
"type": "A",
"name": "ai",
"value": "95.70.252.214"
},
"ssl_certificate": {
"description": "DNS kaydı eklendikten sonra Let's Encrypt sertifikası alınmalı",
"command": "sudo certbot --nginx -d ai.bilgich.com"
}
},
"useful_commands": {
"openclaw_logs": "docker logs openclaw --tail 50",
"openclaw_restart": "docker restart openclaw",
"ollama_models": "ollama list",
"ollama_pull_model": "ollama pull <model_name>",
"docker_ps": "docker ps",
"nginx_test": "sudo nginx -t",
"nginx_reload": "sudo systemctl reload nginx",
"system_resources": "free -h && df -h"
},
"first_time_setup": {
"step1": "DNS A kaydı ekle: ai -> 95.70.252.214",
"step2": "DNS propagation bekle (5-10 dakika)",
"step3": "http://ai.bilgich.com adresine git",
"step4": "İlk hesabı oluştur (admin)",
"step5": "Model otomatik llama3.2:3b olacak",
"step6": "Kullanıma hazır!"
}
}

377
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.16",
"ab-downloader": "^1.0.2",
"axios": "^1.13.1",
"i18next": "^25.6.0",
"next": "16.0.0",
@@ -20,10 +21,14 @@
"next-intl": "^4.4.0",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"ofetch": "^1.5.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"snapsave-media-downloader": "^2.4.0",
"test-downloader": "^1.0.5",
"undici": "^7.22.0",
"yup": "^1.7.1"
},
"devDependencies": {
@@ -146,7 +151,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -556,7 +560,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ark-ui/react": "^5.29.1",
"@emotion/is-prop-valid": "^1.4.0",
@@ -692,7 +695,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1986,7 +1988,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -2063,6 +2064,17 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsdoc/salty": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.10.tgz",
"integrity": "sha512-VFHSsQAQp8y1NJvAJBpLs9I2shHE6hz9TwukocDObuUgGVAq62yZGbTgJg04Z3Fj0XSMWe0sJqGg5dhKGTV92A==",
"dependencies": {
"lodash": "^4.17.23"
},
"engines": {
"node": ">=v12.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2905,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -3067,7 +3078,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3127,7 +3137,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -4549,13 +4558,21 @@
"integrity": "sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==",
"license": "MIT"
},
"node_modules/ab-downloader": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ab-downloader/-/ab-downloader-1.0.2.tgz",
"integrity": "sha512-82wWQhWd2DhO3a7InQKUnv8/X7yUfCJJElYHEn8pTN6h6SX2hW1r3F43fgMyX9Se/x6ERGssOK23f6JRMiP2lQ==",
"dependencies": {
"axios": "^1.4.0",
"docdash": "^2.0.2"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4921,7 +4938,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -4984,6 +5000,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -5028,7 +5049,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5155,6 +5175,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -5301,6 +5361,32 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5452,6 +5538,11 @@
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5461,6 +5552,14 @@
"node": ">=8"
}
},
"node_modules/docdash": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/docdash/-/docdash-2.0.2.tgz",
"integrity": "sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==",
"dependencies": {
"@jsdoc/salty": "^0.2.1"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5474,6 +5573,57 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -5529,6 +5679,29 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -5781,7 +5954,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5983,7 +6155,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6960,6 +7131,35 @@
"react-is": "^16.7.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -7004,6 +7204,17 @@
}
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7745,6 +7956,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7935,7 +8151,6 @@
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "16.0.0",
"@swc/helpers": "0.5.15",
@@ -8146,6 +8361,11 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -8169,6 +8389,17 @@
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
"license": "MIT"
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
@@ -8306,6 +8537,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
"dependencies": {
"destr": "^2.0.5",
"node-fetch-native": "^1.6.7",
"ufo": "^1.6.1"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
@@ -8459,6 +8700,51 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8599,7 +8885,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8747,7 +9032,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8757,7 +9041,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8770,7 +9053,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9045,6 +9327,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -9304,6 +9591,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/snapsave-media-downloader": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/snapsave-media-downloader/-/snapsave-media-downloader-2.4.0.tgz",
"integrity": "sha512-WSaC6rQ0SCCktbZ1i/UAZdZyzcULeq2jibp3YrU3W99rk4xh7c7apGVyzhNb9ksYOtkXZz/1A/5+mjChODGgmg==",
"dependencies": {
"cheerio": "^1.2.0",
"ofetch": "^1.5.1",
"undici": "^7.22.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -9629,6 +9926,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/test-downloader": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/test-downloader/-/test-downloader-1.0.5.tgz",
"integrity": "sha512-SQnzje3VKIobMfmlqenW2GQsskXmgwqceT1sbwW+/7ODCO7h0lkE+AsTy3PXfqOhGTlbWWMB8jIcGvT2KWM+7w==",
"dependencies": {
"axios": "^1.4.0",
"cheerio": "^1.0.0",
"docdash": "^2.0.2"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
@@ -9683,7 +9990,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9864,7 +10170,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9897,6 +10202,11 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9916,6 +10226,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -10049,6 +10367,26 @@
"node": ">= 8"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -10319,7 +10657,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -14,6 +14,7 @@
"@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.16",
"ab-downloader": "^1.0.2",
"axios": "^1.13.1",
"i18next": "^25.6.0",
"next": "16.0.0",
@@ -21,10 +22,14 @@
"next-intl": "^4.4.0",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"ofetch": "^1.5.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"snapsave-media-downloader": "^2.4.0",
"test-downloader": "^1.0.5",
"undici": "^7.22.0",
"yup": "^1.7.1"
},
"devDependencies": {
@@ -41,4 +46,4 @@
"typescript": "^5"
},
"description": "Generated by Frontend CLI"
}
}

8
parse-snapsave.js Normal file
View File

@@ -0,0 +1,8 @@
const url = "https://snapsave.app/render.php?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2aWRlb191cmwiOiJodHRwczovL3ZpZGVvLXpyaDEtMS54eC5mYmNkbi5uZXQvbzEvdi90Mi9mMi9tMzY2L0FRUEE1T3R4MWlRdFl6UzMwTUxhZFJ6ZzFxUkRtSS1DdDhLeHlGYXpaVC1QYjRhRVF1Q3U5eXozakIxSFBqTjg3eTVkX2VoMWd1ekZMVlo1RVZ2TDhFWHp6VzhRb1JQUU5Gd1BwMTgubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9NGtBSjhjMC04Qk1RN2tOdndFLXE2U1YmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZjakpoZGpFdGNqRm5aVzR5ZG5BNVgzRTVNQ0lzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk1UUXpPVEF3TkN3aWRYSnNaMlZ1WDNOdmRYSmpaU0k2SW5kM2R5SjkmY2NiPTE3LTEmX25jX2dpZD1zaVJFVGhySHNIZnhEdW1NZ25aVXdRJl9uY19zcz04Jl9uY196dD0yOCZvaD0wMF9BZnlPcVp0VWxDdjNYcjdCa3lPRzlOdnh6Y1NZNjZsRlBBbTdOYnlGSGJDQmp3Jm9lPTY5QjBCMDlEIiwiYXVkaW9fdXJsIjoiaHR0cHM6Ly92aWRlby16cmgxLTEueHguZmJjZG4ubmV0L28xL3YvdDIvZjIvbTc4L0FRTmRmaExmTWV1Q0pzZnV2T0c0SF8wSzhQY3BSUU1iUDdpVmZYV2FhRmtxVE50eVBQaHBWSllyeEw1elhmU216YlN2OUh3eWJiYVRMdlpQWk55R2QxcnJZQ29iMzctS3NsRmcyN2MubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9U1JobDBDeFE4VW9RN2tOdndHYnlhUXQmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZiRzVmYUdWaFlXTmZkbUp5TTE5aGRXUnBieUlzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk5qWXpPREFzSW5WeWJHZGxibDl6YjNWeVkyVWlPaUozZDNjaWZRJTNEJTNEJmNjYj0xNy0xJl9uY19naWQ9c2lSRVRockhzSGZ4RHVtTWduWlV3USZfbmNfc3M9OCZfbmNfenQ9Mjgmb2g9MDBfQWZ6R0JxM2I3bHpnTGg1Y1poWFpjazRvRU5DNTJHdG5mVTVmbUdUY2FYT0liUSZvZT02OUFDQkQ4NSIsImlkIjoiMTU2ODIyNjA2NzczNjc1NSIsImZpbGVuYW1lIjoiU25hcFNhdmVfQXBwXzE1NjgyMjYwNjc3MzY3NTVfMTA4MHAubXA0In0.3rUj4eUmqKLYJDFl1EfcJBxkZ0KpSlWVQm2JCdxJa0U";
async function run() {
const tokenPayload = url.split("token=")[1].split(".")[1];
const decoded = Buffer.from(tokenPayload, 'base64').toString('utf8');
console.log(JSON.parse(decoded));
}
run();

View File

@@ -0,0 +1,521 @@
# Sosyal Medya İndirici - Mimari Plan
> **Tarih:** 2026-03-05
> **Öncelik:** Yüksek
> **Karmaşıklık:** Düşük-Orta
> **Yaklaşım:** Basit ve Minimal
---
## 📋 Özet
Sosyal medya platformlarından (Instagram, YouTube, TikTok, X, Facebook vb.) video/post indirme özelliği. Kullanıcı bir URL girer, sistem içeriği tespit eder ve indirir.
### Basitleştirilmiş Gereksinimler
- ❌ Auth/giriş sistemi **YOK** (herkes kullanabilir)
- ✅ Basit IP-based rate limiting (abuse önleme)
- ✅ Public Cobalt API kullanımı
- ✅ Minimal ve temiz UI
---
## 🎯 Gereksinimler
### Fonksiyonel Gereksinimler
- URL input ile çoklu platform desteği
- Tek tıkla indirme
- Video kalite seçimi (mümkünse)
- İndirme ilerleme durumu
- Hata yönetimi ve kullanıcı bildirimleri
### Fonksiyonel Olmayan Gereksinimler
- **Güvenlik:** Rate limiting, input validation, abuse prevention
- **Performans:** Async processing, streaming downloads
- **UX:** Responsive design, dark/light mode, i18n support
- **Reliability:** Error recovery, timeout handling
---
## 🏗️ Mimari Tasarım
### High-Level Architecture
```mermaid
flowchart TB
subgraph Frontend[Frontend - Next.js]
UI[Download Page]
Form[URL Input Form]
Progress[Progress Indicator]
Result[Download Result]
end
subgraph API[Next.js API Routes]
Validate[Input Validation]
Detect[Platform Detection]
RateLimit[Rate Limiter]
Download[Download Handler]
end
subgraph External[External Services]
Cobalt[Cobalt API]
YtDlp[yt-dlp wrapper]
Direct[Direct URL Fetch]
end
UI --> Form
Form --> Validate
Validate --> RateLimit
RateLimit --> Detect
Detect --> Download
Download --> Cobalt
Download --> YtDlp
Download --> Direct
Cobalt --> Result
YtDlp --> Result
Direct --> Result
```
---
## 🔐 Güvenlik Katmanları
### 1. Input Validation & Sanitization
```typescript
// src/lib/utils/url-validator.ts
const ALLOWED_DOMAINS = [
"youtube.com",
"youtu.be",
"instagram.com",
"instagr.am",
"tiktok.com",
"vm.tiktok.com",
"twitter.com",
"x.com",
"facebook.com",
"fb.watch",
"vimeo.com",
"pinterest.com",
];
export function validateUrl(url: string): ValidationResult {
// 1. URL format check
// 2. Protocol check (https only)
// 3. Domain whitelist check
// 4. XSS prevention
// 5. SQL injection prevention
}
```
### 2. Rate Limiting
```typescript
// src/lib/security/rate-limiter.ts
const RATE_LIMIT_CONFIG = {
anonymous: { requests: 5, window: 3600 }, // 5 requests/hour
authenticated: { requests: 20, window: 3600 }, // 20 requests/hour
premium: { requests: 100, window: 3600 }, // 100 requests/hour
};
```
**Stratejiler:**
- IP-based limiting (Redis/Memory store)
- User-based limiting (auth token)
- Global rate limit (DDoS prevention)
### 3. Abuse Prevention
- **URL Pattern Blocking:** Malicious patterns engelleme
- **Bot Detection:** User-agent analysis
- **Request Fingerprinting:** Browser fingerprinting
- **CAPTCHA:** Şüpheli aktivitede CAPTCHA tetikleme
### 4. Content Security
- **Virus Scanning:** İndirilen dosyaları tara
- **File Size Limits:** Maksimum dosya boyutu
- **Content-Type Validation:** Sadece izin verilen formatlar
---
## 📁 Dosya Yapısı
```
src/
├── app/
│ ├── [locale]/
│ │ └── (site)/
│ │ └── download/
│ │ └── page.tsx # Download page
│ └── api/
│ └── download/
│ ├── route.ts # Main download endpoint
│ └── info/
│ └── route.ts # Get video info endpoint
├── components/
│ └── site/
│ └── download/
│ ├── download-form.tsx # URL input form
│ ├── download-result.tsx # Result display
│ ├── platform-badge.tsx # Platform indicator
│ ├── quality-selector.tsx # Quality options
│ └── download-progress.tsx # Progress indicator
├── lib/
│ ├── api/
│ │ └── download/
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── service.ts # API service functions
│ │ └── use-hooks.ts # React Query hooks
│ ├── download/
│ │ ├── platform-detector.ts # URL -> Platform mapping
│ │ ├── cobalt-client.ts # Cobalt API client
│ │ └── download-manager.ts # Download orchestration
│ └── security/
│ ├── rate-limiter.ts # Rate limiting logic
│ ├── url-validator.ts # URL validation
│ └── abuse-detector.ts # Abuse detection
└── types/
└── download.ts # Shared types
```
---
## 🔌 API Endpoints
### POST /api/download
**Request:**
```typescript
interface DownloadRequest {
url: string; // Sosyal medya URL
quality?: "high" | "medium" | "low";
format?: "mp4" | "mp3" | "webm";
}
```
**Response:**
```typescript
interface DownloadResponse {
success: boolean;
data?: {
downloadUrl: string;
filename: string;
fileSize: number;
platform: Platform;
metadata: VideoMetadata;
};
error?: {
code: ErrorCode;
message: string;
};
}
```
### POST /api/download/info
**Request:**
```typescript
interface InfoRequest {
url: string;
}
```
**Response:**
```typescript
interface InfoResponse {
success: boolean;
data?: {
platform: Platform;
title: string;
thumbnail: string;
duration: number;
qualities: QualityOption[];
};
}
```
---
## 🌐 Platform Detection Algorithm
```typescript
// src/lib/download/platform-detector.ts
interface PlatformConfig {
name: Platform;
domains: string[];
patterns: RegExp[];
extractor: ExtractorType;
}
const PLATFORM_CONFIGS: PlatformConfig[] = [
{
name: "youtube",
domains: ["youtube.com", "youtu.be", "music.youtube.com"],
patterns: [
/youtube\.com\/watch\?v=[\w-]+/,
/youtu\.be\/[\w-]+/,
/youtube\.com\/shorts\/[\w-]+/,
],
extractor: "cobalt",
},
{
name: "instagram",
domains: ["instagram.com", "instagr.am"],
patterns: [/instagram\.com\/(p|reel|tv)\/[\w-]+/],
extractor: "cobalt",
},
{
name: "tiktok",
domains: ["tiktok.com", "vm.tiktok.com"],
patterns: [/tiktok\.com\/@[\w.]+\/video\/\d+/, /vm\.tiktok\.com\/[\w-]+/],
extractor: "cobalt",
},
{
name: "twitter",
domains: ["twitter.com", "x.com"],
patterns: [/twitter\.com\/\w+\/status\/\d+/, /x\.com\/\w+\/status\/\d+/],
extractor: "cobalt",
},
{
name: "facebook",
domains: ["facebook.com", "fb.watch"],
patterns: [/facebook\.com\/.*\/videos\/\d+/, /fb\.watch\/[\w-]+/],
extractor: "cobalt",
},
];
export function detectPlatform(url: string): Platform | null {
const urlObj = new URL(url);
for (const config of PLATFORM_CONFIGS) {
if (config.domains.some((d) => urlObj.hostname.includes(d))) {
return config.name;
}
}
return null;
}
```
---
## 🎨 UI Component Hierarchy
```mermaid
flowchart TD
Page[DownloadPage] --> Form[DownloadForm]
Page --> Result[DownloadResult]
Form --> Input[URLInput]
Form --> PlatformBadge[PlatformBadge]
Form --> QualitySelector[QualitySelector]
Form --> SubmitButton[SubmitButton]
Result --> Progress[DownloadProgress]
Result --> SuccessResult[SuccessResult]
Result --> ErrorResult[ErrorResult]
Progress --> ProgressBar[ProgressBar]
Progress --> StatusText[StatusText]
SuccessResult --> FilePreview[FilePreview]
SuccessResult --> DownloadButton[DownloadButton]
ErrorResult --> ErrorIcon[ErrorIcon]
ErrorResult --> ErrorMessage[ErrorMessage]
ErrorResult --> RetryButton[RetryButton]
```
---
## 🔄 Veri Akışı
```mermaid
sequenceDiagram
participant User
participant Frontend
participant API as Next.js API
participant Validator
participant RateLimiter
participant Cobalt as Cobalt API
User->>Frontend: URL Gir + İndir
Frontend->>Frontend: URL Format Validation
Frontend->>API: POST /api/download/info
API->>Validator: Validate URL
Validator-->>API: Valid
API->>RateLimiter: Check Rate Limit
RateLimiter-->>API: Allowed
API->>Cobalt: Get Video Info
Cobalt-->>API: Video Metadata
API-->>Frontend: Info Response
Frontend->>User: Show Options
User->>Frontend: Select Quality
Frontend->>API: POST /api/download
API->>Cobalt: Request Download
Cobalt-->>API: Download URL
API-->>Frontend: Download Response
Frontend->>User: Download File
```
---
## 🛡️ Güvenlik Checklist
### Input Security
- [ ] URL format validation
- [ ] Protocol whitelist (https only)
- [ ] Domain whitelist
- [ ] XSS prevention (DOMPurify)
- [ ] SQL injection prevention
- [ ] Path traversal prevention
### Rate Limiting
- [ ] IP-based limiting
- [ ] User-based limiting (authenticated)
- [ ] Global request limiting
- [ ] Sliding window algorithm
### Content Security
- [ ] File type validation
- [ ] File size limits
- [ ] Virus/malware scanning (optional)
- [ ] Content-Disposition headers
### API Security
- [ ] CSRF protection
- [ ] Content-Type validation
- [ ] Request size limits
- [ ] Timeout handling
---
## 📊 Error Handling Strategy
| Error Code | Message | User Action |
| ---------------------- | -------------------------- | ---------------------------- |
| `INVALID_URL` | Geçersiz URL formatı | URL'yi kontrol et |
| `UNSUPPORTED_PLATFORM` | Bu platform desteklenmiyor | Desteklenen platformları gör |
| `RATE_LIMIT_EXCEEDED` | Çok fazla istek | Bekle veya giriş yap |
| `VIDEO_NOT_FOUND` | Video bulunamadı | URL'yi kontrol et |
| `VIDEO_PRIVATE` | Video özel/gizli | Herkese açık video kullan |
| `DOWNLOAD_FAILED` | İndirme başarısız | Tekrar dene |
| `FILE_TOO_LARGE` | Dosya çok büyük | Düşük kalite seç |
---
## 🧪 Test Strategy
### Unit Tests
- URL validator fonksiyonları
- Platform detector logic
- Rate limiter calculations
- API service functions
### Integration Tests
- API endpoint tests
- External API mock tests
- Error scenario tests
### E2E Tests
- Complete download flow
- Error handling UI
- Rate limit scenarios
---
## 📦 Dependencies
### Production
```json
{
"axios": "^1.x",
"zod": "^3.x"
}
```
### Development
```json
{
"@types/node": "^20",
"vitest": "^1.x",
"msw": "^2.x"
}
```
---
## 🚀 Implementation Phases
### Phase 1: Core Infrastructure
1. URL validation ve platform detection
2. API route structure
3. Basic UI components
### Phase 2: External API Integration
1. Cobalt API client
2. Download orchestration
3. Error handling
### Phase 3: Security Layer
1. Rate limiting implementation
2. Abuse detection
3. Input sanitization
### Phase 4: UI/UX Polish
1. Progress indicators
2. Responsive design
3. Dark/light mode
4. i18n translations
### Phase 5: Testing & Optimization
1. Unit tests
2. Integration tests
3. Performance optimization
---
## 💡 Önemli Notlar
1. **Cobalt API:**ık kaynaklı, self-hosted bir sosyal medya indirici API. Kullanımı için:
- Self-hosted instance (önerilen)
- Public instance (rate limit riski)
2. **Copyright Uyarısı:** Kullanıcılara telif hakkı uyarısı gösterilmeli
3. **Terms of Service:** Her platformun ToS'u kontrol edilmeli
4. **Monitoring:** API kullanımı ve hata oranları izlenmeli
---
## ❓ Açık Sorular
1. Cobalt API self-hosted mu kullanılacak yoksa public instance mi?
2. Video maksimum boyutu ne olacak?
3. Oturum açmamış kullanıcılar için limit ne kadar?
4. İndirilen dosyalar sunucuda saklanacak mı (cache)?

BIN
public/favicon/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -27,9 +27,10 @@ export default async function RootLayout({
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
<head>
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='96x96' href='/favicon/favicon-96x96.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
<link rel='manifest' href='/favicon/site.webmanifest' />
<title>Sosyal Medya Post İndirme</title>
</head>
<body className={bricolage.variable}>
<NextIntlClientProvider>

View File

@@ -1,5 +1,186 @@
import { redirect } from 'next/navigation';
/**
* Home Page - Video Downloader
* Main page for social media video downloads
*/
export default async function Page() {
redirect('/home');
'use client';
import * as React from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Box, Container, Heading, Text, VStack, Flex } from '@chakra-ui/react';
import { FiDownloadCloud } from 'react-icons/fi';
import { useTranslations } from 'next-intl';
import { DownloadForm } from '@/components/site/download/download-form';
import { DownloadResult } from '@/components/site/download/download-result';
import { detectPlatform } from '@/lib/download/platform-detector';
import { Platform, DownloadResponse, AudioFormat } from '@/types/download';
import { toaster } from '@/components/ui/feedback/toaster';
export default function HomePage() {
const t = useTranslations('download');
const [url, setUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [detectedPlatform, setDetectedPlatform] = useState<Platform | null>(null);
const [result, setResult] = useState<DownloadResponse | null>(null);
// Detect platform when URL changes
useEffect(() => {
if (url.trim()) {
const detection = detectPlatform(url);
setDetectedPlatform(detection.platform);
} else {
setDetectedPlatform(null);
}
}, [url]);
// Handle download submission
const handleSubmit = useCallback(async (
inputUrl: string,
audioFormat?: AudioFormat,
videoQuality?: string,
audioBitrate?: string
) => {
setUrl(inputUrl);
setIsLoading(true);
setResult(null);
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: inputUrl,
audioFormat: audioFormat,
quality: videoQuality,
audioBitrate: audioBitrate,
}),
});
const data = await response.json();
setResult(data);
// Show error toaster if request failed
if (!data.success && data.error) {
toaster.error({
title: t('errors.downloadFailed'),
description: data.error.message,
});
}
} catch (error) {
console.error('Download error:', error);
const errorResponse = {
success: false,
error: {
code: 'NETWORK_ERROR',
message: 'Network error. Please check your connection.',
},
};
setResult(errorResponse);
// Show error toaster
toaster.error({
title: t('errors.networkError'),
description: errorResponse.error.message,
});
} finally {
setIsLoading(false);
}
}, [t]);
// Handle URL change for platform detection
const handleUrlChange = useCallback((newUrl: string) => {
setUrl(newUrl);
}, []);
// Reset state
const handleReset = useCallback(() => {
setResult(null);
setUrl('');
setDetectedPlatform(null);
}, []);
return (
<Container maxW="container.md" py={{ base: 8, md: 16 }}>
<VStack gap={8} align="stretch">
{/* Header */}
<VStack gap={4} textAlign="center">
<Flex
w={16}
h={16}
borderRadius="full"
bg="primary.100"
_dark={{ bg: 'primary.900' }}
align="center"
justify="center"
mx="auto"
>
<Box as={FiDownloadCloud} boxSize={8} color="primary.500" />
</Flex>
<Heading size={{ base: 'xl', md: '2xl' }} fontWeight="bold">
{t('title')}
</Heading>
<Text fontSize="lg" color="fg.muted" maxW="md" mx="auto">
{t('subtitle')}
</Text>
</VStack>
{/* Main Card */}
<Box
p={{ base: 4, md: 8 }}
borderRadius="2xl"
bg="bg.panel"
border="1px solid"
borderColor="border.emphasized"
boxShadow={{ base: 'none', md: 'lg' }}
>
<VStack gap={6} align="stretch">
{/* Form or Result */}
{result ? (
<DownloadResult result={result} onReset={handleReset} />
) : (
<DownloadForm
onSubmit={handleSubmit}
onUrlChange={handleUrlChange}
isLoading={isLoading}
detectedPlatform={detectedPlatform}
/>
)}
</VStack>
</Box>
{/* Help Section */}
<VStack gap={4} textAlign="center">
<Text fontSize="sm" color="fg.muted">
{t('supportedPlatforms')}
</Text>
<Flex
gap={6}
wrap="wrap"
justify="center"
fontSize="sm"
color="fg.subtle"
>
<Text>YouTube</Text>
<Text>Instagram</Text>
<Text>TikTok</Text>
<Text>X (Twitter)</Text>
<Text>Facebook</Text>
</Flex>
</VStack>
{/* Disclaimer */}
<Text
fontSize="xs"
color="fg.muted"
textAlign="center"
maxW="md"
mx="auto"
>
{t('disclaimer')}
</Text>
</VStack>
</Container>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Download API Route
* Handles social media video download requests
*
* Supports:
* - YouTube: MP4 video, MP3 audio
* - Instagram: Video/Image
* - TikTok: Video
* - Twitter/X: Video
* - Facebook: Video
*/
import { NextRequest, NextResponse } from "next/server";
import { validateUrl } from "@/lib/security/url-validator";
import { withRateLimit } from "@/lib/security/rate-limiter";
import { detectPlatform } from "@/lib/download/platform-detector";
import { fetchVideo, fetchYoutubeAudio, fetchYoutubeVideo } from "@/lib/download/downloader";
import { DownloadRequest, DownloadResponse } from "@/types/download";
/**
* POST /api/download
* Downloads a social media video
*/
export async function POST(
request: NextRequest,
): Promise<NextResponse<DownloadResponse>> {
console.log("========== DOWNLOAD API START ==========");
try {
// 1. Rate limiting check
console.log("[1] Checking rate limit...");
const rateLimitResult = withRateLimit(request);
if (!rateLimitResult.success) {
console.log("[1] Rate limit exceeded:", rateLimitResult.error);
return NextResponse.json(
{
success: false,
error: {
code: "RATE_LIMITED",
message: rateLimitResult.error,
},
},
{
status: 429,
headers: {
"Retry-After": rateLimitResult.retryAfter.toString(),
},
},
);
}
console.log("[1] Rate limit passed");
// 2. Parse request body
console.log("[2] Parsing request body...");
let body: DownloadRequest;
try {
body = await request.json();
console.log("[2] Request body:", JSON.stringify(body, null, 2));
} catch (parseError) {
console.log("[2] Failed to parse body:", parseError);
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_BODY",
message: "Invalid request body",
},
},
{ status: 400 },
);
}
// 3. Validate URL
console.log("[3] Validating URL:", body.url);
const validation = validateUrl(body.url);
console.log("[3] Validation result:", JSON.stringify(validation, null, 2));
if (!validation.valid) {
console.log("[3] URL validation failed:", validation.error);
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_URL",
message: validation.error || "Invalid URL",
},
},
{ status: 400 },
);
}
console.log("[3] URL is valid, sanitized:", validation.sanitizedUrl);
// 4. Detect platform
console.log("[4] Detecting platform...");
const platformResult = detectPlatform(validation.sanitizedUrl!);
console.log("[4] Platform detected:", JSON.stringify(platformResult, null, 2));
if (platformResult.platform === "unknown") {
console.log("[4] Unknown platform");
return NextResponse.json(
{
success: false,
error: {
code: "UNSUPPORTED_PLATFORM",
message:
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
},
},
{ status: 400 },
);
}
// 5. Fetch video/media using appropriate downloader
console.log("[5] Fetching media...");
console.log("[5] Sending URL:", validation.sanitizedUrl);
console.log("[5] Platform:", platformResult.platform);
console.log("[5] Audio format requested:", body.audioFormat);
let downloadResponse: DownloadResponse;
// YouTube: Support both MP4 video and MP3 audio with quality options
if (platformResult.platform === "youtube") {
// If audioFormat is specified, download as MP3 audio
if (body.audioFormat) {
console.log("[5] Downloading YouTube audio (MP3)...");
console.log("[5] Audio bitrate:", body.audioBitrate);
downloadResponse = await fetchYoutubeAudio(validation.sanitizedUrl!, body.audioBitrate);
} else {
console.log("[5] Downloading YouTube video (MP4)...");
console.log("[5] Video quality:", body.quality);
downloadResponse = await fetchYoutubeVideo(validation.sanitizedUrl!, body.quality);
}
} else {
// Other platforms: Use default fetchVideo
downloadResponse = await fetchVideo({
url: validation.sanitizedUrl!,
quality: body.quality,
});
}
console.log("[5] Download response:", JSON.stringify(downloadResponse, null, 2));
// 6. Handle errors
if (!downloadResponse.success || downloadResponse.error) {
console.log("[6] Download failed:", downloadResponse.error);
return NextResponse.json(
{
success: false,
error: downloadResponse.error || {
code: "DOWNLOAD_FAILED",
message: "Failed to download video",
},
},
{ status: 400 },
);
}
// 7. Return success response
console.log("[7] Success! Returning download URL");
console.log("========== DOWNLOAD API END (SUCCESS) ==========");
return NextResponse.json({
success: true,
data: downloadResponse.data,
});
} catch (error) {
console.error("========== DOWNLOAD API ERROR ==========");
console.error("Error type:", error?.constructor?.name);
console.error("Error message:", error instanceof Error ? error.message : String(error));
console.error("Error stack:", error instanceof Error ? error.stack : "No stack trace");
console.error("=========================================");
return NextResponse.json(
{
success: false,
error: {
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "An unexpected error occurred",
},
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
/**
* GET /api/proxy
* Proxies cross-origin URLs to enforce a 'Save As' download in the browser.
*
* Query parameters:
* - url: The remote URL target (e.g. googlevideo.com mp4 link)
* - filename: The desired file name for the downloaded file
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
let filename = searchParams.get("filename") || "download";
if (!url) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
}
// Ensure safe filenames
filename = encodeURIComponent(filename).replace(/['()]/g, escape).replace(/\*/g, '%2A');
try {
const requestHeaders: Record<string, string> = {
// Some servers like YT check User-Agent.
"User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0",
};
// Forward the Range header if the browser sends it for pausing/resuming
if (request.headers.has("range")) {
requestHeaders["Range"] = request.headers.get("range")!;
}
const remoteResponse = await fetch(url, {
method: "GET",
headers: requestHeaders,
});
if (!remoteResponse.ok && remoteResponse.status !== 206) {
throw new Error(`Failed to fetch remote resource: ${remoteResponse.status}`);
}
// Forward the content type or fallback
const contentType = remoteResponse.headers.get("Content-Type") || "application/octet-stream";
const contentLength = remoteResponse.headers.get("Content-Length");
const acceptRanges = remoteResponse.headers.get("Accept-Ranges");
const contentRange = remoteResponse.headers.get("Content-Range");
// Prepare headers for the proxied response to enforce download
const responseHeaders = new Headers();
responseHeaders.set("Content-Type", contentType);
// Explicitly set Content-Disposition to attachment so the browser downloads the file
responseHeaders.set("Content-Disposition", `attachment; filename*=UTF-8''${filename}`);
// Transfer content headers if known, useful for download progress bars and resuming
if (contentLength) responseHeaders.set("Content-Length", contentLength);
if (acceptRanges) responseHeaders.set("Accept-Ranges", acceptRanges);
if (contentRange) responseHeaders.set("Content-Range", contentRange);
// Next.js (Edge/App Router) stream support
return new NextResponse(remoteResponse.body, {
status: remoteResponse.status,
headers: responseHeaders,
});
} catch (error) {
console.error("[PROXY API] Proxy download failed:", error);
return NextResponse.json(
{ error: "Target file could not be proxied for download" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,243 @@
/**
* Download Form Component
* URL input form for social media video downloads
* Supports MP4 video and MP3 audio for YouTube
*/
'use client';
import * as React from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Box, Flex, Text, VStack, Spinner, HStack, ButtonGroup } from '@chakra-ui/react';
import { FiDownload, FiLink, FiX, FiYoutube, FiInstagram, FiMusic, FiVideo } from 'react-icons/fi';
import { FaTiktok, FaFacebook, FaTwitter } from 'react-icons/fa';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/buttons/button';
import { InputGroup } from '@/components/ui/forms/input-group';
import { Field } from '@/components/ui/forms/field';
import { Input } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/react';
import { Platform, PLATFORM_INFO, AudioFormat, VideoQuality } from '@/types/download';
import { NativeSelectRoot, NativeSelectField } from '@/components/ui/forms/native-select';
interface DownloadFormProps {
onSubmit: (url: string, audioFormat?: AudioFormat, videoQuality?: VideoQuality, audioBitrate?: "320" | "256" | "128" | "64") => Promise<void>;
onUrlChange?: (url: string) => void;
isLoading: boolean;
detectedPlatform: Platform | null;
}
const YOUTUBE_VIDEO_QUALITIES = [
{ value: '1080', label: '1080p (FHD)' },
{ value: '720', label: '720p (HD)' },
{ value: '480', label: '480p' },
{ value: '360', label: '360p' },
];
const YOUTUBE_AUDIO_BITRATES = [
{ value: '320', label: '320 kbps (High)' },
{ value: '256', label: '256 kbps' },
{ value: '128', label: '128 kbps (Normal)' },
{ value: '92', label: '92 kbps (Low)' },
];
export function DownloadForm({ onSubmit, onUrlChange, isLoading, detectedPlatform }: DownloadFormProps) {
const t = useTranslations('download');
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [downloadingFormat, setDownloadingFormat] = useState<'video' | 'audio' | null>(null);
// Quality states
const [videoQuality, setVideoQuality] = useState<VideoQuality>('720');
const [audioBitrate, setAudioBitrate] = useState<"320" | "256" | "128" | "64" | "92">('128');
const handleDownload = useCallback(async (format: 'video' | 'audio') => {
if (!url.trim()) {
setError(t('errors.urlRequired'));
return;
}
setError(null);
setDownloadingFormat(format);
// Pass audio format if downloading audio from YouTube
const audioFormat = format === 'audio' ? 'mp3' : undefined;
const vQuality = format === 'video' ? videoQuality : undefined;
const aBitrate = format === 'audio' ? (audioBitrate as "320" | "256" | "128" | "64") : undefined;
await onSubmit(url.trim(), audioFormat, vQuality, aBitrate);
setDownloadingFormat(null);
}, [url, onSubmit, t, videoQuality, audioBitrate]);
const handleClear = useCallback(() => {
setUrl('');
setError(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading && url.trim()) {
handleDownload('video');
}
},
[handleDownload, isLoading, url]
);
const getPlatformIcon = (platform: Platform) => {
switch (platform) {
case 'youtube':
return FiYoutube;
case 'instagram':
return FiInstagram;
case 'tiktok':
return FaTiktok;
case 'twitter':
return FaTwitter;
case 'facebook':
return FaFacebook;
default:
return FiLink;
}
};
const PlatformIcon = detectedPlatform ? getPlatformIcon(detectedPlatform) : FiLink;
return (
<Box as="form" onSubmit={(e) => e.preventDefault()}>
<VStack gap={4} align="stretch">
<Field errorText={error || undefined} >
<InputGroup
width={'100%'}
startElement={
isLoading ? (
<Spinner size="sm" color="primary.500" />
) : (
<Box as={PlatformIcon} color={detectedPlatform ? PLATFORM_INFO[detectedPlatform]?.color : 'gray.500'} />
)
}
endElement={
url && !isLoading ? (
<IconButton aria-label="Clear" variant="ghost" size="xs" onClick={handleClear}>
<Box as={FiX} />
</IconButton>
) : undefined
}
>
<Input
placeholder={t('placeholder')}
value={url}
onChange={(e) => {
const newUrl = e.target.value;
setUrl(newUrl);
setError(null);
onUrlChange?.(newUrl);
}}
onKeyDown={handleKeyDown}
borderRadius="xl"
disabled={isLoading}
size={'xl'}
/>
</InputGroup>
</Field>
{/* YouTube: MP4 and MP3 buttons side by side */}
{detectedPlatform === 'youtube' ? (
<VStack gap={4} align="stretch" width="full">
<Flex gap={4} direction={{ base: 'column', sm: 'row' }} width="full">
{/* Video Download Section */}
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Video Quality</Text>
<NativeSelectRoot size="md" disabled={isLoading}>
<NativeSelectField
value={videoQuality}
onChange={(e) => setVideoQuality(e.currentTarget.value as VideoQuality)}
items={YOUTUBE_VIDEO_QUALITIES}
/>
</NativeSelectRoot>
<Button
type="button"
size="md"
colorPalette="primary"
fontWeight="semibold"
onClick={() => handleDownload('video')}
loading={downloadingFormat === 'video'}
disabled={isLoading || !url.trim()}
mt={2}
>
<Box as={FiVideo} mr={2} />
MP4 Video
</Button>
</VStack>
{/* Audio Download Section */}
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Audio Quality</Text>
<NativeSelectRoot size="md" disabled={isLoading}>
<NativeSelectField
value={audioBitrate}
onChange={(e) => setAudioBitrate(e.currentTarget.value as any)}
items={YOUTUBE_AUDIO_BITRATES}
/>
</NativeSelectRoot>
<Button
type="button"
size="md"
colorPalette="green"
fontWeight="semibold"
onClick={() => handleDownload('audio')}
loading={downloadingFormat === 'audio'}
disabled={isLoading || !url.trim()}
mt={2}
>
<Box as={FiMusic} mr={2} />
MP3 Audio
</Button>
</VStack>
</Flex>
</VStack>
) : (
/* Other platforms: Single download button */
<Button
type="submit"
size="lg"
width="full"
colorPalette="primary"
borderRadius="xl"
fontWeight="semibold"
onClick={() => handleDownload('video')}
loading={isLoading}
disabled={isLoading || !url.trim()}
>
<Box as={FiDownload} mr={2} />
{isLoading ? t('downloading') : t('download')}
</Button>
)}
<Flex justify="center" gap={4} wrap="wrap">
{['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'].map((platform) => {
const IconComponent = getPlatformIcon(platform as Platform);
return (
<Flex
key={platform}
align="center"
gap={1}
opacity={detectedPlatform === platform ? 1 : 0.5}
transition="opacity 0.2s"
>
<Box
as={IconComponent}
color={PLATFORM_INFO[platform as Platform]?.color}
boxSize={4}
/>
<Text fontSize="xs" color="fg.muted">
{PLATFORM_INFO[platform as Platform]?.name}
</Text>
</Flex>
);
})}
</Flex>
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Download Result Component
* Displays download result (success or error)
*/
'use client';
import * as React from 'react';
import { Box, Flex, Text, VStack, Image, HStack } from '@chakra-ui/react';
import { FiCheck, FiDownload, FiAlertCircle, FiX } from 'react-icons/fi';
import { useTranslations } from 'next-intl';
import { Alert } from '@/components/ui/feedback/alert';
import { Button } from '@/components/ui/buttons/button';
import { LinkButton } from '@/components/ui/buttons/link-button';
import { Tag } from '@/components/ui/data-display/tag';
import { Platform, PLATFORM_INFO, DownloadResponse } from '@/types/download';
interface DownloadResultProps {
result: DownloadResponse | null;
onReset: () => void;
}
export function DownloadResult({ result, onReset }: DownloadResultProps) {
const t = useTranslations('download');
if (!result) return null;
// Error state
if (!result.success || !result.data) {
return (
<Alert status="error" borderRadius="xl" variant="surface" icon={<Box as={FiAlertCircle} />}>
<Text fontWeight="medium">{t('error')}</Text>
<Text fontSize="sm">{result.error?.message || t('unknownError')}</Text>
<Button size="sm" variant="outline" onClick={onReset} ml="auto">
<Box as={FiX} mr={1} />
{t('newDownload')}
</Button>
</Alert>
);
}
const { data } = result;
return (
<Box
p={6}
borderRadius="xl"
bg="bg.subtle"
border="1px solid"
borderColor="border.emphasized"
>
<VStack gap={4} align="stretch">
{/* Success Header */}
<Flex align="center" gap={3}>
<Flex
w={10}
h={10}
borderRadius="full"
bg="green.100"
_dark={{ bg: 'green.900' }}
align="center"
justify="center"
>
<Box as={FiCheck} color="green.600" boxSize={5} />
</Flex>
<Box>
<Text fontWeight="semibold" fontSize="lg">
{t('readyToDownload')}
</Text>
<Text fontSize="sm" color="fg.muted">
{data.title || data.filename}
</Text>
</Box>
</Flex>
{/* Platform Badge */}
{data.platform && data.platform !== 'unknown' && (
<HStack>
<Tag colorPalette="primary" variant="subtle" borderRadius="md" px={2} py={1}>
{PLATFORM_INFO[data.platform]?.name}
</Tag>
</HStack>
)}
{/* Thumbnail if available */}
{data.thumbnail && (
<Box borderRadius="lg" overflow="hidden" maxH="200px">
<Image
src={data.thumbnail}
alt={data.title || 'Video thumbnail'}
w="100%"
h="auto"
objectFit="cover"
/>
</Box>
)}
{/* Actions */}
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
<LinkButton
href={`/api/proxy?url=${encodeURIComponent(data.downloadUrl)}&filename=${encodeURIComponent(data.filename)}`}
target="_blank"
rel="noopener noreferrer"
colorPalette="primary"
size="lg"
flex={1}
borderRadius="xl"
>
<Box as={FiDownload} mr={2} />
{t('downloadFile')}
</LinkButton>
<Button
variant="outline"
size="lg"
borderRadius="xl"
onClick={onReset}
>
<Box as={FiX} mr={2} />
{t('newDownload')}
</Button>
</Flex>
{/* Help text */}
<Text fontSize="xs" color="fg.muted" textAlign="center">
{t('downloadHint')}
</Text>
</VStack>
</Box>
);
}

View File

@@ -9,6 +9,5 @@ export type NavItem = {
};
export const NAV_ITEMS: NavItem[] = [
{ label: "home", href: "/home", public: true },
{ label: "predictions", href: "/predictions", public: true },
{ label: "home", href: "/", public: true },
];

View File

@@ -0,0 +1,354 @@
/**
* Cobalt API Client
* Handles communication with Cobalt API for social media downloads
*/
import {
CobaltResponse,
DownloadRequest,
VideoQuality,
AudioFormat,
Platform,
} from "@/types/download";
// Cobalt API instance URL (public instance - v8)
// NOTE: v7 API was shut down on Nov 11th 2024
const COBALT_API_URL =
process.env.COBALT_API_URL || "https://api.cobalt.tools/api/json";
// Request timeout in milliseconds
const REQUEST_TIMEOUT = 30000;
/**
* Maps our quality to Cobalt quality format
*/
function mapQuality(quality?: VideoQuality): string {
const qualityMap: Record<VideoQuality, string> = {
max: "max",
"1080": "1080",
"720": "720",
"480": "480",
"360": "360",
};
return quality ? qualityMap[quality] : "720";
}
/**
* Cobalt API request body
*/
interface CobaltRequestBody {
url: string;
vCodec?: "h264" | "av1" | "vp9";
vQuality?: string;
aFormat?: AudioFormat;
aBitrate?: "320" | "256" | "128" | "64";
isAudioOnly?: boolean;
isAudioMuted?: boolean;
dubBrowserLang?: boolean;
filenamePattern?: "classic" | "pretty" | "basic" | "nerdy";
twitterGif?: boolean;
tiktokFullAudio?: boolean;
tiktokH265?: boolean;
twitterXUrl?: boolean;
}
/**
* Makes a request to Cobalt API
*/
export async function fetchFromCobalt(
request: DownloadRequest,
): Promise<CobaltResponse> {
console.log("[COBALT] Starting fetch for URL:", request.url);
console.log("[COBALT] Using API URL:", COBALT_API_URL);
const requestBody: CobaltRequestBody = {
url: request.url,
vQuality: mapQuality(request.quality),
filenamePattern: "pretty",
aFormat: request.audioFormat || "mp3",
aBitrate: request.audioBitrate || "128",
tiktokFullAudio: true,
twitterXUrl: true,
};
console.log("[COBALT] Request body:", JSON.stringify(requestBody, null, 2));
try {
console.log("[COBALT] Sending POST request...");
const response = await fetch(COBALT_API_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
});
console.log("[COBALT] Response status:", response.status, response.statusText);
console.log("[COBALT] Response headers:", Object.fromEntries(response.headers.entries()));
const data = await response.json();
console.log("[COBALT] Response data:", JSON.stringify(data, null, 2));
if (!response.ok) {
console.log("[COBALT] Response not OK, handling error...");
// Handle Cobalt error response
if (data.error) {
console.log("[COBALT] Error from Cobalt:", data.error);
return {
error: {
code: data.error.code || "error.api.fetch.fail",
message: data.error.message || "Failed to fetch from Cobalt API",
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: `API error: ${response.status} ${response.statusText}`,
},
};
}
console.log("[COBALT] Success! Returning data");
return data as CobaltResponse;
} catch (error) {
console.error("[COBALT] Exception occurred:");
console.error("[COBALT] Error type:", error?.constructor?.name);
console.error("[COBALT] Error message:", error instanceof Error ? error.message : String(error));
if (error instanceof Error) {
if (error.name === "AbortError" || error.name === "TimeoutError") {
return {
error: {
code: "error.api.fetch.timeout",
message: "Request timed out. Please try again.",
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: error.message,
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: "An unexpected error occurred",
},
};
}
}
/**
* Gets video info from Cobalt API
* Note: Cobalt doesn't have a separate info endpoint, so we use the main endpoint
* and extract info from the response
*/
export async function getVideoInfo(
url: string,
): Promise<{
success: boolean;
data?: {
title?: string;
thumbnail?: string;
platform?: Platform;
};
error?: { code: string; message: string };
}> {
try {
// Make a request to get video info
const response = await fetchFromCobalt({ url });
if ("error" in response) {
return {
success: false,
error: {
code: response.error.code,
message: response.error.message || "Unknown error",
},
};
}
// Extract available info from Cobalt response
if (response.status === "redirect" || response.status === "stream") {
return {
success: true,
data: {
// Cobalt doesn't provide title/thumbnail in basic response
// These would need additional API calls or scraping
},
};
}
if (response.status === "picker" && response.picker) {
return {
success: true,
data: {
// Picker means multiple options (e.g., Instagram carousel)
thumbnail: response.picker[0]?.thumb,
},
};
}
return {
success: true,
};
} catch (error) {
return {
success: false,
error: {
code: "error.api.info.fail",
message:
error instanceof Error ? error.message : "Failed to get video info",
},
};
}
}
/**
* Parses Cobalt error codes to user-friendly messages
*/
export function parseCobaltError(
errorCode: string,
locale: string = "en",
): { title: string; description: string } {
const messages: Record<
string,
Record<string, { title: string; description: string }>
> = {
"error.api.link.invalid": {
en: { title: "Invalid URL", description: "The provided URL is not valid." },
tr: { title: "Geçersiz URL", description: "Sağlanan URL geçerli değil." },
},
"error.api.fetch.fail": {
en: {
title: "Fetch Failed",
description: "Could not fetch the content. Please try again.",
},
tr: {
title: "İndirme Başarısız",
description: "İçerik alınamadı. Lütfen tekrar deneyin.",
},
},
"error.api.content.unavailable": {
en: {
title: "Content Unavailable",
description: "This content is not available or has been removed.",
},
tr: {
title: "İçerik Mevcut Değil",
description: "Bu içerik mevcut değil veya kaldırılmış.",
},
},
"error.api.rate_exceeded": {
en: {
title: "Rate Limited",
description: "Too many requests. Please wait a moment and try again.",
},
tr: {
title: "Sınır Aşıldı",
description: "Çok fazla istek. Lütfen bekleyip tekrar deneyin.",
},
},
"error.api.fetch.rate": {
en: {
title: "Service Busy",
description: "The service is currently busy. Please try again later.",
},
tr: {
title: "Servis Meşgul",
description: "Servis şu anda meşgul. Lütfen daha sonra tekrar deneyin.",
},
},
"error.api.content.video_region": {
en: {
title: "Region Restricted",
description: "This video is not available in your region.",
},
tr: {
title: "Bölge Kısıtlaması",
description: "Bu video bölgenizde mevcut değil.",
},
},
"error.api.content.post.private": {
en: {
title: "Private Content",
description: "This content is private and cannot be downloaded.",
},
tr: {
title: "Özel İçerik",
description: "Bu içerik özel ve indirilemez.",
},
},
"error.api.fetch.short": {
en: {
title: "Link Expired",
description: "This link has expired. Please get a fresh link.",
},
tr: {
title: "Bağlantı Süresi Doldu",
description: "Bu bağlantının süresi doldu. Lütfen yeni bir bağlantı alın.",
},
},
"error.api.fetch.critical": {
en: {
title: "Critical Error",
description: "A critical error occurred. Please contact support if this persists.",
},
tr: {
title: "Kritik Hata",
description: "Kritik bir hata oluştu. Bu devam ederse destekle iletişime geçin.",
},
},
"error.api.fetch.timeout": {
en: {
title: "Request Timeout",
description: "The request took too long. Please try again.",
},
tr: {
title: "Zaman Aşımı",
description: "İstek çok uzun sürdü. Lütfen tekrar deneyin.",
},
},
};
const localeMessages = messages[errorCode] || messages["error.api.fetch.fail"];
return localeMessages[locale] || localeMessages["en"];
}
/**
* Gets direct download URL from Cobalt response
*/
export function getDownloadUrl(response: CobaltResponse): string | null {
console.log("[COBALT] Extracting download URL from response...");
if ("error" in response) {
console.log("[COBALT] Response has error, no download URL");
return null;
}
if (response.status === "redirect" && response.url) {
console.log("[COBALT] Found redirect URL:", response.url);
return response.url;
}
if (response.status === "stream" && response.url) {
console.log("[COBALT] Found stream URL:", response.url);
return response.url;
}
if (response.status === "picker" && response.picker?.length) {
console.log("[COBALT] Found picker URL:", response.picker[0].url);
return response.picker[0].url;
}
console.log("[COBALT] No download URL found in response");
return null;
}

View File

@@ -0,0 +1,693 @@
/**
* Social Media Video Downloader using multiple packages
* Supports: Instagram, YouTube, TikTok, Twitter/X, Facebook
*
* - @vreden/youtube_scraper: YouTube (video/audio with quality options)
* - snapsave-media-downloader: Instagram, TikTok, Twitter/X
* - yt-dlp (global): Facebook (fallback)
*/
import { snapsave } from "snapsave-media-downloader";
import { spawn } from "child_process";
import {
DownloadRequest,
DownloadResponse,
Platform,
MediaType,
VideoQuality,
AudioFormat,
} from "@/types/download";
// Removed @vreden types
// ============================================
// SnapSave API response types
// ============================================
interface SnapSaveMedia {
url?: string;
thumbnail?: string;
type?: "video" | "image";
resolution?: string;
shouldRender?: boolean;
}
interface SnapSaveData {
description?: string;
preview?: string;
media?: SnapSaveMedia[];
}
interface SnapSaveResponse {
success: boolean;
data?: SnapSaveData;
message?: string;
}
// ============================================
// yt-dlp video info type
// ============================================
interface YtDlpVideoInfo {
id?: string;
title?: string;
uploader?: string;
uploader_id?: string;
thumbnail?: string;
duration?: number;
duration_string?: string;
view_count?: number;
like_count?: number;
description?: string;
formats?: YtDlpFormat[];
url?: string;
}
interface YtDlpFormat {
format_id?: string;
format_note?: string;
ext?: string;
url?: string;
acodec?: string;
vcodec?: string;
width?: number;
height?: number;
filesize?: number;
abr?: number;
}
// ============================================
// Platform Detection
// ============================================
/**
* Detects the platform from a URL
*/
export function detectPlatform(url: string): Platform {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase().replace(/^www\./, "");
if (
hostname === "youtube.com" ||
hostname === "youtu.be" ||
hostname.includes("youtube")
) {
return "youtube";
}
if (
hostname === "instagram.com" ||
hostname.includes("instagram")
) {
return "instagram";
}
if (
hostname === "tiktok.com" ||
hostname.includes("tiktok")
) {
return "tiktok";
}
if (
hostname === "twitter.com" ||
hostname === "x.com" ||
hostname.includes("twitter")
) {
return "twitter";
}
if (
hostname === "facebook.com" ||
hostname.includes("facebook") ||
hostname === "fb.watch"
) {
return "facebook";
}
return "unknown";
} catch {
return "unknown";
}
}
// ============================================
// Helper Functions
// ============================================
/**
* Detects media type from URL
*/
function detectMediaType(url: string): MediaType {
const lowerUrl = url.toLowerCase();
if (
lowerUrl.includes(".mp4") ||
lowerUrl.includes(".webm") ||
lowerUrl.includes(".mkv") ||
lowerUrl.includes("video")
) {
return "video";
}
if (
lowerUrl.includes(".mp3") ||
lowerUrl.includes(".m4a") ||
lowerUrl.includes(".wav") ||
lowerUrl.includes("audio")
) {
return "audio";
}
if (
lowerUrl.includes(".jpg") ||
lowerUrl.includes(".jpeg") ||
lowerUrl.includes(".png") ||
lowerUrl.includes(".gif") ||
lowerUrl.includes(".webp")
) {
return "image";
}
return "video"; // Default to video
}
/**
* Gets file extension from URL
*/
function getFileExtension(url: string, mediaType?: string): string {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes(".mp4")) return ".mp4";
if (lowerUrl.includes(".webm")) return ".webm";
if (lowerUrl.includes(".mkv")) return ".mkv";
if (lowerUrl.includes(".mp3")) return ".mp3";
if (lowerUrl.includes(".m4a")) return ".m4a";
if (lowerUrl.includes(".jpg") || lowerUrl.includes(".jpeg")) return ".jpg";
if (lowerUrl.includes(".png")) return ".png";
if (lowerUrl.includes(".gif")) return ".gif";
if (lowerUrl.includes(".webp")) return ".webp";
if (mediaType === "image") return ".jpg";
if (mediaType === "audio") return ".mp3";
return ".mp4";
}
/**
* Maps VideoQuality to numeric value for filtering
*/
function getQualityNumber(quality?: VideoQuality): number {
if (!quality || quality === "max") return 1080;
return parseInt(quality);
}
/**
* Run yt-dlp command and get JSON output
*/
async function runYtdlp(url: string, args: string[] = []): Promise<YtDlpVideoInfo> {
return new Promise((resolve, reject) => {
const defaultArgs = [
"--dump-single-json",
"--no-warnings",
"--no-check-certificates",
"--prefer-free-formats",
];
const allArgs = [...defaultArgs, ...args, url];
console.log("[YT-DLP] Running command: yt-dlp", allArgs.join(" "));
const process = spawn("yt-dlp", allArgs);
let stdout = "";
let stderr = "";
process.stdout.on("data", (data) => {
stdout += data.toString();
});
process.stderr.on("data", (data) => {
stderr += data.toString();
});
process.on("close", (code) => {
if (code !== 0) {
console.error("[YT-DLP] Error:", stderr);
reject(new Error(stderr || `yt-dlp exited with code ${code}`));
return;
}
try {
const info = JSON.parse(stdout) as YtDlpVideoInfo;
resolve(info);
} catch (parseError) {
reject(new Error("Failed to parse yt-dlp output"));
}
});
process.on("error", (error) => {
console.error("[YT-DLP] Spawn error:", error);
reject(error);
});
});
}
// ============================================
/**
* Download YouTube video via yt-dlp
*/
async function downloadYoutubeVideo(
url: string,
quality?: VideoQuality
): Promise<DownloadResponse> {
console.log("[YT-DLP-YOUTUBE] Downloading video:", url, "Quality:", quality);
try {
const targetHeight = getQualityNumber(quality);
const info = await runYtdlp(url);
// Find formats that have BOTH video and audio mixed into a single MP4 file.
// yt-dlp lists these as having vcodec and acodec
const videoFormats = info.formats?.filter(
(f: YtDlpFormat) =>
f.url &&
f.ext === "mp4" &&
f.vcodec !== "none" &&
f.acodec !== "none" &&
!f.url.includes(".m3u8") &&
!f.url.includes("/manifest/")
);
if (!videoFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Uygun video formatı bulunamadı.",
},
};
}
// Sort to find the requested quality or closest lower quality
const sortedFormats = videoFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
let bestFormat = sortedFormats.find(f => (f.height || 0) <= targetHeight) || sortedFormats[0];
// Some 1080p combined might not exist (YouTube usually separates them), fallback gracefully
if (!bestFormat) {
bestFormat = sortedFormats[0];
}
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_video";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${safeTitle}.mp4`,
platform: "youtube",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "video",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Video download error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "YouTube video indirme hatası",
},
};
}
}
/**
* Download YouTube audio via yt-dlp
*/
async function downloadYoutubeAudio(
url: string,
format?: AudioFormat,
bitrate?: string
): Promise<DownloadResponse> {
console.log("[YT-DLP-YOUTUBE] Downloading audio:", url, "Bitrate:", bitrate);
try {
const targetBitrate = bitrate ? parseInt(bitrate) : 128;
const info = await runYtdlp(url);
// Find audio-only formats (m4a or webm/mp3 usually without vcodec)
const audioFormats = info.formats?.filter(
(f: YtDlpFormat) =>
f.url &&
f.vcodec === "none" &&
f.acodec !== "none" &&
(f.ext === "m4a" || f.ext === "webm") &&
!f.url.includes(".m3u8") &&
!f.url.includes("/manifest/")
);
if (!audioFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Uygun ses formatı bulunamadı.",
},
};
}
// We prefer higher audio bitrate equal or closest to requested.
const sortedFormats = audioFormats.sort((a, b) => (b.abr || 0) - (a.abr || 0));
let bestFormat = sortedFormats.find(f => (f.abr || 0) <= targetBitrate) || sortedFormats[0];
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_audio";
// YouTube's M4A is widely supported as direct download
const ext = bestFormat.ext || "m4a";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${safeTitle}.${ext}`,
platform: "youtube",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "audio",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Audio download error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "YouTube ses indirme hatası",
},
};
}
}
/**
* Get YouTube video metadata without downloading via yt-dlp
*/
async function getYoutubeMetadata(url: string): Promise<YtDlpVideoInfo | null> {
console.log("[YT-DLP-YOUTUBE] Getting metadata for:", url);
try {
const result = await runYtdlp(url);
return result || null;
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Metadata error:", error);
return null;
}
}
// ============================================
// Facebook Download Functions (yt-dlp fallback)
// ============================================
// Removed obsolete runYtdlp (it is moved UP)
/**
* Download from Facebook using yt-dlp
*/
async function downloadFromFacebookYtDlp(url: string): Promise<DownloadResponse> {
console.log("[YT-DLP] Downloading Facebook:", url);
try {
const info = await runYtdlp(url);
console.log("[YT-DLP] Facebook info:", {
title: info.title,
uploader: info.uploader,
duration: info.duration_string,
});
// Find video format with URL
const videoFormats = info.formats?.filter(
(f: YtDlpFormat) => f.url && (f.ext === "mp4" || f.vcodec !== "none")
);
if (!videoFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Video formatı bulunamadı",
},
};
}
// Prefer higher quality
const bestFormat =
videoFormats.find(
(f: YtDlpFormat) =>
f.format_note?.includes("1080") || f.format_note?.includes("720")
) || videoFormats[0];
const title =
info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "facebook_video";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${title}.mp4`,
platform: "facebook",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "video",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP] Facebook error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "Facebook indirme hatası",
},
};
}
}
// ============================================
// SnapSave Download Functions (Instagram, TikTok, Twitter)
// ============================================
/**
* Download using SnapSave (Instagram, TikTok, Twitter)
*/
async function downloadWithSnapSave(
url: string,
platform: Platform
): Promise<DownloadResponse> {
console.log(`[SNAPSAVE] Downloading ${platform}:`, url);
try {
const result: SnapSaveResponse = await snapsave(url);
console.log(`[SNAPSAVE] ${platform} result:`, JSON.stringify(result, null, 2));
if (!result.success || !result.data?.media?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: result.message || "İçerik bulunamadı",
},
};
}
// Get the best quality media (prefer HD video)
const media = result.data.media;
let selectedMedia: SnapSaveMedia | null = null;
// For videos, prefer HD quality
if (media.some((m) => m.type === "video")) {
// Sort by resolution (prefer higher quality)
const videos = media.filter((m) => m.type === "video");
// Prefer 1080p, then 720p, then HD, then any video
selectedMedia =
videos.find((m) => m.resolution?.includes("1080")) ||
videos.find((m) => m.resolution?.includes("720")) ||
videos.find((m) => m.resolution?.includes("HD")) ||
videos.find((m) => !m.shouldRender) ||
videos[0];
} else {
// For images, take the first one
selectedMedia = media[0];
}
if (!selectedMedia?.url) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Geçerli bir medya URL'si bulunamadı",
},
};
}
const mediaType = selectedMedia.type || detectMediaType(selectedMedia.url);
// Decode SnapSave render token if present so we download the real file instead of an HTML page
let finalDownloadUrl = selectedMedia.url;
if (finalDownloadUrl.includes("render.php?token=")) {
try {
const tokenParts = finalDownloadUrl.split("token=")[1]?.split(".");
if (tokenParts && tokenParts.length > 1) {
const payload = tokenParts[1];
const decoded = Buffer.from(payload, "base64").toString("utf8");
const parsed = JSON.parse(decoded);
// Prefer audio_url if mediaType is audio, otherwise video_url
if (mediaType === "audio" && parsed.audio_url) {
finalDownloadUrl = parsed.audio_url;
} else if (parsed.video_url) {
finalDownloadUrl = parsed.video_url;
}
}
} catch (e) {
console.error("[SNAPSAVE] Failed to decode render token", e);
}
}
const ext = getFileExtension(finalDownloadUrl, mediaType);
return {
success: true,
data: {
downloadUrl: finalDownloadUrl,
filename: `${platform}_${Date.now()}${ext}`,
platform,
title: result.data.description,
thumbnail: result.data.preview || selectedMedia.thumbnail,
mediaType,
},
};
} catch (error) {
console.error(`[SNAPSAVE] ${platform} error:`, error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : `${platform} indirme hatası`,
},
};
}
}
// ============================================
// Main Download Functions
// ============================================
/**
* Main download function
* Routes to platform-specific downloaders based on URL and request parameters
*/
export async function fetchVideo(request: DownloadRequest): Promise<DownloadResponse> {
console.log("[DOWNLOADER] Starting download for URL:", request.url);
console.log("[DOWNLOADER] Request parameters:", {
quality: request.quality,
audioFormat: request.audioFormat,
audioBitrate: request.audioBitrate,
});
const platform = detectPlatform(request.url);
console.log("[DOWNLOADER] Detected platform:", platform);
switch (platform) {
case "youtube":
// If audioFormat is specified, download as audio (MP3)
if (request.audioFormat) {
console.log("[DOWNLOADER] YouTube audio download requested");
return downloadYoutubeAudio(request.url, request.audioFormat, request.audioBitrate);
}
// Otherwise download as video (MP4)
console.log("[DOWNLOADER] YouTube video download requested");
return downloadYoutubeVideo(request.url, request.quality);
case "instagram":
return downloadWithSnapSave(request.url, platform);
case "tiktok":
return downloadWithSnapSave(request.url, platform);
case "twitter":
return downloadWithSnapSave(request.url, platform);
case "facebook":
console.log("[DOWNLOADER] Facebook video download requested");
const snapResult = await downloadWithSnapSave(request.url, platform);
if (snapResult.success) {
return snapResult;
}
console.log("[DOWNLOADER] SnapSave failed for Facebook, falling back to yt-dlp...");
return downloadFromFacebookYtDlp(request.url);
default:
return {
success: false,
error: {
code: "error.download.unsupported_platform",
message:
"Desteklenmeyen platform. Desteklenenler: YouTube, Instagram, TikTok, Twitter/X, Facebook",
},
};
}
}
/**
* Download YouTube video (MP4)
* @param url - YouTube URL or video ID
* @param quality - Video quality (144, 360, 480, 720, 1080)
*/
export async function fetchYoutubeVideo(
url: string,
quality?: VideoQuality
): Promise<DownloadResponse> {
return downloadYoutubeVideo(url, quality);
}
/**
* Download YouTube audio (MP3)
* @param url - YouTube URL or video ID
* @param bitrate - Audio bitrate (92, 128, 256, 320 kbps)
*/
export async function fetchYoutubeAudio(
url: string,
bitrate?: string
): Promise<DownloadResponse> {
return downloadYoutubeAudio(url, undefined, bitrate);
}
/**
* Gets video info without downloading
*/
export async function getVideoInfo(
url: string
): Promise<{ title?: string; thumbnail?: string; platform?: Platform }> {
const platform = detectPlatform(url);
if (platform === "youtube") {
const metadata = await getYoutubeMetadata(url);
if (metadata) {
return {
title: metadata.title,
thumbnail: metadata.thumbnail,
platform,
};
}
}
return { platform: platform || undefined };
}
// Re-export types for convenience
export type { DownloadRequest, DownloadResponse, Platform, MediaType };

View File

@@ -0,0 +1,159 @@
/**
* Platform Detector
* Detects social media platform from URL
*/
import {
Platform,
PlatformDetectionResult,
SUPPORTED_DOMAINS,
} from "@/types/download";
/**
* Detects the social media platform from a URL
* @param url - The URL to detect platform from
* @returns PlatformDetectionResult with platform and confidence
*/
export function detectPlatform(url: string): PlatformDetectionResult {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase().replace("www.", "");
// Check each platform's domains
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
Platform,
string[],
][]) {
if (platform === "unknown") continue;
for (const domain of domains) {
if (hostname === domain || hostname.endsWith("." + domain)) {
return {
platform,
confidence: "high",
originalUrl: url,
normalizedUrl: normalizeUrl(url, platform),
};
}
}
}
// Check for known URL patterns even if domain doesn't match exactly
const patternResult = detectByPattern(url);
if (patternResult) {
return patternResult;
}
return {
platform: "unknown",
confidence: "low",
originalUrl: url,
};
} catch {
return {
platform: "unknown",
confidence: "low",
originalUrl: url,
};
}
}
/**
* Detects platform by URL patterns
*/
function detectByPattern(url: string): PlatformDetectionResult | null {
const patterns: { pattern: RegExp; platform: Platform }[] = [
// YouTube patterns
{ pattern: /youtube\.com\/watch\?v=[\w-]+/i, platform: "youtube" },
{ pattern: /youtu\.be\/[\w-]+/i, platform: "youtube" },
{ pattern: /youtube\.com\/shorts\/[\w-]+/i, platform: "youtube" },
{ pattern: /youtube\.com\/embed\/[\w-]+/i, platform: "youtube" },
// Instagram patterns
{ pattern: /instagram\.com\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
{ pattern: /instagr\.am\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
// TikTok patterns
{ pattern: /tiktok\.com\/@[\w.]+\/video\/\d+/i, platform: "tiktok" },
{ pattern: /vm\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
{ pattern: /vt\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
// Twitter/X patterns
{ pattern: /twitter\.com\/\w+\/status\/\d+/i, platform: "twitter" },
{ pattern: /x\.com\/\w+\/status\/\d+/i, platform: "twitter" },
// Facebook patterns
{ pattern: /facebook\.com\/.*\/videos\/\d+/i, platform: "facebook" },
{ pattern: /facebook\.com\/watch\/?\?v=\d+/i, platform: "facebook" },
{ pattern: /fb\.watch\/[\w-]+/i, platform: "facebook" },
];
for (const { pattern, platform } of patterns) {
if (pattern.test(url)) {
return {
platform,
confidence: "high",
originalUrl: url,
};
}
}
return null;
}
/**
* Normalizes URL for specific platforms
* Some platforms need URL normalization for better API compatibility
*/
function normalizeUrl(url: string, platform: Platform): string {
try {
const urlObj = new URL(url);
switch (platform) {
case "youtube": {
// Convert youtu.be to youtube.com
if (urlObj.hostname === "youtu.be") {
const videoId = urlObj.pathname.slice(1);
return `https://www.youtube.com/watch?v=${videoId}`;
}
// Convert shorts to regular watch URL
if (urlObj.pathname.startsWith("/shorts/")) {
const videoId = urlObj.pathname.replace("/shorts/", "");
return `https://www.youtube.com/watch?v=${videoId}`;
}
return url;
}
case "tiktok": {
// Keep the original URL - Cobalt handles short URLs
return url;
}
case "twitter": {
// Ensure x.com URLs are handled properly
return url;
}
default:
return url;
}
} catch {
return url;
}
}
/**
* Checks if a platform is supported
*/
export function isPlatformSupported(platform: Platform): boolean {
return platform !== "unknown";
}
/**
* Gets the best URL to use for downloading
* Some platforms work better with normalized URLs
*/
export function getBestUrlForDownload(url: string): string {
const result = detectPlatform(url);
return result.normalizedUrl || result.originalUrl;
}

View File

@@ -0,0 +1,220 @@
/**
* Simple Rate Limiter
* IP-based rate limiting for API endpoints
*/
import { NextRequest } from "next/server";
interface RateLimitEntry {
count: number;
resetTime: number;
blocked: boolean;
}
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
blockDurationMs: number; // How long to block after exceeding limit
}
// In-memory store (resets on server restart)
// For production, use Redis or similar
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup interval (every 10 minutes)
const CLEANUP_INTERVAL = 10 * 60 * 1000;
// Default rate limit config
const DEFAULT_CONFIG: RateLimitConfig = {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 20, // 20 requests per hour per IP
blockDurationMs: 30 * 60 * 1000, // 30 minutes block
};
// Stricter config for unauthenticated users
const PUBLIC_CONFIG: RateLimitConfig = {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10, // 10 requests per hour
blockDurationMs: 60 * 60 * 1000, // 1 hour block
};
/**
* Cleanup old entries from the rate limit store
*/
function cleanupStore(): void {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetTime < now && !entry.blocked) {
rateLimitStore.delete(key);
} else if (
entry.blocked &&
entry.resetTime < now - DEFAULT_CONFIG.blockDurationMs
) {
rateLimitStore.delete(key);
}
}
}
// Run cleanup periodically
if (typeof setInterval !== "undefined") {
setInterval(cleanupStore, CLEANUP_INTERVAL);
}
/**
* Gets client IP from request
*/
export function getClientIp(request: NextRequest): string {
// Try various headers that might contain the real IP
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
// x-forwarded-for can contain multiple IPs, first is the client
return forwarded.split(",")[0].trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp) {
return realIp;
}
// Fallback to a default (in development)
return "unknown";
}
/**
* Checks if an IP is rate limited
*/
export function checkRateLimit(
ip: string,
config: RateLimitConfig = DEFAULT_CONFIG,
): {
allowed: boolean;
remaining: number;
resetTime: number;
blocked: boolean;
} {
const now = Date.now();
const entry = rateLimitStore.get(ip);
// If no entry exists, create one
if (!entry) {
rateLimitStore.set(ip, {
count: 1,
resetTime: now + config.windowMs,
blocked: false,
});
return {
allowed: true,
remaining: config.maxRequests - 1,
resetTime: now + config.windowMs,
blocked: false,
};
}
// Check if currently blocked
if (entry.blocked) {
const blockEndTime = entry.resetTime + config.blockDurationMs;
if (now < blockEndTime) {
return {
allowed: false,
remaining: 0,
resetTime: blockEndTime,
blocked: true,
};
}
// Block expired, reset
entry.blocked = false;
entry.count = 0;
entry.resetTime = now + config.windowMs;
}
// Check if window has expired
if (now > entry.resetTime) {
entry.count = 1;
entry.resetTime = now + config.windowMs;
return {
allowed: true,
remaining: config.maxRequests - 1,
resetTime: entry.resetTime,
blocked: false,
};
}
// Check if limit exceeded
if (entry.count >= config.maxRequests) {
entry.blocked = true;
return {
allowed: false,
remaining: 0,
resetTime: entry.resetTime + config.blockDurationMs,
blocked: true,
};
}
// Increment count
entry.count++;
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetTime: entry.resetTime,
blocked: false,
};
}
/**
* Rate limit middleware for API routes
*/
export function withRateLimit(
request: NextRequest,
config: RateLimitConfig = PUBLIC_CONFIG,
): { success: true } | { success: false; error: string; retryAfter: number } {
const ip = getClientIp(request);
const result = checkRateLimit(ip, config);
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
return {
success: false,
error: result.blocked
? "Too many requests. Your IP has been temporarily blocked."
: "Rate limit exceeded. Please try again later.",
retryAfter,
};
}
return { success: true };
}
/**
* Gets remaining requests for an IP
*/
export function getRemainingRequests(
ip: string,
config: RateLimitConfig = DEFAULT_CONFIG,
): number {
const entry = rateLimitStore.get(ip);
if (!entry || Date.now() > entry.resetTime) {
return config.maxRequests;
}
return Math.max(0, config.maxRequests - entry.count);
}
/**
* Resets rate limit for an IP (admin use)
*/
export function resetRateLimit(ip: string): void {
rateLimitStore.delete(ip);
}
/**
* Gets current rate limit stats (admin use)
*/
export function getRateLimitStats(): { totalIps: number; blockedIps: number } {
let blockedIps = 0;
for (const entry of rateLimitStore.values()) {
if (entry.blocked) blockedIps++;
}
return {
totalIps: rateLimitStore.size,
blockedIps,
};
}

View File

@@ -0,0 +1,204 @@
/**
* URL Validator
* Validates and sanitizes social media URLs
*/
import { Platform, SUPPORTED_DOMAINS } from "@/types/download";
export interface ValidationResult {
valid: boolean;
error?: string;
sanitizedUrl?: string;
platform?: Platform;
}
const DANGEROUS_PATTERNS = [
/javascript:/i,
/data:/i,
/vbscript:/i,
/file:/i,
/about:/i,
/blob:/i,
/\s+/g, // Whitespace
/<[^>]*>/g, // HTML tags
/%3C/gi, // Encoded <
/%3E/gi, // Encoded >
/%22/gi, // Encoded "
/%27/gi, // Encoded '
];
/**
* Validates a social media URL
* @param url - The URL to validate
* @returns ValidationResult with validation status and sanitized URL
*/
export function validateUrl(url: string): ValidationResult {
// 1. Check if URL is provided
if (!url || typeof url !== "string") {
return { valid: false, error: "URL is required" };
}
// 2. Trim whitespace
let sanitizedUrl = url.trim();
// 3. Check for dangerous patterns (XSS prevention)
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(sanitizedUrl)) {
return { valid: false, error: "Invalid URL format" };
}
}
// 4. Add protocol if missing
if (
!sanitizedUrl.startsWith("http://") &&
!sanitizedUrl.startsWith("https://")
) {
sanitizedUrl = "https://" + sanitizedUrl;
}
// 5. Parse and validate URL structure
let parsedUrl: URL;
try {
parsedUrl = new URL(sanitizedUrl);
} catch {
return { valid: false, error: "Invalid URL format" };
}
// 6. Enforce HTTPS only
if (parsedUrl.protocol !== "https:") {
// Allow http for localhost development
if (!parsedUrl.hostname.includes("localhost")) {
return { valid: false, error: "Only HTTPS URLs are allowed" };
}
}
// 7. Check for valid hostname
if (!parsedUrl.hostname || parsedUrl.hostname.length < 3) {
return { valid: false, error: "Invalid hostname" };
}
// 8. Check against supported domains
const hostname = parsedUrl.hostname.toLowerCase().replace("www.", "");
let detectedPlatform: Platform | null = null;
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
Platform,
string[],
][]) {
if (platform === "unknown") continue;
for (const domain of domains) {
if (hostname === domain || hostname.endsWith("." + domain)) {
detectedPlatform = platform;
break;
}
}
if (detectedPlatform) break;
}
if (!detectedPlatform) {
return {
valid: false,
error:
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
};
}
// 9. Final sanitization
sanitizedUrl = parsedUrl.toString();
return {
valid: true,
sanitizedUrl,
platform: detectedPlatform,
};
}
/**
* Sanitizes a URL by removing potentially dangerous characters
*/
export function sanitizeUrl(url: string): string {
return url
.trim()
.replace(/\s+/g, "")
.replace(/<[^>]*>/g, "")
.replace(/javascript:/gi, "")
.replace(/data:/gi, "")
.replace(/vbscript:/gi, "");
}
/**
* Checks if URL appears to be a valid social media video URL
*/
export function isVideoUrl(url: string): boolean {
const videoPatterns = [
/youtube\.com\/watch\?v=[\w-]+/i,
/youtu\.be\/[\w-]+/i,
/youtube\.com\/shorts\/[\w-]+/i,
/instagram\.com\/reel\/[\w-]+/i,
/instagram\.com\/tv\/[\w-]+/i,
/tiktok\.com\/@[\w.]+\/video\/\d+/i,
/vm\.tiktok\.com\/[\w-]+/i,
/twitter\.com\/\w+\/status\/\d+/i,
/x\.com\/\w+\/status\/\d+/i,
/facebook\.com\/.*\/videos\/\d+/i,
/fb\.watch\/[\w-]+/i,
];
return videoPatterns.some((pattern) => pattern.test(url));
}
/**
* Extracts video ID from URL (platform-specific)
*/
export function extractVideoId(url: string, platform: Platform): string | null {
try {
const urlObj = new URL(url);
switch (platform) {
case "youtube": {
// youtu.be/VIDEO_ID
if (urlObj.hostname === "youtu.be") {
return urlObj.pathname.slice(1);
}
// youtube.com/watch?v=VIDEO_ID
const vParam = urlObj.searchParams.get("v");
if (vParam) return vParam;
// youtube.com/shorts/VIDEO_ID
if (urlObj.pathname.startsWith("/shorts/")) {
return urlObj.pathname.replace("/shorts/", "");
}
return null;
}
case "instagram": {
// instagram.com/p/POST_ID or /reel/REEL_ID
const match = urlObj.pathname.match(/\/(p|reel|tv)\/([\w-]+)/);
return match ? match[2] : null;
}
case "tiktok": {
// tiktok.com/@user/video/VIDEO_ID
const match = urlObj.pathname.match(/\/video\/(\d+)/);
return match ? match[1] : null;
}
case "twitter": {
// twitter.com/user/status/TWEET_ID
const match = urlObj.pathname.match(/\/status\/(\d+)/);
return match ? match[1] : null;
}
case "facebook": {
// facebook.com/.../videos/VIDEO_ID
const match = urlObj.pathname.match(/\/videos\/(\d+)/);
return match ? match[1] : null;
}
default:
return null;
}
} catch {
return null;
}
}

View File

@@ -1,55 +1,19 @@
import { NAV_ITEMS } from "@/config/navigation";
import { withAuth } from "next-auth/middleware";
import createMiddleware from "next-intl/middleware";
import { NextRequest } from "next/server";
import { routing } from "./i18n/routing";
const publicPages = NAV_ITEMS.flatMap((item) => [
...(!item.protected ? [item.href] : []),
...(item.children
?.filter((child) => !child.protected)
.map((child) => child.href) ?? []),
]);
const handleI18nRouting = createMiddleware(routing);
const authMiddleware = withAuth(
// Note that this callback is only invoked if
// the `authorized` callback has returned `true`
// and not for pages listed in `pages`.
function onSuccess(req) {
return handleI18nRouting(req);
},
{
callbacks: {
authorized: ({ token }) => token != null,
},
pages: {
signIn: "/home",
},
},
);
export default function proxy(req: NextRequest) {
// CRITICAL: Skip API routes entirely - they should not go through i18n or auth middleware
// CRITICAL: Skip API routes entirely - they should not go through i18n middleware
if (req.nextUrl.pathname.startsWith("/api/")) {
return; // Return undefined to pass through without modification
}
const publicPathnameRegex = RegExp(
`^(/(${routing.locales.join("|")}))?(${publicPages.flatMap((p) => (p === "/" ? ["", "/"] : p)).join("|")})/?$`,
"i",
);
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
if (isPublicPage) {
return handleI18nRouting(req);
} else {
return (authMiddleware as any)(req);
}
// All pages are public - no auth required
return handleI18nRouting(req);
}
export const config = {
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
// matcher: ['/', '/(de|en|tr)/:path*'],
};

33
src/types/ab-downloader.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/**
* Type declarations for ab-downloader package
* @see https://www.npmjs.com/package/ab-downloader
*/
declare module "ab-downloader" {
export interface DownloadResult {
url: string;
title?: string;
thumbnail?: string;
duration?: number;
quality?: string;
developer?: string;
contactme?: string;
}
// All-in-one downloader (auto-detects platform)
export function aio(url: string): Promise<DownloadResult | DownloadResult[]>;
// Platform-specific downloaders
// Note: igdl returns an array
export function igdl(url: string): Promise<DownloadResult[]>; // Instagram
export function youtube(url: string): Promise<DownloadResult | DownloadResult[]>; // YouTube
export function ttdl(url: string): Promise<DownloadResult | DownloadResult[]>; // TikTok
export function twitter(url: string): Promise<DownloadResult | DownloadResult[]>; // Twitter/X
export function fbdown(url: string): Promise<DownloadResult | DownloadResult[]>; // Facebook
// Other downloaders
export function mediafire(url: string): Promise<DownloadResult | DownloadResult[]>;
export function capcut(url: string): Promise<DownloadResult | DownloadResult[]>;
export function gdrive(url: string): Promise<DownloadResult | DownloadResult[]>;
export function pinterest(url: string): Promise<DownloadResult | DownloadResult[]>;
}

73
src/types/distube-ytdl-core.d.ts vendored Normal file
View File

@@ -0,0 +1,73 @@
/**
* Type declarations for @distube/ytdl-core
* @see https://www.npmjs.com/package/@distube/ytdl-core
*/
declare module "@distube/ytdl-core" {
export interface VideoDetails {
title: string;
author?: {
name: string;
user?: string;
channel_url?: string;
};
lengthSeconds: string;
viewCount?: string;
thumbnails?: Array<{ url: string; width?: number; height?: number }>;
description?: string;
media?: Record<string, string>;
}
export interface Format {
itag: number;
url: string;
mimeType?: string;
quality?: string;
qualityLabel?: string;
audioQuality?: string;
hasVideo: boolean;
hasAudio: boolean;
container?: string;
codecs?: string;
width?: number;
height?: number;
fps?: number;
bitrate?: number;
audioBitrate?: number;
duration?: string;
}
export interface VideoInfo {
videoDetails: VideoDetails;
formats: Format[];
related_videos?: unknown[];
}
export function getInfo(url: string): Promise<VideoInfo>;
export function getInfo(videoId: string): Promise<VideoInfo>;
export function getURLVideoID(url: string): string;
export function getVideoID(url: string): string;
export function chooseFormat(
formats: Format[],
options?: { quality?: string; filter?: string }
): Format | undefined;
export function filterFormats(
formats: Format[],
filter: string
): Format[];
export function validateID(id: string): boolean;
export function validateURL(url: string): boolean;
const ytdl: {
(url: string, options?: unknown): NodeJS.ReadableStream;
getInfo: typeof getInfo;
getURLVideoID: typeof getURLVideoID;
getVideoID: typeof getVideoID;
chooseFormat: typeof chooseFormat;
filterFormats: typeof filterFormats;
validateID: typeof validateID;
validateURL: typeof validateURL;
};
export default ytdl;
}

154
src/types/download.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Download Feature Types
* Social Media Video Downloader
*/
// Supported platforms
export type Platform =
| "youtube"
| "instagram"
| "tiktok"
| "twitter"
| "facebook"
| "unknown";
// Video quality options
export type VideoQuality = "max" | "1080" | "720" | "480" | "360";
// Audio format options
export type AudioFormat = "mp3" | "opus" | "wav" | "best";
// API Request types
export interface DownloadRequest {
url: string;
quality?: VideoQuality;
audioFormat?: AudioFormat;
audioBitrate?: "320" | "256" | "128" | "64";
}
export interface InfoRequest {
url: string;
}
// API Response types
export interface CobaltErrorResponse {
error: {
code: CobaltErrorCode;
message?: string;
context?: {
service?: string;
};
};
}
export interface CobaltSuccessResponse {
status: "redirect" | "stream" | "picker";
url?: string;
picker?: PickerItem[];
filename?: string;
}
export type CobaltResponse = CobaltErrorResponse | CobaltSuccessResponse;
export interface PickerItem {
type: "video" | "photo";
url: string;
thumb?: string;
}
export type CobaltErrorCode =
| "error.api.link.invalid"
| "error.api.fetch.fail"
| "error.api.content.unavailable"
| "error.api.rate_exceeded"
| "error.api.fetch.rate"
| "error.api.content.video_region"
| "error.api.content.post.private"
| "error.api.fetch.short"
| "error.api.fetch.critical"
| "error.api.fetch.timeout"
| "error.api.info.fail";
// Media type
export type MediaType = 'video' | 'image' | 'audio';
// Our API Response types
export interface DownloadResponse {
success: boolean;
data?: {
downloadUrl: string;
filename: string;
platform: Platform;
title?: string;
thumbnail?: string;
mediaType?: MediaType;
duration?: string;
author?: string;
likes?: string;
views?: string;
};
error?: {
code: string;
message: string;
};
}
export interface InfoResponse {
success: boolean;
data?: {
platform: Platform;
title?: string;
thumbnail?: string;
duration?: number;
qualities?: VideoQuality[];
};
error?: {
code: string;
message: string;
};
}
// Platform detection result
export interface PlatformDetectionResult {
platform: Platform;
confidence: "high" | "low";
originalUrl: string;
normalizedUrl?: string;
}
// Rate limiting
export interface RateLimitInfo {
ip: string;
requests: number;
lastRequest: number;
blocked: boolean;
blockUntil?: number;
}
// Supported domains map
export const SUPPORTED_DOMAINS: Record<Platform, string[]> = {
youtube: [
"youtube.com",
"youtu.be",
"music.youtube.com",
"youtube-nocookie.com",
],
instagram: ["instagram.com", "instagr.am"],
tiktok: ["tiktok.com", "vm.tiktok.com", "vt.tiktok.com"],
twitter: ["twitter.com", "x.com", "t.co"],
facebook: ["facebook.com", "fb.watch", "fb.com"],
unknown: [],
};
// Platform display info
export const PLATFORM_INFO: Record<
Platform,
{ name: string; color: string; icon: string }
> = {
youtube: { name: "YouTube", color: "#FF0000", icon: "youtube" },
instagram: { name: "Instagram", color: "#E4405F", icon: "instagram" },
tiktok: { name: "TikTok", color: "#000000", icon: "tiktok" },
twitter: { name: "X (Twitter)", color: "#1DA1F2", icon: "twitter" },
facebook: { name: "Facebook", color: "#1877F2", icon: "facebook" },
unknown: { name: "Bilinmeyen", color: "#6B7280", icon: "question" },
};

91
src/types/ruhend-scraper.d.ts vendored Normal file
View File

@@ -0,0 +1,91 @@
/**
* Type declarations for ruhend-scraper
* @see https://www.npmjs.com/package/ruhend-scraper
*/
declare module "ruhend-scraper" {
// YouTube MP3 result
export interface Ytmp3Result {
title: string;
audio: string;
author: string;
description: string;
duration: string;
views: string;
upload: string;
thumbnail: string;
}
// YouTube MP4 result
export interface Ytmp4Result {
title: string;
audio: string;
author: string;
description: string;
duration: string;
views: string;
upload: string;
thumbnail: string;
}
// TikTok result
export interface TtdlResult {
title: string;
author: string;
username: string;
published: string;
like: string;
comment: string;
share: string;
views: string;
bookmark: string;
video: string;
cover: string;
music: string;
profilePicture: string;
}
// Instagram/Facebook media item
export interface MediaItem {
url: string;
}
// Instagram/Facebook response
export interface MediaResponse {
data: MediaItem[];
}
// YouTube search video result
export interface YtSearchVideo {
type: "video";
title: string;
url: string;
durationH: string;
publishedTime: string;
view: string;
thumbnail: string;
}
// YouTube search channel result
export interface YtSearchChannel {
type: "channel";
channelName: string;
url: string;
subscriberH: string;
videoCount: string;
}
// YouTube search result
export interface YtSearchResult {
video: YtSearchVideo[];
channel: YtSearchChannel[];
}
// Export functions
export function ytmp3(url: string): Promise<Ytmp3Result>;
export function ytmp4(url: string): Promise<Ytmp4Result>;
export function ttdl(url: string): Promise<TtdlResult>;
export function igdl(url: string): Promise<MediaResponse>;
export function fbdl(url: string): Promise<MediaResponse>;
export function ytsearch(query: string): Promise<YtSearchResult>;
}

29
src/types/test-downloader.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Type declarations for test-downloader
* Social media downloader package
*/
declare module "test-downloader" {
export interface TestDownloaderResult {
developer?: string;
title?: string;
url?: string | Array<{ hd?: string; sd?: string } | Record<string, unknown>>;
thumbnail?: string;
video?: string[];
audio?: string[];
HD?: string;
Normal_video?: string;
status?: string;
message?: string;
}
export function rebelaldwn(url: string): Promise<TestDownloaderResult>;
export function rebelfbdown(url: string): Promise<TestDownloaderResult>;
export function rebelinstadl(url: string): Promise<TestDownloaderResult>;
export function rebeltiktokdl(url: string): Promise<TestDownloaderResult>;
export function rebeltwitter(url: string): Promise<TestDownloaderResult>;
export function rebelyt(url: string): Promise<TestDownloaderResult>;
export function rebelpindl(url: string): Promise<TestDownloaderResult>;
export function rebelcapcutdl(url: string): Promise<TestDownloaderResult>;
export function rebellikeedl(url: string): Promise<TestDownloaderResult>;
}

147
src/types/vreden-youtube-scraper.d.ts vendored Normal file
View File

@@ -0,0 +1,147 @@
/**
* Type declarations for @vreden/youtube_scraper
* YouTube video downloader for audio and video formats
*/
declare module "@vreden/youtube_scraper" {
// Metadata types
export interface VredenAuthor {
name?: string;
url?: string;
}
export interface VredenDuration {
seconds: number;
timestamp: string;
}
export interface VredenMetadata {
type?: "video";
videoId?: string;
url?: string;
title?: string;
description?: string;
image?: string;
thumbnail?: string;
seconds?: number;
timestamp?: string;
duration?: VredenDuration;
ago?: string;
views?: number;
author?: VredenAuthor;
}
// Download types
export interface VredenDownload {
status?: boolean;
quality?: string;
availableQuality?: number[];
url?: string;
filename?: string;
}
// Result type
export interface VredenYoutubeResult {
status?: boolean;
creator?: string;
metadata?: VredenMetadata;
download?: VredenDownload;
}
// Channel metadata types
export interface VredenChannelThumbnail {
quality?: string;
url?: string;
width?: number;
height?: number;
}
export interface VredenChannelStatistics {
view?: string;
video?: string;
subscriber?: string;
}
export interface VredenChannelResult {
id?: string;
title?: string;
description?: string;
username?: string;
thumbnails?: VredenChannelThumbnail[];
banner?: string;
published_date?: string;
published_format?: string;
statistics?: VredenChannelStatistics;
}
// Search result types
export interface VredenSearchResult {
type?: "video" | "channel" | "playlist";
videoId?: string;
title?: string;
description?: string;
thumbnail?: string;
duration?: string;
views?: number;
author?: VredenAuthor;
url?: string;
}
/**
* Download YouTube audio as MP3
* @param link - YouTube URL or video ID
* @param quality - Audio quality in kbps (92, 128, 256, 320)
*/
export function ytmp3(
link: string,
quality?: 92 | 128 | 256 | 320
): Promise<VredenYoutubeResult>;
/**
* Download YouTube video as MP4
* @param link - YouTube URL or video ID
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
*/
export function ytmp4(
link: string,
quality?: 144 | 360 | 480 | 720 | 1080
): Promise<VredenYoutubeResult>;
/**
* Alternative MP3 downloader using api.vreden.my.id
* @param link - YouTube URL or video ID
* @param quality - Audio quality in kbps (92, 128, 256, 320)
*/
export function apimp3(
link: string,
quality?: 92 | 128 | 256 | 320
): Promise<VredenYoutubeResult>;
/**
* Alternative MP4 downloader using api.vreden.my.id
* @param link - YouTube URL or video ID
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
*/
export function apimp4(
link: string,
quality?: 144 | 360 | 480 | 720 | 1080
): Promise<VredenYoutubeResult>;
/**
* Search YouTube for videos, channels, and playlists
* @param query - Search query string
*/
export function search(query: string): Promise<VredenSearchResult[]>;
/**
* Fetch detailed video metadata
* @param link - YouTube video URL or ID
*/
export function metadata(link: string): Promise<VredenMetadata>;
/**
* Fetch channel metadata
* @param usernameOrUrl - YouTube channel URL or username (@handle or custom URL)
*/
export function channel(usernameOrUrl: string): Promise<VredenChannelResult>;
}

File diff suppressed because one or more lines are too long