Initialize project
This commit is contained in:
52
backend/app/core/cleanup.py
Normal file
52
backend/app/core/cleanup.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import shutil
|
||||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
TEMP_DIR = Path("backend/temp_uploads")
|
||||
|
||||
def init_temp_dir():
|
||||
"""임시 디렉토리 생성 (없으면 생성)"""
|
||||
if not TEMP_DIR.exists():
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Created temp directory at: {TEMP_DIR.absolute()}")
|
||||
|
||||
def cleanup_old_files(max_age_seconds: int = 600):
|
||||
"""지정된 시간(기본 10분)보다 오래된 파일/폴더 삭제"""
|
||||
if not TEMP_DIR.exists():
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
deleted_count = 0
|
||||
|
||||
for item in TEMP_DIR.iterdir():
|
||||
try:
|
||||
# 최종 수정 시간 확인
|
||||
item_mtime = item.stat().st_mtime
|
||||
if now - item_mtime > max_age_seconds:
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Error deleting {item}: {e}")
|
||||
|
||||
if deleted_count > 0:
|
||||
print(f"Cleaned up {deleted_count} old items from temp directory.")
|
||||
|
||||
def cleanup_all():
|
||||
"""서버 시작 시 모든 임시 파일 삭제"""
|
||||
if TEMP_DIR.exists():
|
||||
shutil.rmtree(TEMP_DIR)
|
||||
init_temp_dir()
|
||||
print("Cleaned up all temporary files on startup.")
|
||||
|
||||
def start_scheduler():
|
||||
"""백그라운드 스케줄러 시작 (1분마다 정리 작업 실행)"""
|
||||
scheduler = BackgroundScheduler()
|
||||
# 10분(600초) 지난 파일 삭제 작업을 1분마다 수행
|
||||
scheduler.add_job(cleanup_old_files, 'interval', minutes=1, args=[600])
|
||||
scheduler.start()
|
||||
return scheduler
|
||||
164
backend/app/core/registry.py
Normal file
164
backend/app/core/registry.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ToolMetadata(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
category: str
|
||||
tags: List[str]
|
||||
type: str # 'client' or 'server'
|
||||
endpoint: Optional[str] = None # Only for server type
|
||||
|
||||
# === 도구 등록소 ===
|
||||
# 새로운 기능을 추가할 때 여기만 수정하면 검색/메뉴에 자동 반영되도록 구성합니다.
|
||||
TOOLS_REGISTRY = [
|
||||
ToolMetadata(
|
||||
id="url-parser",
|
||||
name="URL Encoder/Decoder",
|
||||
description="Encodes or decodes a URL string.",
|
||||
category="Web",
|
||||
tags=["url", "encode", "decode", "percent-encoding"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="json-formatter",
|
||||
name="JSON Formatter",
|
||||
description="Prettify or minify JSON data.",
|
||||
category="Data",
|
||||
tags=["json", "pretty", "minify", "format"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="py-uuid",
|
||||
name="UUID Generator",
|
||||
description="Generates UUIDs locally (v1/v4).",
|
||||
category="Development",
|
||||
tags=["uuid", "guid", "random", "client-side"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="png-compressor",
|
||||
name="PNG Compressor",
|
||||
description="Compress PNG images using pngquant (Lossy, 60-80% reduction).",
|
||||
category="Image",
|
||||
tags=["image", "png", "compress", "pngquant", "optimize"],
|
||||
type="server",
|
||||
endpoint="/api/tools/png-compress"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="password-generator",
|
||||
name="Password Generator",
|
||||
description="Generate secure random passwords locally (Client-side).",
|
||||
category="Security",
|
||||
tags=["password", "random", "secure", "generator", "client-side"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="jwt-debugger",
|
||||
name="JWT Debugger",
|
||||
description="Decode and inspect JSON Web Tokens.",
|
||||
category="Security",
|
||||
tags=["jwt", "token", "decode", "security"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="cron-generator",
|
||||
name="Cron Schedule Generator",
|
||||
description="Generate and explain cron expressions.",
|
||||
category="Utility",
|
||||
tags=["cron", "schedule", "time", "generator"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="sql-formatter",
|
||||
name="SQL Formatter",
|
||||
description="Beautify and format SQL queries.",
|
||||
category="Formatters",
|
||||
tags=["sql", "format", "database", "query"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="diff-viewer",
|
||||
name="Diff Viewer",
|
||||
description="Compare text and see differences side-by-side.",
|
||||
category="Text",
|
||||
tags=["diff", "compare", "text", "code"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="qr-generator",
|
||||
name="QR Code Generator",
|
||||
description="Generate QR codes from text or URLs.",
|
||||
category="Generators",
|
||||
tags=["qr", "code", "generator", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="base64-encoder",
|
||||
name="Base64 File Encoder",
|
||||
description="Convert files to Base64 strings.",
|
||||
category="Encoders",
|
||||
tags=["base64", "encode", "file", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="json-converter",
|
||||
name="JSON Converter",
|
||||
description="Convert between JSON, CSV, and YAML.",
|
||||
category="Converters",
|
||||
tags=["json", "csv", "yaml", "convert"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="regex-tester",
|
||||
name="Regex Tester",
|
||||
description="Test regular expressions in real-time.",
|
||||
category="Text",
|
||||
tags=["regex", "test", "pattern", "match"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="docker-converter",
|
||||
name="Docker Converter",
|
||||
description="Convert between Docker Run and Docker Compose.",
|
||||
category="DevOps",
|
||||
tags=["docker", "compose", "convert", "devops"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="mock-data-generator",
|
||||
name="Mock Data Generator",
|
||||
description="Generate random user data for testing.",
|
||||
category="Generators",
|
||||
tags=["mock", "data", "fake", "generator"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="svg-optimizer",
|
||||
name="SVG Optimizer",
|
||||
description="Optimize and minify SVG code.",
|
||||
category="Image",
|
||||
tags=["svg", "optimize", "minify", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="video-to-gif",
|
||||
name="Video to GIF",
|
||||
description="Convert video clips to GIF format.",
|
||||
category="Video",
|
||||
tags=["video", "gif", "convert", "ffmpeg"],
|
||||
type="server",
|
||||
endpoint="/api/tools/video/gif"
|
||||
)
|
||||
]
|
||||
|
||||
def search_tools(query: str = "") -> List[ToolMetadata]:
|
||||
if not query:
|
||||
return TOOLS_REGISTRY
|
||||
|
||||
q = query.lower()
|
||||
return [
|
||||
t for t in TOOLS_REGISTRY
|
||||
if q in t.name.lower() or q in t.description.lower() or any(q in tag for tag in t.tags)
|
||||
]
|
||||
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add backend directory to sys.path to resolve 'app' module
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.registry import TOOLS_REGISTRY, search_tools
|
||||
from app.core.cleanup import start_scheduler, cleanup_all, init_temp_dir
|
||||
from app.tools import server_ops, image_ops, video_ops
|
||||
import contextlib
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cleanup_all()
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
|
||||
app = FastAPI(title="Web Utils 2026", description="Personal Web Utilities Server", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(server_ops.router, prefix="/api/tools", tags=["tools"])
|
||||
app.include_router(image_ops.router, prefix="/api/tools", tags=["images"])
|
||||
app.include_router(video_ops.router, prefix="/api/tools/video", tags=["video"])
|
||||
|
||||
@app.get("/api/registry")
|
||||
def get_registry(q: str | None = None):
|
||||
"""프론트엔드 메뉴 구성을 위한 도구 목록 반환"""
|
||||
return search_tools(q or "")
|
||||
|
||||
# Static Files Serving (Frontend 빌드 결과물 서빙용 - 배포 시 활성화)
|
||||
# 개발 중에는 Frontend Dev Server(Vite)를 별도로 띄우는 것이 일반적이지만,
|
||||
# 최종 배포 형태를 고려해 코드를 남겨둡니다.
|
||||
# frontend_dist = "../frontend/dist"
|
||||
# if os.path.exists(frontend_dist):
|
||||
# app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# Use 'app.main:app' to ensure reliable reloading from backend root
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
144
backend/app/tools/image_ops.py
Normal file
144
backend/app/tools/image_ops.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from app.core.cleanup import TEMP_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_png_color_type(file_path: Path) -> int | None:
|
||||
"""PNG 파일의 color type 확인 (참조 문서 로직)"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header = f.read(26)
|
||||
if header[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
return header[25]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@router.post("/png-compress")
|
||||
async def compress_png_files(files: List[UploadFile] = File(...)):
|
||||
"""
|
||||
업로드된 PNG 파일들을 pngquant로 압축하고 통계를 반환합니다.
|
||||
"""
|
||||
# 작업 ID 생성 (폴더 분리용)
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = TEMP_DIR / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
compressed_files = []
|
||||
|
||||
try:
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith('.png'):
|
||||
continue
|
||||
|
||||
# 1. 파일 저장
|
||||
file_path = job_dir / file.filename
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
orig_size = file_path.stat().st_size
|
||||
|
||||
# 2. 이미 압축된 파일인지 확인 (Color Type 3 = Indexed)
|
||||
color_type = get_png_color_type(file_path)
|
||||
if color_type == 3:
|
||||
# 이미 압축됨 -> 그냥 결과에 추가 (압축 안 함)
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": orig_size,
|
||||
"ratio": 0.0,
|
||||
"status": "Skipped (Already Indexed)"
|
||||
})
|
||||
compressed_files.append(file_path)
|
||||
continue
|
||||
|
||||
# 3. pngquant 실행
|
||||
output_path = job_dir / f"compressed_{file.filename}"
|
||||
try:
|
||||
# --force: 덮어쓰기 허용, --quality: 품질 설정
|
||||
cmd = [
|
||||
"pngquant",
|
||||
"--quality", "65-80",
|
||||
"--force",
|
||||
"--output", str(output_path),
|
||||
str(file_path)
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
if output_path.exists():
|
||||
comp_size = output_path.stat().st_size
|
||||
ratio = (1 - comp_size / orig_size) * 100
|
||||
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": comp_size,
|
||||
"ratio": round(ratio, 1),
|
||||
"status": "Success"
|
||||
})
|
||||
compressed_files.append(output_path)
|
||||
else:
|
||||
raise Exception("Output file not created")
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
# 압축 실패 (품질 기준 미달 등) -> 원본 유지
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": orig_size,
|
||||
"ratio": 0.0,
|
||||
"status": "Failed (Quality check)"
|
||||
})
|
||||
compressed_files.append(file_path)
|
||||
|
||||
# 4. ZIP 생성
|
||||
zip_filename = f"compressed_images_{job_id}.zip"
|
||||
zip_path = TEMP_DIR / zip_filename
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||
for f in compressed_files:
|
||||
# ZIP 내부에는 원래 파일명으로 저장
|
||||
arcname = f.name.replace("compressed_", "")
|
||||
zipf.write(f, arcname)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"zip_filename": zip_filename,
|
||||
"stats": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
cleanup_files([job_dir])
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/download/{filename}")
|
||||
async def download_file(filename: str):
|
||||
file_path = TEMP_DIR / filename
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# 즉시 삭제하지 않고 스케줄러(10분 후)에 맡김
|
||||
return FileResponse(file_path, filename=filename)
|
||||
|
||||
@router.get("/download-single/{job_id}/{filename}")
|
||||
async def download_single_file(job_id: str, filename: str):
|
||||
"""개별 파일 다운로드 (삭제 예약 없음 - 전체 ZIP 다운로드 등을 위해 유지)"""
|
||||
file_path = TEMP_DIR / job_id / f"compressed_{filename}"
|
||||
|
||||
# 만약 compressed_ 접두사가 없다면 원본 파일일 수도 있음 (압축 실패 시)
|
||||
if not file_path.exists():
|
||||
file_path = TEMP_DIR / job_id / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(file_path, filename=filename)
|
||||
12
backend/app/tools/server_ops.py
Normal file
12
backend/app/tools/server_ops.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
# --- Server-side Logic Implementations ---
|
||||
# Currently empty as UUID and Password generation moved to client-side.
|
||||
# Future server-side logic can be added here.
|
||||
|
||||
60
backend/app/tools/video_ops.py
Normal file
60
backend/app/tools/video_ops.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, UploadFile, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
import ffmpeg
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TEMP_DIR = Path("temp_uploads")
|
||||
TEMP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def cleanup_file(path: Path):
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up file {path}: {e}")
|
||||
|
||||
@router.post("/gif")
|
||||
async def convert_video_to_gif(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
if not (file.content_type or "").startswith("video/"):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
input_path = TEMP_DIR / f"{file_id}_{file.filename}"
|
||||
output_filename = f"{file_id}.gif"
|
||||
output_path = TEMP_DIR / output_filename
|
||||
|
||||
try:
|
||||
with input_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
stream = ffmpeg.input(str(input_path))
|
||||
stream = ffmpeg.filter(stream, 'fps', fps=10)
|
||||
stream = ffmpeg.filter(stream, 'scale', 320, -1)
|
||||
stream = ffmpeg.output(stream, str(output_path))
|
||||
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
|
||||
|
||||
background_tasks.add_task(cleanup_file, input_path)
|
||||
background_tasks.add_task(cleanup_file, output_path)
|
||||
|
||||
return FileResponse(
|
||||
output_path,
|
||||
media_type="image/gif",
|
||||
filename="converted.gif"
|
||||
)
|
||||
|
||||
except ffmpeg.Error as e:
|
||||
cleanup_file(input_path)
|
||||
if output_path.exists():
|
||||
cleanup_file(output_path)
|
||||
print(e.stderr.decode('utf8'))
|
||||
raise HTTPException(status_code=500, detail="FFmpeg conversion failed")
|
||||
except Exception as e:
|
||||
cleanup_file(input_path)
|
||||
if output_path.exists():
|
||||
cleanup_file(output_path)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
pydantic==2.6.0
|
||||
python-multipart
|
||||
apscheduler==3.10.4
|
||||
Reference in New Issue
Block a user