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이 정상 응답입니다.
→
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(){});