diff --git a/.github/workflows/deploy-getcloser.yml b/.github/workflows/deploy-getcloser.yml index 14b9af3..571c406 100644 --- a/.github/workflows/deploy-getcloser.yml +++ b/.github/workflows/deploy-getcloser.yml @@ -47,6 +47,8 @@ jobs: echo "TEAM_SIZE=${{ vars.TEAM_SIZE}}" >> .env echo "PENDING_TIMEOUT_MINUTES=${{ vars.PENDING_TIMEOUT_MINUTES}}" >> .env echo "DATA_DIR_HOST=${{ vars.DATA_DIR_HOST }}" >> .env + echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env + echo "ENVIRONMENT=prod" >> .env - name: ๐Ÿš€ Deploy to PROD run: | diff --git a/.github/workflows/devfactory-homepage.yml b/.github/workflows/devfactory-homepage.yml index b7b99a9..4265a17 100644 --- a/.github/workflows/devfactory-homepage.yml +++ b/.github/workflows/devfactory-homepage.yml @@ -32,6 +32,7 @@ jobs: cat > .env <<'EOF' APP_HOST=${{ vars.APP_HOST }} DATABASE_URL=${{ secrets.DATABASE_URL }} + ACCESS_LOGGING_IP_SALT=${{ secrets.ACCESS_LOGGING_IP_SALT }} EOF - name: Build & up (prod) diff --git a/cert/backend/.env.example b/cert/backend/.env.example new file mode 100644 index 0000000..c13c356 --- /dev/null +++ b/cert/backend/.env.example @@ -0,0 +1,5 @@ +# LOG_LEVEL=INFO +# ENVIRONMENT=dev + +# CORS Origins (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„) +CORS_ORIGINS=https://cert.pseudo-lab.com,https://dev-cert.pseudolab-devfactory.com,http://localhost:5173 diff --git a/cert/backend/src/main.py b/cert/backend/src/main.py index e024221..662b3d3 100644 --- a/cert/backend/src/main.py +++ b/cert/backend/src/main.py @@ -40,11 +40,23 @@ def configure_logging() -> None: # Access log middleware app.middleware("http")(access_log_middleware) -# CORS ๋ฏธ๋“ค์›จ์–ด ์„ค์ • -origins = os.getenv("CORS_ORIGINS", "").split(",") +# CORS configuration +# Load allowed origins from CORS_ORIGINS environment variable (comma-separated) +cors_origins_str = os.getenv("CORS_ORIGINS", "") +if cors_origins_str: + origins = [origin.strip() for origin in cors_origins_str.split(",") if origin.strip()] +else: + # Default origins for local development and known production/dev domains + origins = [ + "http://localhost:3000", + "http://localhost:5173", + "https://cert.pseudo-lab.com", + "https://dev-cert.pseudolab-devfactory.com", + ] + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/getcloser/backend/.env.example b/getcloser/backend/.env.example new file mode 100644 index 0000000..e54c956 --- /dev/null +++ b/getcloser/backend/.env.example @@ -0,0 +1,7 @@ +# DATABASE_URL=postgresql+psycopg2://user:password@db:5432/app_db + +# ๋ณด์•ˆ์„ ์œ„ํ•ด ๋ฌด์ž‘์œ„ ๋ฌธ์ž์—ด์„ ์ƒ์„ฑํ•˜์—ฌ ์„ค์ •ํ•˜์„ธ์š”. +# ์˜ˆ: openssl rand -hex 32 +SECRET_KEY=your-super-secret-key-here + +# ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/getcloser/backend/app/core/config.py b/getcloser/backend/app/core/config.py index 898895d..4974341 100644 --- a/getcloser/backend/app/core/config.py +++ b/getcloser/backend/app/core/config.py @@ -1,12 +1,29 @@ import os +from pydantic import field_validator from pydantic_settings import BaseSettings class Settings(BaseSettings): + ENVIRONMENT: str = os.getenv("ENVIRONMENT", "dev") DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg2://user:password@db:5432/app_db") + """ JWT ์•ˆ์“ธ ๊ฒƒ ๊ฐ™์•„ ์ผ๋‹จ ์ฃผ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ถ”ํ›„ ํ™•์ • ์‹œ ์‚ญ์ œ """ - SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-prod") + # Secret key for JWT signing. Must be overridden in production using environment variables. + DEFAULT_SECRET_KEY = "default-secret-key-change-it" + SECRET_KEY: str = os.getenv("SECRET_KEY", DEFAULT_SECRET_KEY) + + @field_validator("SECRET_KEY") + @classmethod + def check_secret_key(cls, v, info): + """ + Validate that SECRET_KEY is not using the default placeholder value in production. + """ + env = os.getenv("ENVIRONMENT", "dev").lower() + if env in ["prod", "production"] and v == cls.DEFAULT_SECRET_KEY: + raise ValueError("SECRET_KEY must be a unique, non-default value in production environments.") + return v + ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) diff --git a/platform/.env.example b/platform/.env.example index 8b0e869..64685b6 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -3,3 +3,6 @@ APP_HOST=your-domain.com # Database Setting DATABASE_URL=postgresql://user:pass@devfactory-postgres:5432/dbname + +# Logging Setting +ACCESS_LOGGING_IP_SALT=your-secret-salt-here diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml index 96d4834..ab83126 100644 --- a/platform/docker-compose.yml +++ b/platform/docker-compose.yml @@ -35,6 +35,7 @@ services: restart: unless-stopped environment: - DATABASE_URL=${DATABASE_URL} + - ACCESS_LOGGING_IP_SALT=${ACCESS_LOGGING_IP_SALT} - PORT=3000 networks: - traefik diff --git a/platform/frontend/style.css b/platform/frontend/style.css index 4d8b0e6..38702e5 100644 --- a/platform/frontend/style.css +++ b/platform/frontend/style.css @@ -1422,8 +1422,7 @@ body { /* Activities Section */ #activities { padding-top: 2rem; - padding-bottom: 4rem; - /* Reduced since it's the last section before footer */ + padding-bottom: 6rem; } .activities-grid { @@ -1783,6 +1782,6 @@ body { } #activities { - padding-bottom: 8rem; + padding-bottom: 6rem; } } \ No newline at end of file diff --git a/platform/server/src/index.js b/platform/server/src/index.js index 504b6c2..563dd71 100644 --- a/platform/server/src/index.js +++ b/platform/server/src/index.js @@ -1,5 +1,6 @@ require('dotenv').config(); const express = require('express'); +const crypto = require('crypto'); const { Pool } = require('pg'); const cors = require('cors'); @@ -25,6 +26,35 @@ pool.query('SELECT NOW()', (err, res) => { }); // API Routes + +/** + * Extracts the client IP address from request headers or connection info. + */ +function getClientIp(req) { + // Check X-Forwarded-For header (common for reverse proxies) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + // Can be a comma-separated list; the first one is the original client + return forwardedFor.split(',')[0].trim(); + } + + // Check X-Real-IP header + const realIp = req.headers['x-real-ip']; + if (realIp) { + return realIp; + } + + // Fallback to Express req.ip or socket address + return req.ip || req.socket.remoteAddress; +} + +/** + * Hashes the IP address with a salt, matching the behavior in the cert system. + */ +function hashIp(ip, salt = '') { + if (!ip) return null; + return crypto.createHash('sha256').update(salt + ip).digest('hex'); +} app.get('/api/health', (req, res) => { res.json({ status: 'ok' }); }); @@ -33,12 +63,15 @@ app.get('/api/health', (req, res) => { app.post('/api/stats/visit', async (req, res) => { try { const { path, userAgent } = req.body; - // ๊ธฐ์กด ๋กœ๊ทธ ํฌ๋งท์— ๋งž์ถฐ method๋Š” 'PAGEVIEW'๋กœ, referrer๋Š” ํ˜„์žฌ ํ˜ธ์ŠคํŠธ๋กœ ๊ธฐ๋ก const referrer = req.headers.referer || ''; + // Extract client IP and generate hash + const clientIp = getClientIp(req); + const ipHash = hashIp(clientIp, process.env.ACCESS_LOGGING_IP_SALT || ''); + await pool.query( - 'INSERT INTO logging.access_log (path, method, status, user_agent, referrer, ts) VALUES ($1, $2, $3, $4, $5, NOW())', - [path || '/', 'PAGEVIEW', 200, userAgent, referrer] + 'INSERT INTO logging.access_log (path, method, status, ip_hash, user_agent, referrer, ts) VALUES ($1, $2, $3, $4, $5, $6, NOW())', + [path || '/', 'PAGEVIEW', 200, ipHash, userAgent, referrer] ); res.status(201).json({ message: 'Visit logged successfully' }); } catch (err) {