Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion static/projectstest.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ <h2>3. Check Authentication</h2>
}

try {
const response = await fetch('/api/projects/create', {
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down
11 changes: 5 additions & 6 deletions v1/auth/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
HOST = "redis" if os.getenv("USING_DOCKER") == "true" else "localhost"
r = redis.Redis(password=os.getenv("REDIS_PASSWORD", ""), host=HOST)

with open("v1/auth/otp.html", "r", encoding='utf8') as f:
OTP_EMAIL_TEMPLATE = f.read()

class Permission(Enum):
"""User permissions"""
Expand Down Expand Up @@ -237,15 +239,12 @@ async def refresh_token(
async def send_otp(_request: Request, otp_request: OtpClientRequest):
"""Send OTP to the user's email"""
otp = secrets.SystemRandom().randrange(100000, 999999)
await r.setex(f"otp-{otp_request.email}", 300, otp)
await r.setex(f"otp-{otp_request.email}", 600, otp)
message = EmailMessage()
message["From"] = os.getenv("SMTP_EMAIL", "[email protected]")
message["To"] = otp_request.email
message["Subject"] = "Aces OTP code"
message.set_content(
f"Your OTP for Aces is {otp}! This code will expire in 5 minutes. \n"
f"Happy Hacking!\n\n- Aces Organizing Team"
)
message["Subject"] = f"Aces OTP code: {otp}"
message.set_content(OTP_EMAIL_TEMPLATE.replace("{{OTP}}", str(otp)), subtype="html")

await aiosmtplib.send(
message,
Expand Down
29 changes: 29 additions & 0 deletions v1/auth/otp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #840027; padding: 50px 0px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #a50036; border-radius: 0.5rem; overflow: hidden">
<tr>
<td style="padding: 25px 30px;">
<p style="margin: 0 0 20px; color: #ffffff; font-size: 16px; line-height: 1.5;">
Your one-time password is:
</p>
<div style="text-align: center; margin: 30px 0;">
<span style="display: inline-block; background-color: #eeeeee; border-radius: 0.375rem; padding: 20px 40px; font-size: 32px; font-weight: bold; color: #333333; letter-spacing: 8px;">{{OTP}}</span>
</div>
<p style="margin: 0; color: #ffffff; font-size: 16px; line-height: 1.5;">
This code will expire in <strong>10 minutes</strong>.
</p>
<p style="margin: 0; color: #eeeeee; font-size: 14px; line-height: 1.5;">
If you didn't request this code, please ignore this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
1 change: 1 addition & 0 deletions v1/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class UserProject(Base):
onupdate=lambda: datetime.now(timezone.utc),
)
repo: Mapped[str] = MappedColumn(String, nullable=True, default="")
demo_url: Mapped[str] = MappedColumn(String, nullable=True, default="")
preview_image: Mapped[str] = MappedColumn(String, nullable=True, default="")

# Relationship back to user
Expand Down
92 changes: 46 additions & 46 deletions v1/projects/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import sqlalchemy
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, JSONResponse
from fastapi.responses import Response
from pydantic import BaseModel, ConfigDict, HttpUrl
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
Expand All @@ -25,8 +25,9 @@ class CreateProjectRequest(BaseModel):
"""Create project request from client"""

project_name: str
repo: HttpUrl
preview_image: HttpUrl
repo: Optional[HttpUrl] = None
demo_url: Optional[HttpUrl] = None
preview_image: Optional[HttpUrl] = None


class UpdateProjectRequest(BaseModel):
Expand All @@ -36,14 +37,14 @@ class UpdateProjectRequest(BaseModel):
project_name: Optional[str] = None
hackatime_projects: Optional[List[str]] = None
repo: Optional[HttpUrl] = None
demo_url: Optional[HttpUrl] = None
preview_image: Optional[HttpUrl] = None

class Config:
"""Pydantic config"""

extra = "forbid"


class ProjectResponse(BaseModel):
"""Public representation of a project"""

Expand All @@ -53,6 +54,7 @@ class ProjectResponse(BaseModel):
hackatime_total_hours: float
last_updated: datetime
repo: Optional[str]
demo_url: Optional[str]
preview_image: Optional[str]

model_config = ConfigDict(from_attributes=True)
Expand All @@ -67,6 +69,7 @@ def from_model(cls, project: UserProject) -> "ProjectResponse":
hackatime_total_hours=project.hackatime_total_hours,
last_updated=project.last_updated,
repo=project.repo,
demo_url=project.demo_url,
preview_image=project.preview_image,
)

Expand All @@ -90,6 +93,7 @@ def validate_repo(repo: HttpUrl | None):
raise HTTPException(
status_code=400, detail="repo url host exceeds the length limit"
)
return True


# @protect
Expand Down Expand Up @@ -117,46 +121,39 @@ async def update_project(
if project is None:
raise HTTPException(status_code=404) # if you get this good on you...?

# Validate preview image if being updated
# Validate and update preview image if being updated
if project_request.preview_image is not None:
if project_request.preview_image.host != CDN_HOST:
raise HTTPException(
status_code=400, detail="image must be hosted on the Hack Club CDN"
)
project.preview_image = str(project_request.preview_image)

# Validate repo URL if being updated
# Validate and update demo URL if being updated
if project_request.demo_url is not None:
if not validators.url(str(project_request.demo_url), private=False):
raise HTTPException(
status_code=400, detail="demo url is not valid or is local/private"
)
project.demo_url = str(project_request.demo_url)

# Validate and update repo URL if being updated
if project_request.repo is not None:
validate_repo(project_request.repo)
if validate_repo(project_request.repo):
project.repo = str(project_request.repo)

update_data = project_request.model_dump(
exclude_unset=True, exclude={"project_id"}, mode="python"
)
# Update project name
if project_request.project_name is not None:
project.name = project_request.project_name

allowed_update_fields = {
"project_name",
"hackatime_projects",
"repo",
"preview_image",
}
for field, value in update_data.items():
if field in allowed_update_fields:
model_field = "name" if field == "project_name" else field
# Convert HttpUrl to string if needed
if field in {"repo", "preview_image"} and value is not None:
value = str(value)
setattr(project, model_field, value)
# Update hackatime projects
if project_request.hackatime_projects is not None:
project.hackatime_projects = project_request.hackatime_projects

try:
await session.commit()
await session.refresh(project)
return JSONResponse(
{
"success": True,
"project_info": ProjectResponse.from_model(project).model_dump(
mode="json"
),
}
)
return ProjectResponse.from_model(project)
except Exception: # type: ignore # pylint: disable=broad-exception-caught
await session.rollback()
return Response(status_code=500)
Expand Down Expand Up @@ -241,21 +238,31 @@ async def create_project(
) # if the user hasn't been created yet they shouldn't be authed

# Validate preview image
if project_create_request.preview_image.host != CDN_HOST:
raise HTTPException(
status_code=400, detail="image must be hosted on the Hack Club CDN"
)
if project_create_request.preview_image is not None:
if project_create_request.preview_image.host != CDN_HOST:
raise HTTPException(
status_code=400, detail="image must be hosted on the Hack Club CDN"
)

# Validate demo URL
if project_create_request.demo_url is not None:
if not validators.url(str(project_create_request.demo_url), private=False):
raise HTTPException(
status_code=400, detail="demo url is not valid or is local/private"
)

# Validate repo URL
validate_repo(project_create_request.repo)
if project_create_request.repo is not None:
validate_repo(project_create_request.repo)

new_project = UserProject(
name=project_create_request.project_name,
user_email=user_email,
hackatime_projects=[],
hackatime_total_hours=0.0,
repo=str(project_create_request.repo),
preview_image=str(project_create_request.preview_image),
repo=str(project_create_request.repo or ""),
demo_url=str(project_create_request.demo_url or ""),
preview_image=str(project_create_request.preview_image or ""),
# last_updated=datetime.datetime.now(datetime.timezone.utc)
# this should no longer need manual setting
)
Expand All @@ -264,14 +271,7 @@ async def create_project(
session.add(new_project)
await session.commit()
await session.refresh(new_project)
return JSONResponse(
{
"success": True,
"project_info": ProjectResponse.from_model(new_project).model_dump(
mode="json"
),
}
)
return ProjectResponse.from_model(new_project)
except Exception as e: # type: ignore # pylint: disable=broad-exception-caught
await session.rollback()
print(e)
Expand Down
Loading