Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions server/fishtest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import traceback

import fishtest.github_api as gh
from fishtest.emailer import EmailSender
from fishtest.routes import setup_routes
from fishtest.rundb import RunDb
from pyramid.authentication import AuthTktAuthenticationPolicy
Expand Down Expand Up @@ -47,13 +48,16 @@ def main(global_config, **settings):
# assume the instance is primary for backward compatibility.
is_primary_instance = port == primary_port

email_sender = EmailSender()

rundb = RunDb(port=port, is_primary_instance=is_primary_instance)

def add_rundb(event):
event.request.rundb = rundb
event.request.userdb = rundb.userdb
event.request.actiondb = rundb.actiondb
event.request.workerdb = rundb.workerdb
event.request.email_sender = email_sender

def add_renderer_globals(event):
pass
Expand Down
84 changes: 84 additions & 0 deletions server/fishtest/emailer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import os

import requests

RESEND_API_URL = "https://api.resend.com/emails"
RESEND_TIMEOUT = 10


class EmailSendError(RuntimeError):
pass


class EmailSender:
def __init__(
self,
api_key=None,
from_email=None,
from_name=None,
session=None,
):
self.api_key = api_key or os.getenv("FISHTEST_RESEND_API_KEY")
self.from_email = from_email or os.getenv(
"FISHTEST_RESEND_FROM_EMAIL", "fishtest@resend.dev"
)
self.from_name = from_name or os.getenv("FISHTEST_RESEND_FROM_NAME", "Fishtest")
self.session = session or requests.Session()
Comment thread
Disservin marked this conversation as resolved.
missing_settings = []
if not self.api_key:
missing_settings.append("FISHTEST_RESEND_API_KEY")
if not self.from_email:
missing_settings.append("FISHTEST_RESEND_FROM_EMAIL")
if missing_settings:
print(
"Email sending is not configured; missing {}.".format(
", ".join(missing_settings)
),
flush=True,
)

def _from_field(self):
if not self.from_email:
raise EmailSendError("FISHTEST_RESEND_FROM_EMAIL is missing.")
if self.from_name:
return f"{self.from_name} <{self.from_email}>"
return self.from_email

def send(
self,
to_email,
subject,
text,
html=None,
reply_to=None,
):
Comment thread
Disservin marked this conversation as resolved.
if not self.api_key:
raise EmailSendError("FISHTEST_RESEND_API_KEY is missing.")
if not to_email:
raise EmailSendError("to_email is required.")

payload = {
"from": self._from_field(),
"to": [to_email] if isinstance(to_email, str) else to_email,
"subject": subject,
"text": text,
}
if html:
payload["html"] = html
if reply_to:
payload["reply_to"] = reply_to

headers = {"Authorization": f"Bearer {self.api_key}"}
try:
response = self.session.post(
RESEND_API_URL, json=payload, headers=headers, timeout=RESEND_TIMEOUT
)
except Exception as exc:
raise EmailSendError(f"Failed to reach Resend: {exc}") from exc

if not response.ok:
raise EmailSendError(
f"Resend error {response.status_code}: {response.text}"
)

return response.json()
2 changes: 2 additions & 0 deletions server/fishtest/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def setup_routes(config):

config.add_route("home", "/")
config.add_route("login", "/login")
config.add_route("forgot_password", "/forgot_password")
config.add_route("reset_password", "/reset_password/{token}")
Comment thread
Disservin marked this conversation as resolved.
config.add_route("nn_upload", "/upload")
config.add_route("logout", "/logout")
config.add_route("signup", "/signup")
Expand Down
6 changes: 6 additions & 0 deletions server/fishtest/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ def size_is_length(pgn_doc):
"groups": [str, ...],
"tests_repo": union(github_repo, ""),
"machine_limit": uint,
"password_reset?": {
"token": str,
"expires_at": datetime_utc,
"opened_at?": datetime_utc,
"form_token?": str,
},
}

kvstore_schema = {
Expand Down
43 changes: 43 additions & 0 deletions server/fishtest/templates/forgot_password.mak
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<%inherit file="base.mak"/>

<script>
document.title = "Forgot Password | Stockfish Testing";
</script>

<%block name="head">
<script src='https://www.google.com/recaptcha/api.js'></script>
</%block>

<div class="col-limited-size">
<header class="text-md-center py-2">
<h2>Reset your password</h2>
<div class="alert alert-info">
Enter the email linked to your account and we'll send a reset link.
</div>
</header>

<form method="POST">
<div class="form-floating mb-3">
<input
type="email"
class="form-control mb-3"
id="email"
name="email"
placeholder="Email"
autocomplete="email"
required
autofocus
>
<label for="email" class="d-flex align-items-end">Email</label>
</div>

<div class="g-recaptcha mb-3"
data-sitekey="6LePs8YUAAAAABMmqHZVyVjxat95Z1c_uHrkugZM"></div>

<button type="submit" class="btn btn-primary w-100">Send reset link</button>
</form>

<div class="text-center mt-3">
<a href="${request.route_url('login')}" class="alert-link">Back to login</a>
</div>
</div>
4 changes: 4 additions & 0 deletions server/fishtest/templates/login.mak
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@

<button type="submit" class="btn btn-primary w-100">Login</button>
</form>

<div class="mt-3">
<a href="${request.route_url('forgot_password')}" class="btn btn-outline-secondary w-100">Reset password</a>
</div>
</div>

<script src="${request.static_url('fishtest:static/js/toggle_password.js')}"></script>
57 changes: 57 additions & 0 deletions server/fishtest/templates/reset_password.mak
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<%inherit file="base.mak"/>

<script>
document.title = "Reset Password | Stockfish Testing";
</script>

<div class="col-limited-size">
<header class="text-md-center py-2">
<h2>Choose a new password</h2>
</header>

<form method="POST">
<input type="hidden" name="form_token" value="${form_token}">
<div class="input-group mb-3">
<div class="form-floating">
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="New Password"
pattern=".{8,}"
title="Eight or more characters: a password too simple or trivial to guess will be rejected"
autocomplete="new-password"
required
autofocus
>
<label for="password" class="d-flex align-items-end">New Password</label>
</div>
<span class="input-group-text toggle-password-visibility" role="button">
<i class="fa-solid fa-lg fa-eye pe-none" style="width: 30px"></i>
</span>
</div>

<div class="input-group mb-3">
<div class="form-floating">
<input
type="password"
class="form-control"
id="password2"
name="password2"
placeholder="Repeat Password"
autocomplete="new-password"
required
>
<label for="password2" class="d-flex align-items-end">Repeat Password</label>
</div>
<span class="input-group-text toggle-password-visibility" role="button">
<i class="fa-solid fa-lg fa-eye pe-none" style="width: 30px"></i>
</span>
</div>

<button type="submit" class="btn btn-primary w-100">Reset password</button>
</form>
</div>

<script src="${request.static_url('fishtest:static/js/toggle_password.js')}"></script>
62 changes: 62 additions & 0 deletions server/fishtest/userdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,68 @@ def save_user(self, user):
self.last_blocked_time = 0
self.clear_cache()

def set_password_reset(self, user, token, expires_at):
user["password_reset"] = {"token": token, "expires_at": expires_at}
self.save_user(user)

def clear_expired_password_reset(self, token, now):
result = self.users.update_one(
{"password_reset.token": token, "password_reset.expires_at": {"$lt": now}},
{"$unset": {"password_reset": ""}},
)
if result.matched_count:
self.clear_cache()
return result

def update_password_with_reset_token(self, user_id, token, new_password):
result = self.users.update_one(
{"_id": user_id, "password_reset.token": token},
{"$set": {"password": new_password}, "$unset": {"password_reset": ""}},
)
Comment thread
Disservin marked this conversation as resolved.
if result.modified_count:
self.clear_cache()
return result
Comment on lines +153 to +160
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update_password_with_reset_token method performs an atomic update that checks the token still exists, sets the new password, and removes the password_reset field in a single operation. However, there's a potential race condition if multiple reset password requests are made: if a user requests a password reset twice, the second request will overwrite the first token. When the first token is used successfully, it will invalidate both tokens. This is the expected behavior, but it should be documented or the error message at line 220 could be more specific about this scenario.

Copilot uses AI. Check for mistakes.

def mark_password_reset_opened(self, user_id, token, opened_at):
result = self.users.update_one(
{
"_id": user_id,
"password_reset.token": token,
"password_reset.opened_at": {"$exists": False},
},
{"$set": {"password_reset.opened_at": opened_at}},
)
if result.modified_count:
self.clear_cache()
return result

def set_password_reset_form_token(self, user_id, token, form_token, opened_at):
result = self.users.update_one(
{
"_id": user_id,
"password_reset.token": token,
"password_reset.form_token": {"$exists": False},
},
{
"$set": {
"password_reset.form_token": form_token,
"password_reset.opened_at": opened_at,
}
},
)
if result.modified_count:
self.clear_cache()
return result

def update_password_with_reset_form_token(self, user_id, form_token, new_password):
result = self.users.update_one(
{"_id": user_id, "password_reset.form_token": form_token},
{"$set": {"password": new_password}, "$unset": {"password_reset": ""}},
)
if result.modified_count:
self.clear_cache()
return result

def remove_user(self, user, rejector):
result = self.users.delete_one({"_id": user["_id"]})
if result.deleted_count > 0:
Expand Down
Loading
Loading