Jinfeng Ji 3 vuotta sitten
commit
6d5481e3da

+ 3 - 0
.idea/.gitignore

@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml

+ 8 - 0
.idea/Message-box.iml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="jdk" jdkName="Python 3.6" jdkType="Python SDK" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 15 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,15 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="ignoredPackages">
+        <value>
+          <list size="2">
+            <item index="0" class="java.lang.String" itemvalue="Django" />
+            <item index="1" class="java.lang.String" itemvalue="django_recaptcha" />
+          </list>
+        </value>
+      </option>
+    </inspection_tool>
+  </profile>
+</component>

+ 6 - 0
.idea/inspectionProfiles/profiles_settings.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <settings>
+    <option name="USE_PROJECT_PROFILE" value="false" />
+    <version value="1.0" />
+  </settings>
+</component>

+ 4 - 0
.idea/misc.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.6" project-jdk-type="Python SDK" />
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/Message-box.iml" filepath="$PROJECT_DIR$/.idea/Message-box.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>

+ 0 - 0
api/__init__.py


+ 0 - 0
api/api_v1/__init__.py


+ 14 - 0
api/api_v1/api.py

@@ -0,0 +1,14 @@
+from fastapi import APIRouter
+
+from api.api_v1.endpoints import receive_message
+from api.api_v1.endpoints import send_message
+from api.api_v1.endpoints import open_message
+from api.api_v1.endpoints import message
+
+
+api_router = APIRouter()
+
+api_router.include_router(receive_message.router, prefix="receive_message", tags=["收到的信件"])
+api_router.include_router(send_message.router, prefix="send_message", tags=["寄出的信件"])
+api_router.include_router(open_message.router, prefix="open_message", tags=["公开留言"])
+api_router.include_router(message.router, prefix="message", tags=["信件具体内容"])

+ 0 - 0
api/api_v1/endpoints/__init__.py


+ 0 - 0
api/api_v1/endpoints/message.py


+ 0 - 0
api/api_v1/endpoints/open_message.py


+ 0 - 0
api/api_v1/endpoints/receive_message.py


+ 0 - 0
api/api_v1/endpoints/send_message.py


+ 61 - 0
api/deps.py

@@ -0,0 +1,61 @@
+from typing import Generator
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import jwt
+from pydantic import ValidationError
+from sqlalchemy.orm import Session
+
+from app import crud, models, schemas
+from app.core import security
+from app.core.config import settings
+from app.db.session import SessionLocal
+
+reusable_oauth2 = OAuth2PasswordBearer(
+    tokenUrl=f"{settings.API_V1_STR}/login/access-token"
+)
+
+
+def get_db() -> Generator:
+    try:
+        db = SessionLocal()
+        yield db
+    finally:
+        db.close()
+
+
+def get_current_user(
+    db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
+) -> models.User:
+    try:
+        payload = jwt.decode(
+            token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
+        )
+        token_data = schemas.TokenPayload(**payload)
+    except (jwt.JWTError, ValidationError):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Could not validate credentials",
+        )
+    user = crud.user.get(db, id=token_data.sub)
+    if not user:
+        raise HTTPException(status_code=404, detail="User not found")
+    return user
+
+
+def get_current_active_user(
+    current_user: models.User = Depends(get_current_user),
+) -> models.User:
+    if not crud.user.is_active(current_user):
+        raise HTTPException(status_code=400, detail="Inactive user")
+    return current_user
+
+
+def get_current_active_superuser(
+    current_user: models.User = Depends(get_current_user),
+) -> models.User:
+    if not crud.user.is_superuser(current_user):
+        raise HTTPException(
+            status_code=400, detail="The user doesn't have enough privileges"
+        )
+    return current_user

+ 36 - 0
celeryworker_pre_start.py

@@ -0,0 +1,36 @@
+import logging
+
+from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
+from db.session import db_session
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+max_tries = 60 * 5  # 5 minutes
+wait_seconds = 1
+
+
+@retry(
+    stop=stop_after_attempt(max_tries),
+    wait=wait_fixed(wait_seconds),
+    before=before_log(logger, logging.INFO),
+    after=after_log(logger, logging.WARN),
+)
+def init():
+    try:
+        # Try to create session to check if DB is awake
+        db_session.execute("SELECT 1")
+    except Exception as e:
+        logger.error(e)
+        raise e
+
+
+def main():
+    logger.info("Initializing service")
+    init()
+
+    logger.info("Service finished initializing")
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
core/__init__.py


+ 5 - 0
core/celery_app.py

@@ -0,0 +1,5 @@
+from celery import Celery
+
+celery_app = Celery("worker", broker="amqp://guest@queue//")
+
+celery_app.conf.task_routes = {"app.worker.test_celery": "main-queue"}

+ 89 - 0
core/config.py

@@ -0,0 +1,89 @@
+import secrets
+from typing import Any, Dict, List, Optional, Union
+
+from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator
+
+
+class Settings(BaseSettings):
+    API_V1_STR: str = "/api/v1"
+    SECRET_KEY: str = secrets.token_urlsafe(32)
+    # 60 minutes * 24 hours * 8 days = 8 days
+    ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
+    SERVER_NAME: str
+    SERVER_HOST: AnyHttpUrl
+    # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
+    # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
+    # "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
+    BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
+
+    @validator("BACKEND_CORS_ORIGINS", pre=True)
+    def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
+        if isinstance(v, str) and not v.startswith("["):
+            return [i.strip() for i in v.split(",")]
+        elif isinstance(v, (list, str)):
+            return v
+        raise ValueError(v)
+
+    PROJECT_NAME: str
+    SENTRY_DSN: Optional[HttpUrl] = None
+
+    @validator("SENTRY_DSN", pre=True)
+    def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
+        if len(v) == 0:
+            return None
+        return v
+
+    POSTGRES_SERVER: str
+    POSTGRES_USER: str
+    POSTGRES_PASSWORD: str
+    POSTGRES_DB: str
+    SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None
+
+    @validator("SQLALCHEMY_DATABASE_URI", pre=True)
+    def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
+        if isinstance(v, str):
+            return v
+        return PostgresDsn.build(
+            scheme="postgresql",
+            user=values.get("POSTGRES_USER"),
+            password=values.get("POSTGRES_PASSWORD"),
+            host=values.get("POSTGRES_SERVER"),
+            path=f"/{values.get('POSTGRES_DB') or ''}",
+        )
+
+    SMTP_TLS: bool = True
+    SMTP_PORT: Optional[int] = None
+    SMTP_HOST: Optional[str] = None
+    SMTP_USER: Optional[str] = None
+    SMTP_PASSWORD: Optional[str] = None
+    EMAILS_FROM_EMAIL: Optional[EmailStr] = None
+    EMAILS_FROM_NAME: Optional[str] = None
+
+    @validator("EMAILS_FROM_NAME")
+    def get_project_name(cls, v: Optional[str], values: Dict[str, Any]) -> str:
+        if not v:
+            return values["PROJECT_NAME"]
+        return v
+
+    EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
+    EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
+    EMAILS_ENABLED: bool = False
+
+    @validator("EMAILS_ENABLED", pre=True)
+    def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool:
+        return bool(
+            values.get("SMTP_HOST")
+            and values.get("SMTP_PORT")
+            and values.get("EMAILS_FROM_EMAIL")
+        )
+
+    EMAIL_TEST_USER: EmailStr = "test@example.com"  # type: ignore
+    FIRST_SUPERUSER: EmailStr
+    FIRST_SUPERUSER_PASSWORD: str
+    USERS_OPEN_REGISTRATION: bool = False
+
+    class Config:
+        case_sensitive = True
+
+
+settings = Settings()

+ 34 - 0
core/security.py

@@ -0,0 +1,34 @@
+from datetime import datetime, timedelta
+from typing import Any, Union
+
+from jose import jwt
+from passlib.context import CryptContext
+
+from core.config import settings
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+ALGORITHM = "HS256"
+
+
+def create_access_token(
+    subject: Union[str, Any], expires_delta: timedelta = None
+) -> str:
+    if expires_delta:
+        expire = datetime.utcnow() + expires_delta
+    else:
+        expire = datetime.utcnow() + timedelta(
+            minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
+        )
+    to_encode = {"exp": expire, "sub": str(subject)}
+    encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
+    return encoded_jwt
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    return pwd_context.verify(plain_password, hashed_password)
+
+
+def get_password_hash(password: str) -> str:
+    return pwd_context.hash(password)

+ 0 - 0
crud/__init__.py


+ 0 - 0
db/__init__.py


+ 18 - 0
db/session.py

@@ -0,0 +1,18 @@
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session
+
+from core.config import settings
+import os
+
+pool_size = 20
+if os.getenv("ENV") == "prod":
+    pool_size = 100
+
+engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, pool_size=pool_size)
+db_session = scoped_session(
+    sessionmaker(autocommit=False, autoflush=False, bind=engine)
+)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()

+ 0 - 0
models/__init__.py


+ 17 - 0
models/message.py

@@ -0,0 +1,17 @@
+from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, BigInteger, Text, DateTime
+from sqlalchemy.orm import relationship
+from db.session import Base
+
+
+class Message(Base):
+    __tablename__ = 'message'
+
+    id = Column(BigInteger, primary_key=True, index=True)
+    create_user_id = Column(Integer, ForeignKey("user.id"))
+    create_user = relationship("User", back_populates="message")
+    content = Column(Text)
+    create_time = Column(DateTime)
+    target_user_id = Column(Integer, ForeignKey("user.id"))
+    target_user = relationship("User", back_populates="message")
+    is_bound = Column(Boolean)
+    is_open = Column(Boolean)

+ 14 - 0
models/reply.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, BigInteger, Text, DateTime
+from sqlalchemy.orm import relationship
+from db.session import Base
+
+
+class Commit(Base):
+    __tablename__ = "commit"
+
+    id = Column(Integer, primary_key=True, index=True)
+    message_id = Column(Integer, index=True)
+    user_id = Column(Integer, ForeignKey("user.id"))
+    reply_content = Column(String)
+    reply_time = Column(DateTime)
+    parent_id = Column(Integer, ForeignKey("comment.id"))

+ 12 - 0
models/user.py

@@ -0,0 +1,12 @@
+from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, BigInteger
+from sqlalchemy.orm import relationship
+from db.session import Base
+
+
+class User(Base):
+    __tablename__ = 'user'
+
+    id = Column(Integer, primary_key=True, index=True, name="user_id")
+    avatar = Column(String)
+    nickname = Column(String)
+    message = relationship("Message")

+ 0 - 0
schemas/__init__.py


+ 106 - 0
utils.py

@@ -0,0 +1,106 @@
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import emails
+from emails.template import JinjaTemplate
+from jose import jwt
+
+from core.config import settings
+
+
+def send_email(
+    email_to: str,
+    subject_template: str = "",
+    html_template: str = "",
+    environment: Dict[str, Any] = {},
+) -> None:
+    assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
+    message = emails.Message(
+        subject=JinjaTemplate(subject_template),
+        html=JinjaTemplate(html_template),
+        mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
+    )
+    smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
+    if settings.SMTP_TLS:
+        smtp_options["tls"] = True
+    if settings.SMTP_USER:
+        smtp_options["user"] = settings.SMTP_USER
+    if settings.SMTP_PASSWORD:
+        smtp_options["password"] = settings.SMTP_PASSWORD
+    response = message.send(to=email_to, render=environment, smtp=smtp_options)
+    logging.info(f"send email result: {response}")
+
+
+def send_test_email(email_to: str) -> None:
+    project_name = settings.PROJECT_NAME
+    subject = f"{project_name} - Test email"
+    with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
+        template_str = f.read()
+    send_email(
+        email_to=email_to,
+        subject_template=subject,
+        html_template=template_str,
+        environment={"project_name": settings.PROJECT_NAME, "email": email_to},
+    )
+
+
+def send_reset_password_email(email_to: str, email: str, token: str) -> None:
+    project_name = settings.PROJECT_NAME
+    subject = f"{project_name} - Password recovery for user {email}"
+    with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
+        template_str = f.read()
+    server_host = settings.SERVER_HOST
+    link = f"{server_host}/reset-password?token={token}"
+    send_email(
+        email_to=email_to,
+        subject_template=subject,
+        html_template=template_str,
+        environment={
+            "project_name": settings.PROJECT_NAME,
+            "username": email,
+            "email": email_to,
+            "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
+            "link": link,
+        },
+    )
+
+
+def send_new_account_email(email_to: str, username: str, password: str) -> None:
+    project_name = settings.PROJECT_NAME
+    subject = f"{project_name} - New account for user {username}"
+    with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
+        template_str = f.read()
+    link = settings.SERVER_HOST
+    send_email(
+        email_to=email_to,
+        subject_template=subject,
+        html_template=template_str,
+        environment={
+            "project_name": settings.PROJECT_NAME,
+            "username": username,
+            "password": password,
+            "email": email_to,
+            "link": link,
+        },
+    )
+
+
+def generate_password_reset_token(email: str) -> str:
+    delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
+    now = datetime.utcnow()
+    expires = now + delta
+    exp = expires.timestamp()
+    encoded_jwt = jwt.encode(
+        {"exp": exp, "nbf": now, "sub": email}, settings.SECRET_KEY, algorithm="HS256",
+    )
+    return encoded_jwt
+
+
+def verify_password_reset_token(token: str) -> Optional[str]:
+    try:
+        decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
+        return decoded_token["email"]
+    except jwt.JWTError:
+        return None