Skip to content

Commit 9409a15

Browse files
committed
Address review comment
2 parents 63b3a52 + dc3c8d1 commit 9409a15

130 files changed

Lines changed: 2598 additions & 1363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ make mypy
4444
- gRPC for API (defined in `/app/proto`)
4545
- Background jobs in `couchers/jobs/handlers.py`
4646
- Notifications system in `couchers/notifications/`
47-
- Always run `make format` and `make mypy` after modifying backend code
47+
- Always run `make format` and `make mypy` after modifying backend code. mypy MUST pass — a failing mypy is never acceptable, so fix it before moving on (don't dismiss errors as "pre-existing")
48+
- If mypy or tests fail with import errors or missing symbols from generated proto modules (`couchers.proto.*` — e.g. a message type that exists in a `.proto` source but not in the generated `*_pb2.py`), your locally generated protos are stale: run `make protos` to regenerate them, then re-check
4849
- NEVER try-catch an exception and silently throw it away or just log it. By and large you don't need to wrap code in try-catch blocks, we already handle exceptions
4950
- Use `enum.auto()` for all enums (except in the rare case that they are inherently ordinal and we use that order in business logic)
5051
- Put relationships and constraints at the end of models

app/.gitlab-ci.yml

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,11 @@ preview:backend:
737737
inherit:
738738
# no docker login
739739
default: false
740+
# serialize comment writes within a PR (see preview:pr-comment-stub)
741+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
742+
before_script:
743+
# aws-base has no python; needed for the schema diff and the PR comment
744+
- apt-get update -qq && apt-get install -y --no-install-recommends python3
740745
script:
741746
# Create cleaned schema.sql with git hash header (only if schema file exists)
742747
- |
@@ -752,6 +757,12 @@ preview:backend:
752757
| cat -s
753758
} > artifacts/schema_dumps/schema.sql
754759
fi
760+
# Render a highlighted HTML diff of this branch's schema against develop's
761+
- |
762+
if [ -f "artifacts/schema_dumps/schema.sql" ]; then
763+
curl -fsSL "https://develop--schema.$PREVIEW_DOMAIN/schema.sql" -o develop-schema.sql || true
764+
python3 app/scripts/schema_diff_html.py develop-schema.sql artifacts/schema_dumps/schema.sql artifacts/schema_dumps/diff.html
765+
fi
755766
# Upload coverage report to S3
756767
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/bcov/$CI_COMMIT_SHORT_SHA/ --recursive
757768
- aws s3 cp htmlcov s3://$AWS_PREVIEW_BUCKET/bcov/$CI_COMMIT_SHORT_SHA/ --recursive
@@ -771,6 +782,8 @@ preview:backend:
771782
- aws s3 cp artifacts/test_artifacts s3://$AWS_PREVIEW_BUCKET/test-artifacts/$CI_COMMIT_REF_SLUG/ --recursive 2>/dev/null || true
772783
- echo "Done, test artifacts available at https://$CI_COMMIT_SHORT_SHA--test-artifacts.$PREVIEW_DOMAIN/ and https://$CI_COMMIT_REF_SLUG--test-artifacts.$PREVIEW_DOMAIN/"
773784
- echo "Sample emails index at https://$CI_COMMIT_SHORT_SHA--test-artifacts.$PREVIEW_DOMAIN/emails/index.html and https://$CI_COMMIT_REF_SLUG--test-artifacts.$PREVIEW_DOMAIN/emails/index.html"
785+
# write the backend items into the sticky PR comment (no-op off a PR)
786+
- python3 app/scripts/pr_preview_comment.py --items schema,schema-diff,emails,bcov
774787
artifacts:
775788
paths:
776789
- artifacts/schema_dumps
@@ -830,12 +843,16 @@ preview:wcov:
830843
inherit:
831844
# no docker login
832845
default: false
846+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
847+
before_script:
848+
- apt-get update -qq && apt-get install -y --no-install-recommends python3
833849
script:
834850
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/wcov/$CI_COMMIT_SHORT_SHA/ --recursive
835851
- aws s3 cp artifacts/lcov-report s3://$AWS_PREVIEW_BUCKET/wcov/$CI_COMMIT_SHORT_SHA/ --recursive
836852
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/wcov/$CI_COMMIT_REF_SLUG/ --recursive
837853
- aws s3 cp artifacts/lcov-report s3://$AWS_PREVIEW_BUCKET/wcov/$CI_COMMIT_REF_SLUG/ --recursive
838854
- echo "Done, coverage report available at https://$CI_COMMIT_SHORT_SHA--wcov.$PREVIEW_DOMAIN/ and https://$CI_COMMIT_REF_SLUG--wcov.$PREVIEW_DOMAIN/"
855+
- python3 app/scripts/pr_preview_comment.py --items wcov
839856
rules:
840857
- if: ($BUILD_WEB == "true") && ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
841858
- if: ($BUILD_WEB == "true") && ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH != $RELEASE_BRANCH)
@@ -961,31 +978,48 @@ preview:pr-comment-stub:
961978
image: python:3.14-slim
962979
inherit:
963980
default: false
964-
# serialize comment writes across concurrent pipelines
965-
resource_group: pr-preview-comment
981+
# serialize all writes to one PR's comment; different PRs don't contend
982+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
966983
script:
967-
- pip install --quiet --no-cache-dir requests
968984
- python app/scripts/pr_preview_comment.py --stub
969985
rules:
970986
- if: $CI_COMMIT_BRANCH != $RELEASE_BRANCH
971987

972-
preview:pr-comment:
988+
preview:pr-comment-mobile:
973989
needs:
974990
- job: preview:pr-comment-stub
975991
artifacts: false
976992
- job: preview:mobile-ota-devtool
977993
artifacts: false
978-
optional: true
979994
stage: preview
980995
image: python:3.14-slim
981996
inherit:
982997
default: false
983-
resource_group: pr-preview-comment
998+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
984999
variables:
9851000
OTA_PLATFORMS: "ios android"
9861001
script:
987-
- pip install --quiet --no-cache-dir requests
988-
- python app/scripts/pr_preview_comment.py
1002+
- python app/scripts/pr_preview_comment.py --items mobile
1003+
# mirror preview:mobile-ota-devtool so this runs exactly when the OTA upload does
1004+
rules:
1005+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH != $RELEASE_BRANCH)
1006+
changes:
1007+
- app/proto/**/*
1008+
- app/mobile/**/*
1009+
- app/scripts/**/*
1010+
1011+
# Vercel owns the web deploy, so this resolves the preview URL from its API
1012+
preview:pr-comment-web:
1013+
needs:
1014+
- job: preview:pr-comment-stub
1015+
artifacts: false
1016+
stage: preview
1017+
image: python:3.14-slim
1018+
inherit:
1019+
default: false
1020+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
1021+
script:
1022+
- python app/scripts/pr_preview_comment.py --items web
9891023
rules:
9901024
- if: $CI_COMMIT_BRANCH != $RELEASE_BRANCH
9911025

@@ -1006,7 +1040,9 @@ build:mobile-native-devtool:
10061040
- npm ci
10071041
- npm install -g eas-cli
10081042
- bash scripts/devtool-build.sh ios
1043+
- bash scripts/devtool-build.sh ios-sim
10091044
- bash scripts/devtool-build.sh android
1045+
- bash scripts/devtool-build.sh index
10101046
rules:
10111047
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
10121048

@@ -1304,6 +1340,9 @@ preview:protos:
13041340
inherit:
13051341
# no docker login
13061342
default: false
1343+
resource_group: preview-comment-$CI_COMMIT_REF_SLUG
1344+
before_script:
1345+
- apt-get update -qq && apt-get install -y --no-install-recommends python3
13071346
script:
13081347
- python_sum=$(sha256sum app/proto/gen/python.tar.gz | head -c 64)
13091348
- ts_sum=$(sha256sum app/proto/gen/ts.tar.gz | head -c 64)
@@ -1333,6 +1372,7 @@ preview:protos:
13331372
- aws s3 cp app/proto/gen/index.html s3://$AWS_PREVIEW_BUCKET/protos/$CI_COMMIT_SHORT_SHA/
13341373
- aws s3 cp app/proto/gen/index.html s3://$AWS_PREVIEW_BUCKET/protos/$CI_COMMIT_REF_SLUG/
13351374
- echo "Done, protos available at https://$CI_COMMIT_SHORT_SHA--protos.$PREVIEW_DOMAIN/ and https://$CI_COMMIT_REF_SLUG--protos.$PREVIEW_DOMAIN/"
1375+
- python3 app/scripts/pr_preview_comment.py --items protos
13361376

13371377
# Having this here frees us from having to list out all the needs in each release below.
13381378
# List out all that need to be waited for until releasing a new version

app/backend.dev.env

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ LISTMONK_API_USERNAME=...
6363
LISTMONK_API_KEY=...
6464
LISTMONK_LIST_ID=3
6565

66-
RECAPTHCA_PROJECT_ID=...
67-
RECAPTHCA_API_KEY=...
68-
RECAPTHCA_SITE_KEY=...
69-
7066
SENTRY_ENABLED=0
7167
SENTRY_URL=...
7268

app/backend/feature-flags.dev.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"strong_verification_enabled": false,
55
"log_native_ota_requests": true,
66
"donations_enabled": false,
7-
"recaptcha_enabled": false,
7+
"antibot_enabled": false,
88
"postal_verification_enabled": false,
99
"listmonk_enabled": false,
1010
"notification_translations_enabled": true,

app/backend/proto/internal/jobs.proto

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ message SendEmailPayload {
1515
// source data as to where this email came from
1616
string source_data = 8;
1717
reserved 9; // Previous "attachments" field, which had mime_type+filename subfields.
18-
repeated EmailAttachmentV2 attachments = 10;
18+
repeated EmailPart attachments = 10;
19+
repeated EmailPart html_related_parts = 11; // MIME parts which are multipart/related to the HTML body.
1920
}
2021

21-
message EmailAttachmentV2 {
22+
// A MIME part of an SMPT email, usable for attached files or inline images.
23+
message EmailPart {
2224
bytes data = 1;
2325
string content_disposition = 2; // The Content-Disposition header, including parameters
2426
string content_type = 3; // The Content-Type header, including parameters
27+
string content_id = 4; // The Content-ID header, including parameters
28+
string data_file_path = 5; // The server-side path to the file containing the data (mutually exclusive with "data").
2529
}
2630

2731
message HandleNotificationPayload {

app/backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ module = ["couchers.migrations.versions.*"]
139139
ignore_errors = true
140140

141141
[[tool.mypy.overrides]]
142-
module = ["http_ece", "py_vapid", "luhn", "sqlalchemy_utils.*", "growthbook", "user_agents", "pyroscope"]
142+
module = ["http_ece", "py_vapid", "luhn", "sqlalchemy_utils.*", "growthbook", "user_agents", "pyroscope", "ics"]
143143
# These libraries don't have type stubs or py.typed markers
144144
ignore_missing_imports = true
145145

app/backend/src/couchers/abuse.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from couchers.context import CouchersContext
2+
from couchers.db import session_scope
3+
from couchers.metrics import observe_nonvisible_user_access
4+
from couchers.models import (
5+
NonvisibleUserAccess,
6+
NonvisibleUserAccessType,
7+
NonvisibleUserState,
8+
User,
9+
)
10+
11+
12+
def nonvisible_user_state(user: User) -> NonvisibleUserState | None:
13+
if user.banned_at is not None:
14+
return NonvisibleUserState.banned
15+
if user.shadowed_at is not None:
16+
return NonvisibleUserState.shadowed
17+
if user.deleted_at is not None:
18+
return NonvisibleUserState.deleted
19+
return None
20+
21+
22+
def maybe_log_nonvisible_user_access(
23+
context: CouchersContext,
24+
user: User,
25+
*,
26+
access_type: NonvisibleUserAccessType,
27+
actor_user_id: int | None,
28+
) -> None:
29+
target_state = nonvisible_user_state(user)
30+
if target_state is None:
31+
return
32+
33+
if actor_user_id == user.id:
34+
ip_address = context.get_header("x-couchers-real-ip")
35+
user_agent = context.get_header("user-agent")
36+
sofa = context._sofa
37+
else:
38+
ip_address = None
39+
user_agent = None
40+
sofa = None
41+
42+
with session_scope() as session:
43+
session.add(
44+
NonvisibleUserAccess(
45+
access_type=access_type,
46+
target_user_id=user.id,
47+
target_state=target_state,
48+
actor_user_id=actor_user_id,
49+
ip_address=ip_address,
50+
user_agent=user_agent,
51+
sofa=sofa,
52+
)
53+
)
54+
55+
observe_nonvisible_user_access(access_type, target_state)

app/backend/src/couchers/config.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,6 @@ class Config:
108108
LISTMONK_API_USERNAME: str
109109
LISTMONK_API_KEY: str
110110
LISTMONK_LIST_ID: int
111-
# Google recaptcha antibot (gated at runtime by the `recaptcha_enabled` feature flag)
112-
RECAPTHCA_PROJECT_ID: str
113-
RECAPTHCA_API_KEY: str
114-
RECAPTHCA_SITE_KEY: str
115111
# Whether we're in test
116112
IN_TEST: bool = False
117113
# Dev-only override file; when set, flags are read from it instead of GrowthBook.
@@ -196,8 +192,8 @@ def check(self) -> None:
196192
raise Exception("Listmonk credentials must be configured in production")
197193

198194
# The following features are gated at runtime by feature flags (`strong_verification_enabled`,
199-
# `postal_verification_enabled`, `recaptcha_enabled`), which can be flipped on remotely at any
200-
# time, so prod must always have their credentials present.
195+
# `postal_verification_enabled`), which can be flipped on remotely at any time, so prod must
196+
# always have their credentials present.
201197
if not self.IRIS_ID_PUBKEY or not self.IRIS_ID_SECRET or not self.VERIFICATION_DATA_PUBLIC_KEY:
202198
raise Exception("Iris ID credentials must be configured in production")
203199
if (
@@ -208,8 +204,6 @@ def check(self) -> None:
208204
or not self.MYPOSTCARD_CAMPAIGN_ID
209205
):
210206
raise Exception("MyPostcard API credentials must be configured in production")
211-
if not self.RECAPTHCA_PROJECT_ID or not self.RECAPTHCA_API_KEY or not self.RECAPTHCA_SITE_KEY:
212-
raise Exception("reCAPTCHA credentials must be configured in production")
213207

214208
if self.FEATURE_FLAGS_FILE_OVERRIDE_PATH:
215209
raise Exception("FEATURE_FLAGS_FILE_OVERRIDE_PATH is dev-only and must not be set in production")

app/backend/src/couchers/context.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from couchers import experimentation
66
from couchers.i18n import LocalizationContext
7+
from couchers.i18n.locales import get_translation_component
78

89
if TYPE_CHECKING:
910
from growthbook import GrowthBook
@@ -122,17 +123,30 @@ def abort(self, status_code: grpc.StatusCode, error_message: str) -> NoReturn:
122123
context.abort(status_code, error_message)
123124

124125
def abort_with_error_code(
125-
self, status_code: grpc.StatusCode, error_message_id: str, *, substitutions: dict[str, str | int] | None = None
126+
self,
127+
status_code: grpc.StatusCode,
128+
error_message_id: str,
129+
*,
130+
substitutions: dict[str, str | int] | None = None,
126131
) -> NoReturn:
127132
"""
128133
Raises an error that's returned to the user, but error_message_id should be an entry from translateable errors
134+
135+
error_message_id may be namespaced with a translation component, like i18next, e.g. "admin:object_not_found"
136+
looks up "object_not_found" in the "admin" component (where admin/editor errors live). Without a prefix the
137+
"main" component is used.
129138
"""
130139
if not self.__is_interactive:
131140
raise NonInteractiveAbortException(status_code, error_message_id)
132141
else:
133142
context = cast(grpc.ServicerContext, self._grpc_context)
143+
component, _, error_name = error_message_id.rpartition(":")
134144
# Get the translated error message using the user's language preference
135-
error_message = self.localization.localize_string(f"errors.{error_message_id}", substitutions=substitutions)
145+
error_message = self.localization.localize_string(
146+
f"errors.{error_name}",
147+
i18next=get_translation_component(component or "main"),
148+
substitutions=substitutions,
149+
)
136150
context.abort(status_code, error_message)
137151

138152
def set_cookies(self, cookies: list[str]) -> None:

0 commit comments

Comments
 (0)