feat: Gitea 연동 설정 보완, Apache 역방향 프록시 템플릿 추가 및 기능 개선 요청서 작성

- fix(config): 로컬 실행 시 루트의 .env 파일을 자동 로드하도록 Settings.from_env 개선 (테스트 실행 시 격리)

- fix(ops): 기존 8000번 포트 충돌 방지를 위해 docker-compose.ops.yml 호스트 포트를 127.0.0.1:18000:8000으로 조정

- fix(git-binding): Git 저장소 바인딩 시 projects 테이블의 branch도 함께 동기화하도록 app.py 보완

- ui(projects): 프로젝트 카드의 BRANCH 표시에 바인딩된 브랜치를 우선 표시하고, OWNER 라벨을 '담당'으로 명확화

- feat(deploy): Ubuntu Apache2 웹서버용 Gitea 및 SourceHub 역방향 프록시 설정 템플릿 추가 (deploy/apache-*.conf)

- docs: 프로젝트 CRUD 및 사내 개발자 관리 기능 갭 분석 및 개발 요청서 Word 문서 추가 (docs/)
This commit is contained in:
linkitapps
2026-09-27 01:23:47 +09:00
parent c41f62f880
commit 47181fa5ca
9 changed files with 67 additions and 5 deletions
+2 -2
View File
@@ -42,8 +42,8 @@ SOURCEHUB_MALWARE_SCAN=disabled
# SOURCEHUB_CLAMD_PORT=3310 # SOURCEHUB_CLAMD_PORT=3310
# ---------- Gitea ---------- # ---------- Gitea ----------
# SOURCEHUB_GITEA_BASE_URL=https://git.example.com SOURCEHUB_GITEA_BASE_URL=https://git.nexpa-ai.com
# SOURCEHUB_GITEA_TOKEN=<TOKEN> SOURCEHUB_GITEA_TOKEN=CHANGE_ME
# ---------- Deployment executor ---------- # ---------- Deployment executor ----------
SOURCEHUB_DEPLOY_MODE=disabled SOURCEHUB_DEPLOY_MODE=disabled
+1 -1
View File
@@ -38,7 +38,7 @@ SOURCEHUB_ALLOWED_HOSTS=sourcehub.nexpa-ai.com,localhost,127.0.0.1
SOURCEHUB_ENABLE_HSTS=true SOURCEHUB_ENABLE_HSTS=true
# Gitea (deploy/gitea — host 3200 / SSH 2222) # Gitea (deploy/gitea — host 3200 / SSH 2222)
SOURCEHUB_GITEA_BASE_URL=http://127.0.0.1:3200 SOURCEHUB_GITEA_BASE_URL=https://git.nexpa-ai.com
SOURCEHUB_GITEA_TOKEN=CHANGE_ME SOURCEHUB_GITEA_TOKEN=CHANGE_ME
# Deployment executor: keep disabled until CI/CD target is explicitly registered # Deployment executor: keep disabled until CI/CD target is explicitly registered
+3
View File
@@ -497,6 +497,9 @@ def bind_git(project_code: str, body: RepoBindIn, request: Request, x_role: Opti
conn.execute(update(repo_bindings).where(repo_bindings.c.project_code == project_code).values( conn.execute(update(repo_bindings).where(repo_bindings.c.project_code == project_code).values(
owner=body.owner.strip(), repo=body.repo.strip(), branch=body.branch.strip(), updated_at=now_iso(), updated_by=a.role, owner=body.owner.strip(), repo=body.repo.strip(), branch=body.branch.strip(), updated_at=now_iso(), updated_by=a.role,
)) ))
conn.execute(update(projects).where(projects.c.code == project_code).values(
branch=body.branch.strip(),
))
log(conn, a.role, "Git", "저장소 바인딩 변경", p["name"], {"owner": body.owner, "repo": body.repo, "branch": body.branch}) log(conn, a.role, "Git", "저장소 바인딩 변경", p["name"], {"owner": body.owner, "repo": body.repo, "branch": body.branch})
return git_status(project_code, request, x_role) return git_status(project_code, request, x_role)
+22
View File
@@ -0,0 +1,22 @@
# Apache Reverse Proxy Configuration for Gitea
# Copy to /etc/apache2/sites-available/gitea.conf
# Required modules: sudo a2enmod proxy proxy_http ssl headers rewrite
<VirtualHost *:80>
ServerName git.nexpa-ai.com
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:3200/
ProxyPassReverse / http://127.0.0.1:3200/
RequestHeader set X-Forwarded-Proto "http"
# Allow large git push uploads (500MB)
LimitRequestBody 524288000
</VirtualHost>
# Note: Once SSL certificate is issued via Certbot:
# Run: sudo certbot --apache -d git.nexpa-ai.com
# Certbot will automatically add the SSL (*:443) VirtualHost and HTTPS redirect.
+21
View File
@@ -0,0 +1,21 @@
# Apache Reverse Proxy Configuration for NEXPA SourceHub 3.8
# Copy to /etc/apache2/sites-available/sourcehub.conf
# Required modules: sudo a2enmod proxy proxy_http ssl headers rewrite
<VirtualHost *:80>
ServerName sourcehub.nexpa-ai.com
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:18000/
ProxyPassReverse / http://127.0.0.1:18000/
RequestHeader set X-Forwarded-Proto "http"
LimitRequestBody 524288000
</VirtualHost>
# Note: Once SSL certificate is issued via Certbot:
# Run: sudo certbot --apache -d sourcehub.nexpa-ai.com
# Certbot will automatically add the SSL (*:443) VirtualHost and HTTPS redirect.
+1 -1
View File
@@ -26,7 +26,7 @@ services:
sourcehub: sourcehub:
build: . build: .
ports: ports:
- "8000:8000" - "127.0.0.1:18000:8000"
depends_on: depends_on:
- postgres - postgres
- minio - minio
+16
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -55,6 +56,21 @@ class Settings:
@classmethod @classmethod
def from_env(cls, base_dir: Path | None = None) -> "Settings": def from_env(cls, base_dir: Path | None = None) -> "Settings":
base = (base_dir or Path(__file__).resolve().parents[1]).resolve() base = (base_dir or Path(__file__).resolve().parents[1]).resolve()
env_file = base / ".env"
if env_file.is_file() and "pytest" not in sys.modules:
try:
with open(env_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip("'\"")
if k not in os.environ:
os.environ[k] = v
except Exception:
pass
data_dir = Path(os.getenv("SOURCEHUB_DATA_DIR", str(base / "data"))).expanduser() data_dir = Path(os.getenv("SOURCEHUB_DATA_DIR", str(base / "data"))).expanduser()
if not data_dir.is_absolute(): if not data_dir.is_absolute():
data_dir = (base / data_dir).resolve() data_dir = (base / data_dir).resolve()
+1 -1
View File
@@ -75,7 +75,7 @@ function renderSystem(){const cards=[["DATABASE",system.database.kind,system.dat
function populateProjects(){const opts=projects.map(p=>`<option value="${p.code}">${p.name}</option>`).join("");$("#uploadProject").innerHTML=opts;$("#projectFilter").innerHTML='<option value="">전체 프로젝트</option>'+opts;$("#receiver").innerHTML=Object.entries(roles).map(([k,v])=>`<option value="${k}">${v}</option>`).join("");renderProjects()} function populateProjects(){const opts=projects.map(p=>`<option value="${p.code}">${p.name}</option>`).join("");$("#uploadProject").innerHTML=opts;$("#projectFilter").innerHTML='<option value="">전체 프로젝트</option>'+opts;$("#receiver").innerHTML=Object.entries(roles).map(([k,v])=>`<option value="${k}">${v}</option>`).join("");renderProjects()}
async function loadAll(){try{system=await api("/api/system/status");$("#userPill").textContent=roles[currentRole];await loadDashboard();await loadTransfers();await loadAudit()}catch(e){toast(parseErr(e))}} async function loadAll(){try{system=await api("/api/system/status");$("#userPill").textContent=roles[currentRole];await loadDashboard();await loadTransfers();await loadAudit()}catch(e){toast(parseErr(e))}}
async function loadDashboard(){const [b,t]=await Promise.all([api("/api/baselines",{headers:{"X-Role":currentRole}}),api("/api/transfers",{headers:{"X-Role":currentRole}})]);$("#summary").innerHTML=`<div><span>기준 프로젝트</span><strong>${projects.length}</strong><small>현재 운영 기준</small></div><div><span>진행 전달</span><strong>${t.filter(x=>x.state!=="APPROVED").length}</strong><small>승인 흐름 포함</small></div><div><span>승인 완료</span><strong>${t.filter(x=>x.state==="APPROVED").length}</strong><small>immutable records</small></div><div><span>수정 요청</span><strong>${t.filter(x=>x.state==="CHANGES_REQUESTED").length}</strong><small>새 Revision 필요</small></div>`;$("#baselines").innerHTML=b.map(x=>`<div class="baseRow"><b>${x.kind}</b><div><strong>${x.project_name} ${x.version}</strong><small>${x.package_name}</small></div><span class="tag">${x.state}</span></div>`).join("")} async function loadDashboard(){const [b,t]=await Promise.all([api("/api/baselines",{headers:{"X-Role":currentRole}}),api("/api/transfers",{headers:{"X-Role":currentRole}})]);$("#summary").innerHTML=`<div><span>기준 프로젝트</span><strong>${projects.length}</strong><small>현재 운영 기준</small></div><div><span>진행 전달</span><strong>${t.filter(x=>x.state!=="APPROVED").length}</strong><small>승인 흐름 포함</small></div><div><span>승인 완료</span><strong>${t.filter(x=>x.state==="APPROVED").length}</strong><small>immutable records</small></div><div><span>수정 요청</span><strong>${t.filter(x=>x.state==="CHANGES_REQUESTED").length}</strong><small>새 Revision 필요</small></div>`;$("#baselines").innerHTML=b.map(x=>`<div class="baseRow"><b>${x.kind}</b><div><strong>${x.project_name} ${x.version}</strong><small>${x.package_name}</small></div><span class="tag">${x.state}</span></div>`).join("")}
function renderProjects(){$("#projectGrid").innerHTML=projects.map(p=>{const b=p.repo_binding||{};return `<article class="card"><div style="display:flex;justify-content:space-between;gap:10px"><div><h3>${p.name}</h3><p>현재 기준 ${p.current_version}</p></div><span class="tag">v${p.current_version}</span></div><div class="meta"><div><span>BRANCH</span><strong>${p.branch||"-"}</strong></div><div><span>OWNER</span><strong>${p.owner}</strong></div><div><span>GIT</span><strong>${b.repo?b.owner+"/"+b.repo:"미연결"}</strong></div></div><div class="bar"><i style="width:${p.progress}%"></i></div><div class="actions"><button class="secondary" onclick="checkGit('${p.code}')">연결 확인</button><button class="primary" onclick="openGit('${p.code}')">저장소 바인딩</button></div></article>`}).join("")} function renderProjects(){$("#projectGrid").innerHTML=projects.map(p=>{const b=p.repo_binding||{};return `<article class="card"><div style="display:flex;justify-content:space-between;gap:10px"><div><h3>${p.name}</h3><p>현재 기준 ${p.current_version}</p></div><span class="tag">v${p.current_version}</span></div><div class="meta"><div><span>BRANCH</span><strong>${b.branch||p.branch||"-"}</strong></div><div><span>담당</span><strong>${p.owner}</strong></div><div><span>GIT</span><strong>${b.repo?b.owner+"/"+b.repo:"미연결"}</strong></div></div><div class="bar"><i style="width:${p.progress}%"></i></div><div class="actions"><button class="secondary" onclick="checkGit('${p.code}')">연결 확인</button><button class="primary" onclick="openGit('${p.code}')">저장소 바인딩</button></div></article>`}).join("")}
async function checkGit(code){try{const r=await api(`/api/projects/${code}/git/status`,{headers:{"X-Role":currentRole}});toast(r.status.message+(r.status.commit?` · ${r.status.commit.slice(0,8)}`:""))}catch(e){toast(parseErr(e))}} async function checkGit(code){try{const r=await api(`/api/projects/${code}/git/status`,{headers:{"X-Role":currentRole}});toast(r.status.message+(r.status.commit?` · ${r.status.commit.slice(0,8)}`:""))}catch(e){toast(parseErr(e))}}
function openGit(code){const p=projects.find(x=>x.code===code),b=p.repo_binding||{};const f=$("#gitForm");f.project_code.value=code;f.owner.value=b.owner||"";f.repo.value=b.repo||"";f.branch.value=b.branch||p.branch||"";openModal("gitModal")} function openGit(code){const p=projects.find(x=>x.code===code),b=p.repo_binding||{};const f=$("#gitForm");f.project_code.value=code;f.owner.value=b.owner||"";f.repo.value=b.repo||"";f.branch.value=b.branch||p.branch||"";openModal("gitModal")}
$("#gitForm").onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target),code=fd.get("project_code");try{await api(`/api/projects/${code}/git/bind`,{method:"POST",headers:headers(),body:JSON.stringify({owner:fd.get("owner"),repo:fd.get("repo"),branch:fd.get("branch")})});closeModal("gitModal");projects=await api("/api/projects",{headers:{"X-Role":currentRole}});renderProjects();toast("Git 바인딩이 저장되었습니다.")}catch(err){toast(parseErr(err))}}; $("#gitForm").onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target),code=fd.get("project_code");try{await api(`/api/projects/${code}/git/bind`,{method:"POST",headers:headers(),body:JSON.stringify({owner:fd.get("owner"),repo:fd.get("repo"),branch:fd.get("branch")})});closeModal("gitModal");projects=await api("/api/projects",{headers:{"X-Role":currentRole}});renderProjects();toast("Git 바인딩이 저장되었습니다.")}catch(err){toast(parseErr(err))}};