diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 952354d..56fc207 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,11 +7,18 @@ on: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - name: Login to GHCR - run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image run: | @@ -26,15 +33,18 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} + envs: GHCR_PULL_TOKEN script: | set -e cd ~/Project_RoboSenseAPI git pull origin main - echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "$GHCR_PULL_TOKEN" | docker login ghcr.io -u jeonggihun --password-stdin docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d sleep 15 curl -f http://localhost/health || (echo "Health check failed, rolling back" && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d --force-recreate && exit 1) + env: + GHCR_PULL_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify deployment uses: appleboy/ssh-action@v1 diff --git a/app/main.py b/app/main.py index 10bd48f..01d054e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.cors import CORSMiddleware -from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes +from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes, demo_routes from app.database import engine, Base, init_asyncpg_pool, close_asyncpg_pool, get_asyncpg_pool from app.middleware import RequestIDMiddleware from app.logging_config import RequestIDFilter @@ -77,6 +77,7 @@ async def lifespan(app: FastAPI): app.include_router(robot_routes.router) app.include_router(stats_routes.router) app.include_router(admin_routes.router) +app.include_router(demo_routes.router) @app.get("/", response_class=HTMLResponse) def root() : diff --git a/app/routes/demo_routes.py b/app/routes/demo_routes.py new file mode 100644 index 0000000..169d0db --- /dev/null +++ b/app/routes/demo_routes.py @@ -0,0 +1,73 @@ +"""랜딩페이지용 공개 조회 엔드포인트. + +API Key 없이 접근 가능. **조회 전용**. +쓰기는 `/api/*` 에서만 가능 (서버-to-서버 통합은 API Key 필수). + +왜 쓰기를 제공하지 않나: +- 익명 쓰기를 열면 봇·스크립트가 DB 오염 가능 (rate limit 없음) +- RoboSense는 로봇/IoT 기기 통합 API라 사람 로그인 개념이 없음 + → 쓰기 권한을 익명 브라우저 세션에 부여할 근거가 없음 +- 조회만 공개하면 포트폴리오 시연 요구 + 보안 요구 둘 다 만족 +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional +from datetime import datetime + +from app.database import get_db +from app.models.robot import RobotResponse, RobotDetailResponse +from app.models.sensor import SensorListResponse, SensorResponse, FilteredSensorResponse +from app.routes import robot_routes, sensor_routes, stats_routes + +router = APIRouter(prefix="/demo", tags=["랜딩페이지 공개 조회"]) + + +@router.get("/robots", response_model=list[RobotResponse]) +async def demo_list_robots( + status: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), +): + return await robot_routes.robot_data_list(status=status, db=db, _key=None) + + +@router.get("/robots/{id}", response_model=RobotDetailResponse) +async def demo_get_robot(id: int, db: AsyncSession = Depends(get_db)): + return await robot_routes.robot_data_specific_list(id=id, db=db, _key=None) + + +@router.get("/sensors", response_model=SensorListResponse) +async def demo_list_sensors( + limit: int = Query(100, ge=1, le=1000), + robot_id: Optional[int] = None, + sensor_type: Optional[str] = None, + cursor_id: Optional[int] = None, + db: AsyncSession = Depends(get_db), +): + return await sensor_routes.check_filter_data( + limit=limit, robot_id=robot_id, sensor_type=sensor_type, + cursor_id=cursor_id, db=db, _key=None, + ) + + +@router.get("/sensors/filtered", response_model=FilteredSensorResponse) +async def demo_filtered_sensors(robot_id: int, sensor_type: str, field: str, window_size: int): + return await sensor_routes.check_filter_sensor_data( + robot_id=robot_id, sensor_type=sensor_type, + field=field, window_size=window_size, _key=None, + ) + + +@router.get("/sensors/{id}", response_model=SensorResponse) +async def demo_get_sensor(id: int, db: AsyncSession = Depends(get_db)): + return await sensor_routes.check_filter_specific_data(id=id, db=db, _key=None) + + +@router.get("/stats") +async def demo_stats( + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + db: AsyncSession = Depends(get_db), +): + return await stats_routes.get_stats( + start_time=start_time, end_time=end_time, db=db, _key=None, + ) diff --git a/index.html b/index.html index ba7b348..8d36c6b 100644 --- a/index.html +++ b/index.html @@ -166,6 +166,7 @@

RoboSense API

API 테스트

실제 서버에 요청을 보내고 응답을 확인하세요. 모든 기능은 로봇 등록 후 사용할 수 있습니다.

Step 1 필수 — 센서 데이터 수집, 필터링, 통계 등 모든 기능은 로봇 등록(POST /api/robots) 이후에만 사용할 수 있습니다.
+
🔒
인증 안내조회(GET)는 공개 엔드포인트(/demo/*)로 키 없이 동작합니다. 쓰기(POST/PUT/DELETE)/api/* + X-API-Key 헤더 필요 — 버튼 클릭 시 401이 정상 응답입니다.
1

로봇 등록

POST /api/robots

2

센서 데이터 수집

POST /api/sensors

@@ -332,8 +333,8 @@
Registered Robots
}).catch(function(e){showErr('resp-post-robot',e.message);}); } -function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/api/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});} -function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/api/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});} +function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/demo/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});} +function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/demo/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});} function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/api/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});} function postSensor(){showLoad('resp-post-sensor');try{api('POST','/api/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}} @@ -382,15 +383,15 @@
Registered Robots
}).catch(function(){autoErrN++;document.getElementById('autoErrors').textContent=autoErrN;}); } -function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/api/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});} -function getFiltered(){showLoad('resp-get-filtered');api('GET','/api/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});} -function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/api/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});} +function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/demo/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});} +function getFiltered(){showLoad('resp-get-filtered');api('GET','/demo/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});} +function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/demo/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});} function healthCheck(){showLoad('resp-health');api('GET','/health_check').then(function(r){showResp('resp-health',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-health',e.message);});} function toLocal(d){return new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);} var pageLoad=new Date(); document.getElementById('stats-start').value=toLocal(pageLoad); document.getElementById('stats-end').value=toLocal(new Date(pageLoad.getTime()+3600000)); -fetch(B+'/api/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){}); +fetch(B+'/demo/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){}); diff --git a/nginx.conf b/nginx.conf index 82bc0b3..956b2ec 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,7 +10,7 @@ http { server fastapi-3:8000; server fastapi-4:8000; server fastapi-5:8000; - keepalive 10; + keepalive 16; keepalive_timeout 5; keepalive_requests 1000; } diff --git a/test/test_demo_bff.py b/test/test_demo_bff.py new file mode 100644 index 0000000..cc11304 --- /dev/null +++ b/test/test_demo_bff.py @@ -0,0 +1,100 @@ +"""demo 프록시 라우트 테스트. + +랜딩페이지용 공개 **조회 전용** 엔드포인트. 쓰기는 여전히 `/api/*`(API Key 필요). +""" +from app.models.db_models import Robot + + +# === 읽기 허용 === + +async def test_demo_get_robots_empty(client): + """키 없이 GET /demo/robots 호출 가능""" + del client.headers["X-API-Key"] + response = await client.get("/demo/robots") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_demo_get_robots_with_data(client, db_session): + """GET /demo/robots 가 데이터 반환""" + db_session.add(Robot(name="Demo1", model="m1", status="active", battery_level=70)) + await db_session.commit() + + del client.headers["X-API-Key"] + response = await client.get("/demo/robots") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +async def test_demo_get_sensors(client): + """키 없이 GET /demo/sensors""" + del client.headers["X-API-Key"] + response = await client.get("/demo/sensors?limit=5") + assert response.status_code == 200 + + +async def test_demo_stats(client): + """키 없이 GET /demo/stats""" + del client.headers["X-API-Key"] + response = await client.get("/demo/stats") + assert response.status_code == 200 + + +# === 쓰기 차단 === + +async def test_demo_post_robot_not_allowed(client): + """POST /demo/robots 는 제공되지 않음 — 쓰기는 /api/* + API Key 필요""" + del client.headers["X-API-Key"] + response = await client.post( + "/demo/robots", + json={"name": "X", "model": "v1", "status": "active", "battery_level": 50}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_put_robot_not_allowed(client, db_session): + """PUT /demo/robots/{id} 도 제공되지 않음 (로봇 실존해도 경로 자체 없음)""" + db_session.add(Robot(name="R1", model="m", status="active", battery_level=50)) + await db_session.commit() + + del client.headers["X-API-Key"] + response = await client.put( + "/demo/robots/1", + json={"status": "inactive", "battery_level": 30}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_post_sensor_not_allowed(client): + """POST /demo/sensors 도 제공되지 않음""" + del client.headers["X-API-Key"] + response = await client.post( + "/demo/sensors", + json={"robot_id": 1, "timestamp": "2026-01-01T00:00:00", "sensors": []}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_does_not_expose_reset(client): + """DELETE /demo/reset 도 제공되지 않음""" + del client.headers["X-API-Key"] + response = await client.delete("/demo/reset") + assert response.status_code == 404 + + +# === 회귀: /api/* 는 여전히 API Key 필수 === + +async def test_api_routes_still_require_key(client): + del client.headers["X-API-Key"] + response = await client.get("/api/robots") + assert response.status_code == 401 + assert response.json()["error_code"] == "API_KEY_MISSING" + + +async def test_api_post_still_requires_key(client): + del client.headers["X-API-Key"] + response = await client.post( + "/api/robots", + json={"name": "X", "model": "v1", "status": "active", "battery_level": 50}, + ) + assert response.status_code == 401