Initialize project

This commit is contained in:
woozu.shin
2026-01-28 15:33:47 +09:00
commit 3fe5424732
43 changed files with 6108 additions and 0 deletions

View 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

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

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

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

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

@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn==0.27.0
pydantic==2.6.0
python-multipart
apscheduler==3.10.4