commit 27540269b720bb43f99d309beed6d743a9ec1177 Author: dsyoon Date: Mon Feb 16 17:17:22 2026 +0900 Initial commit: add FastAPI MVP (모프) and existing web app Includes FastAPI+Jinja2+HTMX+SQLite implementation with seed categories, plus deployment templates. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45f5f39 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# 운영용(도커) +# 아래 값을 복사해 .env 로 만들고 채우세요. + +# Next.js 앱이 사용할 DB (원격 Postgres) +DATABASE_URL="postgresql://ncue:@ncue.net:5432/all_prompt?schema=public" + +# Caddy(HTTPS) 인증서 발급용 이메일(필수) +CADDY_EMAIL="you@example.com" + +# (선택) db_init 서비스용 psql 환경변수 +PGHOST="ncue.net" +PGDATABASE="all_prompt" +PGUSER="ncue" +PGPASSWORD="" + +# (선택) 프론트에서 참조할 사이트 URL +NEXT_PUBLIC_SITE_URL="https://prompt.ncue.net" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea0f93f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtualenv +venv/ +.venv/ + +# Local SQLite DB (generated by running the MVP) +all_prompt.db +*.db + +# OS / Editor +.DS_Store + +# Node (existing apps/web) +node_modules/ +.next/ +dist/ +build/ +out/ + diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..5f95077 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,9 @@ +{ + email {$CADDY_EMAIL} +} + +prompt.ncue.net { + encode zstd gzip + reverse_proxy web:3000 +} + diff --git a/PROMPT.txt b/PROMPT.txt new file mode 100644 index 0000000..220afb2 --- /dev/null +++ b/PROMPT.txt @@ -0,0 +1,125 @@ +당신은 MVP 제작에 특화된 시니어 풀스택 엔지니어입니다. +아래 요구사항을 만족하는 프롬프트 공유 커뮤니티 웹 서비스를 설계하고 구현하세요. + +## 1. 서비스 개요 +- 서비스명: all prompt +- 한국어 이름: 모프 +- 목적: 누구나 쉽게 AI 프롬프트를 + - 탐색 + - 등록 + - 복사 + - 간단히 평가(좋아요) + 할 수 있는 초경량 커뮤니티 + +## 2. 기술 스택 (최소 구성) +- Backend: Python + FastAPI +- Frontend: Jinja2 Template + HTMX (React 사용 금지) +- DB: SQLite (초기), ORM은 SQLAlchemy +- Auth: 로그인 없이 시작 (닉네임 기반, 쿠키 저장) +- 실행: `python main.py` 로 바로 실행 가능 +- 배포 고려하지 않음 (로컬/소규모 서버용) + +## 3. UX / UI 원칙 +- 매우 단순할 것 +- 회원가입 없음 +- 프롬프트 등록은 버튼 한 번 +- 모바일에서도 읽기 쉬운 UI +- Tailwind CDN 사용 (빌드 없음) + +## 4. 핵심 기능 +### 4.1 프롬프트 +- 프롬프트 목록 보기 +- 프롬프트 상세 보기 +- 프롬프트 등록 +- 프롬프트 복사 버튼 +- 좋아요 버튼 (중복 방지: IP 또는 쿠키 기반) + +### 4.2 카테고리 +- 예시: + - 글쓰기 + - 코딩 + - 업무 자동화 + - 이미지 생성 + - 데이터 분석 +- 프롬프트는 하나의 카테고리에만 속함 + +### 4.3 검색 +- 제목 + 프롬프트 내용 LIKE 검색 +- 한 페이지에 20개 노출 + +## 5. DB 설계 (반드시 테이블 정의 포함) +- host: ncue.net +- database: all_prompt +- user: ncue +- password: ncue5004! +- 필요한 테이블은 직접 생성하시오 + +### users +- id (PK) +- nickname (string) +- created_at (datetime) + +### categories +- id (PK) +- name (string) +- slug (string) + +### prompts +- id (PK) +- title (string) +- content (text) +- description (text, nullable) +- category_id (FK) +- author_nickname (string) +- created_at (datetime) +- copy_count (int) + +### likes +- id (PK) +- prompt_id (FK) +- user_identifier (string) # 쿠키 or IP 해시 +- created_at (datetime) + +## 6. URL 설계 +- GET / → 프롬프트 리스트 +- GET /prompt/{id} → 프롬프트 상세 +- GET /new → 프롬프트 등록 폼 +- POST /new → 프롬프트 저장 +- POST /like/{id} → 좋아요 +- GET /search?q= → 검색 + +## 7. 디렉토리 구조 +- main.py +- models.py +- database.py +- templates/ + - base.html + - index.html + - detail.html + - new.html +- static/ + - (비워도 됨) + +## 8. 구현 방식 +- FastAPI + Jinja2 TemplateResponse 사용 +- 모든 코드는 하나의 저장소에서 바로 실행 가능 +- migration 도구 사용 금지 (create_all 사용) +- 주석을 충분히 작성해 초보자도 이해 가능하게 + +## 9. 추가 조건 +- "나중에 로그인 붙이기 쉬운 구조"로 작성 +- 코드 가독성 최우선 +- 불필요한 추상화 금지 +- Cursor가 파일 단위로 코드를 생성하도록 안내 + +## 10. 결과물 +- 완전 실행 가능한 코드 +- 실행 방법 설명 포함 +- 초기 카테고리 seed 코드 포함 + +이 요구사항을 기준으로 +1️⃣ 전체 구조 설명 +2️⃣ DB 모델 코드 +3️⃣ main.py +4️⃣ 템플릿 파일들 +을 순서대로 작성하세요. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7feda5d --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# all prompt (모프) + +AI 프롬프트 공유 커뮤니티 플랫폼. 나무위키처럼 **대상(Entity) 중심으로 문서를 탐색**하다가 프롬프트를 발견/이해/기여할 수 있게 설계한다. + +## 핵심 컨셉 + +- **Entity(대상) 중심 구조**: 모든 프롬프트는 특정 Entity 문서에 귀속된다. +- **프롬프트는 문서(위키) 형태**: 원문 + 설명 + 예시 + 모델 + 태그 + 버전 히스토리 + 리믹스(파생). +- **권한 모델** + - 비회원: 열람 + - 회원: 등록/댓글/좋아요/북마크 + - 기여자: 수정 제안(리비전 생성) + - 관리자: 승인/롤백/신고 처리 + +## DB 스키마 + +`db/schema.sql`에 PostgreSQL DDL이 있으며, `prisma/schema.prisma`는 동일 구조의 Prisma 매핑이다. + +### 원격 DB 적용(권장: 로컬에서 실행) + +보안상 이 저장소/문서에는 비밀번호를 포함하지 않는다. 아래처럼 환경변수로 전달해서 실행한다. + +```bash +export PGPASSWORD='' +psql -h ncue.net -U ncue -d all_prompt -f db/schema.sql +``` + +## 도커로 운영(ubuntu 22.04) + +루트에 `docker-compose.yml`, `Caddyfile`이 있으며 **HTTPS 포함**으로 `prompt.ncue.net`을 바로 띄울 수 있다. + +```bash +cp .env.example .env +# .env에서 DATABASE_URL, CADDY_EMAIL, (필요 시) PGPASSWORD 채우기 +docker compose up -d --build +``` + +스키마/시드(필요 시 1회): + +```bash +docker compose --profile init run --rm db_init +docker compose --profile init run --rm db_seed +``` + +## 기술 스택(가정) + +- Frontend: Next.js (App Router) +- Backend: Next.js API(Route Handlers) 또는 NestJS +- DB: PostgreSQL +- ORM: Prisma +- Auth: OAuth + Email + +## 폴더 구조(예시) + +`docs/architecture.md` 참고. + diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..b793565 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,53 @@ +FROM node:20-bookworm-slim AS deps + +WORKDIR /app + +# system deps (openssl for prisma engines; ca-certs for TLS) +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates openssl \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +COPY prisma ./prisma + +RUN npm ci + + +FROM node:20-bookworm-slim AS builder + +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/package-lock.json ./package-lock.json +COPY --from=deps /app/prisma ./prisma + +COPY src ./src +COPY public ./public +COPY next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs ./ + +RUN npx prisma generate --schema prisma/schema.prisma +RUN npm run build + + +FROM node:20-bookworm-slim AS runner + +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates openssl \ + && rm -rf /var/lib/apt/lists/* + +# Next standalone output +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 + +CMD ["node", "server.js"] + diff --git a/apps/web/docs/deploy-server.md b/apps/web/docs/deploy-server.md new file mode 100644 index 0000000..0f82211 --- /dev/null +++ b/apps/web/docs/deploy-server.md @@ -0,0 +1,149 @@ +# 서버에서 바로 실행 배포 가이드 (도커 없이) + +목표: `prompt.ncue.net` → (Nginx 리버스프록시) → `localhost:3000`(Next.js) + +## 1) 서버 준비 + +- Node.js LTS 설치(권장 20.x 이상) +- PostgreSQL은 외부(`ncue.net/all_prompt`) 사용 +- `git`, `build-essential`(일부 패키지 빌드 필요 시) 설치 + +## 2) 코드 배포(예시) + +```bash +cd /opt +sudo git clone all-prompt +cd /opt/all-prompt/apps/web +sudo npm ci +``` + +## 3) 환경변수 설정 + +`.env`를 생성한다(예: `/opt/all-prompt/apps/web/.env`). + +```bash +DATABASE_URL="postgresql://ncue:@ncue.net:5432/all_prompt?schema=public" +NEXT_PUBLIC_SITE_URL="https://prompt.ncue.net" +NODE_ENV="production" +PORT=3000 +``` + +## 4) DB 스키마 적용(1회) + +> 이미 DB에 테이블이 있다면 생략 가능. + +### A안(권장): psql로 DDL 적용 + +```bash +export PGPASSWORD="" +psql -h ncue.net -U ncue -d all_prompt -f db/schema.sql +``` + +### B안: Prisma db push(DDL 대신 Prisma 기준) + +```bash +npm run prisma:generate +npm run db:push +``` + +## 5) 시드 데이터(1회) + +```bash +npm run db:seed +``` + +## 6) 빌드/실행 + +```bash +npm run build +npm run start +``` + +이제 `http://localhost:3000`에서 동작해야 한다. + +## 7) systemd로 상시 실행 + +### 7.1 서비스 유저(권장) + +```bash +sudo useradd -r -s /usr/sbin/nologin allprompt || true +sudo chown -R allprompt:allprompt /opt/all-prompt +``` + +### 7.2 유닛 파일 생성 + +`/etc/systemd/system/allprompt-web.service` + +```ini +[Unit] +Description=all prompt (모프) Next.js web +After=network.target + +[Service] +Type=simple +User=allprompt +WorkingDirectory=/opt/all-prompt/apps/web +Environment=NODE_ENV=production +Environment=PORT=3000 +EnvironmentFile=/opt/all-prompt/apps/web/.env +ExecStart=/usr/bin/npm run start +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +적용: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now allprompt-web +sudo systemctl status allprompt-web +``` + +## 8) Nginx 리버스프록시로 prompt.ncue.net 연결 + +### 8.1 Nginx 설치 및 서버블록 + +`/etc/nginx/sites-available/prompt.ncue.net` + +```nginx +server { + listen 80; + server_name prompt.ncue.net; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +활성화: + +```bash +sudo ln -s /etc/nginx/sites-available/prompt.ncue.net /etc/nginx/sites-enabled/prompt.ncue.net +sudo nginx -t && sudo systemctl reload nginx +``` + +### 8.2 HTTPS(권장): Let’s Encrypt + +```bash +sudo apt-get update +sudo apt-get install -y certbot python3-certbot-nginx +sudo certbot --nginx -d prompt.ncue.net +``` + +## 9) 배포 후 확인 + +- `https://prompt.ncue.net/api/health` +- `https://prompt.ncue.net/entities` +- `https://prompt.ncue.net/prompts` + diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..0493aa3 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + poweredByHeader: false, + serverExternalPackages: ["@prisma/client"], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..86449e5 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "prisma:generate": "prisma generate", + "db:push": "prisma db push --schema prisma/schema.prisma", + "db:migrate": "prisma migrate dev --schema prisma/schema.prisma", + "db:seed": "prisma db seed --schema prisma/schema.prisma" + }, + "dependencies": { + "@prisma/client": "^6.16.0", + "@tailwindcss/typography": "^0.5.19", + "next": "16.1.6", + "prisma": "^6.16.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "tsx": "^4.21.0", + "typescript": "^5" + }, + "prisma": { + "schema": "prisma/schema.prisma", + "seed": "tsx prisma/seed.ts" + } +} diff --git a/apps/web/prisma/seed.ts b/apps/web/prisma/seed.ts new file mode 100644 index 0000000..7326099 --- /dev/null +++ b/apps/web/prisma/seed.ts @@ -0,0 +1,197 @@ +import { PrismaClient, PromptModelProvider } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + // 최소 사용자(시드): 운영 초기에는 OAuth/Email 붙이기 전까지 작성자 null 허용 + + // 카테고리 + const catModels = await prisma.entityCategory.upsert({ + where: { slug: "models" }, + update: {}, + create: { slug: "models", name: "모델", description: "LLM/이미지 모델" }, + }); + + const catWork = await prisma.entityCategory.upsert({ + where: { slug: "work" }, + update: {}, + create: { slug: "work", name: "업무", description: "업무/상황 기반 대상" }, + }); + + // 엔티티 + const entityChatGPT = await prisma.entity.upsert({ + where: { slug: "chatgpt" }, + update: {}, + create: { + slug: "chatgpt", + name: "ChatGPT", + summary: "대화형 LLM 기반 생산성/개발/글쓰기 도구", + contentMd: + "## 개요\nChatGPT는 자연어로 지시를 내리면 다양한 결과물을 생성하는 대화형 모델입니다.\n\n## 추천 사용\n- 요약/정리\n- 문서 초안\n- 코드 리뷰/디버깅\n", + categoryId: catModels.id, + }, + }); + + const entityInterview = await prisma.entity.upsert({ + where: { slug: "interview" }, + update: {}, + create: { + slug: "interview", + name: "면접", + summary: "면접 준비/질문/답변 구조화", + contentMd: + "## 개요\n면접은 직무 역량과 커뮤니케이션을 검증하는 과정입니다.\n\n## 프롬프트 활용\n- 경험 정리(STAR)\n- 예상 질문 대비\n- 모의면접\n", + categoryId: catWork.id, + }, + }); + + // 모델 + const modelChatGPT = await prisma.promptModel.upsert({ + where: { slug: "chatgpt" }, + update: {}, + create: { + slug: "chatgpt", + provider: PromptModelProvider.openai, + name: "ChatGPT (OpenAI)", + description: "OpenAI ChatGPT 계열", + }, + }); + + const modelClaude = await prisma.promptModel.upsert({ + where: { slug: "claude" }, + update: {}, + create: { + slug: "claude", + provider: PromptModelProvider.anthropic, + name: "Claude (Anthropic)", + description: "Anthropic Claude 계열", + }, + }); + + // 태그 + const tagSummary = await prisma.tag.upsert({ + where: { slug: "summary" }, + update: {}, + create: { slug: "summary", name: "요약" }, + }); + const tagDev = await prisma.tag.upsert({ + where: { slug: "dev" }, + update: {}, + create: { slug: "dev", name: "개발" }, + }); + const tagInterview = await prisma.tag.upsert({ + where: { slug: "interview" }, + update: {}, + create: { slug: "interview", name: "면접" }, + }); + + // Prompt 생성 헬퍼(버전 1 + current_version 세팅 + 예시 + 태그) + async function createPromptWithV1(args: { + entityId: string; + title: string; + descriptionMd?: string; + modelId?: string; + promptText: string; + changelog?: string; + tags: string[]; // tag ids + examples?: Array<{ input?: string; output?: string; note?: string }>; + }) { + return prisma.$transaction(async (tx) => { + const prompt = await tx.prompt.create({ + data: { + entityId: args.entityId, + title: args.title, + descriptionMd: args.descriptionMd, + modelId: args.modelId, + }, + }); + + const v1 = await tx.promptVersion.create({ + data: { + promptId: prompt.id, + versionNo: 1, + promptText: args.promptText, + changelog: args.changelog ?? "초기 등록", + isApproved: true, + approvedAt: new Date(), + }, + }); + + await tx.prompt.update({ + where: { id: prompt.id }, + data: { currentVersionId: v1.id }, + }); + + if (args.tags.length) { + await tx.promptTag.createMany({ + data: args.tags.map((tagId) => ({ promptId: prompt.id, tagId })), + skipDuplicates: true, + }); + } + + if (args.examples?.length) { + await tx.promptExample.createMany({ + data: args.examples.map((ex) => ({ + promptVersionId: v1.id, + inputExample: ex.input, + outputExample: ex.output, + note: ex.note, + })), + }); + } + + return { promptId: prompt.id, versionId: v1.id }; + }); + } + + // 샘플 프롬프트 + await createPromptWithV1({ + entityId: entityChatGPT.id, + title: "긴 글 핵심 요약 + 액션아이템 추출", + descriptionMd: + "회의록/기사/긴 문서를 붙여넣고 **핵심 요약 + 액션아이템**을 구조화해서 뽑습니다.", + modelId: modelChatGPT.id, + promptText: + "너는 뛰어난 문서 편집자다.\n\n아래 텍스트를 읽고 다음 형식으로 답해라:\n1) 5줄 요약\n2) 핵심 포인트(불릿 5개)\n3) 액션 아이템(담당/기한 포함, 모르면 '미정')\n4) 리스크/의문점\n\n[텍스트]\n{{TEXT}}\n", + tags: [tagSummary.id], + examples: [ + { + input: "TEXT=오늘 회의에서 A는 3/1까지…", + output: "1) 5줄 요약…\n2) 핵심 포인트…\n3) 액션 아이템…", + }, + ], + }); + + await createPromptWithV1({ + entityId: entityChatGPT.id, + title: "코드 리뷰어(버그/보안/성능 체크리스트)", + descriptionMd: + "PR diff를 주면 **버그/보안/성능/가독성** 관점에서 리뷰 코멘트를 작성합니다.", + modelId: modelClaude.id, + promptText: + "너는 시니어 코드 리뷰어다.\n\n다음을 수행해라:\n- 잠재 버그\n- 보안 취약점\n- 성능 병목\n- 코드 스타일/가독성\n- 테스트 제안\n\n출력은 섹션별로 bullet로.\n\n[DIFF]\n{{DIFF}}\n", + tags: [tagDev.id], + }); + + await createPromptWithV1({ + entityId: entityInterview.id, + title: "STAR 기반 경험 정리 + 면접 답변 리라이트", + descriptionMd: + "경험을 STAR로 재구성하고, 면접 답변으로 자연스럽게 리라이트합니다.", + modelId: modelChatGPT.id, + promptText: + "너는 면접 코치다.\n\n아래 경험을 STAR(상황/과제/행동/결과)로 정리하고, 60~90초 답변 스크립트로 리라이트해라.\n- 과장 금지, 수치/근거 우선\n- 리스크/실패가 있으면 학습 포인트 포함\n\n[경험]\n{{EXP}}\n", + tags: [tagInterview.id], + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); + diff --git a/apps/web/src/app/api/entities/[slug]/route.ts b/apps/web/src/app/api/entities/[slug]/route.ts new file mode 100644 index 0000000..26cbb6d --- /dev/null +++ b/apps/web/src/app/api/entities/[slug]/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + const entity = await prisma.entity.findUnique({ + where: { slug }, + select: { + id: true, + slug: true, + name: true, + summary: true, + contentMd: true, + updatedAt: true, + category: { select: { slug: true, name: true } }, + prompts: { + where: { isPublished: true }, + orderBy: { updatedAt: "desc" }, + take: 20, + select: { + id: true, + title: true, + descriptionMd: true, + updatedAt: true, + model: { select: { slug: true, name: true, provider: true } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + }, + }, + }, + }); + + if (!entity) { + return NextResponse.json( + { ok: false, error: "NOT_FOUND" }, + { status: 404 }, + ); + } + + return NextResponse.json({ ok: true, entity }); +} + diff --git a/apps/web/src/app/api/entities/route.ts b/apps/web/src/app/api/entities/route.ts new file mode 100644 index 0000000..b42eb48 --- /dev/null +++ b/apps/web/src/app/api/entities/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; + +const QuerySchema = z.object({ + query: z.string().trim().min(1).max(100).optional(), + category: z.string().trim().min(1).max(100).optional(), // category slug + limit: z.coerce.number().int().min(1).max(50).optional().default(20), + offset: z.coerce.number().int().min(0).max(5000).optional().default(0), + sort: z.enum(["name", "recent"]).optional().default("name"), +}); + +export async function GET(req: Request) { + const url = new URL(req.url); + const parsed = QuerySchema.safeParse({ + query: url.searchParams.get("query") ?? undefined, + category: url.searchParams.get("category") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + offset: url.searchParams.get("offset") ?? undefined, + sort: url.searchParams.get("sort") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json( + { ok: false, error: "INVALID_QUERY", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { query, category, limit, offset, sort } = parsed.data; + + const where = { + isPublished: true, + ...(category ? { category: { slug: category } } : {}), + ...(query + ? { + OR: [ + { name: { contains: query, mode: "insensitive" as const } }, + { slug: { contains: query, mode: "insensitive" as const } }, + { summary: { contains: query, mode: "insensitive" as const } }, + ], + } + : {}), + }; + + const entities = await prisma.entity.findMany({ + where, + orderBy: + sort === "recent" + ? ({ updatedAt: "desc" } as const) + : ({ name: "asc" } as const), + take: limit, + skip: offset, + select: { + id: true, + slug: true, + name: true, + summary: true, + updatedAt: true, + category: { select: { slug: true, name: true } }, + _count: { select: { prompts: true } }, + }, + }); + + return NextResponse.json({ ok: true, items: entities }); +} + diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts new file mode 100644 index 0000000..d528b3d --- /dev/null +++ b/apps/web/src/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + ok: true, + service: "all prompt (모프)", + time: new Date().toISOString(), + }); +} + diff --git a/apps/web/src/app/api/prompts/[id]/route.ts b/apps/web/src/app/api/prompts/[id]/route.ts new file mode 100644 index 0000000..d893ead --- /dev/null +++ b/apps/web/src/app/api/prompts/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const prompt = await prisma.prompt.findUnique({ + where: { id }, + select: { + id: true, + title: true, + descriptionMd: true, + createdAt: true, + updatedAt: true, + entity: { select: { id: true, slug: true, name: true } }, + model: { select: { slug: true, name: true, provider: true } }, + sourcePrompt: { select: { id: true, title: true } }, + remixes: { take: 20, select: { id: true, title: true } }, + tags: { select: { tag: { select: { slug: true, name: true } } } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + currentVersion: { + select: { + id: true, + versionNo: true, + promptText: true, + createdAt: true, + isApproved: true, + examples: { + select: { + id: true, + inputExample: true, + outputExample: true, + note: true, + }, + }, + }, + }, + versions: { + orderBy: { versionNo: "desc" }, + take: 30, + select: { + id: true, + versionNo: true, + changelog: true, + createdAt: true, + isApproved: true, + }, + }, + }, + }); + + if (!prompt) { + return NextResponse.json( + { ok: false, error: "NOT_FOUND" }, + { status: 404 }, + ); + } + + return NextResponse.json({ ok: true, prompt }); +} + diff --git a/apps/web/src/app/api/prompts/route.ts b/apps/web/src/app/api/prompts/route.ts new file mode 100644 index 0000000..4d1f4ae --- /dev/null +++ b/apps/web/src/app/api/prompts/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; + +const QuerySchema = z.object({ + query: z.string().trim().min(1).max(200).optional(), + entity: z.string().trim().min(1).max(120).optional(), // entity slug + tag: z.string().trim().min(1).max(120).optional(), // tag slug + model: z.string().trim().min(1).max(120).optional(), // model slug + sort: z.enum(["new", "popular"]).optional().default("new"), + limit: z.coerce.number().int().min(1).max(50).optional().default(20), + offset: z.coerce.number().int().min(0).max(5000).optional().default(0), +}); + +export async function GET(req: Request) { + const url = new URL(req.url); + const parsed = QuerySchema.safeParse({ + query: url.searchParams.get("query") ?? undefined, + entity: url.searchParams.get("entity") ?? undefined, + tag: url.searchParams.get("tag") ?? undefined, + model: url.searchParams.get("model") ?? undefined, + sort: url.searchParams.get("sort") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + offset: url.searchParams.get("offset") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json( + { ok: false, error: "INVALID_QUERY", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { query, entity, tag, model, sort, limit, offset } = parsed.data; + + const where = { + isPublished: true, + ...(entity ? { entity: { slug: entity } } : {}), + ...(model ? { model: { slug: model } } : {}), + ...(tag + ? { tags: { some: { tag: { slug: tag } } } } + : {}), + ...(query + ? { + OR: [ + { title: { contains: query, mode: "insensitive" as const } }, + { + descriptionMd: { contains: query, mode: "insensitive" as const }, + }, + ], + } + : {}), + }; + + const orderBy = + sort === "popular" + ? ({ likes: { _count: "desc" } } as const) + : ({ createdAt: "desc" } as const); + + const prompts = await prisma.prompt.findMany({ + where, + orderBy, + take: limit, + skip: offset, + select: { + id: true, + title: true, + descriptionMd: true, + createdAt: true, + updatedAt: true, + entity: { select: { slug: true, name: true } }, + model: { select: { slug: true, name: true, provider: true } }, + tags: { select: { tag: { select: { slug: true, name: true } } } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + }, + }); + + return NextResponse.json({ ok: true, items: prompts }); +} + diff --git a/apps/web/src/app/entities/[slug]/page.tsx b/apps/web/src/app/entities/[slug]/page.tsx new file mode 100644 index 0000000..2c5e9d3 --- /dev/null +++ b/apps/web/src/app/entities/[slug]/page.tsx @@ -0,0 +1,164 @@ +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { prisma } from "@/lib/prisma"; + +export default async function EntityDetailPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + + const entity = await prisma.entity.findUnique({ + where: { slug }, + select: { + id: true, + slug: true, + name: true, + summary: true, + contentMd: true, + updatedAt: true, + category: { select: { slug: true, name: true } }, + prompts: { + where: { isPublished: true }, + orderBy: { updatedAt: "desc" }, + take: 50, + select: { + id: true, + title: true, + descriptionMd: true, + updatedAt: true, + model: { select: { slug: true, name: true, provider: true } }, + tags: { select: { tag: { select: { slug: true, name: true } } } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + }, + }, + }, + }); + + if (!entity) { + return ( +
+
+
+

대상을 찾을 수 없습니다.

+ + 목록으로 + +
+
+
+ ); + } + + return ( +
+
+
+
+
+ + ← 대상 목록 + +
+

+ {entity.name} +

+

+ {entity.category?.name ?? "미분류"} · {entity.slug} +

+
+ {entity.summary ? ( +

+ {entity.summary} +

+ ) : null} +
+ + + 이 대상의 프롬프트 검색 + +
+ +
+
+ + {entity.contentMd ?? "## 문서가 비어있습니다.\n\n기여로 채워주세요."} + +
+
+ +
+
+

추천 프롬프트

+

{entity.prompts.length}개

+
+
+ {entity.prompts.map((p) => ( + +
+
+

{p.title}

+ {p.model ? ( +

{p.model.name}

+ ) : ( +

모델 미지정

+ )} +
+
+

좋아요 {p._count.likes}

+

댓글 {p._count.comments}

+

북마크 {p._count.bookmarks}

+
+
+ {p.descriptionMd ? ( +

+ {p.descriptionMd} +

+ ) : null} + {p.tags.length ? ( +
+ {p.tags.map((t) => ( + + #{t.tag.name} + + ))} +
+ ) : null} + + ))} +
+
+ +
+

API

+

+ 상세 API:{" "} + + /api/entities/{entity.slug} + +

+
+
+
+
+ ); +} + diff --git a/apps/web/src/app/entities/page.tsx b/apps/web/src/app/entities/page.tsx new file mode 100644 index 0000000..7117824 --- /dev/null +++ b/apps/web/src/app/entities/page.tsx @@ -0,0 +1,116 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export default async function EntitiesPage({ + searchParams, +}: { + searchParams: Promise<{ query?: string }>; +}) { + const { query } = await searchParams; + const q = (query ?? "").trim(); + + const items = await prisma.entity.findMany({ + where: { + isPublished: true, + ...(q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { slug: { contains: q, mode: "insensitive" } }, + { summary: { contains: q, mode: "insensitive" } }, + ], + } + : {}), + }, + orderBy: { name: "asc" }, + take: 50, + select: { + slug: true, + name: true, + summary: true, + updatedAt: true, + category: { select: { slug: true, name: true } }, + _count: { select: { prompts: true } }, + }, + }); + + return ( +
+
+
+
+
+

모프

+

대상(Entity)

+
+ + 프롬프트로 이동 + +
+ +
+ +
+ + +
+
+ +
+ {items.map((e) => ( + +
+
+

{e.name}

+

{e.slug}

+
+
+

+ {e._count.prompts} prompts +

+

+ {e.category?.name ?? "미분류"} +

+
+
+ {e.summary ? ( +

+ {e.summary} +

+ ) : null} + + ))} +
+ +
+

API

+

+ 목록 API:{" "} + /api/entities +

+
+
+
+
+ ); +} + diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..37ff499 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,27 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..52d97cd --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "모프 (all prompt)", + description: "대상(Entity) 중심 프롬프트 공유 커뮤니티", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..50b3692 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,76 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+
+
+
+
+

모프 · all prompt

+

+ 대상(Entity)을 이해하다가 프롬프트를 발견하는 커뮤니티 +

+

+ 나무위키처럼 “대상 중심”으로 구조화된 프롬프트 문서 허브. 초보자부터 + 실무자까지 바로 써먹을 수 있는 프롬프트를 검색하고 토론하세요. +

+
+ +
+ + 대상 둘러보기 + + + 프롬프트 검색 + +
+ +
+
+

대상 중심

+

+ ChatGPT·Midjourney·면접 같은 “대상 문서”에서 탐색 시작 +

+
+
+

위키형 문서

+

+ 설명/예시/모델/태그/버전 히스토리까지 표준화 +

+
+
+

리믹스

+

+ 좋은 프롬프트를 복제 후 개선하며 파생 구조를 축적 +

+
+
+ +
+
+

API 상태

+

+ 서버 배포 후 /api/health로 + 헬스체크 +

+
+ + health 확인 + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/prompts/[id]/page.tsx b/apps/web/src/app/prompts/[id]/page.tsx new file mode 100644 index 0000000..af75dfb --- /dev/null +++ b/apps/web/src/app/prompts/[id]/page.tsx @@ -0,0 +1,256 @@ +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { prisma } from "@/lib/prisma"; + +function CodeBlock({ + title, + content, +}: { + title: string; + content: string | null | undefined; +}) { + return ( +
+
+

{title}

+
+
+        {content ?? ""}
+      
+
+ ); +} + +export default async function PromptDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const prompt = await prisma.prompt.findUnique({ + where: { id }, + select: { + id: true, + title: true, + descriptionMd: true, + createdAt: true, + updatedAt: true, + entity: { select: { slug: true, name: true } }, + model: { select: { slug: true, name: true, provider: true } }, + tags: { select: { tag: { select: { slug: true, name: true } } } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + sourcePrompt: { select: { id: true, title: true } }, + remixes: { take: 20, select: { id: true, title: true } }, + currentVersion: { + select: { + id: true, + versionNo: true, + promptText: true, + createdAt: true, + isApproved: true, + examples: { + select: { + id: true, + inputExample: true, + outputExample: true, + note: true, + }, + }, + }, + }, + versions: { + orderBy: { versionNo: "desc" }, + take: 30, + select: { id: true, versionNo: true, changelog: true, createdAt: true }, + }, + }, + }); + + if (!prompt) { + return ( +
+
+
+

프롬프트를 찾을 수 없습니다.

+ + 목록으로 + +
+
+
+ ); + } + + const cv = prompt.currentVersion; + + return ( +
+
+
+
+ + ← 프롬프트 목록 + +

{prompt.title}

+
+ + {prompt.entity.name} + + · + {prompt.model?.name ?? "모델 미지정"} + · + 좋아요 {prompt._count.likes} + · + 북마크 {prompt._count.bookmarks} + · + 댓글 {prompt._count.comments} +
+ + {prompt.tags.length ? ( +
+ {prompt.tags.map((t) => ( + + #{t.tag.name} + + ))} +
+ ) : null} +
+ +
+ + +
+

설명

+
+ + {prompt.descriptionMd ?? "설명이 아직 없습니다."} + +
+ + {prompt.sourcePrompt ? ( +
+

리믹스 원본

+ + {prompt.sourcePrompt.title} + +
+ ) : null} +
+
+ +
+
+

입력/출력 예시

+
+ {cv?.examples?.length ? ( + cv.examples.map((ex, idx) => ( +
+

+ 예시 {idx + 1} +

+ {ex.inputExample ? ( +
+

+ 입력 +

+
+                            {ex.inputExample}
+                          
+
+ ) : null} + {ex.outputExample ? ( +
+

+ 출력 +

+
+                            {ex.outputExample}
+                          
+
+ ) : null} + {ex.note ? ( +

{ex.note}

+ ) : null} +
+ )) + ) : ( +

예시가 없습니다.

+ )} +
+
+ +
+

버전 히스토리

+
+ {prompt.versions.map((v) => ( +
+
+

v{v.versionNo}

+

+ {v.changelog ?? "변경 요약 없음"} +

+
+

+ {new Date(v.createdAt).toLocaleDateString("ko-KR")} +

+
+ ))} +
+ + {prompt.remixes.length ? ( +
+

파생 프롬프트(리믹스)

+
+ {prompt.remixes.map((r) => ( + + {r.title} + + ))} +
+
+ ) : null} +
+
+ +
+

API

+

+ 상세 API:{" "} + /api/prompts/{prompt.id} +

+
+
+
+
+ ); +} + diff --git a/apps/web/src/app/prompts/page.tsx b/apps/web/src/app/prompts/page.tsx new file mode 100644 index 0000000..37ad528 --- /dev/null +++ b/apps/web/src/app/prompts/page.tsx @@ -0,0 +1,260 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export default async function PromptsPage({ + searchParams, +}: { + searchParams: Promise<{ + query?: string; + entity?: string; + tag?: string; + model?: string; + sort?: "new" | "popular"; + }>; +}) { + const sp = await searchParams; + const query = (sp.query ?? "").trim(); + const entity = (sp.entity ?? "").trim(); + const tag = (sp.tag ?? "").trim(); + const model = (sp.model ?? "").trim(); + const sort = sp.sort === "popular" ? "popular" : "new"; + + const [entities, tags, models, prompts] = await Promise.all([ + prisma.entity.findMany({ + where: { isPublished: true }, + orderBy: { name: "asc" }, + take: 100, + select: { slug: true, name: true }, + }), + prisma.tag.findMany({ + orderBy: { name: "asc" }, + take: 200, + select: { slug: true, name: true }, + }), + prisma.promptModel.findMany({ + orderBy: { name: "asc" }, + take: 100, + select: { slug: true, name: true, provider: true }, + }), + prisma.prompt.findMany({ + where: { + isPublished: true, + ...(entity ? { entity: { slug: entity } } : {}), + ...(model ? { model: { slug: model } } : {}), + ...(tag ? { tags: { some: { tag: { slug: tag } } } } : {}), + ...(query + ? { + OR: [ + { title: { contains: query, mode: "insensitive" } }, + { descriptionMd: { contains: query, mode: "insensitive" } }, + ], + } + : {}), + }, + orderBy: + sort === "popular" + ? { likes: { _count: "desc" } } + : { createdAt: "desc" }, + take: 50, + select: { + id: true, + title: true, + descriptionMd: true, + createdAt: true, + entity: { select: { slug: true, name: true } }, + model: { select: { slug: true, name: true, provider: true } }, + tags: { select: { tag: { select: { slug: true, name: true } } } }, + _count: { select: { likes: true, comments: true, bookmarks: true } }, + }, + }), + ]); + + return ( +
+
+
+
+
+

모프

+

+ 프롬프트 검색 +

+
+ + 대상으로 이동 + +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + + 초기화 + +
+
+
+ +
+
+

결과

+

{prompts.length}개

+
+ +
+ {prompts.map((p) => ( + +
+
+

{p.title}

+

+ {p.entity.name} + {p.model ? ` · ${p.model.name}` : ""} +

+
+
+

좋아요 {p._count.likes}

+

댓글 {p._count.comments}

+

북마크 {p._count.bookmarks}

+
+
+ + {p.descriptionMd ? ( +

+ {p.descriptionMd} +

+ ) : null} + + {p.tags.length ? ( +
+ {p.tags.map((t) => ( + + #{t.tag.name} + + ))} +
+ ) : null} + + ))} + {!prompts.length ? ( +
+ 결과가 없습니다. 필터를 줄이거나 키워드를 바꿔보세요. +
+ ) : null} +
+
+ +
+

API

+

+ 검색 API:{" "} + /api/prompts +

+
+
+
+
+ ); +} + diff --git a/apps/web/src/lib/prisma.ts b/apps/web/src/lib/prisma.ts new file mode 100644 index 0000000..17d928e --- /dev/null +++ b/apps/web/src/lib/prisma.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: + process.env.NODE_ENV === "development" + ? ["query", "error", "warn"] + : ["error"], + }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; + diff --git a/database.py b/database.py new file mode 100644 index 0000000..4875de2 --- /dev/null +++ b/database.py @@ -0,0 +1,88 @@ +""" +database.py + +- 기본 DB는 SQLite 파일(`all_prompt.db`)을 사용합니다. +- 나중에 PostgreSQL 등으로 바꾸기 쉽도록 `MOPF_DATABASE_URL` 환경변수를 지원합니다. +- migration 도구 없이 `create_all()`로 테이블을 자동 생성합니다. + +요구사항에 등장한 원격 DB 정보(참고): + host: ncue.net + database: all_prompt + user: ncue + password: (문서/코드에 하드코딩하지 말고 환경변수로 전달 권장) +""" + +from __future__ import annotations + +import os +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from models import Base, Category + + +def _default_sqlite_url() -> str: + # 루트에서 `python main.py`를 실행하는 것을 전제로, 현재 작업 디렉토리에 DB 파일이 생성됩니다. + return "sqlite:///./all_prompt.db" + + +# 이 저장소에는 기존(Next.js/Prisma 등)에서 `DATABASE_URL`을 쓰는 코드가 이미 있을 수 있습니다. +# 따라서 Python MVP는 충돌을 피하려고 별도의 환경변수명을 사용합니다. +DATABASE_URL = os.getenv("MOPF_DATABASE_URL", _default_sqlite_url()) + +# SQLite는 멀티스레드에서 같은 커넥션을 공유할 때 옵션이 필요합니다. +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + future=True, +) + +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, # 템플릿 렌더링에서 접근하기 편하게 + class_=Session, +) + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency로 사용하는 DB 세션.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +DEFAULT_CATEGORIES = [ + # (name, slug) + ("글쓰기", "writing"), + ("코딩", "coding"), + ("업무 자동화", "automation"), + ("이미지 생성", "image-generation"), + ("데이터 분석", "data-analysis"), +] + + +def init_db_and_seed() -> None: + """ + - 테이블 생성(create_all) + - 초기 카테고리 seed(이미 있으면 건너뜀) + """ + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + existing = db.query(Category).count() + if existing == 0: + for name, slug in DEFAULT_CATEGORIES: + db.add(Category(name=name, slug=slug)) + db.commit() + finally: + db.close() + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6b3e9a9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + web: + build: + context: ./apps/web + dockerfile: Dockerfile + environment: + NODE_ENV: production + PORT: 3000 + DATABASE_URL: ${DATABASE_URL} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://prompt.ncue.net} + restart: unless-stopped + networks: + - allprompt + + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + CADDY_EMAIL: ${CADDY_EMAIL} + depends_on: + - web + restart: unless-stopped + networks: + - allprompt + + # 1회성: 스키마 적용(필요할 때만) + db_init: + image: postgres:16 + profiles: ["init"] + volumes: + - ./apps/web/db/schema.sql:/schema.sql:ro + environment: + PGHOST: ${PGHOST:-ncue.net} + PGDATABASE: ${PGDATABASE:-all_prompt} + PGUSER: ${PGUSER:-ncue} + PGPASSWORD: ${PGPASSWORD} + entrypoint: ["/bin/bash", "-lc"] + command: ["psql -v ON_ERROR_STOP=1 -f /schema.sql"] + networks: + - allprompt + + # 1회성: 시드 데이터(필요할 때만) + db_seed: + build: + context: ./apps/web + dockerfile: Dockerfile + profiles: ["init"] + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL} + entrypoint: ["/bin/bash", "-lc"] + command: ["node -e \"console.log('Seeding...')\" && npx prisma db seed --schema prisma/schema.prisma"] + networks: + - allprompt + +networks: + allprompt: + driver: bridge + +volumes: + caddy_data: + caddy_config: + diff --git a/docs/deploy-ubuntu-docker.md b/docs/deploy-ubuntu-docker.md new file mode 100644 index 0000000..837df33 --- /dev/null +++ b/docs/deploy-ubuntu-docker.md @@ -0,0 +1,94 @@ +# Ubuntu 22.04 + Docker로 운영 배포 (prompt.ncue.net) + +목표: `https://prompt.ncue.net` → Caddy(자동 HTTPS) → `web`(Next.js) → PostgreSQL(`ncue.net/all_prompt`) + +## 1) 서버 사전 준비 + +### 1.1 DNS + +- `prompt.ncue.net` A 레코드가 서버 공인 IP를 가리키도록 설정 + +### 1.2 방화벽 + +- TCP 80, 443 오픈 + +### 1.3 Docker 설치 + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg + +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo systemctl enable --now docker +``` + +## 2) 배포 + +```bash +sudo mkdir -p /opt/all-prompt +sudo chown -R $USER:$USER /opt/all-prompt + +cd /opt/all-prompt +git clone . + +cp .env.example .env +nano .env +``` + +`.env` 필수: + +- `DATABASE_URL`: `postgresql://ncue:@ncue.net:5432/all_prompt?schema=public` +- `CADDY_EMAIL`: 인증서 발급용 이메일 + +실행: + +```bash +docker compose up -d --build +docker compose ps +``` + +확인: + +- `https://prompt.ncue.net/api/health` +- `https://prompt.ncue.net/entities` +- `https://prompt.ncue.net/prompts` + +## 3) 초기 스키마/시드(필요할 때만 1회) + +> 원격 DB에 테이블이 없다면 실행. + +```bash +docker compose --profile init run --rm db_init +docker compose --profile init run --rm db_seed +``` + +## 4) 운영 팁 + +### 로그 + +```bash +docker compose logs -f --tail=200 web +docker compose logs -f --tail=200 caddy +``` + +### 업데이트 + +```bash +git pull +docker compose up -d --build +``` + +### 인증서/설정 데이터 + +- Caddy 데이터는 도커 볼륨(`caddy_data`, `caddy_config`)에 유지됨 + diff --git a/docs/deploy-ubuntu-fastapi.md b/docs/deploy-ubuntu-fastapi.md new file mode 100644 index 0000000..468d495 --- /dev/null +++ b/docs/deploy-ubuntu-fastapi.md @@ -0,0 +1,163 @@ +# Ubuntu 22.04(22.14가 아니라 보통 22.04입니다) + systemd + Caddy로 모프(FastAPI) 배포 + +목표: + +- `https://prompt.ncue.net` → Caddy(자동 HTTPS) → `uvicorn(FastAPI)`(localhost:8000) +- DB는 기본 **SQLite 파일**(서버 로컬) 사용 + +> 기존 저장소에는 Next.js/도커 배포 문서(`docs/deploy-ubuntu-docker.md`)가 있습니다. +> 이 문서는 **루트의 `main.py`(FastAPI 버전 모프)** 를 서버에 올리는 방법입니다. + +--- + +## 0) 전제 + +- DNS: `prompt.ncue.net` A 레코드가 서버 공인 IP를 가리킴 +- 방화벽: TCP 80/443 오픈 + +--- + +## 1) 서버 패키지 설치 + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip git +``` + +--- + +## 2) 코드 배치 + +```bash +sudo mkdir -p /opt/mopf +sudo mkdir -p /opt/mopf/data +sudo chown -R $USER:$USER /opt/mopf + +cd /opt/mopf +git clone . +``` + +--- + +## 3) 가상환경 + 의존성 설치 + +```bash +cd /opt/mopf +python3 -m venv venv +./venv/bin/pip install -r requirements.txt +``` + +--- + +## 4) systemd로 앱 상시 실행 + +1) 서비스 파일 복사 + +```bash +sudo cp /opt/mopf/ops/mopf.service /etc/systemd/system/mopf.service +``` + +2) 권한/유저 확인 + +- 기본 템플릿은 `www-data`로 실행합니다. +- `/opt/mopf`를 `www-data`가 읽을 수 있어야 하고, DB 파일 디렉토리(`/opt/mopf/data`)는 쓰기가 가능해야 합니다. + +```bash +sudo chown -R www-data:www-data /opt/mopf +sudo chmod -R 755 /opt/mopf +sudo chmod -R 775 /opt/mopf/data +``` + +3) 환경변수 수정(권장) + +`/etc/systemd/system/mopf.service`에서 아래를 원하는 값으로 바꾸세요. + +- `MOPF_DATABASE_URL=sqlite:////opt/mopf/data/all_prompt.db` +- `MOPF_SALT=CHANGE_ME_TO_RANDOM_STRING` ← 반드시 랜덤 문자열로 변경 권장 + +4) 실행/자동시작 + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now mopf +sudo systemctl status mopf --no-pager +``` + +로그 확인: + +```bash +journalctl -u mopf -f +``` + +--- + +## 5) Caddy로 HTTPS 리버스 프록시 + +### 옵션 A) Caddy를 “호스트에 직접 설치”(가장 간단) + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install -y caddy +``` + +Caddy 설정: + +```bash +sudo cp /opt/mopf/ops/Caddyfile.mopf /etc/caddy/Caddyfile +sudo mkdir -p /etc/caddy +sudo nano /etc/caddy/Caddyfile +``` + +`CADDY_EMAIL`은 보통 systemd 환경변수로 넣습니다: + +```bash +sudo systemctl edit caddy +``` + +내용 예시: + +```ini +[Service] +Environment="CADDY_EMAIL=you@example.com" +``` + +적용/재시작: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart caddy +sudo systemctl status caddy --no-pager +``` + +### 옵션 B) “기존 docker-compose의 Caddy”를 그대로 쓴다면 + +이미 `docker-compose.yml`에서 Caddy가 80/443을 점유 중이면, +`Caddyfile`의 `reverse_proxy web:3000`을 `reverse_proxy 127.0.0.1:8000`으로 바꿔야 합니다. + +--- + +## 6) 동작 확인 + +- `https://prompt.ncue.net/` 접속 +- 프롬프트 등록: `https://prompt.ncue.net/new` +- 검색: `https://prompt.ncue.net/search?q=...` + +서버 로컬에서 빠른 체크: + +```bash +curl -I http://127.0.0.1:8000/ +``` + +--- + +## 7) 업데이트(코드 갱신) + +```bash +cd /opt/mopf +sudo -u www-data git pull +sudo systemctl restart mopf +``` + diff --git a/main.py b/main.py new file mode 100644 index 0000000..a421a19 --- /dev/null +++ b/main.py @@ -0,0 +1,445 @@ +""" +main.py + +FastAPI + Jinja2 + HTMX + SQLite(SQLAlchemy)로 만드는 초경량 프롬프트 공유 커뮤니티 "모프(all prompt)". + +실행: + python main.py + +핵심 UX: +- 회원가입/로그인 없음 +- 닉네임은 쿠키에 저장(처음 프롬프트 등록 시 입력) +- 프롬프트: 탐색/등록/복사/좋아요 +""" + +from __future__ import annotations + +import hashlib +import os +import uuid +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Any + +import uvicorn +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from database import get_db, init_db_and_seed +from models import Category, Like, Prompt, User + + +APP_NAME = "all prompt" +APP_KO_NAME = "모프" + +COOKIE_UID = "mopf_uid" # 브라우저별 고유 식별자(익명) +COOKIE_NICKNAME = "mopf_nickname" + +# 식별자 해시에 사용하는 salt(로컬 MVP라 간단히). 운영이면 환경변수로 설정 권장. +IDENTIFIER_SALT = os.getenv("MOPF_SALT", "mopf-local-dev-salt") + +PAGE_SIZE = 20 + +@asynccontextmanager +async def lifespan(app: FastAPI): + # migration 도구 없이 create_all + seed + init_db_and_seed() + yield + + +app = FastAPI(title=f"{APP_NAME} ({APP_KO_NAME})", lifespan=lifespan) +templates = Jinja2Templates(directory="templates") + +# static 폴더가 비어 있어도 mount 가능 +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.middleware("http") +async def ensure_uid_cookie(request: Request, call_next): + """ + 브라우저마다 고유 uid 쿠키를 하나 발급합니다. + - 좋아요 중복 방지(쿠키 기반) + - 익명 닉네임 자동 생성 시에도 사용 + """ + uid = request.cookies.get(COOKIE_UID) + response = await call_next(request) + if not uid: + uid = uuid.uuid4().hex + response.set_cookie( + key=COOKIE_UID, + value=uid, + max_age=60 * 60 * 24 * 365 * 5, # 5년 + httponly=True, + samesite="lax", + ) + return response + + +def _client_ip(request: Request) -> str: + """ + 프록시 뒤일 수 있으니 X-Forwarded-For를 우선 사용합니다. + 로컬 개발/소규모 서버용이므로 최소 구현만 합니다. + """ + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + if request.client: + return request.client.host + return "0.0.0.0" + + +def user_identifier(request: Request) -> str: + """ + 쿠키 uid 또는 IP를 기반으로 식별자를 만들고, DB에는 해시만 저장합니다. + (요구사항: 쿠키 or IP 해시) + """ + raw = request.cookies.get(COOKIE_UID) or _client_ip(request) + digest = hashlib.sha256(f"{IDENTIFIER_SALT}:{raw}".encode("utf-8")).hexdigest() + return digest + + +def current_nickname(request: Request) -> str | None: + nick = request.cookies.get(COOKIE_NICKNAME) + if not nick: + return None + nick = nick.strip() + return nick or None + + +def _get_or_create_user(db: Session, nickname: str) -> None: + """중복 nick은 unique 제약으로 방어하고, 실패 시 무시.""" + if not nickname: + return + if db.query(User.id).filter(User.nickname == nickname).first(): + return + db.add(User(nickname=nickname)) + try: + db.commit() + except IntegrityError: + db.rollback() + + +def _like_container_html(prompt_id: int, liked: bool, like_count: int) -> str: + """ + HTMX가 교체할 작은 HTML 조각. + - 템플릿 파일을 추가하지 않기 위해(요구된 템플릿 4개 유지) 서버에서 간단히 생성. + - user 입력이 들어가지 않는 숫자/ID만 포함하므로 XSS 위험이 낮음. + """ + label = "좋아요" if not liked else "좋아요✓" + disabled = "disabled" if liked else "" + btn_classes = ( + "px-3 py-1 rounded border text-sm " + + ("bg-gray-100 text-gray-500 cursor-not-allowed" if liked else "bg-white hover:bg-gray-50") + ) + return f""" +
+ +
+""".strip() + + +def _paginate(page: int) -> tuple[int, int]: + page = max(page, 1) + offset = (page - 1) * PAGE_SIZE + return page, offset + + +def _list_prompts( + db: Session, + request: Request, + *, + q: str | None, + page: int, + category_id: int | None, +) -> dict[str, Any]: + """목록/검색 화면에서 공용으로 쓰는 조회 로직.""" + page, offset = _paginate(page) + + base = db.query(Prompt).join(Category, Prompt.category_id == Category.id) + if category_id: + base = base.filter(Prompt.category_id == category_id) + + if q: + # SQLite에서 대소문자 차이를 줄이기 위해 lower+LIKE 사용(초경량 MVP). + q2 = q.strip() + if q2: + like = f"%{q2.lower()}%" + base = base.filter( + or_( + func.lower(Prompt.title).like(like), + func.lower(Prompt.content).like(like), + ) + ) + + total = base.with_entities(func.count(Prompt.id)).scalar() or 0 + prompts = ( + base.order_by(Prompt.created_at.desc()) + .offset(offset) + .limit(PAGE_SIZE) + .all() + ) + + prompt_ids = [p.id for p in prompts] + + like_counts: dict[int, int] = {} + liked_ids: set[int] = set() + if prompt_ids: + like_counts_rows = ( + db.query(Like.prompt_id, func.count(Like.id)) + .filter(Like.prompt_id.in_(prompt_ids)) + .group_by(Like.prompt_id) + .all() + ) + like_counts = {pid: int(cnt) for pid, cnt in like_counts_rows} + + ident = user_identifier(request) + liked_rows = ( + db.query(Like.prompt_id) + .filter(Like.prompt_id.in_(prompt_ids), Like.user_identifier == ident) + .all() + ) + liked_ids = {pid for (pid,) in liked_rows} + + categories = db.query(Category).order_by(Category.name.asc()).all() + + return { + "page": page, + "page_size": PAGE_SIZE, + "total": total, + "prompts": prompts, + "categories": categories, + "like_counts": like_counts, + "liked_ids": liked_ids, + "category_id": category_id, + "q": q or "", + "now": datetime.now(), + } + + +@app.get("/", response_class=HTMLResponse) +def index( + request: Request, + page: int = 1, + category: int | None = None, + db: Session = Depends(get_db), +): + ctx = _list_prompts(db, request, q=None, page=page, category_id=category) + ctx.update( + { + "request": request, + "app_name": APP_NAME, + "app_ko_name": APP_KO_NAME, + "nickname": current_nickname(request), + } + ) + return templates.TemplateResponse("index.html", ctx) + + +@app.get("/search", response_class=HTMLResponse) +def search( + request: Request, + q: str = "", + page: int = 1, + category: int | None = None, + db: Session = Depends(get_db), +): + q = (q or "").strip() + ctx = _list_prompts(db, request, q=q, page=page, category_id=category) + ctx.update( + { + "request": request, + "app_name": APP_NAME, + "app_ko_name": APP_KO_NAME, + "nickname": current_nickname(request), + } + ) + return templates.TemplateResponse("index.html", ctx) + + +@app.get("/prompt/{id}", response_class=HTMLResponse) +def prompt_detail( + request: Request, + id: int, + db: Session = Depends(get_db), +): + prompt = db.query(Prompt).filter(Prompt.id == id).first() + if not prompt: + raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.") + + like_count = db.query(func.count(Like.id)).filter(Like.prompt_id == id).scalar() or 0 + liked = ( + db.query(Like.id) + .filter(Like.prompt_id == id, Like.user_identifier == user_identifier(request)) + .first() + is not None + ) + + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "detail.html", + { + "request": request, + "app_name": APP_NAME, + "app_ko_name": APP_KO_NAME, + "nickname": current_nickname(request), + "prompt": prompt, + "categories": categories, + "like_count": like_count, + "liked": liked, + }, + ) + + +@app.get("/new", response_class=HTMLResponse) +def new_prompt_form( + request: Request, + db: Session = Depends(get_db), +): + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "new.html", + { + "request": request, + "app_name": APP_NAME, + "app_ko_name": APP_KO_NAME, + "nickname": current_nickname(request), + "categories": categories, + "error": None, + }, + ) + + +@app.post("/new") +def create_prompt( + request: Request, + title: str = Form(...), + content: str = Form(...), + category_id: int = Form(...), + description: str | None = Form(None), + nickname: str | None = Form(None), + db: Session = Depends(get_db), +): + title = (title or "").strip() + content = (content or "").strip() + description = (description or "").strip() if description is not None else None + + if not title or not content: + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "new.html", + { + "request": request, + "app_name": APP_NAME, + "app_ko_name": APP_KO_NAME, + "nickname": current_nickname(request), + "categories": categories, + "error": "제목과 프롬프트 내용은 필수입니다.", + }, + status_code=400, + ) + + # 닉네임: 쿠키에 있으면 우선, 없으면 폼 입력, 그래도 없으면 uid 기반 자동 생성 + nick = current_nickname(request) or (nickname or "").strip() + uid = request.cookies.get(COOKIE_UID, "") + if not nick: + nick = f"익명-{uid[:6] or 'user'}" + + # 카테고리 존재 여부 확인(잘못된 ID 방지) + cat = db.query(Category).filter(Category.id == category_id).first() + if not cat: + raise HTTPException(status_code=400, detail="카테고리가 올바르지 않습니다.") + + # users 테이블에도 nickname을 기록(나중에 로그인 붙이기 쉬움) + _get_or_create_user(db, nick) + + prompt = Prompt( + title=title, + content=content, + description=description or None, + category_id=category_id, + author_nickname=nick, + copy_count=0, + ) + db.add(prompt) + db.commit() + db.refresh(prompt) + + resp = RedirectResponse(url=f"/prompt/{prompt.id}", status_code=303) + # 닉네임 쿠키 저장(“로그인 없이 시작”) + if not current_nickname(request) and nick: + resp.set_cookie( + key=COOKIE_NICKNAME, + value=nick, + max_age=60 * 60 * 24 * 365 * 5, + httponly=False, # UI에 표시/폼 기본값에 사용 + samesite="lax", + ) + return resp + + +@app.post("/like/{id}", response_class=HTMLResponse) +def like_prompt( + request: Request, + id: int, + db: Session = Depends(get_db), +): + # 프롬프트 존재 확인 + prompt_exists = db.query(Prompt.id).filter(Prompt.id == id).first() + if not prompt_exists: + raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.") + + ident = user_identifier(request) + + # 이미 좋아요가 있으면 그대로(중복 방지) + existing = db.query(Like.id).filter(Like.prompt_id == id, Like.user_identifier == ident).first() + if not existing: + db.add(Like(prompt_id=id, user_identifier=ident)) + try: + db.commit() + except IntegrityError: + # 유니크 제약으로 중복이 걸릴 수 있으니 안전하게 처리 + db.rollback() + + like_count = db.query(func.count(Like.id)).filter(Like.prompt_id == id).scalar() or 0 + liked = ( + db.query(Like.id) + .filter(Like.prompt_id == id, Like.user_identifier == ident) + .first() + is not None + ) + return HTMLResponse(_like_container_html(id, liked, int(like_count))) + + +@app.post("/copy/{id}") +def increment_copy_count( + request: Request, + id: int, + db: Session = Depends(get_db), +): + prompt = db.query(Prompt).filter(Prompt.id == id).first() + if not prompt: + raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.") + prompt.copy_count = int(prompt.copy_count or 0) + 1 + db.commit() + return JSONResponse({"copy_count": int(prompt.copy_count)}) + + +if __name__ == "__main__": + # 개발 편의상 uvicorn을 코드에서 직접 실행합니다. + # 포트 변경: `PORT=8001 python main.py` + port = int(os.getenv("PORT", "8000")) + uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False) + diff --git a/models.py b/models.py new file mode 100644 index 0000000..84087c7 --- /dev/null +++ b/models.py @@ -0,0 +1,89 @@ +""" +models.py + +요구사항의 테이블을 SQLAlchemy ORM 모델로 정의합니다. + +테이블: +- users +- categories +- prompts +- likes + +핵심 포인트: +- likes에는 (prompt_id, user_identifier) 유니크 제약을 걸어 "중복 좋아요"를 DB 레벨에서도 방지합니다. +- 프롬프트 검색은 title/content LIKE로 구현합니다(초경량 MVP 목적). +- 나중에 로그인(계정/세션)을 붙이기 쉽도록 users 테이블을 별도로 유지합니다. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + nickname: Mapped[str] = mapped_column(String(40), unique=True, index=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(40), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(60), unique=True, index=True, nullable=False) + + prompts: Mapped[list["Prompt"]] = relationship(back_populates="category") + + +class Prompt(Base): + __tablename__ = "prompts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(120), index=True, nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), index=True, nullable=False) + + # MVP에서는 로그인 없이 nickname 문자열로 작성자를 기록합니다. + author_nickname: Mapped[str] = mapped_column(String(40), index=True, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True, nullable=False) + copy_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + category: Mapped["Category"] = relationship(back_populates="prompts") + likes: Mapped[list["Like"]] = relationship(back_populates="prompt", cascade="all, delete-orphan") + + __table_args__ = ( + # 정렬/필터가 잦은 필드 위주로 인덱스(SQLite에서도 도움). + Index("ix_prompts_category_created", "category_id", "created_at"), + ) + + +class Like(Base): + __tablename__ = "likes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + prompt_id: Mapped[int] = mapped_column(ForeignKey("prompts.id"), index=True, nullable=False) + + # 쿠키 UUID 또는 IP 기반 식별자를 해시한 값(개인정보 최소화) + user_identifier: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + prompt: Mapped["Prompt"] = relationship(back_populates="likes") + + __table_args__ = ( + UniqueConstraint("prompt_id", "user_identifier", name="uq_likes_prompt_user"), + ) + diff --git a/ops/Caddyfile.mopf b/ops/Caddyfile.mopf new file mode 100644 index 0000000..f91eca4 --- /dev/null +++ b/ops/Caddyfile.mopf @@ -0,0 +1,12 @@ +{ + # 인증서 발급용 이메일(서버에서 환경변수로 주는 것을 권장) + email {$CADDY_EMAIL} +} + +prompt.ncue.net { + encode zstd gzip + + # FastAPI(uvicorn)로 리버스 프록시 + reverse_proxy 127.0.0.1:8000 +} + diff --git a/ops/mopf.service b/ops/mopf.service new file mode 100644 index 0000000..043b784 --- /dev/null +++ b/ops/mopf.service @@ -0,0 +1,29 @@ +[Unit] +Description=모프(all prompt) FastAPI 서비스 +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/mopf + +# venv 경로는 설치 방식에 맞게 수정 +Environment="PATH=/opt/mopf/venv/bin" + +# DB/식별자 salt (필수는 아님, 운영에서는 salt 변경 권장) +Environment="MOPF_DATABASE_URL=sqlite:////opt/mopf/data/all_prompt.db" +Environment="MOPF_SALT=CHANGE_ME_TO_RANDOM_STRING" + +# uvicorn을 main.py 내부에서 실행하므로 python main.py로 실행 +ExecStart=/opt/mopf/venv/bin/python /opt/mopf/main.py +Restart=always +RestartSec=2 + +# 로그는 journalctl로 확인 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a91bdf1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115 +uvicorn>=0.30 +jinja2>=3.1 +sqlalchemy>=2.0 +python-multipart>=0.0.9 + diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/static/.gitkeep @@ -0,0 +1 @@ + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..aed77ae --- /dev/null +++ b/templates/base.html @@ -0,0 +1,59 @@ + + + + + + {{ app_ko_name }} · {{ app_name }} + + + + + + +
+
+
+ + {{ app_ko_name }} + ({{ app_name }}) + +
+
+ + {% if category_id %} + + {% endif %} + +
+ + + 등록 + +
+
+
+ {% if nickname %} + 현재 닉네임: {{ nickname }} + {% else %} + 닉네임은 프롬프트 등록 시 자동 저장됩니다. + {% endif %} +
+
+ +
+ {% block content %}{% endblock %} +
+ +
+ 초경량 프롬프트 커뮤니티 · 모프 +
+
+ + + diff --git a/templates/detail.html b/templates/detail.html new file mode 100644 index 0000000..d565f3f --- /dev/null +++ b/templates/detail.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ prompt.title }}

+
+ + {% for c in categories %} + {% if c.id == prompt.category_id %}{{ c.name }}{% endif %} + {% endfor %} + + 작성자: {{ prompt.author_nickname }} + 복사 {{ prompt.copy_count }} +
+ {% if prompt.description %} +
{{ prompt.description }}
+ {% endif %} +
+ +
+
+ +
+ + + + + 새 프롬프트 + +
+
+ +
+
프롬프트
+
{{ prompt.content }}
+ +
+ + +{% endblock %} + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1bd6ee7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
{{ total }}
+ {% if q %} +
검색어: {{ q }}
+ {% endif %} +
+ 전체 보기 +
+ +
+ + 전체 + + {% for c in categories %} + + {{ c.name }} + + {% endfor %} +
+ +
+ {% if prompts|length == 0 %} +
+ 아직 프롬프트가 없습니다. 첫 프롬프트를 등록해보세요. +
+ {% endif %} + + {% for p in prompts %} +
+
+
+ + {{ p.title }} + +
+ + {% for c in categories %} + {% if c.id == p.category_id %}{{ c.name }}{% endif %} + {% endfor %} + + 작성자: {{ p.author_nickname }} + 복사 {{ p.copy_count }} +
+ {% if p.description %} +
{{ p.description }}
+ {% endif %} +
+ +
+
+ {% set liked = (p.id in liked_ids) %} + {% set cnt = like_counts.get(p.id, 0) %} + +
+
+
+
+ {% endfor %} +
+ + {% set has_prev = page > 1 %} + {% set has_next = (page * page_size) < total %} + {% if has_prev or has_next %} +
+
+ {% if has_prev %} + {% if q %} + ← 이전 + {% else %} + ← 이전 + {% endif %} + {% endif %} +
+
페이지 {{ page }}
+
+ {% if has_next %} + {% if q %} + 다음 → + {% else %} + 다음 → + {% endif %} + {% endif %} +
+
+ {% endif %} +{% endblock %} + diff --git a/templates/new.html b/templates/new.html new file mode 100644 index 0000000..cdc20fc --- /dev/null +++ b/templates/new.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block content %} +

프롬프트 등록

+

+ 회원가입 없이 바로 등록할 수 있어요. 닉네임은 쿠키에 저장됩니다. +

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
비워도 되지만, 자동으로 익명 닉네임이 생성됩니다.
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 취소 + +
+
+{% endblock %} +