Add Telegram Auth and Dev mode
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
DISABLE_DOCS=true
|
||||
DEV_MODE=true # true = docs enabled + auth disabled; false = production mode
|
||||
|
||||
MONGO_HOST=127.0.0.1
|
||||
MONGO_PORT=27017
|
||||
|
||||
+44
-18
@@ -17,20 +17,20 @@ from rabbit_worker import RabbitWorker
|
||||
from tools.base_moderation import moderate_text
|
||||
from logger import logger, setup_logging
|
||||
from middleware import RequestLoggingMiddleware
|
||||
from tg_auth import get_current_user_id, DEV_MODE
|
||||
|
||||
|
||||
setup_logging()
|
||||
load_dotenv()
|
||||
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
title="This OR That",
|
||||
summary="OpenAPI schema for \"This OR That\" project!",
|
||||
version="0.1",
|
||||
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
||||
docs_url=None if disable_docs else "/docs",
|
||||
redoc_url=None if disable_docs else "/redoc",
|
||||
openapi_url=None if disable_docs else "/openapi.json",
|
||||
docs_url="/docs" if DEV_MODE else None,
|
||||
redoc_url="/redoc" if DEV_MODE else None,
|
||||
openapi_url="/openapi.json" if DEV_MODE else None,
|
||||
)
|
||||
config = SecurityConfig(
|
||||
enable_rate_limiting=True,
|
||||
@@ -38,7 +38,6 @@ config = SecurityConfig(
|
||||
rate_limit_window=3, # TODO: check rate limits in real usage
|
||||
enable_redis=False,
|
||||
enable_ip_banning=True,
|
||||
custom_log_file="security.log",
|
||||
|
||||
enable_penetration_detection=True,
|
||||
auto_ban_threshold=3,
|
||||
@@ -88,6 +87,8 @@ async def verify_moderation_secret(
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
await mongo_worker.create_indexes()
|
||||
if DEV_MODE:
|
||||
logger.warning("⚠️ DEV_MODE is enabled — docs are exposed and Telegram initData auth is DISABLED")
|
||||
logger.info("Application started on :5000")
|
||||
|
||||
|
||||
@@ -114,10 +115,14 @@ async def get_user(
|
||||
async def add_user(
|
||||
new_user: AddUserBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
if not await mongo.check_user(new_user.user_id):
|
||||
user_id = auth_user_id if auth_user_id is not None else new_user.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
if not await mongo.check_user(user_id):
|
||||
result = await mongo.add_user(
|
||||
new_user.user_id,
|
||||
user_id,
|
||||
new_user.username,
|
||||
new_user.first_name,
|
||||
new_user.last_name,
|
||||
@@ -142,10 +147,14 @@ async def get_card(
|
||||
@app.get("/get_random_cards", status_code=200)
|
||||
@guard_deco.rate_limit(requests=5, window=60)
|
||||
async def get_random_cards(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
user_id: Optional[int] = None,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
cards_visited = await mongo.get_visited_cards(user_id)
|
||||
resolved_user_id = auth_user_id if auth_user_id is not None else user_id
|
||||
if resolved_user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
cards_visited = await mongo.get_visited_cards(resolved_user_id)
|
||||
|
||||
if cards_visited.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
@@ -164,9 +173,13 @@ async def get_random_cards(
|
||||
async def add_card(
|
||||
new_card: AddCardBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
author_id = auth_user_id if auth_user_id is not None else new_card.author_id
|
||||
if author_id is None:
|
||||
raise HTTPException(status_code=422, detail="author_id is required")
|
||||
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
|
||||
card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, new_card.author_id)
|
||||
card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, author_id)
|
||||
try:
|
||||
await get_rabbit_worker().send_to_moderation(card)
|
||||
except Exception as exc:
|
||||
@@ -200,17 +213,18 @@ async def card_reject(
|
||||
async def select_choice(
|
||||
choice_data: SelectChoice,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
user_id = auth_user_id if auth_user_id is not None else choice_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
# Verify that the user exists before proceeding.
|
||||
if not await mongo.check_user(choice_data.user_id):
|
||||
if not await mongo.check_user(user_id):
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
# Atomically mark the card as visited.
|
||||
# try_mark_visited uses a conditional MongoDB filter ($ne) so that only one
|
||||
# concurrent request can "win" — eliminating the TOCTOU race condition where
|
||||
# two parallel requests both pass the visited-check before either writes.
|
||||
newly_visited = await mongo.try_mark_visited(choice_data.user_id, choice_data.card_id)
|
||||
newly_visited = await mongo.try_mark_visited(user_id, choice_data.card_id)
|
||||
if not newly_visited:
|
||||
response.status_code = status.HTTP_403_FORBIDDEN
|
||||
return BaseResponse(result="Card already visited!", error=True)
|
||||
@@ -227,8 +241,12 @@ async def select_choice(
|
||||
async def like_card(
|
||||
like_data: ReactionCard,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
result = await mongo.like_card(like_data.card_id, like_data.user_id)
|
||||
user_id = auth_user_id if auth_user_id is not None else like_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
result = await mongo.like_card(like_data.card_id, user_id)
|
||||
if not result.error and result.result:
|
||||
return BaseResponse(result="Added like to card")
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
@@ -238,8 +256,12 @@ async def like_card(
|
||||
async def dislike_card(
|
||||
dislike_data: ReactionCard,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
result = await mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
|
||||
user_id = auth_user_id if auth_user_id is not None else dislike_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
result = await mongo.dislike_card(dislike_data.card_id, user_id)
|
||||
if not result.error and result.result:
|
||||
return BaseResponse(result="Added dislike to card")
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
@@ -250,12 +272,16 @@ async def dislike_card(
|
||||
async def comment(
|
||||
comment_info: AddCommentBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
author_id = auth_user_id if auth_user_id is not None else comment_info.author_id
|
||||
if author_id is None:
|
||||
raise HTTPException(status_code=422, detail="author_id is required")
|
||||
if not moderate_text(comment_info.comment_text):
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
return BaseResponse(result="Comment has not passed base moderation", error=True)
|
||||
|
||||
result = await mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text)
|
||||
result = await mongo.add_comment(author_id, comment_info.card_id, comment_info.comment_text)
|
||||
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
@@ -2,13 +2,15 @@ import typing
|
||||
|
||||
from pydantic import BaseModel, NonNegativeInt
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
result: typing.Any
|
||||
error: bool = False
|
||||
|
||||
class AddUserBody(BaseModel):
|
||||
user_id: NonNegativeInt
|
||||
user_id: Optional[NonNegativeInt] = None
|
||||
username: str
|
||||
|
||||
first_name: str
|
||||
@@ -19,20 +21,20 @@ class AddCardBody(BaseModel):
|
||||
choice_A: str
|
||||
choice_B: str
|
||||
|
||||
author_id: NonNegativeInt
|
||||
author_id: Optional[NonNegativeInt] = None
|
||||
|
||||
class SelectChoice(BaseModel):
|
||||
user_id: NonNegativeInt
|
||||
user_id: Optional[NonNegativeInt] = None
|
||||
card_id: NonNegativeInt
|
||||
|
||||
choice: typing.Literal["A", "B"]
|
||||
|
||||
class ReactionCard(BaseModel):
|
||||
user_id: NonNegativeInt
|
||||
user_id: Optional[NonNegativeInt] = None
|
||||
card_id: NonNegativeInt
|
||||
|
||||
class AddCommentBody(BaseModel):
|
||||
author_id: NonNegativeInt
|
||||
author_id: Optional[NonNegativeInt] = None
|
||||
card_id: NonNegativeInt
|
||||
|
||||
comment_text: str
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Tests for Telegram initData HMAC-SHA256 validation (tg_auth module).
|
||||
|
||||
These tests directly exercise the ``validate_init_data`` function with
|
||||
synthetic initData, covering happy-path and all failure modes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tg_auth import validate_init_data
|
||||
|
||||
BOT_TOKEN = "7765587867:AAHYpUR_XHEZ1YjCKCAgjWaOiepeDY4XtPA"
|
||||
|
||||
|
||||
def _build_init_data(
|
||||
bot_token: str,
|
||||
user: dict,
|
||||
auth_date: int | None = None,
|
||||
tamper_hash: bool = False,
|
||||
omit_hash: bool = False,
|
||||
omit_user: bool = False,
|
||||
) -> str:
|
||||
"""Helper that constructs a valid (or intentionally broken) initData string."""
|
||||
if auth_date is None:
|
||||
auth_date = int(time.time())
|
||||
|
||||
params: dict[str, str] = {
|
||||
"auth_date": str(auth_date),
|
||||
}
|
||||
if not omit_user:
|
||||
params["user"] = json.dumps(user)
|
||||
|
||||
# Build data-check-string (sorted, \n-separated).
|
||||
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
|
||||
# secret_key = HMAC-SHA256("WebAppData", bot_token)
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if tamper_hash:
|
||||
computed_hash = "a" * 64 # obviously wrong
|
||||
|
||||
if not omit_hash:
|
||||
params["hash"] = computed_hash
|
||||
|
||||
return urlencode(params)
|
||||
|
||||
|
||||
VALID_USER = {
|
||||
"id": 123456789,
|
||||
"first_name": "Igor",
|
||||
"last_name": "Volochay",
|
||||
"username": "IgorVolochay",
|
||||
"photo_url": "https://t.me/photo.jpg",
|
||||
}
|
||||
|
||||
|
||||
# ── Happy path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_init_data():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER)
|
||||
result = validate_init_data(raw, BOT_TOKEN)
|
||||
assert result["user_id"] == 123456789
|
||||
assert result["username"] == "IgorVolochay"
|
||||
assert result["first_name"] == "Igor"
|
||||
assert result["last_name"] == "Volochay"
|
||||
assert result["photo_url"] == "https://t.me/photo.jpg"
|
||||
|
||||
|
||||
# ── Failure modes ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_init_data():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data("", BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_missing_hash():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_hash=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "hash" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_tampered_hash():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, tamper_hash=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "signature" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_expired_auth_date():
|
||||
old_date = int(time.time()) - 7200 # 2 hours ago
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=old_date)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN, max_age=3600)
|
||||
assert exc.value.status_code == 403
|
||||
assert "expired" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_missing_user():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_user=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "user" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_missing_user_id():
|
||||
user_no_id = {"first_name": "Igor", "username": "test"}
|
||||
raw = _build_init_data(BOT_TOKEN, user_no_id)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "user.id" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_wrong_bot_token():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, "wrong:token")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_fresh_auth_date_passes():
|
||||
"""auth_date exactly 5 seconds ago should be fine with default max_age."""
|
||||
recent = int(time.time()) - 5
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=recent)
|
||||
result = validate_init_data(raw, BOT_TOKEN)
|
||||
assert result["user_id"] == 123456789
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Telegram Mini App initData authentication module.
|
||||
|
||||
Validates initData from the Telegram WebApp using HMAC-SHA256
|
||||
per the official specification:
|
||||
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
|
||||
In DEV_MODE (default) authentication is skipped — user_id is taken
|
||||
from the request body / query parameters as-is.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from logger import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEV_MODE: bool = os.getenv("DEV_MODE", "true").lower() == "true"
|
||||
TG_BOT_TOKEN: str = os.getenv("TG_BOT_TOKEN", "")
|
||||
|
||||
# Maximum allowed age of initData in seconds (1 hour).
|
||||
INIT_DATA_MAX_AGE: int = int(os.getenv("INIT_DATA_MAX_AGE", "3600"))
|
||||
|
||||
|
||||
def validate_init_data(
|
||||
init_data_raw: str,
|
||||
bot_token: str,
|
||||
max_age: int = INIT_DATA_MAX_AGE,
|
||||
) -> dict:
|
||||
"""
|
||||
Validates Telegram Mini App initData and returns the parsed ``user`` dict.
|
||||
|
||||
Algorithm (per Telegram docs):
|
||||
1. Parse the query-string into key→value pairs.
|
||||
2. Extract the ``hash`` value; build ``data-check-string`` from the
|
||||
remaining fields sorted by key, joined with ``\\n``.
|
||||
3. ``secret_key = HMAC-SHA256(bot_token, "WebAppData")``
|
||||
4. ``computed = HMAC-SHA256(data_check_string, secret_key)``
|
||||
5. Compare ``computed`` with ``hash`` using constant-time comparison.
|
||||
6. Optionally verify ``auth_date`` freshness.
|
||||
|
||||
Returns a dict with keys: user_id, username, first_name, last_name, photo_url.
|
||||
|
||||
Raises ``HTTPException(403)`` on any validation failure.
|
||||
"""
|
||||
if not init_data_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing initData",
|
||||
)
|
||||
|
||||
parsed = parse_qs(init_data_raw, keep_blank_values=True)
|
||||
|
||||
# parse_qs returns lists — flatten to single values.
|
||||
flat: dict[str, str] = {k: v[0] for k, v in parsed.items()}
|
||||
|
||||
received_hash = flat.pop("hash", None)
|
||||
if not received_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing hash in initData",
|
||||
)
|
||||
|
||||
# Build data-check-string: sorted key=value pairs joined by \n.
|
||||
data_check_string = "\n".join(
|
||||
f"{k}={v}" for k, v in sorted(flat.items())
|
||||
)
|
||||
|
||||
# secret_key = HMAC-SHA256("WebAppData", bot_token)
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid initData signature",
|
||||
)
|
||||
|
||||
# Verify auth_date freshness.
|
||||
auth_date_str = flat.get("auth_date")
|
||||
if auth_date_str:
|
||||
try:
|
||||
auth_date = int(auth_date_str)
|
||||
if time.time() - auth_date > max_age:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="initData expired",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid auth_date",
|
||||
)
|
||||
|
||||
# Extract user data.
|
||||
user_raw = flat.get("user")
|
||||
if not user_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing user in initData",
|
||||
)
|
||||
|
||||
try:
|
||||
user = json.loads(user_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid user JSON in initData",
|
||||
)
|
||||
|
||||
user_id = user.get("id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing user.id in initData",
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": int(user_id),
|
||||
"username": user.get("username", ""),
|
||||
"first_name": user.get("first_name", ""),
|
||||
"last_name": user.get("last_name", ""),
|
||||
"photo_url": user.get("photo_url", ""),
|
||||
}
|
||||
|
||||
|
||||
async def get_current_user_id(request: Request) -> Optional[int]:
|
||||
"""
|
||||
FastAPI dependency that resolves the authenticated user_id.
|
||||
|
||||
- **DEV_MODE=true**: returns ``None`` — endpoints use user_id from
|
||||
body/params as before (backward compatible).
|
||||
- **DEV_MODE=false**: reads ``X-Init-Data`` header, validates it
|
||||
via HMAC-SHA256, and returns the verified ``user_id``.
|
||||
"""
|
||||
if DEV_MODE:
|
||||
return None
|
||||
|
||||
init_data_raw = request.headers.get("X-Init-Data", "")
|
||||
if not init_data_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="X-Init-Data header is required",
|
||||
)
|
||||
|
||||
if not TG_BOT_TOKEN:
|
||||
logger.error("TG_BOT_TOKEN is not set but DEV_MODE is disabled")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication misconfiguration",
|
||||
)
|
||||
|
||||
user_data = validate_init_data(init_data_raw, TG_BOT_TOKEN)
|
||||
return user_data["user_id"]
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
mongodb:
|
||||
image: mongodb/mongodb-community-server
|
||||
image: mongo
|
||||
container_name: tort-mongodb
|
||||
restart: always
|
||||
environment:
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
RABBIT_USER: ${RABBIT_USER}
|
||||
RABBIT_PASS: ${RABBIT_PASS}
|
||||
MODERATION_SECRET: ${MODERATION_SECRET}
|
||||
DISABLE_DOCS: ${DISABLE_DOCS:-true}
|
||||
DEV_MODE: ${DEV_MODE:-false}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "127.0.0.1:5000:5000"
|
||||
|
||||
Reference in New Issue
Block a user