Skip to content

Chore/Oauth Refactor & Dead code Cleanup - #116

Merged
Zingzy merged 15 commits into
mainfrom
chore/dead-code-cleanup
Feb 21, 2026
Merged

Chore/Oauth Refactor & Dead code Cleanup#116
Zingzy merged 15 commits into
mainfrom
chore/dead-code-cleanup

Conversation

@Zingzy

@Zingzy Zingzy commented Feb 19, 2026

Copy link
Copy Markdown
Member

This pull request centralizes and standardizes all rate limit configurations by introducing a single Limits class, which is then used throughout the codebase to replace hardcoded rate limit strings. This improves maintainability and consistency of rate limiting, and makes future adjustments much easier. Additionally, the default limits and storage configuration in the rate limiter are updated, and the .env.example file is cleaned up to remove redundant variables.

Centralized rate limit configuration:

  • Added the Limits class in blueprints/limits.py as the single source of truth for all rate limit strings used in the application.
  • Replaced hardcoded rate limit strings in all route decorators in blueprints/auth.py, blueprints/contact.py, blueprints/dashboard.py, and api/v1/keys.py with references to the Limits class. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] [14] [15] [16] [17] [18] [19] [20] [21] [22]

Rate limiter configuration improvements:

  • Updated blueprints/limiter.py to use the new Limits class for default limits, and to select Redis or MongoDB as the storage backend based on environment configuration. Also added caching for IP bypasses.

Code cleanup and minor improvements:

  • Updated password validation calls to match the new signature, passing an extra return value. [1] [2] [3]
  • Refactored builder usage in api/v1/management.py for improved readability.

Environment file cleanup:

  • Removed redundant development-specific variables from .env.example and cleaned up rate limit configuration comments. [1] [2]

Summary by Sourcery

Refactor OAuth handling, caching, and rate limiting while cleaning up dead code and tightening configuration defaults.

New Features:

  • Introduce a centralized Limits class as the single source of truth for all route rate limits.
  • Add a generic OAuth provider strategy layer and shared callback flow to support all providers through unified endpoints.
  • Introduce a CacheStore abstraction and function-level cache decorator backed by Redis via walrus.
  • Add a reusable GeoIPService for country and city lookups with lazy-loaded MaxMind databases.

Bug Fixes:

  • Fix legacy URL v1 handling by correctly reading block-bots metadata and returning 410 Gone for expired URLs instead of 400.
  • Ensure password validation uses the updated API everywhere, including URL password protection flows.
  • Avoid duplicate auth resolution work per request by caching owner resolution on Flask's g object.
  • Make contact webhooks optional by reading configuration via os.environ.get instead of requiring them.

Enhancements:

  • Switch rate limiter storage to prefer Redis over MongoDB and align default limits with the new Limits configuration.
  • Unify OAuth routes into parameterized endpoints, removing provider-specific duplication and simplifying redirects.
  • Simplify URL cache handling to rely on a shared CacheStore and drop deprecated UrlData helpers.
  • Harden blocked-URL validation by caching patterns and using regex with timeouts and error handling.
  • Adjust dual-cache behaviour to return immediately on lock contention so callers can degrade gracefully.
  • Require FLASK_SECRET_KEY at startup to avoid running with unsigned session cookies.
  • Refine stats and query builders and other builders for clearer chaining and to remove unused or slow utilities.
  • Use central logging configuration via setup_logging and standard logger access across modules.
  • Update Docker and Render configs for better production defaults and add missing environment variables.

Build:

  • Switch dependency management to uv in Render build configuration and lockfile, and add walrus and regex while updating some library versions.
  • Update docker-compose to ensure MongoDB restarts automatically in development.

Deployment:

  • Clarify and expand Render environment variable definitions for runtime mode, OAuth, webhooks, email, and Sentry configuration.

Tests:

  • Update password and stats tests to reflect the new password validation API and simplified error payloads.

Chores:

  • Remove unused helpers and dead code in stats, time bucket, password, URL, and MongoDB utilities, and rename the stats query builder to ClickQueryBuilder for clarity.

Summary by CodeRabbit

  • New Features

    • Provider-agnostic social login/linking; centralized rate-limit settings; GeoIP country/city lookups; verification may trigger a welcome email.
  • Bug Fixes

    • Expired/blocked links now return 410; password error responses simplified; improved blocked-URL and bot-block handling.
  • Performance

    • Shared cache store with walrus-backed caching and decorator; reduced DB lookups and tighter caching.
  • Deployment

    • Mandatory secret key on startup; docker/db restart policy added.

Copilot AI review requested due to automatic review settings February 19, 2026 21:52
@sourcery-ai

sourcery-ai Bot commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Refactors OAuth and caching infrastructure, centralizes rate-limit configuration, and removes unused/dead code while tightening configuration and deployment defaults (Redis-based rate limiting, GeoIP, password validation, stats, URL helpers, and logging).

Sequence diagram for generic OAuth login and callback flow

sequenceDiagram
    actor User
    participant Browser
    participant oauth_blueprint as oauth_blueprint_module
    participant ProviderStrategies as oauth_providers_module
    participant AuthlibClient as OAuth_client
    participant OAuthServer as OAuth_provider
    participant OAuthUtils as oauth_utils_module
    participant EmailService as email_service

    User->>Browser: Click login with provider
    Browser->>oauth_blueprint: GET /oauth/<provider>
    oauth_blueprint->>ProviderStrategies: lookup PROVIDER_STRATEGIES[provider]
    oauth_blueprint->>oauth_blueprint: _get_client(provider)
    oauth_blueprint->>OAuthUtils: generate_oauth_state(provider, login)
    oauth_blueprint->>AuthlibClient: authorize_redirect(redirect_uri, state)
    AuthlibClient-->>Browser: 302 redirect to OAuth provider
    Browser->>OAuthServer: GET authorization_url

    OAuthServer-->>Browser: 302 redirect back with code,state
    Browser->>oauth_blueprint: GET /oauth/<provider>/callback?code&state
    oauth_blueprint->>ProviderStrategies: strategy = PROVIDER_STRATEGIES[provider]
    oauth_blueprint->>oauth_blueprint: _get_client(provider)
    oauth_blueprint->>OAuthUtils: verify_oauth_state(state, provider)
    oauth_blueprint->>AuthlibClient: authorize_access_token()
    AuthlibClient-->>oauth_blueprint: token
    oauth_blueprint->>ProviderStrategies: strategy.fetch_user_info(client, token)
    ProviderStrategies-->>oauth_blueprint: provider_info

    oauth_blueprint->>OAuthUtils: find_user_by_provider(provider, provider_user_id)
    alt existing linked user
        OAuthUtils-->>oauth_blueprint: existing_user
        oauth_blueprint->>OAuthUtils: update_user_last_login(user_id)
    else no linked user
        oauth_blueprint->>OAuthUtils: get_user_by_email(email)
        alt existing email user auto link
            oauth_blueprint->>OAuthUtils: can_auto_link_accounts(...)
            OAuthUtils-->>oauth_blueprint: True
            oauth_blueprint->>OAuthUtils: link_provider_to_user(user_id, provider_info, provider)
        else new user
            oauth_blueprint->>OAuthUtils: create_oauth_user(provider_info, provider)
            OAuthUtils-->>oauth_blueprint: user_id
            oauth_blueprint->>EmailService: send_welcome_email(email, name)
        end
    end

    oauth_blueprint->>oauth_blueprint: _make_auth_response(user_id, provider)
    oauth_blueprint-->>Browser: 302 redirect to dashboard with cookies
    Browser-->>User: Dashboard page with authenticated session
Loading

Class diagram for centralized rate limit configuration and limiter integration

classDiagram
    class Limits {
        +DEFAULT_MINUTE : str
        +DEFAULT_HOUR : str
        +DEFAULT_DAY : str
        +API_AUTHED : str
        +API_ANON : str
        +LOGIN : str
        +SIGNUP : str
        +LOGOUT : str
        +TOKEN_REFRESH : str
        +AUTH_READ : str
        +SET_PASSWORD : str
        +RESEND_VERIFICATION : str
        +EMAIL_VERIFY : str
        +PASSWORD_RESET_REQUEST : str
        +PASSWORD_RESET_CONFIRM : str
        +OAUTH_INIT : str
        +OAUTH_CALLBACK : str
        +OAUTH_LINK : str
        +OAUTH_DISCONNECT : str
        +DASHBOARD_READ : str
        +DASHBOARD_WRITE : str
        +DASHBOARD_SENSITIVE : str
        +API_KEY_CREATE : str
        +API_KEY_READ : str
        +CONTACT_MINUTE : str
        +CONTACT_HOUR : str
        +CONTACT_DAY : str
        +SHORTEN_LEGACY : str
        +STATS_LEGACY_PAGE : str
        +STATS_LEGACY_EXPORT : str
        +PASSWORD_CHECK : str
    }

    class Limiter {
        +key_func
        +default_limits : list[str]
        +storage_uri : str
        +strategy : str
        +headers_enabled : bool
        +limit(limit_value) decorated
        +request_filter(fn) decorated
    }

    class limiter_module {
        +_redis_uri : str
        +_storage_uri : str
        +limiter : Limiter
        +_get_ip_bypasses() list[str]
        +ip_whitelist() bool
        +dynamic_limit_for_request(authenticated, anonymous) str
    }

    class auth_blueprint {
        +login()
        +refresh()
        +logout()
        +me()
        +register()
        +set_password()
        +verify_page()
        +send_verification_email()
        +verify_email()
        +request_password_reset()
        +reset_password()
    }

    class dashboard_blueprint {
        +dashboard()
        +dashboard_links()
        +dashboard_keys()
        +dashboard_statistics()
        +dashboard_settings()
        +dashboard_billing()
        +get_profile_pictures()
        +set_profile_picture()
    }

    class contact_blueprint {
        +contact_route()
        +report()
    }

    class url_shortener_blueprint {
        +preview_url(short_code)
    }

    class stats_blueprint {
        +stats_route()
        +analytics(short_code)
        +export(short_code,format)
    }

    class oauth_blueprint {
        +oauth_login(provider)
        +oauth_callback(provider)
        +oauth_link(provider)
    }

    class api_keys_blueprint {
        +create_api_key()
        +list_api_keys()
    }

    limiter_module --> Limits : uses
    limiter_module --> Limiter : configures

    auth_blueprint --> limiter_module : uses_limits
    dashboard_blueprint --> limiter_module : uses_limits
    contact_blueprint --> limiter_module : uses_limits
    url_shortener_blueprint --> limiter_module : uses_limits
    stats_blueprint --> limiter_module : uses_limits
    oauth_blueprint --> limiter_module : uses_limits
    api_keys_blueprint --> limiter_module : uses_limits
Loading

Class diagram for caching infrastructure refactor

classDiagram
    class CacheStore {
        -_cache : Cache
        +CacheStore(cache)
        +cached(key, ttl)
        +get(key) Any
        +set(key, value, ttl) void
        +delete(key) void
    }

    class UrlCacheData {
        +url : str
        +short_code : str
        +password_hash : str
        +block_bots : bool
        +expiration_time : str
        +max_clicks : int
        +click_count : int
        +owner_id : str
    }

    class UrlCache {
        -_store : CacheStore
        +ttl_seconds : int
        +UrlCache(store, ttl_seconds)
        +set_url_cache_data(short_code, url_cache_data) void
        +get_url_cache_data(short_code) UrlCacheData
        +invalidate_url_cache(short_code) void
    }

    class DualCache {
        +primary_ttl : int
        +stale_ttl : int
        +lock_ttl : int
        +get_or_set(base_key, query_fn, serializer_fn, primary_ttl, stale_ttl) Any
        +_refresh(base_key, query_fn, serializer_fn, primary_ttl, stale_ttl) void
        +get(key) Any
        +set(key, value, ttl) void
        +_lock(lock_key) bool
    }

    class cache_updates {
        -r : Database
        +ttl_seconds : int
        +cache_updates(ttl_seconds)
        +add_data(slug, clickData) void
        +pull(slug) dict
    }

    class clickData {
        +ip_address : str
        +referer : str
        +user_agent : str
        +country : str
        +city : str
        +timestamp : datetime
    }

    class cache_package {
        +cache_store : CacheStore
        +cache_query : UrlCache
        +dual_cache : DualCache
    }

    class redis_client {
        +get_redis() Database
        +get_cache() Cache
    }

    class walrus_Database {
        +from_url(url) Database
        +ping() void
        +cache() Cache
        +pipeline()
    }

    class walrus_Cache {
        +get(key) Any
        +set(key, value, ttl) void
        +delete(key) void
    }

    cache_package --> CacheStore : owns
    cache_package --> UrlCache : owns
    cache_package --> DualCache : owns

    UrlCache --> CacheStore : uses
    DualCache --> CacheStore : uses_get_set_via_self

    cache_updates --> redis_client : uses
    redis_client --> walrus_Database : wraps
    CacheStore --> walrus_Cache : wraps

    clickData --> cache_updates : used_as_value
Loading

Class diagram for OAuth provider strategy and geoip refactor

classDiagram
    class OAuthProviderStrategy {
        <<interface>>
        +key : str
        +fetch_user_info(client, token) dict
    }

    class GoogleStrategy {
        +key : str
        +fetch_user_info(client, token) dict
    }

    class GitHubStrategy {
        +key : str
        +fetch_user_info(client, token) dict
    }

    class DiscordStrategy {
        +key : str
        +fetch_user_info(client, token) dict
    }

    class oauth_providers_module {
        +PROVIDER_STRATEGIES : dict[str,OAuthProviderStrategy]
    }

    class oauth_blueprint_module {
        +DASHBOARD_URL : str
        -_providers : dict
        +init_oauth_for_app(app)
        +_get_client(provider)
        +_make_auth_response(user_id, provider_key)
        +_handle_callback(strategy, client, provider_key)
        +oauth_login(provider)
        +oauth_callback(provider)
        +oauth_link(provider)
        +list_auth_providers()
        +unlink_oauth_provider(provider_name)
    }

    class oauth_utils_module {
        +generate_oauth_state(provider, action, user_id)
        +verify_oauth_state(state, provider)
        +get_oauth_redirect_url(provider, action)
        +create_oauth_user(provider_info, provider_key)
        +find_user_by_provider(provider_key, provider_user_id)
        +link_provider_to_user(user_id, provider_info, provider_key)
        +can_auto_link_accounts(existing_user, provider_info, provider_key)
        +update_user_last_login(user_id)
    }

    class GeoIPService {
        -_COUNTRY_DB : str
        -_CITY_DB : str
        -_country_reader
        -_city_reader
        -_country_loaded : bool
        -_city_loaded : bool
        +GeoIPService()
        +get_country(ip_address) str
        +get_city(ip_address) str
        -_get_country_reader()
        -_get_city_reader()
    }

    class geoip_module {
        +geoip : GeoIPService
    }

    class url_utils_module {
        +get_country(ip_address) str
        +get_city(ip_address) str
        +get_client_ip() str
        +validate_url_password(password) bool
        +validate_url(url) bool
        +convert_to_gmt(expiration_time)
    }

    OAuthProviderStrategy <|.. GoogleStrategy
    OAuthProviderStrategy <|.. GitHubStrategy
    OAuthProviderStrategy <|.. DiscordStrategy

    oauth_providers_module --> GoogleStrategy : registers
    oauth_providers_module --> GitHubStrategy : registers
    oauth_providers_module --> DiscordStrategy : registers

    oauth_blueprint_module --> oauth_providers_module : uses_PROVIDER_STRATEGIES
    oauth_blueprint_module --> oauth_utils_module : uses

    url_utils_module --> geoip_module : uses_geoip
    geoip_module --> GeoIPService : exposes_instance
Loading

File-Level Changes

Change Details Files
Centralize rate limit configuration and update limiter defaults/storage.
  • Introduce a Limits class as the single source of truth for all rate-limit strings and apply it across auth, dashboard, stats, URL shortener, contact, API keys, OAuth, and redirector routes.
  • Change Limiter defaults to use Limits constants and select Redis as the primary storage backend when REDIS_URI is configured, falling back to MongoDB otherwise.
  • Add cached IP-bypass lookup for rate limiting using the shared cache store.
blueprints/limits.py
blueprints/limiter.py
blueprints/auth.py
blueprints/dashboard.py
blueprints/contact.py
blueprints/stats.py
blueprints/url_shortener.py
blueprints/redirector.py
api/v1/keys.py
Refactor OAuth flows into generic provider-agnostic endpoints and strategies.
  • Replace provider-specific OAuth routes for Google, GitHub, and Discord with generic /, //callback, and //link routes using shared helper functions.
  • Introduce an OAuthProviderStrategy abstraction and a PROVIDER_STRATEGIES registry encapsulating provider-specific user-info fetch logic.
  • Unify post-auth token generation/redirect logic, centralize dashboard redirect URL, and adjust oauth_utils redirect URL generation to target the generic callback route.
  • Simplify provider unlink endpoints and ensure they check for last-auth-method constraints with clearer logging and error messages.
blueprints/oauth.py
utils/oauth_providers.py
utils/oauth_utils.py
Introduce a shared CacheStore abstraction and migrate URL/cache usage to walrus-based Redis.
  • Add CacheStore wrapper with safe get/set/delete and a @cached decorator, and wire a global cache_store instance backed by walrus.Cache.
  • Refactor UrlCache to depend on CacheStore rather than BaseCache/redis directly and remove deprecated UrlData and legacy methods.
  • Change dual cache locking behavior to avoid blocking retries under lock contention and instead return None for callers to handle.
  • Update cache_updates and BaseCache to use walrus Database instead of raw redis, and add a convenience get_cache() around walrus.Database cache().
cache/store.py
cache/__init__.py
cache/cache_url.py
cache/dual_cache.py
cache/cache_updates.py
cache/base_cache.py
cache/redis_client.py
blueprints/redirector.py
Improve blocked URL validation and URL/GeoIP helpers while removing unused utilities.
  • Replace simple blocked URL regex checks with a cached blocked pattern list, using the new cache_store.cached decorator and the regex library with timeout and error handling.
  • Extract GeoIP lookups into a reusable GeoIPService that keeps readers open and is used from url_utils.get_country/get_city instead of opening databases per call.
  • Rename validate_password used for URL passwords to validate_url_password and remove broken custom expiration-time validation, adjusting builders and tests accordingly.
  • Remove unused helpers such as growth metrics, country-name lookup, estimate_bucket_count, get_bucket_strategy_info, URL v2 insert/ownership helpers, passkey generator, and some stats timing/debug code.
utils/mongo_utils.py
utils/url_utils.py
utils/geoip.py
utils/time_bucket_utils.py
utils/stats_utils.py
utils/mongo_utils.py
utils/general.py
builders/base.py
tests/test_password.py
tests/test_stats.py
Tighten auth/password handling, logging setup, and stats/API behavior.
  • Extend validate_password to also return a strength score and update all call sites to handle the extra return value.
  • Cache resolve_owner_id_from_request results on Flask's g object for both verified and any-owner modes to avoid repeat DB lookups per request.
  • Move logging initialization into an explicit setup_logging() call in main.py and require FLASK_SECRET_KEY at startup instead of silently running without it.
  • Ensure stats query builders use the renamed ClickQueryBuilder, normalize Z-suffixed timestamps in query builders, and adjust stats metrics caching to handle lock contention by returning 204 when dual cache returns None.
utils/password_utils.py
blueprints/auth.py
utils/auth_utils.py
utils/logging_config.py
main.py
utils/query_builder.py
builders/query.py
builders/stats.py
builders/update.py
blueprints/url_shortener.py
Deployment and configuration cleanup (Render, env, contact webhooks, requirements).
  • Switch Render build to use uv sync with a lockfile, expand env var specification with grouped sections (runtime, DB, Flask, JWT, OAuth, hCaptcha, Zepto, Sentry), and add restart: always for the MongoDB service in docker-compose.
  • Relax CONTACT_WEBHOOK/URL_REPORT_WEBHOOK to be optional environment variables instead of required, and add HCAPTCHA_SECRET and other config wiring.
  • Add walrus and regex dependencies, adjust urllib3 and werkzeug pinned versions in both requirements.txt and pyproject.toml, and refresh uv.lock accordingly.
  • Clean up .env.example by removing redundant dev-only variables to match the new configuration expectations.
render.yaml
docker-compose.yml
utils/contact_utils.py
requirements.txt
pyproject.toml
.env.example
uv.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Feb 19, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralizes rate-limit rules (Limits), replaces literal limiter strings, refactors OAuth to a provider-agnostic strategy, migrates caching from raw Redis to a Walrus-backed CacheStore/Protocol, renames password validation APIs, and removes several deprecated utilities — plus assorted minor API and config adjustments.

Changes

Cohort / File(s) Summary
Rate-limit constants & usage
blueprints/limits.py, blueprints/limiter.py, blueprints/auth.py, blueprints/contact.py, blueprints/dashboard.py, blueprints/redirector.py, blueprints/stats.py, blueprints/url_shortener.py, api/v1/keys.py
Adds centralized Limits class and replaces many hard-coded limiter strings with Limits constants; updates limiter defaults, key derivation, and IP-bypass caching.
OAuth provider refactor
blueprints/oauth.py, utils/oauth_providers.py, utils/oauth_utils.py
Replaces provider-specific flows with a generic provider-key/strategy framework: generic routes (/<provider>, callback, link), strategy registry, shared callback/link handling, and unified token/user linking logic. Significant control-flow and public-route changes.
Cache abstraction & backend swap
cache/store.py, cache/protocol.py, cache/__init__.py, cache/redis_client.py, cache/base_cache.py, cache/cache_url.py, cache/cache_updates.py, cache/dual_cache.py
Introduces CacheStore and CacheBackend protocol; swaps Redis client usage to Walrus (Database/Cache); updates UrlCache and click buffer APIs/signatures; changes dual-cache locking/refresh behavior.
URL & password validation API updates
utils/url_utils.py, utils/password_utils.py, builders/base.py, blueprints/url_shortener.py, tests/test_password.py, tests/test_stats.py
Renames validate_passwordvalidate_url_password; validate_password now returns an extra strength value (call sites updated/unpacked); replaces some validate_password calls and updates tests/response expectations.
Builders & request flow tweaks
builders/update.py, builders/query.py, builders/stats.py, api/v1/management.py
Adds early-return for existing errors, normalizes ISO Z parsing, moves local imports to module scope, and uses fluent/chained builder expressions.
Auth owner caching
utils/auth_utils.py
Adds request-scoped caching for owner resolution via Flask g and a private _resolve_owner_id helper to reduce repeated header/DB work.
GeoIP service
utils/geoip.py, utils/url_utils.py
Adds lazy, thread-safe GeoIPService and delegates country/city lookups to it, removing direct geoip2 Reader usage from callers.
Mongo blocked-patterns & removals
utils/mongo_utils.py
Adds cached blocked-pattern fetcher (via cache_store) and rewrites blocked-URL validation to use cached regex patterns; removes legacy v2 URL helpers.
Utility removals & renames
utils/general.py, utils/stats_utils.py, utils/time_bucket_utils.py, utils/query_builder.py
Removes deprecated helpers (passkey generator, growth metrics, bucket helpers) and renames StatsQueryBuilderClickQueryBuilder.
Config, deps & infra
.env.example, pyproject.toml, requirements.txt, render.yaml, docker-compose.yml, main.py, .github/workflows/api_test.yaml, utils/contact_utils.py, utils/logging_config.py, dockerfile, .dockerignore
Updates example env vars, adds walrus and regex deps, changes start/CI commands, makes FLASK_SECRET_KEY required, uses env .get for some hooks, adds dockerignore and db restart policy.
Tests
tests/test_stats.py, tests/test_password.py
Adjusts test expectations and imports to match validation API and response shape changes.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Browser
    participant App as App (Flask)
    participant oauth_bp as OAuth Blueprint
    participant _get_client as _get_client(provider)
    participant Strategy as OAuthStrategy
    participant OAuthProvider as OAuth Provider
    participant DB as Database
    participant _make_auth as _make_auth_response

    User->>Browser: Click "Login with Provider"
    Browser->>oauth_bp: GET /<provider>
    oauth_bp->>_get_client: request client & strategy
    _get_client-->>oauth_bp: client & Strategy
    oauth_bp->>OAuthProvider: Redirect to provider auth

    OAuthProvider->>Browser: Authorize & redirect with code
    Browser->>oauth_bp: GET /<provider>/callback?code=...
    oauth_bp->>_get_client: _get_client(provider)
    _get_client-->>oauth_bp: client & Strategy
    oauth_bp->>Strategy: exchange code / fetch user info
    Strategy->>OAuthProvider: token / userinfo requests
    OAuthProvider-->>Strategy: user info
    Strategy-->>oauth_bp: normalized user info
    oauth_bp->>DB: lookup/create user, link provider
    DB-->>oauth_bp: user record
    oauth_bp->>_make_auth: generate tokens, set cookies, redirect
    _make_auth-->>Browser: Set cookies & redirect to dashboard
Loading
sequenceDiagram
    participant App
    participant CacheStore
    participant Walrus as Walrus Cache
    participant DB as Data Source

    rect rgba(76, 175, 80, 0.5)
    Note over App,Walrus: Cache Hit
    App->>CacheStore: get(key)
    CacheStore->>Walrus: fetch key
    Walrus-->>CacheStore: cached value
    CacheStore-->>App: return value
    end

    rect rgba(255, 193, 7, 0.5)
    Note over App,DB: Cache Miss
    App->>CacheStore: get(key)
    CacheStore->>Walrus: fetch key
    Walrus-->>CacheStore: miss
    App->>DB: compute value
    DB-->>App: value
    App->>CacheStore: set(key, value, ttl)
    CacheStore->>Walrus: store value
    CacheStore-->>App: return value
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

✨ Refactor, backend, database, documentation

Poem

🐰 I swapped some Redis for a Walrus friend,

Limits lined up tidy, each string on trend.
OAuth learned to hop to any provider's name,
CacheStore guards keys and keeps them tame.
A little thump — the refactor's done, let's mend!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title references both OAuth refactoring and dead code cleanup, which align with significant changes in the PR (OAuth generalization, rate-limit centralization, cache abstraction, and removal of unused utilities).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/dead-code-cleanup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • The change to DualCache.get_or_set to return None on lock contention alters its contract; please verify all existing call sites handle None (as you did for the metrics endpoint) or consider keeping a bounded wait/retry to preserve previous behavior.
  • In utils.mongo_utils.validate_blocked_url, you're using regex.match but the module appears to previously import re; make sure regex is explicitly imported and re usages are fully updated to avoid a NameError at runtime.
  • The CacheStore.cached decorator currently uses a fixed key and ignores function arguments, which could lead to subtle cache key collisions if reused on parameterized functions; consider including arguments in the key or documenting that it must only be used on zero-argument functions.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The change to `DualCache.get_or_set` to return `None` on lock contention alters its contract; please verify all existing call sites handle `None` (as you did for the metrics endpoint) or consider keeping a bounded wait/retry to preserve previous behavior.
- In `utils.mongo_utils.validate_blocked_url`, you're using `regex.match` but the module appears to previously import `re`; make sure `regex` is explicitly imported and `re` usages are fully updated to avoid a `NameError` at runtime.
- The `CacheStore.cached` decorator currently uses a fixed `key` and ignores function arguments, which could lead to subtle cache key collisions if reused on parameterized functions; consider including arguments in the key or documenting that it must only be used on zero-argument functions.

## Individual Comments

### Comment 1
<location> `utils/mongo_utils.py:126-133` </location>
<code_context>
-        if re.match(blocked_url, url):
-            return False

+def validate_blocked_url(url: str) -> bool:
+    for pattern in _fetch_blocked_patterns():
+        try:
+            if regex.match(pattern, url, timeout=0.2):
+                return False
+        except TimeoutError:
+            log.warning("blocked_url_pattern_timeout", pattern=pattern)
+        except regex.error:
+            log.warning("blocked_url_pattern_invalid", pattern=pattern)
     return True
</code_context>

<issue_to_address>
**issue (bug_risk):** The new blocked-URL validation uses `regex` but the module is never imported, which will raise at runtime.

`validate_blocked_url` calls `regex.match` and `regex.error`, but this module only imports `re`, so it will immediately raise a `NameError`. Either add `import regex` (consistent with the new `pyproject.toml` dependency) or update the code to use `re` instead if you don’t require the `regex`-specific features such as timeouts.
</issue_to_address>

### Comment 2
<location> `cache/store.py:21-30` </location>
<code_context>
+    def cached(self, key: str, ttl: int):
</code_context>

<issue_to_address>
**suggestion (bug_risk):** The `cached` decorator ignores function arguments, which makes it easy to misuse on parameterized functions.

The cache currently uses only the explicit `key`, so all calls share one entry regardless of `*args`/`**kwargs`. This is safe for the current zero-arg helpers but risky if later applied to parameterized functions.

To avoid accidental misuse, either incorporate arguments into the cache key (e.g., `f"{key}:{args_hash}"`) or enforce that only argumentless functions are supported (e.g., assert `not args and not kwargs` or codify this in the decorator’s contract).

Suggested implementation:

```python
    def cached(self, key: str, ttl: int):
        """
        Decorator that caches a function's return value in Redis.

        This decorator is intentionally limited to functions that take no
        arguments; it will assert at runtime if called with *args or
        **kwargs. This avoids subtle cache corruption when used on
        parameterized functions without encoding arguments into the key.

        On hit  : returns the cached value without calling the function.
        On miss : calls the function, stores the result, returns it.
        On error: falls back to calling the function directly.
        """

        def decorator(fn):
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                # Enforce that cached() is only used on argument-less functions.
                # If you need caching for parameterized functions, extend the
                # key to incorporate arguments instead of reusing this helper.
                assert not args and not kwargs, (
                    "Cache.cached() decorator currently only supports "
                    "functions without arguments. Either refactor to a "
                    "no-arg helper or extend the cache key to include "
                    "arguments explicitly."
                )

```

The new `wrapper` definition introduced in this patch must still call the underlying cache and original function as it did before. Ensure that the existing body of `wrapper` (cache lookup, set, error handling, and final `return`) remains immediately after the new `assert` block. If your original `wrapper` contained a single-line body on the `def wrapper(...):` line, you’ll need to expand it into a multi-line function and place that logic below the `assert`.
</issue_to_address>

### Comment 3
<location> `cache/store.py:30-43` </location>
<code_context>
+
+        def decorator(fn):
+            @functools.wraps(fn)
+            def wrapper(*args, **kwargs):
+                try:
+                    result = self._cache.get(key)
+                    if result is not None:
+                        return result
+                    result = fn(*args, **kwargs)
+                    self._cache.set(key, result, ttl)
+                    return result
+                except Exception:
+                    return fn(*args, **kwargs)
+
+            return wrapper
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Swallowing all exceptions in the caching decorator can hide cache-layer problems and complicate debugging.

The wrapper currently catches a bare `Exception` and silently falls back to `fn(*args, **kwargs)`, which makes cache connectivity/serialization issues effectively invisible. To retain graceful degradation but keep observability, at least log the exception here (as you do in the explicit `get`/`set`/`delete` paths) so cache-layer failures can be detected and investigated.

```suggestion
        def decorator(fn):
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                try:
                    result = self._cache.get(key)
                    if result is not None:
                        return result
                    result = fn(*args, **kwargs)
                    self._cache.set(key, result, ttl)
                    return result
                except Exception as e:
                    log.error(
                        "cache_decorator_failed",
                        key=key,
                        fn=getattr(fn, "__name__", str(fn)),
                        error=str(e),
                        error_type=type(e).__name__,
                    )
                    return fn(*args, **kwargs)

            return wrapper
```
</issue_to_address>

### Comment 4
<location> `tests/test_password.py:1` </location>
<code_context>
-from utils.url_utils import validate_password
+from utils.url_utils import validate_url_password as validate_password


</code_context>

<issue_to_address>
**suggestion (testing):** Add tests for the updated `utils.password_utils.validate_password` API, including the strength score

The tests currently cover only `validate_url_password`, which still returns a boolean, while `utils.password_utils.validate_password` now returns `(is_valid, missing_requirements, strength_score)` and is unpacked in `auth.py`. Please add unit tests for `validate_password` that:

- Check `strength_score` for weak/medium/strong passwords.
- Validate `missing_requirements` for clearly invalid passwords (e.g., very short or obviously weak ones).
- Ensure the new return shape is stable for callers.

These can go next to this module or in `tests/test_password_utils.py`.

Suggested implementation:

```python
from utils.password_utils import validate_password

```

```python
def test_password_too_short():
    # Clearly too short / weak password should be invalid and have missing requirements
    is_valid, missing_requirements, strength_score = validate_password("a1!")

    assert is_valid is False
    assert isinstance(missing_requirements, (list, tuple))
    assert len(missing_requirements) > 0
    # Each missing requirement should be a human-readable string
    assert all(isinstance(req, str) and req for req in missing_requirements)
    # Strength score for such a weak password should be very low
    assert isinstance(strength_score, (int, float))
    assert strength_score >= 0

def test_validate_password_strength_score_progression():
    # Choose three increasingly strong passwords; exact score values may vary,
    # but they should be strictly increasing with password strength.
    weak_password = "password"
    medium_password = "Password123"
    strong_password = "P@ssw0rd123!"

    is_valid_weak, _, score_weak = validate_password(weak_password)
    is_valid_medium, _, score_medium = validate_password(medium_password)
    is_valid_strong, _, score_strong = validate_password(strong_password)

    assert isinstance(score_weak, (int, float))
    assert isinstance(score_medium, (int, float))
    assert isinstance(score_strong, (int, float))

    # Weak may or may not be considered valid, but scores should increase
    assert score_weak < score_medium < score_strong
    # Strong password should be valid according to our policy
    assert is_valid_strong is True

def test_validate_password_missing_requirements_for_obviously_weak():
    # A very short password with no complexity should fail multiple requirements
    is_valid, missing_requirements, strength_score = validate_password("aa")

    assert is_valid is False
    assert isinstance(missing_requirements, (list, tuple))
    assert len(missing_requirements) >= 1
    assert all(isinstance(req, str) and req for req in missing_requirements)
    assert isinstance(strength_score, (int, float))

def test_validate_password_return_shape_stable():
    # Ensure callers can reliably unpack (is_valid, missing_requirements, strength_score)
    result = validate_password("StableSh4pe!")
    # Should be a 3-tuple-like object
    assert hasattr(result, "__iter__")

    is_valid, missing_requirements, strength_score = result

    assert isinstance(is_valid, bool)
    assert isinstance(missing_requirements, (list, tuple))
    assert isinstance(strength_score, (int, float))

```
</issue_to_address>

### Comment 5
<location> `tests/test_stats.py:148` </location>
<code_context>
     response = client.post("/stats/validcode")
     assert response.status_code == 400
-    assert response.json == {"PasswordError": "Invalid Password", "entered-pass": None}
+    assert response.json == {"PasswordError": "Invalid Password"}


</code_context>

<issue_to_address>
**suggestion (testing):** Extend stats tests to cover the changed behavior for expired/blocked URLs and 410 response codes

This test captures the simplified JSON payload for password errors. In this PR, redirect/export flows were also changed to return 410 (Gone) for expired URLs and to render 410 in the error template for specific paths. Please add tests that:

- Exercise the updated stats/redirect/export endpoints with expired URLs and assert 410 plus the expected payload/template.
- Cover both blocked and expired cases to verify the mapping between internal status values (e.g. `BLOCKED`, `EXPIRED`) and their HTTP status codes.

Locating these next to the existing stats tests will keep the new semantics clearly enforced.

Suggested implementation:

```python
    response = client.post("/stats/validcode")
    assert response.status_code == 400
    assert response.json == {"PasswordError": "Invalid Password"}


@pytest.mark.parametrize(
    "endpoint",
    [
        "/stats/validcode",
        "/stats/redirect/validcode",
        "/stats/export/validcode",
    ],
)
@pytest.mark.parametrize("internal_status", ["EXPIRED", "BLOCKED"])
def test_stats_expired_or_blocked_urls_return_410(client, mocker, endpoint, internal_status):
    """
    Expired or blocked URLs should map to HTTP 410 Gone on stats/redirect/export endpoints.
    """
    # Patch the lookup used by the stats views so we can control the internal status.
    url_getter = mocker.patch("app.stats.get_url_by_code")
    url = mocker.Mock()
    url.status = internal_status
    url_getter.return_value = url

    response = client.get(endpoint)

    assert response.status_code == 410

    # For the JSON stats endpoint we assert on the payload;
    # for redirect/export we assert that the 410 template is rendered.
    if endpoint == "/stats/validcode":
        assert response.is_json
        # The exact payload shape may vary; minimally assert the status mapping is surfaced.
        assert response.json.get("status") == internal_status
    else:
        body = response.get_data(as_text=True)
        # The error template for 410 should include the status code somewhere in the body.
        assert "410" in body
        # Optionally, verify that the internal status is reflected or at least the concept of "expired/blocked"
        # is visible in the rendered template.
        if internal_status == "EXPIRED":
            assert "expired" in body.lower()
        elif internal_status == "BLOCKED":
            assert "blocked" in body.lower()


def test_stats_get_password_protected_correct_password(client, mocker):

```

1. Ensure `pytest` is imported at the top of `tests/test_stats.py`:
   `import pytest`.
2. Adjust `"app.stats.get_url_by_code"` in the `mocker.patch` call to match the actual import path used by your stats views for fetching the URL/Link object.
3. If `/stats/redirect/validcode` and `/stats/export/validcode` differ from these example paths, update the `endpoint` parametrization to the real routes.
4. If the JSON payload for 410 on `/stats/validcode` uses a different key than `"status"`, update the assertion to match the real response shape.
5. If the 410 error template does not include literal `"expired"` / `"blocked"` strings, adjust those assertions to match the actual template content (or drop them if you only care about 410 + template rendering).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread utils/mongo_utils.py
Comment thread cache/store.py
Comment thread cache/store.py
Comment thread tests/test_password.py
Comment thread tests/test_stats.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This pull request centralizes rate limit configuration and improves cache infrastructure by introducing a Limits class for consistent rate limit strings, adding a new CacheStore abstraction with the walrus library, refactoring OAuth to use a Strategy pattern, and cleaning up dead code. The changes aim to improve maintainability, standardize rate limiting across the application, and simplify future OAuth provider additions.

Changes:

  • Centralized all rate limit strings into a single Limits class in blueprints/limits.py
  • Introduced CacheStore abstraction using walrus Cache for simplified Redis interactions
  • Refactored OAuth implementation to use a Strategy pattern, reducing duplication across providers
  • Cleaned up unused utility functions and improved GeoIP service with lazy initialization
  • Fixed HTTP status codes for expired URLs (400 → 410) and updated datetime parsing to handle "Z" suffix

Reviewed changes

Copilot reviewed 43 out of 45 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
blueprints/limits.py New file defining centralized rate limit constants
blueprints/limiter.py Updated to use Limits class and support Redis/MongoDB fallback for rate limiting
blueprints/auth.py Updated all route decorators to reference Limits class constants
blueprints/dashboard.py Updated rate limit decorators to use Limits class
blueprints/contact.py Updated rate limit decorators to use Limits class
blueprints/oauth.py Major refactoring to use generic routes and Strategy pattern for providers
blueprints/stats.py Updated rate limits and removed extraneous password error field
blueprints/redirector.py Fixed block-bots field name and changed expired URL status codes to 410
blueprints/url_shortener.py Updated to use Limits class and handle dual cache None returns
api/v1/keys.py Updated rate limit decorators to use Limits class
api/v1/management.py Improved builder pattern usage for readability
cache/store.py New CacheStore abstraction for Redis caching with graceful error handling
cache/redis_client.py Updated to use walrus Database instead of raw redis client
cache/init.py Updated to initialize CacheStore with walrus Cache
cache/dual_cache.py Updated to return None on lock contention instead of blocking
cache/cache_url.py Refactored to use CacheStore instead of BaseCache
cache/cache_updates.py Updated to use walrus Database type hints
cache/base_cache.py Updated type hints for walrus Database
utils/oauth_providers.py New Strategy pattern implementation for OAuth providers
utils/oauth_utils.py Simplified OAuth redirect URL logic to use generic callback route
utils/password_utils.py Updated validate_password to return strength score as third value
utils/url_utils.py Renamed validate_password to validate_url_password, removed validate_expiration_time
utils/geoip.py New GeoIPService class with lazy initialization and graceful error handling
utils/auth_utils.py Added caching for resolve_owner_id_from_request using Flask's g object
utils/mongo_utils.py Updated validate_blocked_url to use regex with timeout and cache patterns
utils/contact_utils.py Changed to use os.environ.get() for optional webhook variables
utils/query_builder.py Renamed StatsQueryBuilder to ClickQueryBuilder for clarity
utils/stats_utils.py Removed unused functions (calculate_growth_metrics, get_country_name_from_code)
utils/time_bucket_utils.py Removed unused functions (estimate_bucket_count, get_bucket_strategy_info)
utils/general.py Removed unused generate_passkey function
utils/logging_config.py Removed auto-initialization on module import
utils/aggregation_strategies.py Updated to use structured logger instead of standard logging
builders/query.py Fixed datetime parsing to handle "Z" suffix
builders/base.py Updated to use renamed validate_url_password function
builders/stats.py Moved time import to top of file
builders/update.py Added early return check to prevent error overwriting
main.py Explicit setup_logging() call and stricter FLASK_SECRET_KEY validation
tests/test_stats.py Updated test expectation to match removed password error field
tests/test_password.py Updated import to use renamed validate_url_password
render.yaml Reorganized environment variables with comments and added missing JWT_SECRET
docker-compose.yml Added restart: always to MongoDB service
.env.example Removed redundant development-specific variables
uv.lock Added regex and walrus dependencies
requirements.txt Added regex and walrus, updated urllib3 and werkzeug versions
pyproject.toml Added regex and walrus dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cache/__init__.py Outdated
Comment thread blueprints/limiter.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
utils/contact_utils.py (1)

112-122: ⚠️ Potential issue | 🟠 Major

email is logged as a structured field in both the warning and exception paths — PII leak.

Lines 113 and 120 include the raw email value in log records emitted from send_contact_message. Depending on log aggregation and retention policies, this stores user-provided PII in error logs indefinitely, which is a GDPR/CCPA compliance risk.

Consider replacing it with a hashed or masked representation:

🔒️ Suggested fix
+import hashlib
+
+def _mask_email(email: str) -> str:
+    return hashlib.sha256(email.encode()).hexdigest()[:12]
-                email=email,
+                email=_mask_email(email),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/contact_utils.py` around lines 112 - 122, The logs in
send_contact_message currently include raw PII via the email variable in both
the "contact_webhook_failed" warning and "contact_webhook_request_failed"
exception paths; replace the raw email with a non-reversible or masked
representation (e.g., SHA-256 hex digest of the email or a masked form like
first char + **** + domain) before logging. Update the uses of email in the
log.warning call for "contact_webhook_failed" and the log.error call for
"contact_webhook_request_failed" to use the sanitized value (e.g., hashed_email
or masked_email) and ensure any helper you add to produce the sanitized value is
deterministic and referenced where logging occurs. Ensure you do not change
other payloads that must send the real email to the webhook — only change what
is passed into logging fields.
api/v1/management.py (1)

113-124: ⚠️ Potential issue | 🟠 Major

Missing parse_status_change() in update_url_v1 builder chain — arbitrary status values written to DB.

build_update() unconditionally applies status from the payload (lines 143-147 in builders/update.py) without any validation. Because parse_status_change() is not in this chain, a caller sending {"status": "DELETED"} (or any arbitrary string) bypasses the "ACTIVE" / "INACTIVE" constraint enforced by parse_status_change(), and the invalid value is persisted to MongoDB.

update_url_status_v1 (line 205) correctly calls .parse_status_change(), but the general update endpoint does not.

🐛 Proposed fix
     builder = (
         UpdateUrlRequestBuilder(payload, url_id)
         .parse_auth_scope(required_scopes={"urls:manage", "admin:all"})
         .load_and_validate_ownership()
         .validate_long_url_if_present()
         .validate_alias_custom()
         .validate_password()
         .parse_max_clicks()
         .parse_expire_after()
         .parse_block_bots()
         .parse_private_stats()
+        .parse_status_change()
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/management.py` around lines 113 - 124, The UpdateUrlRequestBuilder
usage in update_url_v1 omits parse_status_change(), allowing arbitrary status
values to be written; update the builder chain in update_url_v1 to include
.parse_status_change() (the same validator used by update_url_status_v1) before
calling build_update so status is validated, referencing
UpdateUrlRequestBuilder, parse_status_change(), update_url_v1, and build_update
in builders/update.py.
utils/auth_utils.py (1)

338-417: ⚠️ Potential issue | 🟡 Minor

API-key authentication skips require_verified check — edge case when email status changes post-creation

The require_verified parameter is enforced on the JWT path (line 417) but ignored on the spoo_ API-key path (lines 341–408). While API keys can only be created by users with email_verified=True (see api/v1/keys.py:143), this creates an edge case: if a user's email is unverified after creating the key (or by an admin action), routes using resolve_owner_id_from_request(require_verified=True) can still be accessed with that API key, bypassing the gate entirely.

This may be intentional—API keys as pre-authorised, long-lived credentials exempt from per-request verification checks—but should be documented explicitly. If email verification is meant to apply retroactively to API-key access, add a check in the API-key branch:

🛡️ Proposed fix — add `require_verified` check to the API-key path
             user_id = key_doc.get("user_id")
+            if require_verified:
+                from utils.mongo_utils import get_user_by_id
+                owner = get_user_by_id(user_id, projection={"email_verified": 1})
+                if not (owner and owner.get("email_verified", False)):
+                    try:
+                        g.verification_error = {
+                            "error": "Email verification required",
+                            "code": "EMAIL_NOT_VERIFIED",
+                            "message": "You must verify your email address before creating resources.",
+                        }
+                    except Exception:
+                        pass
+                    return None
             try:
                 return (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/auth_utils.py` around lines 338 - 417, The API-key branch (token
starting with "spoo_") never enforces the require_verified flag, allowing keys
created by verified users to bypass later verification changes; update the
API-key path (after you validate key_doc and before returning the
user_id/ObjectId) to enforce require_verified by looking up the associated user
(using key_doc.get("user_id")) and verifying their email_verified flag (or
calling the same helper used by the JWT path), log a warning and return None if
require_verified is True and the user's email is not verified; keep existing
assignments to g.api_key and request.api_key and reuse find_api_key_by_hash /
key_doc checks to locate where to add this check.
utils/password_utils.py (1)

5-82: ⚠️ Potential issue | 🟡 Minor

strength_score is unbounded below — can return negative values

Cumulative penalties (-10, -15, -20) with no floor mean the returned int can be negative (e.g., a short sequential weak password scores -45 before any bonuses). Any caller that treats this as a 0–100 percentage, passes it to a UI progress bar, or checks if score > 0 will behave incorrectly.

🛡️ Proposed fix — clamp before returning
-    return is_valid, missing, strength_score
+    return is_valid, missing, max(0, min(strength_score, 100))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/password_utils.py` around lines 5 - 82, The validate_password function
allows strength_score to go negative; clamp strength_score to a reasonable range
(e.g., 0–100) before returning to prevent negative or >100 values. Update
validate_password to compute the score as currently done, then apply a clamp
(min 0, max 100) to strength_score right before the return, keeping the existing
is_valid and missing behavior; reference validate_password, strength_score, and
weak_patterns when locating the change.
🧹 Nitpick comments (13)
utils/auth_utils.py (2)

318-331: Two separate cache keys can cause _resolve_owner_id (and find_api_key_by_hash) to be called twice per request

Rate-limiting functions typically call resolve_owner_id_from_request() (i.e., require_verified=False) while the route handler calls it with require_verified=True. Because the two variants use distinct g keys, each miss triggers a full _resolve_owner_id execution — meaning two find_api_key_by_hash DB round-trips and/or two JWT crypto-verifications in the same request.

The standard fix is to cache the raw resolved credentials once and apply the require_verified gate on top of the shared cache:

♻️ Suggested approach
+_AUTH_RAW_KEY = "_auth_raw"  # (user_id_or_none, email_verified: bool | None)
+
 def resolve_owner_id_from_request(require_verified: bool = False):
-    cache_key = "_owner_id_verified" if require_verified else "_owner_id_any"
     try:
-        cached = getattr(g, cache_key, _MISSING)
-        if cached is not _MISSING:
-            return cached
+        raw = getattr(g, _AUTH_RAW_KEY, _MISSING)
+        if raw is _MISSING:
+            raw = _resolve_raw_auth()   # returns (ObjectId|None, email_verified: bool|None)
+            setattr(g, _AUTH_RAW_KEY, raw)
     except RuntimeError:
         return _resolve_owner_id(require_verified)
 
-    result = _resolve_owner_id(require_verified)
-    try:
-        setattr(g, cache_key, result)
-    except RuntimeError:
-        pass
-    return result
+    user_id, email_verified = raw
+    if require_verified and user_id is not None and not email_verified:
+        try:
+            g.verification_error = { ... }
+        except RuntimeError:
+            pass
+        return None
+    return user_id

This requires splitting _resolve_owner_id into a _resolve_raw_auth that returns (user_id, email_verified) without the require_verified gate, and applying the gate here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/auth_utils.py` around lines 318 - 331, The current dual-cache-key
approach (cache_key = "_owner_id_verified" vs "_owner_id_any") causes
_resolve_owner_id (and find_api_key_by_hash) to run twice; change this to cache
the raw auth once: introduce a helper _resolve_raw_auth that returns (owner_id,
email_verified) (replacing current _resolve_owner_id internals), store that
single raw tuple on g under a single key like "_owner_id_raw" (use _MISSING/g
getattr/setattr handling as before), and in resolve_owner_id_from_request (the
caller using cache_key and require_verified) read the cached raw tuple and
enforce the require_verified gate there (return owner_id only if verified when
require_verified=True, otherwise return owner_id) so the DB/crypto work happens
only once per request.

386-390: Redundant always-true guard obscures control flow

By the time execution reaches line 386, all three conditions are already guaranteed True by the preceding early returns (lines 348-383). The if block will unconditionally execute, but its presence implies a fallthrough path into the JWT section which cannot actually occur. This misleads readers and can lead to confusion during future maintenance.

♻️ Suggested simplification
-            # Check if key is valid (not revoked and not expired)
-            if (
-                key_doc
-                and not key_doc.get("revoked", False)
-                and (not expires_at or expires_at > now)
-            ):
-                # Attach scopes for downstream checks
-                try:
-                    g.api_key = key_doc  # type: ignore[attr-defined]
-                except Exception:
-                    pass
-                try:
-                    request.api_key = key_doc  # type: ignore[attr-defined]
-                except Exception:
-                    pass
-                user_id = key_doc.get("user_id")
-                try:
-                    return (
-                        ObjectId(user_id)
-                        if not isinstance(user_id, ObjectId)
-                        else user_id
-                    )
-                except Exception:
-                    return None
+            # All prior guards ensure the key is valid here
+            try:
+                g.api_key = key_doc  # type: ignore[attr-defined]
+            except Exception:
+                pass
+            try:
+                request.api_key = key_doc  # type: ignore[attr-defined]
+            except Exception:
+                pass
+            user_id = key_doc.get("user_id")
+            try:
+                return (
+                    ObjectId(user_id)
+                    if not isinstance(user_id, ObjectId)
+                    else user_id
+                )
+            except Exception:
+                return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/auth_utils.py` around lines 386 - 390, The conditional guard checking
key_doc, key_doc.get("revoked", False), and (not expires_at or expires_at > now)
is redundant because earlier early returns already guarantee those truths;
remove the if statement and dedent its body so the code executes directly (clean
up any else/return logic related to the JWT section as needed) to simplify
control flow in auth_utils.py around the key_doc/expires_at/now checks and avoid
the misleading always-true branch.
utils/query_builder.py (1)

107-108: Consider renaming StatsQueryBuilderFactory to ClickQueryBuilderFactory for naming consistency.

The builder class was renamed from StatsQueryBuilder to ClickQueryBuilder, but the factory at line 107 still uses the old Stats prefix. Both the factory's methods (for_user_stats, for_anonymous_stats) and its return type now point to ClickQueryBuilder, so renaming the factory would align the terminology. The factory is used in 2 places within builders/stats.py, making this a low-impact refactoring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/query_builder.py` around lines 107 - 108, Rename the class
StatsQueryBuilderFactory to ClickQueryBuilderFactory to match the renamed
builder; update the class declaration and any references (including the factory
methods for_user_stats and for_anonymous_stats and their return type
annotations) to use ClickQueryBuilderFactory/ClickQueryBuilder, and update the
two call sites in builders/stats.py to import/use ClickQueryBuilderFactory
instead of StatsQueryBuilderFactory so names are consistent across the codebase.
blueprints/dashboard.py (2)

63-65: Hardcoded rate-limit string left on /statistics.

This endpoint still uses a literal "60 per minute" instead of Limits.DASHBOARD_READ, which is the same value. This undermines the centralization goal of the PR.

♻️ Proposed fix
 `@dashboard_bp.route`("/statistics", methods=["GET"])
 `@requires_auth`
-@limiter.limit(
-    "60 per minute", key_func=rate_limit_key_for_request
-)  # same as authenticated limit in stats API
+@limiter.limit(Limits.DASHBOARD_READ, key_func=rate_limit_key_for_request)
 def dashboard_statistics():
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/dashboard.py` around lines 63 - 65, The `@limiter.limit` decorator
on the /statistics endpoint uses a hardcoded string "60 per minute"; replace
that literal with the centralized constant Limits.DASHBOARD_READ to enforce the
same limit while keeping configuration centralized—update the decorator on the
statistics route (the `@limiter.limit` call using
key_func=rate_limit_key_for_request) to use Limits.DASHBOARD_READ instead of the
string.

108-110: DASHBOARD_WRITE applied to a read-only GET endpoint.

GET /profile-pictures is a read operation but uses Limits.DASHBOARD_WRITE (30/min) instead of Limits.DASHBOARD_READ (60/min). If the tighter limit is intentional (e.g., because the handler iterates over OAuth providers), a comment would help; otherwise, DASHBOARD_READ seems more appropriate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/dashboard.py` around lines 108 - 110, The rate limit on the
profile pictures GET route is using Limits.DASHBOARD_WRITE instead of the read
limit; update the limiter on the `@dashboard_bp.route`("/profile-pictures") view
(the decorator that currently calls limiter.limit(Limits.DASHBOARD_WRITE,
key_func=rate_limit_key_for_request)) to use Limits.DASHBOARD_READ (or, if the
stricter write limit is intentional, add a clear inline comment above the
decorator explaining why DASHBOARD_WRITE is required) so the GET endpoint uses
the appropriate read-rate policy.
cache/cache_url.py (1)

36-39: UrlCacheData(**data) will raise TypeError if cached data has stale/extra keys.

If the UrlCacheData fields change across deploys, cached entries written by the old code will have mismatched keys. The 300s TTL limits the blast radius, but during rolling deploys this could cause transient 500s on cache hits.

A defensive pattern would catch TypeError and return None (cache miss), letting the caller fall through to the DB.

🛡️ Defensive deserialization
     def get_url_cache_data(self, short_code: str) -> Optional[UrlCacheData]:
         """Get URL data using the new cache schema"""
         data = self._store.get(f"url_cache:{short_code}")
-        return UrlCacheData(**data) if data else None
+        if not data:
+            return None
+        try:
+            return UrlCacheData(**data)
+        except (TypeError, KeyError):
+            self._store.delete(f"url_cache:{short_code}")
+            return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/cache_url.py` around lines 36 - 39, In get_url_cache_data, constructing
UrlCacheData(**data) can raise TypeError when cached dict has stale/extra keys;
wrap the deserialization around a try/except that catches TypeError (and
optionally ValueError) coming from UrlCacheData(...) and return None on failure
so the caller treats it as a cache miss; locate the call in get_url_cache_data,
protect the UrlCacheData construction using the _store.get result, and ensure
any exception is swallowed only for deserialization errors (log debug if
desired) while leaving other errors untouched.
blueprints/limiter.py (1)

28-37: Consider returning a set from _get_ip_bypasses() for O(1) membership tests.

ip_whitelist() is invoked on every request via @limiter.request_filter. The in check on a list is O(n). If the bypass list grows, this becomes a hot-path bottleneck. Returning a set makes the lookup O(1) with no other code changes needed.

♻️ Proposed fix
 `@cache_store.cached`(key="cache:ip_bypasses", ttl=120)
-def _get_ip_bypasses() -> list:
-    return [doc["_id"] for doc in ip_bypasses.find()]
+def _get_ip_bypasses() -> set:
+    return {doc["_id"] for doc in ip_bypasses.find()}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/limiter.py` around lines 28 - 37, The _get_ip_bypasses() function
currently returns a list which makes the membership test in ip_whitelist() O(n);
change _get_ip_bypasses() to return a set of IDs (e.g., set comprehension over
ip_bypasses.find()) so ip_whitelist()'s "client_ip in _get_ip_bypasses()"
becomes O(1); keep the `@cache_store.cached`(key="cache:ip_bypasses", ttl=120)
decorator in place so the set is cached and no other call sites need
modification.
blueprints/oauth.py (2)

310-312: list_auth_providers has no explicit rate limit.

Other authenticated endpoints in this blueprint use Limits.OAUTH_* constants. This endpoint relies only on the global defaults (10/min, 100/hr, 500/day). If that's intentional, a brief comment would help; otherwise, Limits.AUTH_READ or Limits.DASHBOARD_READ would be consistent with similar read endpoints elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/oauth.py` around lines 310 - 312, The list_auth_providers()
endpoint lacks an explicit rate limit; update the oauth_bp route by adding the
appropriate rate-limiting decorator (e.g., use Limits.AUTH_READ or
Limits.DASHBOARD_READ to match other read endpoints) above the function or, if
leaving global defaults intentionally, add a concise comment explaining that
decision; reference the list_auth_providers function and the Limits.AUTH_READ /
Limits.DASHBOARD_READ constants when making the change so it matches the
blueprint's existing patterns.

346-358: Unlink safety check is correct but has a minor TOCTOU window.

The pre-check (line 349) filters remaining providers locally before the $pull at line 364. A concurrent request unlinking a different provider between these two operations could leave the user locked out. This is a pre-existing concern and low-risk given the narrow window, but worth noting for future hardening — an atomic MongoDB update with a condition (e.g., {"$expr": {"$gt": [{"$size": "$auth_providers"}, 1]}}) would close the gap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/oauth.py` around lines 346 - 358, The unlink pre-check using
auth_providers and remaining (variables user, provider_name, remaining) has a
TOCTOU race: replace the two-step check + "$pull" with a single atomic
conditional update (e.g., use the users collection's find_one_and_update) whose
filter ensures the user still has more than one auth provider or password_set is
true (use a MongoDB condition such as {"$expr": {"$gt": [{"$size":
"$auth_providers"}, 1]}} or combine with password_set flag), perform the $pull
in that same operation, and then treat a null result as the "cannot unlink last
authentication method" error; locate the code around auth_providers / remaining
in the unlink handler in blueprints/oauth.py and change the logic to the
conditional find_one_and_update and subsequent nil-check instead of the current
pre-check + separate update.
blueprints/url_shortener.py (1)

349-351: Limits.SHORTEN_LEGACY is misnamed for a preview endpoint.

The docstring on SHORTEN_LEGACY says "URL shortener (legacy endpoint)", yet it is applied to the /<short_code>+ preview route — not the actual legacy POST / shortening route, which has no explicit rate-limit at all and falls back to the low global defaults (10 per minute). If the intent was always to cap the preview route at 100 req/min, a constant named PREVIEW_URL (or similar) would be clearer and reduce the risk of accidentally using this constant on the wrong endpoint in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/url_shortener.py` around lines 349 - 351, The preview route
decorated at url_shortener.route("/<short_code>+") is using
Limits.SHORTEN_LEGACY which is misnamed for a preview endpoint; create a new
constant (e.g. Limits.PREVIEW_URL or Limits.SHORTEN_PREVIEW) with the intended
limit (100 req/min), update its docstring to reflect “preview endpoint” and
replace Limits.SHORTEN_LEGACY with the new constant on the preview_url function,
or alternatively rename and update SHORTEN_LEGACY’s name and docstring if it
truly only applies to previews so other endpoints don’t mistakenly reuse it.
cache/redis_client.py (1)

33-34: get_cache() creates a new Cache object on every invocation.

db.cache() constructs a fresh Cache instance each time it's called. Since cache/__init__.py calls get_cache() exactly once this is benign today, but any future caller that invokes get_cache() directly will get an independent Cache object, bypassing any shared state (e.g., metrics counters internal to Cache). Consider memoizing the result to guarantee a true singleton:

♻️ Proposed fix
+_cache: Cache | None = None
+
+
 def get_cache() -> Cache:
-    return get_redis().cache()
+    global _cache
+    if _cache is None:
+        _cache = get_redis().cache()
+    return _cache
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/redis_client.py` around lines 33 - 34, get_cache currently calls
get_redis().cache() each time, creating a new Cache instance on every
invocation; change get_cache to memoize the created Cache in a module-level
variable (e.g., _cache) and return the cached instance thereafter so subsequent
calls to get_cache() return the same Cache object; locate and update the
get_cache function and ensure it uses get_redis() only once to initialize the
singleton Cache, referencing the Cache type and get_redis() function in your
change.
cache/cache_updates.py (1)

80-83: raise e swallows the original traceback; use bare raise.

raise e creates a new exception with the except handler as the origin, discarding the stack below pipe.execute(). Use bare raise to re-raise with the original traceback intact.

♻️ Proposed fix
         try:
             pipe.execute()
         except Exception as e:
-            raise e
+            raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/cache_updates.py` around lines 80 - 83, The except block that catches
exceptions from pipe.execute() uses "raise e", which resets the traceback;
change it to a bare "raise" so the original traceback is preserved (i.e.,
replace "except Exception as e: raise e" with "except Exception: raise" or
simply "except: raise") — locate the try/except around the pipe.execute() call
in cache_updates.py and update the re-raise accordingly.
utils/geoip.py (1)

8-9: Relative DB paths depend on the process's working directory.

"misc/GeoLite2-Country.mmdb" is resolved against the CWD at runtime, not against the source file's location. If Flask is launched from any directory other than the project root, FileNotFoundError is silently caught and the service permanently returns "Unknown" / None with no diagnostic. Use __file__-anchored paths instead.

♻️ Proposed fix: anchor paths to the module file
+from pathlib import Path
+
 class GeoIPService:
-    _COUNTRY_DB = "misc/GeoLite2-Country.mmdb"
-    _CITY_DB = "misc/GeoLite2-City.mmdb"
+    _COUNTRY_DB = Path(__file__).parent.parent / "misc" / "GeoLite2-Country.mmdb"
+    _CITY_DB = Path(__file__).parent.parent / "misc" / "GeoLite2-City.mmdb"

geoip2.database.Reader accepts pathlib.Path objects directly, so no str() cast is needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/geoip.py` around lines 8 - 9, The constants _COUNTRY_DB and _CITY_DB
use relative paths which resolve against the process CWD; change them to be
anchored to the module location by resolving Path(__file__).parent /
"misc/GeoLite2-Country.mmdb" and Path(__file__).parent /
"misc/GeoLite2-City.mmdb" (use pathlib.Path) and pass those Path objects
directly into geoip2.database.Reader instead of str(); update any code that
catches FileNotFoundError around Reader creation to surface or log the resolved
path for diagnostics so missing DBs are detectable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@blueprints/limits.py`:
- Around line 49-50: SHORTEN_LEGACY = "100 per minute" is an outlier compared to
other mutating limits and may be a copy-paste error; confirm intent and if
unintentional, change the SHORTEN_LEGACY constant in blueprints/limits.py to
match the typical write-endpoint range (e.g., "30 per minute" or "60 per
minute") and add a brief comment noting why this rate differs if you
intentionally want higher traffic for the legacy URL shortener.

In `@cache/__init__.py`:
- Around line 11-12: Module-level call to get_cache() causes import-time crashes
when Redis is unavailable; change to lazy initialization and graceful
degradation by removing the direct cache_store = CacheStore(get_cache()) /
cache_query = UrlCache(...) expressions and instead provide a lazily-evaluated
accessor (e.g., get_cache_store or a lazy CacheStore factory) that calls
get_cache() inside a try/except catching RuntimeError and RedisError and returns
None or an unconnected CacheStore; update construction of UrlCache (and any
module-level dual_cache/DualCache usage) to use that accessor so the package
imports succeed when Redis is down and let BaseCache/CacheStore handle r == None
behavior.

In `@cache/cache_updates.py`:
- Around line 22-27: Modify cache_updates.__init__ to mirror BaseCache: wrap
get_redis() in try/except, log the exception and set self.r: Optional[Database]
= None on failure; update type annotation of self.r accordingly. In add_data
(which calls self.r.pipeline()) and in pull (which uses self.r throughout),
guard every access to self.r (early-return or no-op when None) so these methods
do not dereference a missing Redis client. Finally, replace the explicit "raise
e" with a bare "raise" in pull to preserve the original traceback.

In `@cache/dual_cache.py`:
- Around line 62-72: The block that acquires the Redis lock via
self._lock(lock_key) and then calls query_fn() can leave the lock stranded if
query_fn() raises and also needlessly holds the lock until expiry on success;
wrap the query_fn() + cache set path in a try/finally (or try/except/finally) to
ensure the lock is explicitly released (e.g., call the existing unlock helper or
delete the lock key) in all cases, mirroring the pattern used in _refresh; keep
the cache set calls (self.set(primary_key, serialize(data), primary_ttl) and
self.set(stale_key, serialize(data), stale_ttl)) inside the try so success still
populates cache before releasing the lock.

In `@cache/store.py`:
- Around line 30-41: The cached decorator captures a static string key at
decoration time and ignores wrapper(*args, **kwargs), causing incorrect cache
hits for functions with parameters and silently swallowing errors; update the
decorator in cached to either (A) rename the parameter to static_key (or
document clearly) to make argument-ignorance explicit for functions like
_fetch_blocked_patterns, or (B) compute a runtime cache key that incorporates
args/kwargs (e.g., serialize args/kwargs) so different calls are cached
separately; also replace the bare except in wrapper with error handling that
logs the exception using the same logger pattern used by get/set/delete (e.g.,
self.<logger>.exception or .error) before falling back to calling fn(*args,
**kwargs). Ensure changes reference the decorator function named cached, the
inner wrapper, and keep behavior for argument-less functions intact.

In `@main.py`:
- Around line 39-43: The module-level guard in main.py that raises RuntimeError
when FLASK_SECRET_KEY is missing prevents tests from importing the app; fix the
test environment so the variable exists before importing main.py by setting
FLASK_SECRET_KEY to a safe test value (e.g., in conftest.py using
os.environ.setdefault or via pytest ini/pyproject env config) so that
app.secret_key assignment in main.py succeeds and tests can collect; reference
the FLASK_SECRET_KEY environment variable and the app.secret_key assignment when
making the change.

In `@requirements.txt`:
- Line 70: The requirements.txt downgrade pins urllib3==2.5.0 which reintroduces
three HIGH CVEs; update the pin to a secure release (e.g., change the urllib3
requirement from "urllib3==2.5.0" to "urllib3==2.6.0" or pin to the 2.6.x series
like "urllib3>=2.6.0,<2.7.0") so the three advisories (GHSA-2xpw-w6gg-jr37,
GHSA-38jv-5279-wg99, GHSA-gm62-xv2j-4w53) are no longer present.
- Line 74: The requirements change downgrades the werkzeug dependency from 3.1.4
to 3.1.3 causing a regression; restore the original version by changing the
werkzeug entry back to werkzeug==3.1.4 in the requirements list (i.e., revert
the modification of the werkzeug package line) unless you have a documented,
specific incompatibility with 3.1.4—if that incompatibility exists, add a brief
comment explaining why the downgrade is required.

In `@utils/contact_utils.py`:
- Around line 11-12: CONTACT_WEBHOOK and URL_REPORT_WEBHOOK may be None now;
update callers or the send functions so they validate the webhook URI before
attempting requests.post. Specifically, in send_contact_message and send_report
add an early-exit check that returns a clear failure/False (or raises a specific
error) if webhook_uri is falsy, and at call sites in blueprints/contact.py guard
before calling send_contact_message(CONTACT_WEBHOOK, ...) and
send_report(URL_REPORT_WEBHOOK, ...) to avoid calling with None. Also remove or
redact the email field from log messages in send_contact_message (both the
warning and exception paths) to avoid logging PII. Ensure you reference the
existing function names send_contact_message and send_report and the constants
CONTACT_WEBHOOK and URL_REPORT_WEBHOOK when making the changes.

In `@utils/geoip.py`:
- Around line 35-51: The get_country and get_city methods currently only catch
geoip2.errors.AddressNotFoundError but will raise ValueError for invalid inputs
(e.g., empty string from get_client_ip()); update get_country (which calls
_get_country_reader and reader.country) and get_city (which calls
_get_city_reader and reader.city) to also catch ValueError and return the same
defaults ("Unknown" for get_country, None for get_city); ensure the try/except
blocks handle both AddressNotFoundError and ValueError so invalid IPs don't
surface as uncaught exceptions.
- Around line 17-33: Protect the lazy initialization in _get_country_reader and
_get_city_reader with a shared threading.Lock and use double-checked locking:
first check _country_loaded/_city_loaded, acquire the lock, check again, then
try to construct geoip2.database.Reader(self._COUNTRY_DB)/Reader(self._CITY_DB);
catch both FileNotFoundError and PermissionError and set
_country_reader/_city_reader to None on those errors, finally set the
_country_loaded/_city_loaded flag before releasing the lock and return the
reader; this ensures thread-safe single initialization and handles unreadable
files.

In `@utils/mongo_utils.py`:
- Around line 126-135: In validate_blocked_url, the pattern check uses
regex.match which only checks the start of the URL; replace the call to
regex.match(pattern, url, timeout=0.2) with regex.search(pattern, url,
timeout=0.2) so stored patterns can match anywhere in the URL (keep the same
timeout and existing TimeoutError and regex.error handling in the function).

In `@utils/oauth_providers.py`:
- Around line 54-58: fetch_user_info currently passes the raw JSON from
client.get("user/emails") into extract_user_info_from_github which assumes a
list and will crash if the API returned an error dict; modify fetch_user_info to
validate the emails response: check the HTTP response status (or that the parsed
JSON is a list) before calling extract_user_info_from_github, and handle error
cases by either logging/raising a clear error or passing an empty list/default
structure to extract_user_info_from_github so it never receives a dict;
reference fetch_user_info and extract_user_info_from_github to locate where to
add the response type/status check and error handling.
- Around line 47-48: The current expression uses a falsy `or` which causes an
API call when `token.get("userinfo")` is an empty dict; change the logic to
explicitly check for None (e.g., temp = token.get("userinfo"); if temp is not
None use it, else call client.get("userinfo", token=token).json()) and then pass
the chosen `userinfo` to `extract_user_info_from_google`; update the code around
the `userInfo` assignment where `token.get("userinfo") or client.get("userinfo",
token=token).json()` appears to use an `is not None` guard instead of a
truthy/falsy `or`.

---

Outside diff comments:
In `@api/v1/management.py`:
- Around line 113-124: The UpdateUrlRequestBuilder usage in update_url_v1 omits
parse_status_change(), allowing arbitrary status values to be written; update
the builder chain in update_url_v1 to include .parse_status_change() (the same
validator used by update_url_status_v1) before calling build_update so status is
validated, referencing UpdateUrlRequestBuilder, parse_status_change(),
update_url_v1, and build_update in builders/update.py.

In `@utils/auth_utils.py`:
- Around line 338-417: The API-key branch (token starting with "spoo_") never
enforces the require_verified flag, allowing keys created by verified users to
bypass later verification changes; update the API-key path (after you validate
key_doc and before returning the user_id/ObjectId) to enforce require_verified
by looking up the associated user (using key_doc.get("user_id")) and verifying
their email_verified flag (or calling the same helper used by the JWT path), log
a warning and return None if require_verified is True and the user's email is
not verified; keep existing assignments to g.api_key and request.api_key and
reuse find_api_key_by_hash / key_doc checks to locate where to add this check.

In `@utils/contact_utils.py`:
- Around line 112-122: The logs in send_contact_message currently include raw
PII via the email variable in both the "contact_webhook_failed" warning and
"contact_webhook_request_failed" exception paths; replace the raw email with a
non-reversible or masked representation (e.g., SHA-256 hex digest of the email
or a masked form like first char + **** + domain) before logging. Update the
uses of email in the log.warning call for "contact_webhook_failed" and the
log.error call for "contact_webhook_request_failed" to use the sanitized value
(e.g., hashed_email or masked_email) and ensure any helper you add to produce
the sanitized value is deterministic and referenced where logging occurs. Ensure
you do not change other payloads that must send the real email to the webhook —
only change what is passed into logging fields.

In `@utils/password_utils.py`:
- Around line 5-82: The validate_password function allows strength_score to go
negative; clamp strength_score to a reasonable range (e.g., 0–100) before
returning to prevent negative or >100 values. Update validate_password to
compute the score as currently done, then apply a clamp (min 0, max 100) to
strength_score right before the return, keeping the existing is_valid and
missing behavior; reference validate_password, strength_score, and weak_patterns
when locating the change.

---

Nitpick comments:
In `@blueprints/dashboard.py`:
- Around line 63-65: The `@limiter.limit` decorator on the /statistics endpoint
uses a hardcoded string "60 per minute"; replace that literal with the
centralized constant Limits.DASHBOARD_READ to enforce the same limit while
keeping configuration centralized—update the decorator on the statistics route
(the `@limiter.limit` call using key_func=rate_limit_key_for_request) to use
Limits.DASHBOARD_READ instead of the string.
- Around line 108-110: The rate limit on the profile pictures GET route is using
Limits.DASHBOARD_WRITE instead of the read limit; update the limiter on the
`@dashboard_bp.route`("/profile-pictures") view (the decorator that currently
calls limiter.limit(Limits.DASHBOARD_WRITE,
key_func=rate_limit_key_for_request)) to use Limits.DASHBOARD_READ (or, if the
stricter write limit is intentional, add a clear inline comment above the
decorator explaining why DASHBOARD_WRITE is required) so the GET endpoint uses
the appropriate read-rate policy.

In `@blueprints/limiter.py`:
- Around line 28-37: The _get_ip_bypasses() function currently returns a list
which makes the membership test in ip_whitelist() O(n); change
_get_ip_bypasses() to return a set of IDs (e.g., set comprehension over
ip_bypasses.find()) so ip_whitelist()'s "client_ip in _get_ip_bypasses()"
becomes O(1); keep the `@cache_store.cached`(key="cache:ip_bypasses", ttl=120)
decorator in place so the set is cached and no other call sites need
modification.

In `@blueprints/oauth.py`:
- Around line 310-312: The list_auth_providers() endpoint lacks an explicit rate
limit; update the oauth_bp route by adding the appropriate rate-limiting
decorator (e.g., use Limits.AUTH_READ or Limits.DASHBOARD_READ to match other
read endpoints) above the function or, if leaving global defaults intentionally,
add a concise comment explaining that decision; reference the
list_auth_providers function and the Limits.AUTH_READ / Limits.DASHBOARD_READ
constants when making the change so it matches the blueprint's existing
patterns.
- Around line 346-358: The unlink pre-check using auth_providers and remaining
(variables user, provider_name, remaining) has a TOCTOU race: replace the
two-step check + "$pull" with a single atomic conditional update (e.g., use the
users collection's find_one_and_update) whose filter ensures the user still has
more than one auth provider or password_set is true (use a MongoDB condition
such as {"$expr": {"$gt": [{"$size": "$auth_providers"}, 1]}} or combine with
password_set flag), perform the $pull in that same operation, and then treat a
null result as the "cannot unlink last authentication method" error; locate the
code around auth_providers / remaining in the unlink handler in
blueprints/oauth.py and change the logic to the conditional find_one_and_update
and subsequent nil-check instead of the current pre-check + separate update.

In `@blueprints/url_shortener.py`:
- Around line 349-351: The preview route decorated at
url_shortener.route("/<short_code>+") is using Limits.SHORTEN_LEGACY which is
misnamed for a preview endpoint; create a new constant (e.g. Limits.PREVIEW_URL
or Limits.SHORTEN_PREVIEW) with the intended limit (100 req/min), update its
docstring to reflect “preview endpoint” and replace Limits.SHORTEN_LEGACY with
the new constant on the preview_url function, or alternatively rename and update
SHORTEN_LEGACY’s name and docstring if it truly only applies to previews so
other endpoints don’t mistakenly reuse it.

In `@cache/cache_updates.py`:
- Around line 80-83: The except block that catches exceptions from
pipe.execute() uses "raise e", which resets the traceback; change it to a bare
"raise" so the original traceback is preserved (i.e., replace "except Exception
as e: raise e" with "except Exception: raise" or simply "except: raise") —
locate the try/except around the pipe.execute() call in cache_updates.py and
update the re-raise accordingly.

In `@cache/cache_url.py`:
- Around line 36-39: In get_url_cache_data, constructing UrlCacheData(**data)
can raise TypeError when cached dict has stale/extra keys; wrap the
deserialization around a try/except that catches TypeError (and optionally
ValueError) coming from UrlCacheData(...) and return None on failure so the
caller treats it as a cache miss; locate the call in get_url_cache_data, protect
the UrlCacheData construction using the _store.get result, and ensure any
exception is swallowed only for deserialization errors (log debug if desired)
while leaving other errors untouched.

In `@cache/redis_client.py`:
- Around line 33-34: get_cache currently calls get_redis().cache() each time,
creating a new Cache instance on every invocation; change get_cache to memoize
the created Cache in a module-level variable (e.g., _cache) and return the
cached instance thereafter so subsequent calls to get_cache() return the same
Cache object; locate and update the get_cache function and ensure it uses
get_redis() only once to initialize the singleton Cache, referencing the Cache
type and get_redis() function in your change.

In `@utils/auth_utils.py`:
- Around line 318-331: The current dual-cache-key approach (cache_key =
"_owner_id_verified" vs "_owner_id_any") causes _resolve_owner_id (and
find_api_key_by_hash) to run twice; change this to cache the raw auth once:
introduce a helper _resolve_raw_auth that returns (owner_id, email_verified)
(replacing current _resolve_owner_id internals), store that single raw tuple on
g under a single key like "_owner_id_raw" (use _MISSING/g getattr/setattr
handling as before), and in resolve_owner_id_from_request (the caller using
cache_key and require_verified) read the cached raw tuple and enforce the
require_verified gate there (return owner_id only if verified when
require_verified=True, otherwise return owner_id) so the DB/crypto work happens
only once per request.
- Around line 386-390: The conditional guard checking key_doc,
key_doc.get("revoked", False), and (not expires_at or expires_at > now) is
redundant because earlier early returns already guarantee those truths; remove
the if statement and dedent its body so the code executes directly (clean up any
else/return logic related to the JWT section as needed) to simplify control flow
in auth_utils.py around the key_doc/expires_at/now checks and avoid the
misleading always-true branch.

In `@utils/geoip.py`:
- Around line 8-9: The constants _COUNTRY_DB and _CITY_DB use relative paths
which resolve against the process CWD; change them to be anchored to the module
location by resolving Path(__file__).parent / "misc/GeoLite2-Country.mmdb" and
Path(__file__).parent / "misc/GeoLite2-City.mmdb" (use pathlib.Path) and pass
those Path objects directly into geoip2.database.Reader instead of str(); update
any code that catches FileNotFoundError around Reader creation to surface or log
the resolved path for diagnostics so missing DBs are detectable.

In `@utils/query_builder.py`:
- Around line 107-108: Rename the class StatsQueryBuilderFactory to
ClickQueryBuilderFactory to match the renamed builder; update the class
declaration and any references (including the factory methods for_user_stats and
for_anonymous_stats and their return type annotations) to use
ClickQueryBuilderFactory/ClickQueryBuilder, and update the two call sites in
builders/stats.py to import/use ClickQueryBuilderFactory instead of
StatsQueryBuilderFactory so names are consistent across the codebase.

Comment thread blueprints/limits.py
Comment thread cache/__init__.py Outdated
Comment thread cache/cache_updates.py
Comment thread cache/dual_cache.py
Comment thread cache/store.py
Comment thread utils/geoip.py
Comment thread utils/geoip.py
Comment thread utils/mongo_utils.py
Comment thread utils/oauth_providers.py Outdated
Comment thread utils/oauth_providers.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
blueprints/limiter.py (1)

28-30: _get_ip_bypasses() returns a list; membership test is O(n) — consider returning a set.

client_ip in _get_ip_bypasses() does a linear scan on every request that passes through ip_whitelist(). Returning a frozenset instead costs nothing extra on the cache hit path and makes the membership test O(1).

♻️ Proposed refactor
 `@cache_store.cached`(key="ip_bypasses", ttl=120)
 def _get_ip_bypasses() -> list:
-    return [doc["_id"] for doc in ip_bypasses.find()]
+    return frozenset(doc["_id"] for doc in ip_bypasses.find())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blueprints/limiter.py` around lines 28 - 30, The _get_ip_bypasses() function
returns a list which makes the membership check in ip_whitelist() O(n); change
_get_ip_bypasses() to return a frozenset (or set) of IDs instead (e.g.,
frozenset(doc["_id"] for doc in ip_bypasses.find())) so cached results allow
O(1) membership tests; keep the `@cache_store.cached` decorator and update any
callers expecting a list to treat the return value as a set/frozenset.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cache/store.py`:
- Around line 46-48: The current hit-check using result =
self._cache.get(cache_key); if result is not None: treats stored None as a miss;
change the logic to distinguish a cached None by using a unique sentinel or
using walrus.Cache.get(..., default=sentinel) if supported: define a
module-level sentinel, call self._cache.get(cache_key, default=sentinel) (or
pass the sentinel via the default arg), then treat only sentinel as a miss
(i.e., if result is not sentinel: return result), and ensure any cache writes
for actual None values still store None under the same cache_key so subsequent
gets return None instead of re-executing the function.

---

Duplicate comments:
In `@cache/store.py`:
- Around line 52-53: The cached decorator currently swallows exceptions in
"except Exception" without logging; update the except block inside the cached
wrapper to capture the exception as e and log it using the same logger pattern
used by the class's get/set/delete methods (e.g., self.logger.error or the class
logger) with a clear message that includes the exception details, then fall back
to returning fn(*args, **kwargs) as before; reference the cached decorator and
fn in your change so the log mirrors existing cache error logs.

In `@utils/mongo_utils.py`:
- Around line 126-135: The validate_blocked_url function currently uses
regex.match which only checks the start of the string and therefore misses
patterns that appear later in URLs; update validate_blocked_url to use
regex.search(pattern, url, timeout=0.2) instead of regex.match so blocked
patterns like "example\.com" are found anywhere in the URL, preserving the
existing try/except handling for TimeoutError and regex.error and the same
logging calls (log.warning "blocked_url_pattern_timeout" and
"blocked_url_pattern_invalid").

---

Nitpick comments:
In `@blueprints/limiter.py`:
- Around line 28-30: The _get_ip_bypasses() function returns a list which makes
the membership check in ip_whitelist() O(n); change _get_ip_bypasses() to return
a frozenset (or set) of IDs instead (e.g., frozenset(doc["_id"] for doc in
ip_bypasses.find())) so cached results allow O(1) membership tests; keep the
`@cache_store.cached` decorator and update any callers expecting a list to treat
the return value as a set/frozenset.

Comment thread cache/store.py Outdated
…ion logging and retaining lock on query failure
… webhook reporting in contact_utils, and update environment setup in CI workflow

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (5)
cache/store.py (1)

42-69: fn() called twice when it raises — broad except catches application errors too.

The try block (lines 45–61) encompasses both cache operations (self._cache.get, self._cache.set) and the decorated function call (fn(*args, **kwargs) at line 59). If fn() itself raises, the except Exception at line 62 catches it, logs a misleading cache_decorated_call_failed event, and then calls fn() again at line 69 — which will likely raise the same error, propagating to the caller.

This is functionally safe (the real error does surface), but the double invocation is wasteful and the log message is misleading since it wasn't a cache failure.

Suggested fix — narrow the try scope around cache ops
             def wrapper(*args, **kwargs):
                 if not self._cache:
                     return fn(*args, **kwargs)
                 try:
                     if args or kwargs:
                         args_hash = hashlib.md5(
                             json.dumps(
                                 (args, sorted(kwargs.items())),
                                 default=str,
                             ).encode()
                         ).hexdigest()[:8]
                         cache_key = f"{key}:{args_hash}"
                     else:
                         cache_key = key
                     result = self._cache.get(cache_key, default=_MISS)
                     if result is not _MISS:
                         return result
                 except Exception as e:
                     log.error(
                         "cache_decorated_call_failed",
                         key=key,
                         error=str(e),
                         error_type=type(e).__name__,
                     )
+                    return fn(*args, **kwargs)
+
+                result = fn(*args, **kwargs)
+                try:
+                    self._cache.set(cache_key, result, ttl)
+                except Exception as e:
+                    log.warning("cache_set_after_call_failed", key=key, error=str(e))
-                    result = fn(*args, **kwargs)
-                    self._cache.set(cache_key, result, ttl)
-                    return result
-                except Exception as e:
-                    log.error(
-                        "cache_decorated_call_failed",
-                        key=key,
-                        error=str(e),
-                        error_type=type(e).__name__,
-                    )
-                    return fn(*args, **kwargs)
+                return result
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/store.py` around lines 42 - 69, The wrapper currently wraps fn(*args,
**kwargs) inside a broad try that also covers self._cache.get/set, causing fn to
be called again in the except path and logging cache failures for application
errors; fix by narrowing the try to only the cache operations: call fn once
outside of the try when cache miss (i.e., compute result = fn(...) only after
checking cache), then wrap only self._cache.set (and/or self._cache.get if you
expect it to raise) in try/except to log cache-specific errors; ensure wrapper
returns the computed result and that any exception from fn propagates (do not
call fn again in the except).
cache/dual_cache.py (1)

62-84: Lock handling improvement looks good; minor note on unreachable comment placement.

The try/except block correctly addresses the previously-flagged lock-stranding issue — the lock is explicitly released on success (line 68) and intentionally retained on failure to rate-limit retries. The design rationale is sound.

However, the explanatory comment on lines 78–79 is placed after return None on line 77, making it technically unreachable dead code. Consider moving it above the return for clarity.

Suggested placement
             except Exception as e:
                 log.error(
                     "dual_cache_query_failed",
                     base_key=base_key,
                     error=str(e),
                     error_type=type(e).__name__,
                 )
-                return None
-                # lock intentionally not released on failure — expires after
-                # lock_ttl to rate-limit retries while the query is broken
+                # lock intentionally not released on failure — expires after
+                # lock_ttl to rate-limit retries while the query is broken
+                return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/dual_cache.py` around lines 62 - 84, The explanatory comment about
intentionally not releasing the lock after failure is currently placed after the
`return None` (making it unreachable); move that comment to immediately above
the `return None` inside the except block (near the `delete(lock_key)`/except
block handling) or otherwise relocate it so it sits before the `return` that
exits the except path; reference `lock_key`, the except block that logs
`dual_cache_query_failed`, and the `return None` that follows so the intent is
visible and not dead code.
cache/cache_updates.py (2)

29-74: Pipeline execution lacks error handling — unrecoverable click data loss on Redis failure.

pipe.execute() at line 74 has no try/except. If Redis becomes unavailable between the guard at line 30 and the execute at line 74, the exception propagates to the caller and the click data is silently lost. Consider wrapping the pipeline execution to at least log the failure.

Suggested minimal safeguard
-        pipe.execute()
+        try:
+            pipe.execute()
+        except Exception as e:
+            log.error(
+                "click_buffer_add_failed",
+                slug=slug,
+                error=str(e),
+                error_type=type(e).__name__,
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/cache_updates.py` around lines 29 - 74, The add_data method currently
calls pipe.execute() without error handling: wrap the pipe.execute() call inside
a try/except that catches Redis connection/command errors (e.g.,
redis.exceptions.RedisError or a broad Exception as a minimal safeguard), log
the failure including the slug and exception (use the class logger or add one if
missing), and avoid dropping the exception silently (either swallow after
logging for best-effort or re-raise if callers must handle it). Place this
try/except around pipe.execute() in add_data so failures between the self.r
guard and execution are recorded; keep existing keys/ttl_seconds/ClickData usage
intact.

76-121: pull() also lacks error handling around Redis operations after the guard.

Similar to add_data, the hgetall, scan_iter, smembers, and delete calls (lines 85–119) can all raise if Redis becomes unavailable after the initial self.r check. A single try/except around the body would prevent unhandled exceptions from propagating to the caller during the periodic flush.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache/cache_updates.py` around lines 76 - 121, The pull method currently
calls Redis operations (self.r.hgetall, self.r.scan_iter,
pipeline().smembers/execute, self.r.delete, self.r.srem) without error handling;
wrap the main body after the initial self.r guard in a try/except that catches
Redis/connection errors (e.g., redis.RedisError/Exception), log the exception
(using the same logger pattern as elsewhere) and return None on failure to avoid
bubbling exceptions during periodic flushes; ensure the try covers counts/meta
retrieval, scanning/reading ip sets, and the delete/srem cleanup, while leaving
the initial existence check (check_exists) intact so behavior is unchanged on
success.
utils/geoip.py (1)

15-20: Consider separate locks per reader to avoid startup serialization.

The single self._lock means _get_country_reader() and _get_city_reader() cannot initialize concurrently — the first to acquire the lock blocks the other for the full duration of its file I/O. Since you should use the same Reader object across multiple requests as creation of it is expensive, this one-time serialization is a minor startup cost, but separate locks would allow both DBs to load in parallel on the first request burst.

♻️ Proposed refactor
     def __init__(self):
         self._country_reader = None
         self._city_reader = None
         self._country_loaded = False
         self._city_loaded = False
-        self._lock = threading.Lock()
+        self._country_lock = threading.Lock()
+        self._city_lock = threading.Lock()

Then replace self._lock with self._country_lock in _get_country_reader and self._city_lock in _get_city_reader.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/geoip.py` around lines 15 - 20, The single lock _lock serializes both
reader initializations; update the GeoIP initializer to create two locks
(self._country_lock and self._city_lock) instead of self._lock, then in
_get_country_reader use self._country_lock and in _get_city_reader use
self._city_lock while keeping the existing double-checked pattern around
self._country_reader and self._city_reader to ensure thread-safe one-time
initialization of the expensive Reader objects.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/api_test.yaml:
- Around line 25-26: Replace the use of the `python` executable in the
env-generation command with `python3` to ensure the secrets.token_hex call works
reliably on runners where `python` may not exist; specifically update the echo
line that generates FLASK_SECRET_KEY (the command containing python -c 'import
secrets; print(secrets.token_hex(32))') to call python3 instead.

In `@utils/geoip.py`:
- Around line 58-61: The lookup code currently catches
geoip2.errors.AddressNotFoundError and ValueError but misses
maxminddb.InvalidDatabaseError, so update the exception handlers around the
reader.country(ip_address).country.name (and the similar block later) to also
catch maxminddb.InvalidDatabaseError; specifically add
maxminddb.InvalidDatabaseError to the except tuples that currently list
geoip2.errors.AddressNotFoundError and ValueError so malformed or invalid DB
errors return "Unknown" instead of propagating.
- Around line 26-35: The geoip2.database.Reader construction can raise
maxminddb.InvalidDatabaseError which is not caught by the current except OSError
in the block that sets self._country_reader, causing uncaught exceptions and
preventing self._country_loaded from being set; update the try/except to also
catch maxminddb.InvalidDatabaseError (or Exception) when creating
geoip2.database.Reader for _country_reader, ensure you set self._country_reader
= None on error and guarantee self._country_loaded = True regardless (e.g. move
setting into a finally or after the except), and apply the same change to the
analogous _get_city_reader / _city_reader construction to prevent repeated
failures and retries.

In `@utils/oauth_providers.py`:
- Line 49: The userinfo HTTP calls currently call .json() directly (e.g., the
client.get("userinfo", token=token).json() invocation) and must validate the
response status before parsing to avoid propagating error payloads as empty
identities; update each strategy that fetches userinfo (the three userinfo GETs
around the client.get("userinfo", token=token) sites) to check response.ok or
response.status_code and handle non-2xx responses by logging/raising or
returning an explicit error/null instead of .json(), mirroring the existing
GitHub emails pattern (check status, log error including response.text/status)
so downstream extractors never receive an error JSON as a valid userinfo dict.

---

Duplicate comments:
In `@cache/__init__.py`:
- Around line 6-12: Imports instantiate cache_store, cache_query, and dual_cache
at module import which previously risked crashing, so ensure CacheStore() uses
get_cache() → get_redis() that returns None on failure and that all downstream
initializations (UrlCache(store=cache_store, ttl_seconds=300) and
DualCache(primary_ttl=10 * 60, stale_ttl=60 * 60, lock_ttl=60)) handle a None
Redis client gracefully; confirm the redis_client.py global _db implements a
singleton shared connection and adjust CacheStore, UrlCache, or DualCache
constructors to perform lazy/no-op behavior when get_redis() returns None so
import-time failures cannot occur.

In `@cache/cache_updates.py`:
- Around line 22-27: The ClickBuffer __init__ now aligns with BaseCache by
assigning self.r = get_redis(), setting ttl_seconds, and logging a warning if
self.r is None; ensure all ClickBuffer methods (e.g., any method referencing
self.r) continue to guard for self.r is None with early returns so no
dereference occurs — keep the get_redis usage and the warning in __init__ as
implemented.

In `@utils/contact_utils.py`:
- Around line 119-131: In send_contact_message, do not pass raw PII (email) to
the structured logger; update the log.warning and log.error calls (the ones
currently passing email=email) to either remove the email field entirely or
replace it with a non-PII identifier (e.g., a short hash or truncated token
derived from the email). Use the existing log variable and ensure the new field
name makes clear it is a redacted_id (or similar) so callers can correlate
events without exposing the address; if you create a small helper to compute the
identifier do so and reference it from send_contact_message when building both
warning and error log entries.

In `@utils/oauth_providers.py`:
- Around line 59-62: Ensure the GitHub emails handling in the OAuth flow keeps
the guard that only parses emails_response.json() when the response is
successful and coerces non-list results to an empty list: after calling
client.get("user/emails", token=token) assign emails = emails_response.json()
only if emails_response.status_code == 200 (or emails_response.ok), and then
ensure if not isinstance(emails, list): emails = []; this protects
extract_user_info_from_github from receiving an error dict instead of a list.
- Around line 47-50: The previous bug came from using a falsy `or` that caused
valid empty dicts to trigger an unnecessary API call; replace any occurrence of
`userinfo = token.get("userinfo") or client.get("userinfo", token=token).json()`
with the explicit None check as shown: retrieve userinfo via
`token.get("userinfo")`, then if `userinfo is None` call `client.get("userinfo",
token=token).json()`, and finally pass the result to
`extract_user_info_from_google(userinfo)`—ensure this pattern is used in the
same function where `userinfo`, `client.get`, and
`extract_user_info_from_google` appear so empty dicts are preserved and
redundant requests avoided.

---

Nitpick comments:
In `@cache/cache_updates.py`:
- Around line 29-74: The add_data method currently calls pipe.execute() without
error handling: wrap the pipe.execute() call inside a try/except that catches
Redis connection/command errors (e.g., redis.exceptions.RedisError or a broad
Exception as a minimal safeguard), log the failure including the slug and
exception (use the class logger or add one if missing), and avoid dropping the
exception silently (either swallow after logging for best-effort or re-raise if
callers must handle it). Place this try/except around pipe.execute() in add_data
so failures between the self.r guard and execution are recorded; keep existing
keys/ttl_seconds/ClickData usage intact.
- Around line 76-121: The pull method currently calls Redis operations
(self.r.hgetall, self.r.scan_iter, pipeline().smembers/execute, self.r.delete,
self.r.srem) without error handling; wrap the main body after the initial self.r
guard in a try/except that catches Redis/connection errors (e.g.,
redis.RedisError/Exception), log the exception (using the same logger pattern as
elsewhere) and return None on failure to avoid bubbling exceptions during
periodic flushes; ensure the try covers counts/meta retrieval, scanning/reading
ip sets, and the delete/srem cleanup, while leaving the initial existence check
(check_exists) intact so behavior is unchanged on success.

In `@cache/dual_cache.py`:
- Around line 62-84: The explanatory comment about intentionally not releasing
the lock after failure is currently placed after the `return None` (making it
unreachable); move that comment to immediately above the `return None` inside
the except block (near the `delete(lock_key)`/except block handling) or
otherwise relocate it so it sits before the `return` that exits the except path;
reference `lock_key`, the except block that logs `dual_cache_query_failed`, and
the `return None` that follows so the intent is visible and not dead code.

In `@cache/store.py`:
- Around line 42-69: The wrapper currently wraps fn(*args, **kwargs) inside a
broad try that also covers self._cache.get/set, causing fn to be called again in
the except path and logging cache failures for application errors; fix by
narrowing the try to only the cache operations: call fn once outside of the try
when cache miss (i.e., compute result = fn(...) only after checking cache), then
wrap only self._cache.set (and/or self._cache.get if you expect it to raise) in
try/except to log cache-specific errors; ensure wrapper returns the computed
result and that any exception from fn propagates (do not call fn again in the
except).

In `@utils/geoip.py`:
- Around line 15-20: The single lock _lock serializes both reader
initializations; update the GeoIP initializer to create two locks
(self._country_lock and self._city_lock) instead of self._lock, then in
_get_country_reader use self._country_lock and in _get_city_reader use
self._city_lock while keeping the existing double-checked pattern around
self._country_reader and self._city_reader to ensure thread-safe one-time
initialization of the expensive Reader objects.

Comment thread .github/workflows/api_test.yaml Outdated
Comment thread utils/geoip.py
Comment thread utils/geoip.py
Comment thread utils/oauth_providers.py Outdated
…rfile CMD for production, update CI workflow to use python3, and enhance error handling in OAuth provider strategies

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dockerfile (1)

1-13: ⚠️ Potential issue | 🟠 Major

Container runs as root and exposes no documented port

Two hardening gaps in the Dockerfile:

  1. No USER directive — the process runs as root inside the container, violating least-privilege. If the application or one of its dependencies is compromised, the attacker immediately has root within the container.
  2. No EXPOSE instruction — while not required for runtime, it documents the expected port and is required for -P / Docker Compose ports: auto-mapping to work correctly.
🔒 Proposed hardening additions
 WORKDIR /app
 RUN uv sync --frozen --no-cache

+RUN adduser --disabled-password --gecos "" appuser
+USER appuser
+
+EXPOSE 8000
+
-CMD ["/app/.venv/bin/gunicorn", "main:app"]
+CMD ["/app/.venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "main:app"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dockerfile` around lines 1 - 13, Add a non-root runtime user and document the
application's listening port: create and switch to a dedicated user (e.g., add a
user/group and a USER directive) before the CMD so the container does not run as
root, ensure ownership/permissions of /app and any runtime dirs are set for that
user (update ownership after COPY if needed), and add an EXPOSE instruction for
the port gunicorn serves (reference the CMD "gunicorn", the Dockerfile USER
directive to add, and EXPOSE to declare the port) so Docker/Compose can auto-map
and the port is documented.
🧹 Nitpick comments (4)
.dockerignore (1)

1-34: Consider adding a few commonly omitted patterns.

The following patterns are worth adding:

  • .github/ — CI workflow files (which may reference secrets) don't belong in the image layer.
  • docker-compose*.yml — local dev compose files often embed credentials and service topology irrelevant to the production image.
  • *.egg-info/ — generated packaging metadata can accumulate and increase image context size.
♻️ Suggested additions
 # Dev/test files
 tests/
 k6-tests/
 local_test_db/
 misc/
 thoughts/
+docker-compose*.yml
+
+# CI/CD
+.github/
 
 # Editor/OS
 .vscode/
 .DS_Store
 
 # Docs
 *.md
+
+# Build artifacts
+*.egg-info/
+dist/
+build/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dockerignore around lines 1 - 34, Add common omitted patterns to the
.dockerignore to avoid leaking CI/workflow files and generated artifacts: update
the .dockerignore (current content shows patterns like .env, .git, __pycache__)
to also exclude .github/, docker-compose*.yml, and *.egg-info/ so CI workflows,
local compose files, and packaging metadata are not sent in the build context or
baked into images.
dockerfile (2)

4-4: Pin the uv version for reproducible builds

ghcr.io/astral-sh/uv:latest is floating — a future uv release could introduce breaking changes that silently affect the build without any change to the Dockerfile.

♻️ Proposed fix — pin uv to a specific version
-COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
+COPY --from=ghcr.io/astral-sh/uv:0.6.3 /uv /uvx /bin/

Replace 0.6.3 with the specific version you have tested against.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dockerfile` at line 4, The Dockerfile currently copies from the floating
image tag `ghcr.io/astral-sh/uv:latest` which risks non-reproducible builds;
change the COPY source to a pinned uv image (e.g., `ghcr.io/astral-sh/uv:0.6.3`)
by replacing `ghcr.io/astral-sh/uv:latest` in the `COPY
--from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/` line with the specific tested
version you want to lock to, then rebuild and verify the image to ensure no
runtime regressions.

13-13: Gunicorn should explicitly bind to 0.0.0.0 and specify worker count for production

Gunicorn's default binding is 127.0.0.1:8000, which on Render will be overridden by the auto-injected PORT environment variable. However, explicitly setting --bind 0.0.0.0:8000 makes the container's network behavior clear and removes reliance on implicit runtime environment configuration.

For production deployments, add --workers (typically 2 * nCPU + 1). A single worker serializes all requests, creating a throughput bottleneck.

Recommended changes
-CMD ["/app/.venv/bin/gunicorn", "main:app"]
+CMD ["/app/.venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "main:app"]

Also consider:

  • Adding USER nobody or a dedicated unprivileged user (container currently runs as root)
  • Pinning uv:latest to a specific version for reproducible builds (e.g., uv:0.4.14)
  • Adding EXPOSE 8000 for documentation
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dockerfile` at line 13, Update the Docker CMD that currently invokes gunicorn
via CMD ["/app/.venv/bin/gunicorn", "main:app"] to explicitly bind to 0.0.0.0
and set a worker count; e.g. add a --bind flag (preferably --bind 0.0.0.0:$PORT
to honor injected PORT) and a --workers argument (use a formula like 2 *
$(nproc) + 1 or set a sensible default such as 3) so the CMD becomes the
gunicorn executable with "main:app" plus --bind and --workers flags; keep the
same executable path ("/app/.venv/bin/gunicorn") and ensure the updated CMD
replaces the existing CMD entry.
utils/oauth_providers.py (1)

20-24: @property @abstractmethod`` + plain class-attribute override is semantically inconsistent — and the registry depends on it.

key is declared as @property @abstractmethod`` in the ABC, yet all three concrete classes satisfy it with a plain class attribute (key = "google", etc.) rather than a `@property`. This works in Python 3 (as verified: the ABC mechanism accepts the class attribute as satisfying the abstract property), but it creates a semantic trap for future maintainers.

The ABC's @property @abstractmethod`` signals: "override me as a property." Yet the registry at line 82 accesses s.key on the class (not an instance). If a future contributor correctly followed the ABC's apparent contract and implemented:

`@property`
def key(self) -> str:
    return "newprovider"

…then s.key would return the property descriptor object, which would silently become a dict key instead of "newprovider", breaking the registry with no syntax or runtime error.

The ABC declaration should match the intended usage pattern. Consider either:

♻️ Option A — drop @property; annotate key as a class variable
+from typing import ClassVar
 
 class OAuthProviderStrategy(ABC):
-    `@property`
-    `@abstractmethod`
-    def key(self) -> str:
-        """Provider key used in URLs and the database (e.g. 'google')."""
-        ...
+    #: Provider key used in URLs and the database (e.g. ``'google'``).
+    #: Must be overridden as a class attribute in every concrete subclass.
+    key: ClassVar[str]
♻️ Option B — make it an explicit abstract classmethod (if enforcement is desired)
 class OAuthProviderStrategy(ABC):
-    `@property`
     `@classmethod`
     `@abstractmethod`
     def key(cls) -> str:
         """Provider key used in URLs and the database (e.g. 'google')."""
         ...

And in each concrete class:

-    key = "google"
+    `@classmethod`
+    def key(cls) -> str:
+        return "google"

Also applies to: 42, 56, 71

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/oauth_providers.py` around lines 20 - 24, The abstract base currently
declares key as `@property` `@abstractmethod` but all concrete providers implement
it as a class attribute (key = "…") and the registry reads s.key on the class,
so change the ABC to declare key as a class-level attribute (e.g. from typing
import ClassVar; key: ClassVar[str]) and remove the `@property/`@abstractmethod
decoration; leave each concrete class' key = "provider" as-is so the registry
access (s.key) continues to work; alternatively, if you prefer enforcement,
replace the abstract property with `@classmethod` `@abstractmethod` def key(cls) ->
str and update all concrete providers to implement `@classmethod` def key(cls) ->
str: return "name" so class-level access returns the string rather than a
property descriptor.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.dockerignore:
- Line 34: The .dockerignore currently excludes all Markdown files via the
'*.md' pattern which prevents README.md (referenced by pyproject.toml's readme =
"README.md") from being included in the build context; fix by removing the
'*.md' entry or adding an exception line '!README.md' so README.md is copied
into the Docker build context and uv sync can access package metadata.

---

Outside diff comments:
In `@dockerfile`:
- Around line 1-13: Add a non-root runtime user and document the application's
listening port: create and switch to a dedicated user (e.g., add a user/group
and a USER directive) before the CMD so the container does not run as root,
ensure ownership/permissions of /app and any runtime dirs are set for that user
(update ownership after COPY if needed), and add an EXPOSE instruction for the
port gunicorn serves (reference the CMD "gunicorn", the Dockerfile USER
directive to add, and EXPOSE to declare the port) so Docker/Compose can auto-map
and the port is documented.

---

Nitpick comments:
In @.dockerignore:
- Around line 1-34: Add common omitted patterns to the .dockerignore to avoid
leaking CI/workflow files and generated artifacts: update the .dockerignore
(current content shows patterns like .env, .git, __pycache__) to also exclude
.github/, docker-compose*.yml, and *.egg-info/ so CI workflows, local compose
files, and packaging metadata are not sent in the build context or baked into
images.

In `@dockerfile`:
- Line 4: The Dockerfile currently copies from the floating image tag
`ghcr.io/astral-sh/uv:latest` which risks non-reproducible builds; change the
COPY source to a pinned uv image (e.g., `ghcr.io/astral-sh/uv:0.6.3`) by
replacing `ghcr.io/astral-sh/uv:latest` in the `COPY
--from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/` line with the specific tested
version you want to lock to, then rebuild and verify the image to ensure no
runtime regressions.
- Line 13: Update the Docker CMD that currently invokes gunicorn via CMD
["/app/.venv/bin/gunicorn", "main:app"] to explicitly bind to 0.0.0.0 and set a
worker count; e.g. add a --bind flag (preferably --bind 0.0.0.0:$PORT to honor
injected PORT) and a --workers argument (use a formula like 2 * $(nproc) + 1 or
set a sensible default such as 3) so the CMD becomes the gunicorn executable
with "main:app" plus --bind and --workers flags; keep the same executable path
("/app/.venv/bin/gunicorn") and ensure the updated CMD replaces the existing CMD
entry.

In `@utils/oauth_providers.py`:
- Around line 20-24: The abstract base currently declares key as `@property`
`@abstractmethod` but all concrete providers implement it as a class attribute
(key = "…") and the registry reads s.key on the class, so change the ABC to
declare key as a class-level attribute (e.g. from typing import ClassVar; key:
ClassVar[str]) and remove the `@property/`@abstractmethod decoration; leave each
concrete class' key = "provider" as-is so the registry access (s.key) continues
to work; alternatively, if you prefer enforcement, replace the abstract property
with `@classmethod` `@abstractmethod` def key(cls) -> str and update all concrete
providers to implement `@classmethod` def key(cls) -> str: return "name" so
class-level access returns the string rather than a property descriptor.

Comment thread .dockerignore
… flow, including IP hashing for security and improved state verification

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
utils/oauth_utils.py (1)

134-152: ⚠️ Potential issue | 🔴 Critical

Unsigned state with user_id enables account-linking takeover

The TODO at lines 139–140 explicitly acknowledges that the state is not signed. verify_oauth_state only checks provider name and timestamp — it never verifies state integrity. This means an attacker can trivially craft:

provider=google&action=link&nonce=<anything>&timestamp=<now>&user_id=<victim_id>

and have their provider linked to any arbitrary account when the OAuth callback fires. Since account linking reads user_id directly out of state_data (which verify_oauth_state returns as trusted), this is a critical account-takeover vector.

The nonce field is generated (line 130) and parsed back, but is never stored server-side and compared, so it provides no CSRF protection either.

Recommended fix — choose one:

  1. HMAC-sign the state using FLASK_SECRET_KEY before returning from generate_oauth_state and verify the signature in verify_oauth_state before trusting any field.
  2. Server-side nonce store: stash {nonce → {provider, action, user_id}} in the session or cache, return only the nonce as the OAuth state, and look it up (then delete) on callback — never trust user_id from the inbound state string.
🔒 Sketch: HMAC-signed state (Option 1)
+import hmac
+import hashlib
+import base64
+from flask import current_app

 def generate_oauth_state(
     provider: str, action: str = "login", user_id: Optional[str] = None
 ) -> str:
     ...
-    return "&".join(state_parts)
+    payload = "&".join(state_parts)
+    secret = current_app.config["SECRET_KEY"].encode()
+    sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()
+    return f"{payload}&sig={sig}"


 def verify_oauth_state(
     state: str, expected_provider: str
 ) -> Tuple[bool, Dict[str, Any], Optional[str]]:
     try:
+        # Separate and verify HMAC signature before parsing any fields
+        if "&sig=" not in state:
+            return False, {}, "parse_error"
+        payload, _, received_sig = state.rpartition("&sig=")
+        secret = current_app.config["SECRET_KEY"].encode()
+        expected_sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()
+        if not hmac.compare_digest(expected_sig, received_sig):
+            return False, {}, "parse_error"
         state_data = {}
-        for part in state.split("&"):
+        for part in payload.split("&"):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/oauth_utils.py` around lines 134 - 152, The state returned by
generate_oauth_state is unsigned and verify_oauth_state does not validate
integrity, allowing an attacker to forge user_id; fix by either (A) implementing
HMAC signing: inside generate_oauth_state include user_id in state_data, compute
an HMAC signature using FLASK_SECRET_KEY over the serialized state, append the
signature to the state string, and update verify_oauth_state to verify the
signature (and timestamp) before trusting any fields and reject if
invalid/expired; or (B) switch to a server-side nonce store: have
generate_oauth_state persist {nonce → {provider, action, user_id, timestamp}} in
the user session or cache and return only the nonce as the state, then make
verify_oauth_state lookup-and-delete the nonce entry and use that server-side
record (never trust incoming user_id). Ensure changes are made in
generate_oauth_state and verify_oauth_state and that nonce entries are
single-use and time-limited.
🧹 Nitpick comments (1)
utils/oauth_utils.py (1)

496-519: action parameter is declared but never used

The action parameter has a default value of "login" but is not referenced anywhere in the function body — neither in the env-var lookup nor the url_for fallback. All callers invoke this function with only the provider argument.

♻️ Remove unused parameter
-def get_oauth_redirect_url(provider: str, action: str = "login") -> str:
+def get_oauth_redirect_url(provider: str) -> str:
     """Generate OAuth redirect URL for the given provider

     First checks for environment variable {PROVIDER}_OAUTH_REDIRECT_URI,
     then falls back to dynamic generation using Flask's url_for.

     Args:
         provider: OAuth provider name
-        action: Action being performed ('login' or 'link')

     Returns:
         Full redirect URL
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/oauth_utils.py` around lines 496 - 519, The function
get_oauth_redirect_url declares an unused parameter action; remove the unused
parameter from the signature and its mention in the docstring so the function
only accepts provider, and update any callers if necessary; keep the
env_var_name lookup (f"{provider.upper()}_OAUTH_REDIRECT_URI") and the
url_for("oauth.oauth_callback", provider=provider, _external=True) fallback
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@blueprints/oauth.py`:
- Around line 181-187: Remove direct email logging in the OAuth log events: in
the log.info call that emits "oauth_account_linked" (the call that currently
passes user_id=link_user_id, provider=provider_key,
email=provider_info["email"], ip_hash=hash_ip(get_client_ip())) and the similar
"oauth_auto_linked" call, drop the email=provider_info["email"] field to avoid
PII in structured logs; if a non-PII identifier is required, replace it with a
deterministic hash (e.g., hash_email(provider_info["email"])) or another
non-reversible token, otherwise just omit the email field and keep the other
fields (user_id/link_user_id, provider/provider_key, ip_hash).
- Around line 350-351: The route function list_auth_providers (decorated with
`@oauth_bp.route`("/providers", methods=["GET"]) and `@requires_auth`) is missing a
rate-limit; add the same limiter guard used elsewhere by applying
`@limiter.limit`(...) above the route (e.g., matching other endpoints' policy such
as "100 per hour" or whichever app-wide rule is used) so authenticated requests
to list_auth_providers are throttled; ensure the limiter import/instance used by
other routes is referenced and placed above `@requires_auth` to match decorator
ordering.
- Around line 387-407: The current in-memory check using
remaining/auth_providers is racy; change to an atomic DB-filtered update: remove
the in-memory guard and call users_collection.update_one with a filter that
includes the user _id, that auth_providers contains the provider to remove, and
that either password_set is true OR auth_providers has at least 2 entries (e.g.
{"$or": [{"password_set": True}, {"auth_providers.1": {"$exists": True}}]}),
then $pull the provider and $set updated_at; after the update check
matched_count/modified_count and return the same 400 error if no document was
updated so concurrent attempts cannot leave the account with zero auth methods
(refer to remaining/auth_providers, provider_name, users_collection.update_one,
g.user_id).
- Line 117: The debug log in the oauth flow currently expands **provider_info in
log.debug("oauth_userinfo_received", provider=provider_key, **provider_info)
which pours PII into logs; remove the **provider_info spread and instead log
only non‑PII fields or a minimal marker (e.g., provider=provider_key and a safe
status flag), or if you must reference user identity include a hashed/masked
provider_user_id; update the call site in blueprints/oauth.py (the log.debug
invocation named "oauth_userinfo_received") to stop expanding provider_info and
only emit approved safe fields.

In `@utils/oauth_utils.py`:
- Around line 182-189: In the state expiry check inside utils/oauth_utils.py
(where state_data, timestamp_str, timestamp and age are used), treat a missing
timestamp as expired instead of silently accepting it: if
state_data.get("timestamp") is falsy, immediately return False, {}, "expired";
otherwise parse the timestamp into a timezone-aware datetime, compute age as
currently done, and return False, {}, "expired" when age > 600, otherwise return
True, state_data, None. This ensures both absent and stale timestamps are
rejected.

---

Outside diff comments:
In `@utils/oauth_utils.py`:
- Around line 134-152: The state returned by generate_oauth_state is unsigned
and verify_oauth_state does not validate integrity, allowing an attacker to
forge user_id; fix by either (A) implementing HMAC signing: inside
generate_oauth_state include user_id in state_data, compute an HMAC signature
using FLASK_SECRET_KEY over the serialized state, append the signature to the
state string, and update verify_oauth_state to verify the signature (and
timestamp) before trusting any fields and reject if invalid/expired; or (B)
switch to a server-side nonce store: have generate_oauth_state persist {nonce →
{provider, action, user_id, timestamp}} in the user session or cache and return
only the nonce as the state, then make verify_oauth_state lookup-and-delete the
nonce entry and use that server-side record (never trust incoming user_id).
Ensure changes are made in generate_oauth_state and verify_oauth_state and that
nonce entries are single-use and time-limited.

---

Nitpick comments:
In `@utils/oauth_utils.py`:
- Around line 496-519: The function get_oauth_redirect_url declares an unused
parameter action; remove the unused parameter from the signature and its mention
in the docstring so the function only accepts provider, and update any callers
if necessary; keep the env_var_name lookup
(f"{provider.upper()}_OAUTH_REDIRECT_URI") and the
url_for("oauth.oauth_callback", provider=provider, _external=True) fallback
unchanged.

Comment thread blueprints/oauth.py
Comment thread blueprints/oauth.py
Comment thread blueprints/oauth.py
Comment thread blueprints/oauth.py
Comment thread utils/oauth_utils.py
…ve timestamp validation in state verification
@Zingzy Zingzy changed the title Chore/dead code cleanup Chore/Oauth Refactor & Dead code Cleanup Feb 21, 2026
@Zingzy
Zingzy merged commit 77b7aaa into main Feb 21, 2026
9 checks passed
@Zingzy
Zingzy deleted the chore/dead-code-cleanup branch August 16, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants