diff --git a/.cspell.yml b/.cspell.yml new file mode 100644 index 00000000..a550c090 --- /dev/null +++ b/.cspell.yml @@ -0,0 +1,120 @@ +version: "0.2" +dictionaries: + - coding-terms + - cpp-compound-words + - data-science + - docker + - en-gb + - en-us + - filetypes + - fullstack + - npm + - python + - python-common + - software-tools +enabled: true +enabledFileTypes: + "*": true +dot: true +useGitignore: true +cache: + useCache: true + cacheStrategy: content +ignorePaths: + - .cspell.yml + - .cspellcache + - .devcontainer + - .dockerignore + - .git + - .gitignore + - .trivyignore + - .vscode + - package-lock.json + - pnpm-lock.yaml + - uv.lock + - backend/app/api/auth/resources/disposable_email_domains.txt + - frontend-app/src/assets/data/** + - frontend-app/src/types/api.generated.ts + +language: en_us +words: + # Proper nouns + - Donati + - Lierde + - RELab + + # Font names + - grotesk + + # Technical terms + - linkinator + - LogQL + - mpegurl + - nosniff + - OTLP + - shellcheck + - subrepo + - subrepos + - trivyignores + - trixie + - tsvector + + # Miscellaneous + - orcid + - refurbishers + - remanufacturability + - remanufacturable + - repairability + - zenodo + +ignoreWords: + - USEPOLLING + +overrides: + - filename: "{backend,docs}/**" + words: + # Model terms + - categorymateriallink + - categoryproducttypelink + - circularityproperties + - fileparenttype + - imageparenttype + - materialproductlink + - newslettersubscriber + - organizationrole + - oauthaccount + - physicalproperties + - producttype + - subcomponent + - subcomponents + - supercategory + - taxonomydomain + + - filename: "backend/**" + words: + # Technical terms + - instrumentor + - instrumentors + - piexif + - PYTHONDONTWRITEBYTECODE + - PYTHONUNBUFFERED + - primaryjoin + - selectinload + - tsquery + - uninstrument + - xdist + - zxcvbn + + - filename: "docs/**" + words: + - RTMP + - HIBP + + - filename: "frontend-app/**" + words: + # Technical terms + - ellipsize + - pressable + - pressables + - refetches + - worklets diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json index b6cd62af..065dceb0 100644 --- a/.devcontainer/backend/devcontainer.json +++ b/.devcontainer/backend/devcontainer.json @@ -1,37 +1,26 @@ { "name": "relab-backend", - "dockerComposeFile": ["../../compose.yml", "../../compose.override.yml"], - "service": "backend", - "runServices": ["backend"], + "dockerComposeFile": ["../../compose.yml", "../../compose.dev.yml"], + "service": "api", + "runServices": ["api", "redis", "postgres"], "workspaceFolder": "/opt/relab/backend", - // The local workspace is mounted in /opt/relab for git integration "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "overrideCommand": true, - "postCreateCommand": "", - "postAttachCommand": "echo 'šŸš€ Backend dev container ready!\\nšŸ’” To start the FastAPI dev server, run: fastapi dev\\nšŸ”„ If that fails, try: uv run fastapi dev\\n🌐 The server will be available at http://localhost:8011 (forwarded port)'", + "postAttachCommand": "echo 'RELab backend devcontainer ready.\\nUse this for focused backend work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nAPI: http://localhost:8011'", "features": { "ghcr.io/devcontainers/features/git:1": {} }, "customizations": { "vscode": { - "extensions": ["charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"], "settings": { - "[python][notebook]": { - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "charliermarsh.ruff" + "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python", + "ty.configuration": { + "environment": { + "root": ["${containerWorkspaceFolder}"], + "python": "${containerWorkspaceFolder}/.venv/" + } }, - "editor.formatOnSave": true, - "python-envs.terminal.showActivateButton": true, - "python.analysis.autoFormatStrings": true, - "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, - "python.terminal.activateEnvInCurrentTerminal": true, - "python.terminal.activateEnvironment": true, - "python.testing.pytestEnabled": true + "ty.interpreter": ["${containerWorkspaceFolder}/.venv/bin/python"] } } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 162e29f9..bc985440 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,8 @@ { "name": "relab-fullstack", - "dockerComposeFile": ["../compose.yml", "../compose.override.yml"], - "service": "frontend-web", + "dockerComposeFile": ["../compose.yml", "../compose.dev.yml"], + "service": "app-site", + "runServices": ["api", "redis", "postgres", "docs-site", "web-site", "app-site"], "workspaceFolder": "/opt/relab", "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "features": { @@ -9,66 +10,5 @@ "ghcr.io/devcontainers-extra/features/expo-cli:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {} }, - "postAttachCommand": "echo 'šŸš€ Fullstack dev container ready!\\nšŸ’” Frontend: http://localhost:8010\\nšŸ’” Backend: http://localhost:8011\\nšŸ’” Docs: http://localhost:8012 (all forwarded ports)'", - "customizations": { - "vscode": { - "extensions": [ - // Frontend - "msjsdiag.vscode-react-native", - "christian-kohler.npm-intellisense", - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "expo.vscode-expo-tools", - // Backend - "charliermarsh.ruff", - "ms-python.python", - "wholroyd.jinja", - // Docs - "bierner.markdown-mermaid", - "bierner.markdown-preview-github-styles", - "DavidAnson.vscode-markdownlint", - "shd101wyy.markdown-preview-enhanced", - "yzhang.markdown-all-in-one" - ], - "settings": { - // Frontend - "[javascript][typescript][javascriptreact][typescriptreact]": { - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "always", - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "eslint.format.enable": true, - "eslint.lintTask.enable": true, - "eslint.run": "onSave", - // Backend - "[python][notebook]": { - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "charliermarsh.ruff" - }, - "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, - "python.terminal.activateEnvInCurrentTerminal": true, - "python.terminal.activateEnvironment": true, - "python.testing.pytestEnabled": true, - // Docs - "[markdown]": { - "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "editor.formatOnSave": true, - "github.copilot.enable": { - "markdown": true - }, - "markdown.extension.completion.enabled": true, - "markdown.extension.orderedList.marker": "one", - "markdown.extension.tableFormatter.normalizeIndentation": true, - "markdown.extension.theming.decoration.renderTrailingSpace": true - } - } - } + "postAttachCommand": "echo 'RELab full-stack devcontainer ready.\\nPrimary workflow: run just install once, then use just dev / just ci from the repo root.\\nPlatform: http://localhost:8010\\nAPI: http://localhost:8011\\nDocs: http://localhost:8012\\nApp: http://localhost:8013'" } diff --git a/.devcontainer/docs/devcontainer.json b/.devcontainer/docs/devcontainer.json index 876dab6b..518e33b7 100644 --- a/.devcontainer/docs/devcontainer.json +++ b/.devcontainer/docs/devcontainer.json @@ -1,44 +1,13 @@ { "name": "relab-docs", - "dockerComposeFile": ["../../compose.yml", "../../compose.override.yml"], - "service": "docs", - "runServices": ["docs"], + "dockerComposeFile": ["../../compose.yml", "../../compose.dev.yml"], + "service": "docs-site", + "runServices": ["docs-site"], "workspaceFolder": "/opt/relab/docs", - /* - NOTE: The non-trivial mount setup to allow live reload and git: - - [Devcontainer: /opt/relab/docs] ⇄ [Local ./docs] ⇄ [Devcontainer: /docs] - - - Edit in git-integrated /opt/relab/docs (devcontainer) → updates local ./docs - - MkDocs in the devcontainer live reloads /docs (synced from local ./docs) - */ "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "postAttachCommand": "echo 'RELab docs devcontainer ready.\\nUse this for focused docs work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nDocs: http://localhost:8012'", "features": { "ghcr.io/cirolosapio/devcontainers-features/alpine-bash:0": {}, "ghcr.io/devcontainers/features/git:1": {} - }, - "customizations": { - "vscode": { - "extensions": [ - "bierner.markdown-mermaid", - "bierner.markdown-preview-github-styles", - "DavidAnson.vscode-markdownlint", - "shd101wyy.markdown-preview-enhanced", - "yzhang.markdown-all-in-one" - ] - }, - "settings": { - "[markdown]": { - "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "editor.formatOnSave": true, - "github.copilot.enable": { - "markdown": true - }, - "markdown.extension.completion.enabled": true, - "markdown.extension.orderedList.marker": "one", - "markdown.extension.tableFormatter.normalizeIndentation": true, - "markdown.extension.theming.decoration.renderTrailingSpace": true - } } } diff --git a/.devcontainer/frontend-app/devcontainer.json b/.devcontainer/frontend-app/devcontainer.json new file mode 100644 index 00000000..c9d937a7 --- /dev/null +++ b/.devcontainer/frontend-app/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "relab-frontend-app", + "dockerComposeFile": ["../../compose.yml", "../../compose.dev.yml"], + "service": "app-site", + "runServices": ["app-site"], + "workspaceFolder": "/opt/relab/frontend-app", + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "overrideCommand": true, + "postAttachCommand": "echo 'RELab app devcontainer ready.\\nUse this for focused app work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nApp: http://localhost:8013'", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers-extra/features/expo-cli:1": {} + } +} diff --git a/.devcontainer/frontend-web/devcontainer.json b/.devcontainer/frontend-web/devcontainer.json new file mode 100644 index 00000000..d0c6f720 --- /dev/null +++ b/.devcontainer/frontend-web/devcontainer.json @@ -0,0 +1,13 @@ +{ + "name": "relab-frontend-web", + "dockerComposeFile": ["../../compose.yml", "../../compose.dev.yml"], + "service": "web-site", + "runServices": ["web-site"], + "workspaceFolder": "/opt/relab/frontend-web", + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "overrideCommand": true, + "postAttachCommand": "echo 'RELab web devcontainer ready.\\nUse this for focused web work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nWeb: http://localhost:8010'", + "features": { + "ghcr.io/devcontainers/features/git:1": {} + } +} diff --git a/.devcontainer/frontend/devcontainer.json b/.devcontainer/frontend/devcontainer.json deleted file mode 100644 index 5006f9e9..00000000 --- a/.devcontainer/frontend/devcontainer.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "relab-frontend-web", - "dockerComposeFile": ["../../compose.yml", "../../compose.override.yml"], - "service": "frontend-web", - "runServices": ["frontend-web"], - "workspaceFolder": "/opt/relab/frontend-web", - // The local workspace is mounted in /opt/relab for git integration - "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], - "overrideCommand": true, - "postAttachCommand": "echo 'šŸš€ Frontend dev container ready!\\nšŸ’” To start the Expo dev server, run: npx expo start --web\\n🌐 The server will be available at http://localhost:8010 (forwarded port)'", - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers-extra/features/expo-cli:1": {} - }, - "customizations": { - "vscode": { - "extensions": [ - "msjsdiag.vscode-react-native", - "christian-kohler.npm-intellisense", - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "expo.vscode-expo-tools" - ], - "settings": { - "[javascript][typescript][javascriptreact][typescriptreact]": { - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "always", - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[json][jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "editor.formatOnSave": true, - "eslint.format.enable": true, - "eslint.lintTask.enable": true, - "eslint.run": "onSave" - } - } - } -} diff --git a/.env.example b/.env.example index b371bd67..c4f9f3ac 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,49 @@ -# Example of root .env file +# Root .env — committed template for host-local secrets. +# Copy to `.env` on each deploy host (prod, staging, dev laptop) and fill in +# values. This file is auto-loaded by `docker compose` for `${VAR}` +# interpolation. +# +# Non-secret per-environment config (APP_ENV, URLs, worker counts, …) lives +# in the committed `.env.prod.compose` / `.env.staging.compose` files at repo +# root — do not duplicate them here. The justfile `prod_compose` / +# `staging_compose` recipes pick the right one per environment. -# Enable docker compose bake COMPOSE_BAKE=true -# Cloudflare Tunnel Token -TUNNEL_TOKEN=your_token +# --- Cloudflare tunnel ----------------------------------------------- +TUNNEL_TOKEN=your_token # šŸ”€ prod or staging token depending on this host -# Host directory where database and user upload backups are stored +# --- Central monitoring stack (optional) ---------------------------- +# All three vars below are OPTIONAL. Setting an endpoint enables the +# corresponding exporter; leaving it unset disables it. There is no separate +# on/off flag — the endpoint IS the switch. +# +# Auth pattern (when the central ingress is public-but-protected, e.g. behind a +# Cloudflare WAF rule requiring Authorization: Basic …): +# printf '%s:%s' relab "$SECRET" | base64 # → +# Loki driver has no custom-header config → auth goes in the URL. +# OTEL SDK reads OTEL_EXPORTER_OTLP_HEADERS → pass it there (URL-encode the space). +# +# One-time, per host, before enabling Loki shipping: +# docker plugin install grafana/loki-docker-driver:latest \ +# --alias loki --grant-all-permissions + +# Logs → Loki push endpoint (auth embedded in URL) +# LOKI_URL=https://relab:@logs.cml-relab.org/loki/api/v1/push + +# Traces/metrics → OTLP HTTP collector (+ matching auth header) +# OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.cml-relab.org +# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20 + +# --- Backup destinations --------------------------------------------- BACKUP_DIR=./backups -# Remote backup config (for use of backend/scripts/backup/backup_rclone.sh script) -BACKUP_REMOTE_HOST=user@host -BACKUP_REMOTE_PATH=/path/to/remote/backup +# Remote rsync backup config (for use of backend/scripts/backup/rsync_backup.sh script) +BACKUP_RSYNC_REMOTE_HOST=user@host # šŸ”€ +BACKUP_RSYNC_REMOTE_PATH=/path/to/remote/backup # šŸ”€ + +# Remote rclone backup config (for use of backend/scripts/backup/rclone_backup.sh script) +BACKUP_RCLONE_REMOTE=myremote:/path/to/remote/backup # šŸ”€ +BACKUP_RCLONE_MULTI_THREAD_STREAMS=16 +BACKUP_RCLONE_TIMEOUT=5m +BACKUP_RCLONE_USE_COOKIES=false diff --git a/.env.prod.compose b/.env.prod.compose new file mode 100644 index 00000000..56c1e124 --- /dev/null +++ b/.env.prod.compose @@ -0,0 +1,11 @@ +# .env.prod.compose — committed, non-secret Compose interpolation vars for prod. +# Loaded via `docker compose --env-file .env --env-file .env.prod.compose ...` +# (see justfile `prod_compose`). Secrets (TUNNEL_TOKEN, etc.) live in the +# gitignored root `.env` on each deploy host. + +APP_ENV=prod +COMPOSE_PROJECT_NAME=relab_prod +WEB_CONCURRENCY=4 +BUILD_MODE=prod +PUBLIC_SITE_URL=https://docs.cml-relab.org +CSP_API_ORIGIN=https://api.cml-relab.org diff --git a/.env.staging.compose b/.env.staging.compose new file mode 100644 index 00000000..63ac47d6 --- /dev/null +++ b/.env.staging.compose @@ -0,0 +1,11 @@ +# .env.staging.compose — committed, non-secret Compose interpolation vars for staging. +# Loaded via `docker compose --env-file .env --env-file .env.staging.compose ...` +# (see justfile `staging_compose`). Secrets (TUNNEL_TOKEN, etc.) live in the +# gitignored root `.env` on each deploy host. + +APP_ENV=staging +COMPOSE_PROJECT_NAME=relab_staging +WEB_CONCURRENCY=2 +BUILD_MODE=staging +PUBLIC_SITE_URL=https://docs-test.cml-relab.org +CSP_API_ORIGIN=https://api-test.cml-relab.org diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 00000000..2be9c43c --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.2.0" +} diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..c738973f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,63 @@ +name: Bug report +description: Report a bug +title: "bug: " +labels: [bug] +body: + - type: dropdown + id: area + attributes: + label: Area + options: + - backend + - frontend-web + - frontend-app + - docs + - infrastructure + - other + validations: + required: true + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug and its impact. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: How to reproduce + description: Minimal steps that trigger the bug. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: input + id: version + attributes: + label: Version or commit + description: Git SHA, release tag, or build number. + + - type: input + id: environment + attributes: + label: Environment + description: OS, browser, device, or runtime version (only if relevant). + + - type: textarea + id: context + attributes: + label: Additional context + description: Logs, screenshots, failing CI run links, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 0d8647c3..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: 'bug: ' -labels: bug -assignees: '' ---- - -## Bug description - -_A clear and concise description of the bug_ - -## Environment - -_For example: **Desktop**_ - -- _OS: [e.g. iOS]_ -- _Browser [e.g. chrome, safari]_ -- _Version [e.g. 22]_ - -## To Reproduce - -_Steps to reproduce the behavior, e.g.:_ - -1. _Go to '...'_ -1. _Click on '....'_ -1. _Scroll down to '....'_ -1. _See error_ - -## Expected behavior - -_A clear and concise description of what you expected to happen._ - -## Additional context - -_Optional: Add screenshots or any other context about the problem here._ diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4870b249 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Contribution guide + url: https://github.com/CMLPlatform/relab/blob/main/CONTRIBUTING.md + about: Read this first if you need repo conventions, setup help, or contribution guidance. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index eea6c5bf..00000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: 'feature request: ' -labels: feature request -assignees: '' ---- - -## Problem statement - -_A clear and concise description of the problem._ - -## Proposed solution - -_A clear and concise description of what you want to happen._ - -## Implementation ideas - -_Optional: Share any thoughts on how this might be implemented._ - -## Additional context - -_Optional: Add any other context or screenshots about the feature request here._ diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 00000000..075516cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,46 @@ +name: Feature request +description: Propose an improvement +title: "feat: " +labels: [feature request] +body: + - type: dropdown + id: area + attributes: + label: Area + options: + - backend + - frontend-web + - frontend-app + - docs + - infrastructure + - cross-cutting + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What are users trying to do, and what's getting in the way? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: The smallest change that would solve it. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches and why they were set aside. + + - type: textarea + id: context + attributes: + label: Additional context + description: Links, screenshots, prior discussion, or related issues. diff --git a/.github/ISSUE_TEMPLATE/internal-ticket.md b/.github/ISSUE_TEMPLATE/internal-ticket.md deleted file mode 100644 index e6873341..00000000 --- a/.github/ISSUE_TEMPLATE/internal-ticket.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Internal ticket -about: For internal development -title: '' -labels: '' -assignees: '' ---- - -## Problem - -## Proposed Solution - -## Acceptance Criteria - -- \[ \] diff --git a/.github/ISSUE_TEMPLATE/internal-ticket.yml b/.github/ISSUE_TEMPLATE/internal-ticket.yml new file mode 100644 index 00000000..98c7853f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/internal-ticket.yml @@ -0,0 +1,43 @@ +name: Internal ticket +description: Internal task for the team +title: "" +labels: [] +body: + - type: dropdown + id: area + attributes: + label: Area + options: + - backend + - frontend-web + - frontend-app + - docs + - infrastructure + - cross-cutting + validations: + required: true + + - type: textarea + id: goal + attributes: + label: Goal + description: What are we trying to change or ship? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: Links, constraints, dependencies, or prior discussion. + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: Concrete, verifiable outcomes. + value: | + - [ ] + - [ ] + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 308b5568..9a3be3ea 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,10 @@ # Pull Request -## Description +## Summary -Please provide a brief description of the changes in this pull request. +_What does this PR change, in one or two sentences?_ -## Type of Change +## Type of change - [ ] šŸš€ feat: New feature - [ ] šŸ› fix: Bug fix @@ -15,28 +15,23 @@ Please provide a brief description of the changes in this pull request. - [ ] ā™»ļø refactor: Code refactoring (no functional changes) - [ ] šŸŽØ style: Code style/formatting changes - [ ] āœ… test: Adding or updating tests +- [ ] šŸ”§ chore: Other maintenance work + +## Why + +_What problem does this solve or why is it worth merging?_ ## Checklist - [ ] I've read the [contributing guidelines](../CONTRIBUTING.md) -- [ ] Code follows style guidelines and passes quality checks (ruff, pyright) -- [ ] Unit tests added/updated and passing locally +- [ ] Code follows style guidelines and passes quality checks (`just ci`) +- [ ] Unit tests added/updated and passing locally (`just test`) - [ ] Documentation updated (if applicable) - [ ] Database migrations created (if applicable) -## Related Issues - -- Closes #[issue-number] -- Related to #[issue-number] - -## Additional Context - -Add any relevant context about the pull request here, such as: +## Notes for reviewers -- Implementation details or approach -- Challenges encountered and how they were addressed -- Alternative solutions that were considered -- Screenshots or GIFs demonstrating visual changes (if applicable) +_Add rollout notes, tradeoffs, follow-up work, or links to related issues._ +# RELab: Reverse Engineering Lab [![Version](https://img.shields.io/github/v/release/CMLPlatform/relab?include_prereleases&filter=v*)](CHANGELOG.md) [![License: AGPL-v3+](https://img.shields.io/badge/License-AGPL--v3+-rebeccapurple.svg)](LICENSE.md) [![Data License: ODbL](https://img.shields.io/badge/Data_License-ODbL-rebeccapurple.svg)](https://opendatacommons.org/licenses/odbl/) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.16637742.svg)](https://doi.org/10.5281/zenodo.16637742) +[![Coverage](https://img.shields.io/codecov/c/github/CMLPlatform/relab)](https://codecov.io/gh/CMLPlatform/relab) +[![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22322&r=123) +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) +[![Deployed](https://img.shields.io/website?url=https%3A%2F%2Fcml-relab.org&label=website)](https://cml-relab.org) - +RELab is an open-source research platform for collecting and publicly viewing data on the disassembly of durable goods. It is built at [CML, Leiden University](https://www.universiteitleiden.nl/en/science/environmental-sciences) to support industrial ecology and circular economy research through better primary product data generation. - +It combines: -[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) -[![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22322&r=123) +- a FastAPI backend for structured product, media, and user data +- an Expo / React Native app for authenticated data collection +- an Astro site for publicly viewing project and dataset information +- a separate docs site for architecture, workflows, and deployment notes - +The platform is meant to do two things at once: -[![Deployed](https://img.shields.io/website?url=https%3A%2F%2Fcml-relab.org&label=website)](https://cml-relab.org) +- support structured data collection during disassembly work +- make that data easier to publish, browse, and reuse later + +The broader research vision comes from a simple problem: industrial ecology has many data platforms, but far fewer open, low-barrier workflows for generating new standardized product-level observations. + +RELab addresses that gap with a bottom-up model: + +- middle- and end-of-life actors such as repairers, refurbishers, dismantlers, and recyclers can contribute data directly +- collaborative and citizen-science style workflows can turn routine repair and disassembly into structured observations +- the resulting records can be shared openly, linked to related databases, and reused in later research + +The long-term goal is to contribute to an open industrial ecology data commons by combining collaborative data collection, public data access, interoperability with existing and upcoming databases, and AI-ready structured observations. + +## Start Here + +The fastest path is the hosted platform: + +[app.cml-relab.org](https://app.cml-relab.org) + +If you want to go deeper: + +- [INSTALL.md](INSTALL.md) for running or self-hosting the stack +- [CONTRIBUTING.md](CONTRIBUTING.md) for making code or docs changes +- [docs.cml-relab.org](https://docs.cml-relab.org) for architecture and user-facing docs + +## Monorepo + +| Path | Purpose | +| --------------- | ----------------------------------------------------- | +| `backend/` | FastAPI API, auth, data model, file handling, plugins | +| `frontend-app/` | Expo / React Native research app | +| `frontend-web/` | Astro public website | +| `docs/` | Documentation site | + +Infrastructure is orchestrated with Docker Compose from the repo root. + +## Common Commands + +```bash +just setup # install workspace dependencies and pre-commit hooks +just ci # run the canonical local CI pipeline +just test # run local test suites +just security # run dependency and security checks +just dev # start the full Docker dev stack with file watching +``` + +## Project Links -A data collection platform for disassembled durable goods to support circular economy research and computer vision applications, developed by the Institute of Environmental Sciences (CML) at Leiden University. +- [Live Platform](https://app.cml-relab.org) +- [Documentation](https://docs.cml-relab.org) +- [API Docs](https://api.cml-relab.org/docs) +- [Roadmap](https://docs.cml-relab.org/project/roadmap) -## Platform Documentation +## Community and Policy -- šŸš€ **[Get Started](https://cml-relab.org)** - Access the live platform -- šŸ“– **[Full Documentation](https://docs.cml-relab.org)** - Complete guides and architecture -- šŸ” **[API Documentation](https://api.cml-relab.org/docs)** - Interactive API reference -- šŸ¤ **[Contributing Guidelines](CONTRIBUTING.md)** - How to contribute -- šŸ“‹ **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community standards -- šŸ“ **[Changelog](CHANGELOG.md)** - Version history -- šŸ“‘ **[Citation Guidelines](CITATION.cff)** - How to attribute this work -- āš–ļø **[License Information](LICENSE)** - The software code is licensed under [AGPL-v3+](https://spdx.org/licenses/AGPL-3.0-or-later.html), the data is licensed under [ODbL](https://opendatacommons.org/licenses/odbl/). +- [Contributing](CONTRIBUTING.md) +- [Installation](INSTALL.md) +- [Security](SECURITY.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Changelog](CHANGELOG.md) +- [Citation](CITATION.cff) +- [License](LICENSE) ## Contact -For questions about the platform, code or dataset, please contact [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl). +Questions about the platform, code, or dataset: [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8a3d7de3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Reporting a Vulnerability + +Do not open a public GitHub issue for security vulnerabilities. + +Instead, email [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) with: + +- a clear description of the issue and its potential impact +- steps to reproduce it, or a proof of concept +- any mitigations or patches you have already identified + +## What to Expect + +- We aim to acknowledge reports within 5 business days. +- We aim to validate and triage confirmed issues as quickly as possible. +- For confirmed vulnerabilities, we will coordinate a fix and responsible disclosure timeline with the reporter where practical. + +Please include enough detail for us to reproduce the problem. That saves time for everyone. diff --git a/backend/.dockerignore b/backend/.dockerignore index 4c510a33..0d46b080 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,98 +1,46 @@ -# Python bytecode files and caches -**/__pycache__ -**/*.py[cod] -**/*$py.class - -# Distribution / packaging -.Python -build -develop-eggs -dist -downloads -eggs -.eggs -lib -lib64 -parts -sdist -var -wheels -share/python-wheels -*.egg-info -.installed.cfg -*.egg -MANIFEST - -# Unit test / coverage reports -**/htmlcov -**/.tox -**/.nox -**/.coverage -**/.coverage.* -**/.cache -**/nosetests.xml -**/coverage.xml -*.cover -*.py,cover -**/.hypothesis -**/.pytest_cache -**/cover - -# Jupyter Notebook checkpoints -**/.ipynb_checkpoints - -# IPython config -**/profile_default -**/ipython_config.py - -# Environment folders and files -**/env -**/.env -**/.env.* -**/venv -**/.venv -# Keep the .env files in top-level directories -!.env -!*/.env - -# Ruff cache -**/.ruff_cache - -# macOS system files -**/.DS_Store -**/.AppleDouble -**/.LSOverride -**/._* -**/.DocumentRevisions-V100 -**/.fseventsd -**/.Spotlight-V100 -**/.TemporaryItems -**/.Trashes -**/.VolumeIcon.icns -**/.com.apple.timemachine.donotpresent -**/.AppleDB -**/.AppleDesktop -**/Network Trash Folder -**/Temporary Items -**/.apdisk +# Virtual environment (created locally by uv) +.venv/ + +# Python bytecode +__pycache__/ +*.pyc +*.pyo + +# Local runtime artifacts +# Keep committed seed/reference payloads available to Docker builds, but ignore +# generated uploads and other local runtime data. +data/* +!data/seed/ +!data/seed/** +logs/ +reports/ + +# Test code +tests/ + +# Dev tooling +.vscode/ +.ruff_cache/ +justfile +README.md +local_setup.* + +# Secrets +.env +.env.* + +# Docker and git +Dockerfile +Dockerfile.* +.dockerignore +.git +.gitignore + +# macOS +.DS_Store # Linux system files **/.fuse_hidden* **/.directory **/.Trash-* **/.nfs* - -# VS Code settings -**/.vscode -**/*.code-workspace -**/.history - -# Debugging and local development files -./playground.ipynb -.local_setup.* - -# Locally uploaded user data -./data - -# Local logs -./logs diff --git a/backend/.env.dev.example b/backend/.env.dev.example new file mode 100644 index 00000000..8f9d9912 --- /dev/null +++ b/backend/.env.dev.example @@ -0,0 +1,51 @@ +# Development environment variables. +# Copy this file to .env.dev and fill in the values marked with šŸ”€. +# This file is loaded automatically when ENVIRONMENT=dev (the default). + +# Database settings +DATABASE_HOST='localhost' # Overridden by Compose in Docker +DATABASE_SSL='false' # Disable TLS for the internal Docker Postgres service +POSTGRES_USER='postgres' # šŸ”€ Username that has access to the database +POSTGRES_PASSWORD='password' # šŸ”€ +POSTGRES_DB='relab_db' # šŸ”€ Name of the database + +## Authentication settings +FASTAPI_USERS_SECRET='secret-key' # šŸ”€ Secret key for authentication token generation. Generate a new one using `uv run python -c "import secrets; print(secrets.token_urlsafe(32))"` +NEWSLETTER_SECRET='secret-key' # Secret key for confirming and unsubscribing newsletter subscribers. Generate a new one using `openssl rand -hex 32` + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='google-oauth-client-id' # šŸ”€ Client ID for Google OAuth +GOOGLE_OAUTH_CLIENT_SECRET='google-oauth-client-secret' # šŸ”€ Client secret for Google OAuth +GITHUB_OAUTH_CLIENT_ID='github-oauth-client-id' # šŸ”€ Client ID for GitHub OAuth +GITHUB_OAUTH_CLIENT_SECRET='github-oauth-client-secret' # šŸ”€ Client secret for GitHub OAuth + +# Settings used to configure the email server for sending emails from the app. +EMAIL_HOST='smtp.example.com' # šŸ”€ +EMAIL_USERNAME='your.email@example.com' # šŸ”€ Username for the SMTP server +EMAIL_PASSWORD='your-email-password' # šŸ”€ Password for the SMTP server +EMAIL_FROM='Your Name ' # Optional. Defaults to EMAIL_USERNAME when omitted. +EMAIL_REPLY_TO='your.replyto.alias.@example.com' # Optional. Defaults to EMAIL_USERNAME when omitted. + +# Redis settings for caching (disposable email domains, sessions, etc.) +REDIS_HOST='localhost' # Overridden by Compose in Docker +REDIS_PASSWORD='' # Redis password (leave empty for local dev) + +# Superuser details +SUPERUSER_EMAIL='your-email@example.com' # šŸ”€ +SUPERUSER_PASSWORD='example_password' # šŸ”€ +SUPERUSER_NAME='your_name' # Optional. Can only contain lowercase letters, numbers, and underscores. + +# Network settings (overridden by compose.dev.yml in Docker) +BACKEND_API_URL='http://127.0.0.1:8001' +FRONTEND_APP_URL='http://127.0.0.1:8003' +FRONTEND_WEB_URL='http://127.0.0.1:8000' + +# Allow CORS from any LAN IP (dev only; blocked in production). +# Default covers localhost, 127.0.0.1, and the 192.168.x.x subnet. +# If your local network uses a different range (e.g. 10.0.x.x), update this regex. +CORS_ORIGIN_REGEX=r'https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+)(:\d+)?' + +# Optional OpenTelemetry tracing + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='secret-key' # šŸ”€ Fernet key for encrypting the RPi camera plugin API keys. Generate a new one using `uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index ab1001c9..00000000 --- a/backend/.env.example +++ /dev/null @@ -1,41 +0,0 @@ -# Note: Environment variables requiring input are marked with šŸ”€ - -## Main settings -DEBUG='True' # Set to 'True' to enable debug mode (which enables echoing of SQL queries) - -# Database settings -DATABASE_HOST='localhost' # In docker contexts, this is overridden to 'postgres' -DATABASE_PORT='5432' # Default port for PostgreSQL -POSTGRES_USER='postgres' # šŸ”€ Username that has access to the database -POSTGRES_PASSWORD='password' # šŸ”€ -POSTGRES_DB='relab_db' # šŸ”€ Name of the database -POSTGRES_TEST_DB='relab_test_db' # šŸ”€ Name of the test database - -## Authentication settings -FASTAPI_USERS_SECRET='secret-key' # šŸ”€ Secret key for authentication token generation. Generate a new one using `python -c "import secrets; print(secrets.token_urlsafe(32))"` -NEWSLETTER_SECRET='secret-key' # Secret key for confirming and unsubscribing newsletter subscribers. Generate a new one using `openssl rand -hex 32` - -# OAuth settings -GOOGLE_OAUTH_CLIENT_ID='google-oauth-client-id' # šŸ”€ Client ID for Google OAuth -GOOGLE_OAUTH_CLIENT_SECRET='google-oauth-client-secret' # šŸ”€ Client secret for Google OAuth -GITHUB_OAUTH_CLIENT_ID='github-oauth-client-id' # šŸ”€ Client ID for GitHub OAuth -GITHUB_OAUTH_CLIENT_SECRET='github-oauth-client-secret' # šŸ”€ Client secret for GitHub OAuth - -# Settings used to configure the email server for sending emails from the app. -EMAIL_HOST='smtp.example.com' # šŸ”€ -EMAIL_USERNAME='your.email@example.com' # šŸ”€ Username for the SMTP server -EMAIL_PASSWORD='your-email-password' # šŸ”€ Password for the SMTP server -EMAIL_FROM='Your Name ' # šŸ”€ Email address from which the emails are sent. Can be different from the SMTP server username. -EMAIL_REPLY_TO='your.replyto.alias.@example.com' # šŸ”€ Email address to which replies are sent. Can be different from the SMTP server username. - -# Superuser details -SUPERUSER_EMAIL='your-email@example.com' # šŸ”€ -SUPERUSER_PASSWORD='example_password' # šŸ”€ - -# Network settings -FRONTEND_WEB_URL='http://127.0.0.1:8000' # URL of the homepage frontend. Used for cookie management and reference to main website. -FRONTEND_APP_URL='http://127.0.0.1:8004' # URL of the application frontend. Used for generating links in emails. -ALLOWED_ORIGINS='["http://127.0.0.1:8000", "http://127.0.0.1:8010/"]' # List of allowed origins for CORS. - -## Plugin settings -RPI_CAM_PLUGIN_SECRET='secret-key' # šŸ”€ Fernet key for encrypting the RPi camera plugin API keys. Generate a new one using `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` diff --git a/backend/.env.prod.example b/backend/.env.prod.example new file mode 100644 index 00000000..ac0da882 --- /dev/null +++ b/backend/.env.prod.example @@ -0,0 +1,51 @@ +# Production environment variables. +# Copy this file to .env.prod and fill in the values marked with šŸ”€. +# This file is loaded automatically when ENVIRONMENT=prod. + +# Database settings +DATABASE_HOST='localhost' # Overridden by Compose in Docker +DATABASE_SSL='false' # Disable TLS for the internal Docker Postgres service +POSTGRES_USER='postgres' # šŸ”€ +POSTGRES_PASSWORD='' # šŸ”€ Use a strong generated password +POSTGRES_DB='relab_db' # šŸ”€ + +## Authentication settings +FASTAPI_USERS_SECRET='' # šŸ”€ Generate: uv run python -c "import secrets; print(secrets.token_urlsafe(32))" +NEWSLETTER_SECRET='' # šŸ”€ Generate: openssl rand -hex 32 + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='' # šŸ”€ +GOOGLE_OAUTH_CLIENT_SECRET='' # šŸ”€ +GITHUB_OAUTH_CLIENT_ID='' # šŸ”€ +GITHUB_OAUTH_CLIENT_SECRET='' # šŸ”€ + +# Email settings +EMAIL_HOST='' # šŸ”€ +EMAIL_USERNAME='' # šŸ”€ +EMAIL_PASSWORD='' # šŸ”€ +EMAIL_FROM='' # šŸ”€ +EMAIL_REPLY_TO='' # šŸ”€ + +# Redis settings +REDIS_HOST='localhost' # Overridden by Compose in Docker +REDIS_PASSWORD='' # šŸ”€ + +# Superuser details (only used on first deploy to create the account) +SUPERUSER_EMAIL='' # šŸ”€ +SUPERUSER_PASSWORD='' # šŸ”€ +SUPERUSER_NAME='' # Optional. Can only contain lowercase letters, numbers, and underscores. + +# Network settings +BACKEND_API_URL='https://api.cml-relab.org' +FRONTEND_APP_URL='https://app.cml-relab.org' +FRONTEND_WEB_URL='https://cml-relab.org' + +# Observability (OpenTelemetry tracing) — OFF by default. +# Set OTEL_EXPORTER_OTLP_ENDPOINT in the host's root .env to enable. +# Empty/unset endpoint = OTEL disabled. + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='' # šŸ”€ Generate: uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +# Resource tuning (worker processes, DB pool, image threads) +# → configured in compose.prod.yml under the "Resource knobs" section diff --git a/backend/.env.staging.example b/backend/.env.staging.example new file mode 100644 index 00000000..ef5c0242 --- /dev/null +++ b/backend/.env.staging.example @@ -0,0 +1,51 @@ +# Staging environment variables. +# Copy this file to .env.staging and fill in the values marked with šŸ”€. +# This file is loaded automatically when ENVIRONMENT=staging. + +# Database settings +DATABASE_HOST='localhost' # Overridden by Compose in Docker +DATABASE_SSL='false' # Disable TLS for the internal Docker Postgres service +POSTGRES_USER='postgres' # šŸ”€ +POSTGRES_PASSWORD='' # šŸ”€ Use a strong generated password +POSTGRES_DB='relab_db' # šŸ”€ + +## Authentication settings +FASTAPI_USERS_SECRET='' # šŸ”€ Generate: uv run python -c "import secrets; print(secrets.token_urlsafe(32))" +NEWSLETTER_SECRET='' # šŸ”€ Generate: openssl rand -hex 32 + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='' # šŸ”€ +GOOGLE_OAUTH_CLIENT_SECRET='' # šŸ”€ +GITHUB_OAUTH_CLIENT_ID='' # šŸ”€ +GITHUB_OAUTH_CLIENT_SECRET='' # šŸ”€ + +# Email settings +EMAIL_HOST='' # šŸ”€ +EMAIL_USERNAME='' # šŸ”€ +EMAIL_PASSWORD='' # šŸ”€ +EMAIL_FROM='' # šŸ”€ +EMAIL_REPLY_TO='' # šŸ”€ + +# Redis settings +REDIS_HOST='localhost' # Overridden by Compose in Docker +REDIS_PASSWORD='' # šŸ”€ + +# Superuser details +SUPERUSER_EMAIL='' # šŸ”€ +SUPERUSER_PASSWORD='' # šŸ”€ +SUPERUSER_NAME='' # Optional. Can only contain lowercase letters, numbers, and underscores. + +# Network settings +BACKEND_API_URL='https://api-test.cml-relab.org' +FRONTEND_APP_URL='https://app-test.cml-relab.org' +FRONTEND_WEB_URL='https://web-test.cml-relab.org' + +# Observability (OpenTelemetry tracing) — OFF by default. +# Set OTEL_EXPORTER_OTLP_ENDPOINT in the host's root .env to enable. +# Empty/unset endpoint = OTEL disabled. + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='' # šŸ”€ Generate: uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +# Resource tuning (worker processes, DB pool, image threads) +# → configured in compose.staging.yml under the "Resource knobs" section diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..36429eef --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,48 @@ +# Test-only environment variables; safe to commit. +# Used exclusively by compose.e2e.yml and the full-stack E2E test suite. +# Do NOT use these values in any non-test environment. + +# Database +DATABASE_HOST=localhost +DATABASE_SSL=false +POSTGRES_USER=postgres +POSTGRES_PASSWORD=test_pg_password +POSTGRES_DB=relab_e2e_db + +# Auth +FASTAPI_USERS_SECRET=test-jwt-secret-do-not-use-in-production +NEWSLETTER_SECRET=test-newsletter-secret-do-not-use-in-production + +# OAuth (placeholder values; OAuth flows are not tested in E2E) +GOOGLE_OAUTH_CLIENT_ID=dummy-google-client-id +GOOGLE_OAUTH_CLIENT_SECRET=dummy-google-client-secret +GITHUB_OAUTH_CLIENT_ID=dummy-github-client-id +GITHUB_OAUTH_CLIENT_SECRET=dummy-github-client-secret + +# Email (no real SMTP needed; emails are not sent in E2E) +EMAIL_HOST=localhost +EMAIL_USERNAME=e2e@example.com +EMAIL_PASSWORD=test-email-password +EMAIL_FROM=E2E Tests +EMAIL_REPLY_TO=e2e@example.com + +# Redis (no password for test simplicity) +REDIS_HOST=localhost +REDIS_PASSWORD= + +# Known test superuser; used by create_superuser.py and Playwright tests +SUPERUSER_EMAIL=e2e-admin@example.com +SUPERUSER_NAME=e2e_admin +SUPERUSER_PASSWORD=E2eTestPass123! + +# Network (overridden by compose.e2e.yml via environment: block) +BACKEND_API_URL=http://localhost:8000 +FRONTEND_APP_URL=http://localhost:8081 +FRONTEND_WEB_URL=http://localhost:8010 + +# Observability +OTEL_EXPORTER_OTLP_ENDPOINT= + +# RPI cam plugin; must be a valid 32-byte URL-safe base64 Fernet key. +# This key is test-only and provides no security guarantee. +RPI_CAM_PLUGIN_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= diff --git a/backend/.gitignore b/backend/.gitignore index 38f0b266..13b23816 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,57 +6,18 @@ __pycache__/ *.py[cod] *$py.class -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# Virtual environment (uv) +.venv + +# Ruff linter +.ruff_cache/ -# Unit test / coverage reports +# Test / coverage artifacts htmlcov/ -.tox/ -.nox/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ -cover/ - -# Jupyter Notebook -.ipynb_checkpoints -*/.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# Environments -.env -.venv -env/ -venv/ - -# Ruff linter -.ruff_cache/ +.hypothesis/ ### Manual additions # Debugging @@ -65,9 +26,6 @@ playground.ipynb # User-uploaded data data/uploads/* -# Seed files (will be downloaded locally if needed) -data/seed/* - # Cache files data/cache/* @@ -80,3 +38,22 @@ backups/* # VS Code settings !.vscode/settings.json !.vscode/extensions.json + +# Include built email templates +!app/templates/emails/build/ + +# Test coverage reports +reports/coverage/* +!reports/coverage/badge.svg + +# Performance artifacts (k6 summaries + dated baselines) +reports/performance/*.json +reports/performance/*.md +!reports/performance/README.md + +# Ignore all .env files except for the example files and .env.test (which is used in CI) +.env +.env.* + +!.env.test +!.env.*.example diff --git a/backend/.vscode/extensions.json b/backend/.vscode/extensions.json deleted file mode 100644 index 5ab2c28d..00000000 --- a/backend/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"] -} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json deleted file mode 100644 index 57b9152a..00000000 --- a/backend/.vscode/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "[python][notebook]": { - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "charliermarsh.ruff" - }, - "python-envs.terminal.showActivateButton": true, - "python.analysis.autoFormatStrings": true, - "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, - "python.terminal.activateEnvInCurrentTerminal": true, - "python.terminal.activateEnvironment": true, - "python.testing.pytestEnabled": true -} diff --git a/backend/Dockerfile b/backend/Dockerfile index 1ccdb8aa..8960ceab 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,64 +1,89 @@ -# --- Builder stage --- -FROM ghcr.io/astral-sh/uv:0.11-python3.13-trixie-slim@sha256:58f82df75c88dd53d3ddc0f50ea5cb18086724bc9b60852310cead94e41e46f9 AS builder +# syntax=docker/dockerfile:1 -# Install git for custom dependencies (fastapi-users-db-sqlmodel) -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean +# Multi-target Dockerfile for the Backend +# - `dev` → hot-reload dev image (all groups synced) +# - `runtime` → slim prod image (default) -# Set the working directory inside the container -WORKDIR /opt/relab/backend +ARG WORKDIR=/opt/relab/backend + +# --- Build stage (prod deps only) --- +FROM ghcr.io/astral-sh/uv:0.11-python3.14-trixie-slim@sha256:37ec7fe8c82064a87c1c3d57e8ef5ff108b64bc34b17f64a4c00094b64928330 AS build -# Create needed directories for logs and uploads -RUN mkdir -p logs data/uploads/files data/uploads/images +ARG WORKDIR +WORKDIR $WORKDIR -# uv optimizations (see https://docs.astral.sh/uv/guides/integration/docker/#optimizations) ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=0 -# Copy dependency files COPY .python-version pyproject.toml uv.lock ./ -# Install dependencies (see https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers) RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-install-project --no-editable --no-default-groups --group=api + uv sync --locked --no-install-project --no-editable --no-default-groups -# Copy application directory COPY app/ app/ -# Final sync with project code (see https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers) RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-editable --no-default-groups --group=api + uv sync --locked --no-editable --no-default-groups -# --- Final runtime stage --- -FROM python:3.14-slim@sha256:5e59aae31ff0e87511226be8e2b94d78c58f05216efda3b07dbbed938ec8583b +# --- Dev stage (all groups, hot reload) --- +FROM ghcr.io/astral-sh/uv:0.11-python3.14-trixie-slim@sha256:37ec7fe8c82064a87c1c3d57e8ef5ff108b64bc34b17f64a4c00094b64928330 AS dev -# Build arguments -ARG WORKDIR=/opt/relab/backend -ARG APP_PORT=8000 -ARG APP_USER=appuser +ARG WORKDIR +WORKDIR $WORKDIR + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +COPY .python-version pyproject.toml uv.lock ./ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-default-groups --group=dev --no-install-project --no-editable -# Set up a non-root user -RUN useradd -m $APP_USER +COPY . . -# Copy built app and environment from builder -COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR +RUN mkdir -p logs data/uploads/files data/uploads/images \ + && chmod -R 0775 data/uploads + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=$WORKDIR \ + PYTHONUNBUFFERED=1 \ + PATH="$WORKDIR/.venv/bin:$PATH" + +EXPOSE 8000 + +CMD [".venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"] + +# --- Runtime stage (default) --- +FROM python:3.14-slim-trixie@sha256:bc389f7dfcb21413e72a28f491985326994795e34d2b86c8ae2f417b4e7818aa AS runtime + +ARG WORKDIR +ARG APP_USER=appuser +ARG APP_UID=1001 + +RUN useradd --create-home --uid "$APP_UID" --shell /usr/sbin/nologin "$APP_USER" WORKDIR $WORKDIR -# Set Python variables -ENV PYTHONPATH=$WORKDIR \ +RUN install -d -o "$APP_UID" -g 0 -m 0775 \ + data/uploads \ + data/uploads/files \ + data/uploads/images + +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/.venv $WORKDIR/.venv +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/app $WORKDIR/app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=$WORKDIR \ PYTHONUNBUFFERED=1 \ PATH="$WORKDIR/.venv/bin:$PATH" -# Expose the application port EXPOSE 8000 -# Switch to non-root user USER $APP_USER -# Run the FastAPI application -CMD [".venv/bin/fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/live',timeout=5).status==200 else 1)" + +CMD ["sh", "-c", ".venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='${FORWARDED_ALLOW_IPS:-127.0.0.1}' --workers ${WEB_CONCURRENCY:-1}"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev deleted file mode 100644 index ac5824d0..00000000 --- a/backend/Dockerfile.dev +++ /dev/null @@ -1,41 +0,0 @@ -# Development Dockerfile for FastAPI Backend -# Note: This requires mounting the source code as a volume in docker-compose.override.yml -FROM ghcr.io/astral-sh/uv:0.11-python3.13-trixie-slim@sha256:58f82df75c88dd53d3ddc0f50ea5cb18086724bc9b60852310cead94e41e46f9 - -# Build arguments -ARG WORKDIR=/opt/relab/backend - -# Install git for custom dependencies (fastapi-users-db-sqlmodel) -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean - -# Set the working directory inside the container -WORKDIR $WORKDIR - -# Create needed directories for logs and uploads -RUN mkdir -p logs data/uploads/files data/uploads/images - -# uv optimizations (see https://docs.astral.sh/uv/guides/integration/docker/#optimizations) -ENV UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy \ - UV_PYTHON_DOWNLOADS=0 - -# Copy dependency files -COPY .python-version pyproject.toml uv.lock ./ - -# Install dependencies (see https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-install-project --no-editable - -# Set Python variables -ENV PYTHONPATH=$WORKDIR \ - PYTHONUNBUFFERED=1 \ - PATH="$WORKDIR/.venv/bin:$PATH" - -EXPOSE 8000 - -# Run the FastAPI application in development mode -CMD [".venv/bin/fastapi", "dev", "app/main.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/Dockerfile.migrations b/backend/Dockerfile.migrations index 17375fce..3eebb2cd 100644 --- a/backend/Dockerfile.migrations +++ b/backend/Dockerfile.migrations @@ -1,59 +1,65 @@ -# --- Builder stage --- -FROM ghcr.io/astral-sh/uv:0.11-python3.13-trixie-slim@sha256:58f82df75c88dd53d3ddc0f50ea5cb18086724bc9b60852310cead94e41e46f9 AS builder +# syntax=docker/dockerfile:1 -WORKDIR /opt/relab/backend_migrations +# Production Dockerfile for Backend Migrations -# Install git for custom dependencies (fastapi-users-db-sqlmodel) -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean +# --- Build stage --- +FROM ghcr.io/astral-sh/uv:0.11-python3.14-trixie-slim@sha256:37ec7fe8c82064a87c1c3d57e8ef5ff108b64bc34b17f64a4c00094b64928330 AS build +WORKDIR /opt/relab/backend-migrations # Create needed directories data uploads for seeding example images and files -RUN mkdir -p data/uploads/files data/uploads/images +RUN install -d -m 0775 data/uploads/files data/uploads/images # uv optimizations (see https://docs.astral.sh/uv/guides/integration/docker/#optimizations) ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=0 +ARG INCLUDE_TAXONOMY_SEED_DEPS=false + # Copy dependency files COPY .python-version pyproject.toml uv.lock ./ # Install dependencies # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-default-groups --group=migrations --no-install-project + if [ "$INCLUDE_TAXONOMY_SEED_DEPS" = "true" ]; then \ + uv sync --locked --no-default-groups --group=migrations --group=seed-taxonomies --no-install-project --no-editable; \ + else \ + uv sync --locked --no-default-groups --group=migrations --no-install-project --no-editable; \ + fi # Copy alembic migrations, scripts, and source code -COPY alembic.ini ./ COPY alembic/ alembic/ COPY scripts/ scripts/ COPY app/ app/ +COPY data/seed/ data/seed/ -# --- Final runtime stage --- -FROM python:3.14-slim@sha256:5e59aae31ff0e87511226be8e2b94d78c58f05216efda3b07dbbed938ec8583b +# --- Runtime stage --- +FROM python:3.14-slim-trixie@sha256:bc389f7dfcb21413e72a28f491985326994795e34d2b86c8ae2f417b4e7818aa -# Build arguments -ARG WORKDIR=/opt/relab/backend_migrations +ARG WORKDIR=/opt/relab/backend-migrations ARG APP_USER=appuser +ARG APP_UID=1001 -# Set up a non-root user -RUN useradd $APP_USER - -# Copy built app and environment from builder -COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR +RUN useradd --create-home --uid "$APP_UID" --shell /usr/sbin/nologin "$APP_USER" WORKDIR $WORKDIR -# Set Python variables +RUN install -d -o "$APP_UID" -g 0 -m 0775 data/uploads/files data/uploads/images + +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/.venv $WORKDIR/.venv +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/alembic $WORKDIR/alembic +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/app $WORKDIR/app +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/data/seed $WORKDIR/data/seed +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/pyproject.toml $WORKDIR/pyproject.toml +COPY --link --from=build --chown=$APP_UID:$APP_UID $WORKDIR/scripts $WORKDIR/scripts + +# spell-checker: ignore PYTHONUNBUFFERED ENV PYTHONPATH=$WORKDIR \ + PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="$WORKDIR/.venv/bin:$PATH" -# Switch to non-root user USER $APP_USER -# Run the entrypoint ENTRYPOINT ["./scripts/seed/migrations_entrypoint.sh"] diff --git a/backend/Dockerfile.user-upload-backups b/backend/Dockerfile.user-upload-backups new file mode 100644 index 00000000..cce9f219 --- /dev/null +++ b/backend/Dockerfile.user-upload-backups @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +# Production Dockerfile for User Upload Backups +FROM alpine:3.22@sha256:55ae5d250caebc548793f321534bc6a8ef1d116f334f18f4ada1b2daad3251b2 + +# Build arguments +ARG WORKDIR=/opt/relab/backend_backups +ARG BACKUP_SCRIPT_NAME=backup_user_uploads.sh + +# Install the GNU userland tools required by the backup script. +RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ + apk add --no-cache \ + coreutils \ + findutils \ + su-exec \ + tar \ + zstd + +RUN adduser -D -u 1001 backupuser + +WORKDIR $WORKDIR + +# Set BACKUP_SCRIPT variable for entrypoint script +ENV BACKUP_SCRIPT=$WORKDIR/$BACKUP_SCRIPT_NAME + +COPY --chmod=755 scripts/backup/$BACKUP_SCRIPT_NAME . +COPY --chmod=755 scripts/backup/user_upload_backups_entrypoint.sh . + +# Container starts as root so the entrypoint can chown the bind-mounted backup dir; +# the backup script itself is run as backupuser via su-exec. +ENTRYPOINT ["./user_upload_backups_entrypoint.sh"] diff --git a/backend/Dockerfile.user_upload_backups b/backend/Dockerfile.user_upload_backups deleted file mode 100644 index 4767ce8a..00000000 --- a/backend/Dockerfile.user_upload_backups +++ /dev/null @@ -1,26 +0,0 @@ -FROM alpine:latest@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 - -# Build arguments -ARG WORKDIR=/opt/relab/backend_backups -ARG BACKUP_SCRIPT_NAME=backup_user_uploads.sh - -# Install GNU tar and zstd for faster compression -RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ - apk -U upgrade && apk add --no-interactive\ - tar \ - zstd - -WORKDIR $WORKDIR - -# Set BACKUP_SCRIPT variable for entrypoint script -ENV BACKUP_SCRIPT=$WORKDIR/$BACKUP_SCRIPT_NAME - -# Copy backup script -COPY scripts/backup/$BACKUP_SCRIPT_NAME . -RUN chmod +x ./$BACKUP_SCRIPT_NAME - -# Copy entrypoint script -COPY scripts/backup/user_upload_backups_entrypoint.sh . -RUN chmod +x ./user_upload_backups_entrypoint.sh - -ENTRYPOINT ["./user_upload_backups_entrypoint.sh"] diff --git a/backend/README.md b/backend/README.md index a5a44601..2376db02 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,10 +1,65 @@ -# ReLab Backend +# RELab Backend -The backend of the ReLab project is built using [FastAPI](https://fastapi.tiangolo.com/) and [PostgreSQL](https://www.postgresql.org/), providing a RESTful API and database management for the platform. +The backend provides the API, authentication flows, product and component data model, media handling, newsletter endpoints, and plugin integrations. It is built with [FastAPI](https://fastapi.tiangolo.com/), PostgreSQL, Redis, and `uv`. -For backend-specific contribution and workflow details, see: +## Quick Start -- [CONTRIBUTING.md: Backend Setup](../CONTRIBUTING.md#backend-setup) -- [CONTRIBUTING.md: Backend Development](../CONTRIBUTING.md#backend-development) +```bash +just install +cp .env.dev.example .env.dev +./scripts/local_setup.sh +just dev +``` -Please refer to the [main README](../README.md) for overall project information and setup instructions. +The API is then available at . + +- Public API docs: +- Full API docs: after authenticating as a superuser + +## Common Commands + +```bash +just check # lint + typecheck +just test # run all tests +just test-unit # fast unit tests +just test-cov # tests with coverage +just refresh-disposable-email-domains # update the committed disposable-email fallback list +just perf-baseline # run the k6 baseline suite and export a JSON summary (output under reports/performance/ is gitignored) +just migrate # apply migrations +just fix # lint autofix + format +``` + +The disposable-email validator now seeds itself from the committed runtime fallback file in [app/api/auth/resources/disposable_email_domains.txt](app/api/auth/resources/disposable_email_domains.txt), so startup works offline. Remote updates are still optional and happen via the background refresh path or the maintenance command above. + +Committed migration/bootstrap payloads live under [data/seed/](data/seed/). The migrations image includes that directory, while generated uploads stay excluded from Docker build contexts. + +Taxonomy imports are intentionally opt-in for the migrations image. If you want `SEED_CPV_*` or `SEED_HS_CATEGORIES`, rebuild `backend/Dockerfile.migrations` with `BACKEND_MIGRATIONS_INCLUDE_TAXONOMY_SEED_DEPS=true` so the optional `seed-taxonomies` dependency group is available. + +The main [`backend/Dockerfile`](Dockerfile) is multi-target: the default `runtime` stage builds the slim production image, and `--target dev` produces the hot-reload dev image used by `compose.dev.yml`. + +## Current Backend Shape + +The backend is intentionally moving toward explicit, domain-owned seams instead of broad internal registries. + +- Routers should stay thin orchestration layers. +- Domain read paths should prefer small local `select(...).where(...)` helpers over generic query-builder indirection. +- The shared CRUD/query kernel is intentionally small: keep `require_model`, `require_models`, `page_models`, `exists`, and persistence helpers. Older convenience helpers such as `QueryOptions`, `build_query`, and `list_models` are retired. +- Recursive endpoints such as `/products/tree` and `/categories/tree` remain supported public APIs, but they should use bounded tree loaders plus pure serialization, never lazy ORM traversal during response assembly. + +Two examples of the preferred shape: + +- `app/api/data_collection/crud/products.py` is now the stable product-domain entrypoint, with tree reads in `product_tree_queries.py` and mutations in `product_commands.py`. +- `app/api/file_storage/crud/` is split by concern; avoid reintroducing a broad `file_storage.crud` compatibility surface. + +## RPi Camera Contract Boundary + +The Raspberry Pi camera integration has two intentional contract layers: + +- **Public/frontend contract**: backend routes and OpenAPI remain the only app-facing API surface +- **Private device seam**: `relab-rpi-cam-models` owns the backend\<->plugin transport DTOs for pairing, relay envelopes, local-access bootstrap, and direct upload acknowledgements + +Frontend code should keep consuming backend-generated OpenAPI types rather than importing private device-seam DTOs directly. + +## More + +For Docker setup, local development, migration workflow, and testing conventions, see [CONTRIBUTING.md](../CONTRIBUTING.md#backend-development). diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 42395499..00000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,122 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -# Use forward slashes (/) also on windows to provide an os agnostic path -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = %(sqlalchemy.url)s - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -hooks = ruff, ruff_format - -# Lint with attempts to fix using "ruff" -ruff.type = exec -ruff.executable = %(here)s/.venv/bin/ruff -ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Format using "ruff" - use the exec runner, execute a binary -ruff_format.type = exec -ruff_format.executable = %(here)s/.venv/bin/ruff -ruff_format.options = format REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 397a112d..3f2ec1d0 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,55 +1,49 @@ -# noqa: D100, INP001 (the alembic folder should not be recognized as a module) +# noqa: D100 (the alembic folder should not be recognized as a module) +import contextlib +import logging import sys -from logging.config import fileConfig from pathlib import Path -import alembic_postgresql_enum # noqa: F401 (Make sure the PostgreSQL ENUM type is recognized) +with contextlib.suppress(ModuleNotFoundError): + import alembic_postgresql_enum # Registers Alembic plugin for enum migrations; installed via `migrations` extra + from alembic import context from sqlalchemy import engine_from_config, pool -from sqlmodel import SQLModel # Include the SQLModel metadata +from sqlalchemy.engine.url import make_url + +from app.api.common.models.base import Base +from app.core.config import settings +from app.core.logging import setup_logging +from app.core.model_registry import load_models # Load settings from the FastAPI app config project_root = Path(__file__).resolve().parents[1] sys.path.append(str(project_root)) -from app.core.config import settings # noqa: E402, I001 # Allow the settings to be imported after the project root is added to the path - # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# Set the database URL dynamically from the loaded settings -config.set_main_option("sqlalchemy.url", settings.sync_database_url) - -# Import your models to include their metadata -from app.api.auth.models import OAuthAccount, Organization, User # noqa: E402, F401 -from app.api.background_data.models import ( # noqa: E402, F401 - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, -) -from app.api.data_collection.models import ( # noqa: E402, F401 - PhysicalProperties, - Product, -) -from app.api.file_storage.models.models import File, Image, Video # noqa: E402, F401 -from app.api.newsletter.models import NewsletterSubscriber # noqa: E402, F401 -from app.api.plugins.rpi_cam.models import Camera # noqa: E402, F401 +# Set the synchronous database URL if not already set in the test environment +if config.get_alembic_option("is_test") != "true": # noqa: PLR2004 # This variable is set in tests/conftest.py to indicate a test environment + setup_logging() + config.set_main_option("sqlalchemy.url", settings.sync_database_url) +else: + # In tests, logging is already configured in conftest.py. + # We just need to ensure the alembic.env logger exists. + pass + +logger = logging.getLogger("alembic.env") + +# Import all models so Base.metadata is complete for autogenerate +load_models() # Combine metadata from all imported models -target_metadata = SQLModel.metadata +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: -# my_important_option = config.get_main_option("my_important_option") +# my_important_option = config.get_main_option("my_important_option") # noqa: ERA001 # ... etc. @@ -65,7 +59,10 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = config.get_main_option("sqlalchemy.url", "") + + logger.info("Running migrations offline on database: %s", make_url(url).render_as_string(hide_password=True)) + context.configure( url=url, target_metadata=target_metadata, @@ -84,11 +81,12 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + url = config.get_main_option("sqlalchemy.url", "") + engine_config = config.get_section(config.config_ini_section, {"sqlalchemy.url": url}) + + connectable = engine_from_config(engine_config, prefix="sqlalchemy.", poolclass=pool.NullPool) + + logger.info("Running migrations online on database: %s", make_url(url).render_as_string(hide_password=True)) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index c8331289..fbc4b07d 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -9,8 +9,6 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa -import sqlmodel -import app.api.common.models.custom_types ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/backend/alembic/versions/07d992454431_add_fks.py b/backend/alembic/versions/07d992454431_add_fks.py index b5bdf76a..49134b04 100644 --- a/backend/alembic/versions/07d992454431_add_fks.py +++ b/backend/alembic/versions/07d992454431_add_fks.py @@ -10,9 +10,6 @@ from typing import Union import sqlalchemy as sa -import sqlmodel - -import app.api.common.models.custom_types from alembic import op # revision identifiers, used by Alembic. diff --git a/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py b/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py new file mode 100644 index 00000000..cc29acb3 --- /dev/null +++ b/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py @@ -0,0 +1,52 @@ +"""Move from weight_kg to weight_g + +Revision ID: 0faa2fa19f62 +Revises: b43d157d07f1 +Create Date: 2025-11-17 14:52:08.201228 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0faa2fa19f62" +down_revision: str | None = "b43d157d07f1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("physicalproperties", sa.Column("weight_g", sa.Float(), nullable=True)) + + # Migrate data: convert kg to g (multiply by 1000) + op.execute(""" + UPDATE physicalproperties + SET weight_g = weight_kg * 1000 + WHERE weight_kg IS NOT NULL + """) + + op.drop_column("physicalproperties", "weight_kg") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "physicalproperties", + sa.Column("weight_kg", sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True), + ) + + # Migrate data back: convert g to kg (divide by 1000) + op.execute(""" + UPDATE physicalproperties + SET weight_kg = weight_g / 1000 + WHERE weight_g IS NOT NULL + """) + + op.drop_column("physicalproperties", "weight_g") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/33b00b31e537_initial.py b/backend/alembic/versions/33b00b31e537_initial.py index e17d28ce..684ddee2 100644 --- a/backend/alembic/versions/33b00b31e537_initial.py +++ b/backend/alembic/versions/33b00b31e537_initial.py @@ -5,16 +5,16 @@ Create Date: 2025-06-29 18:10:44.514384 """ +# spell-checker: ignore astext from collections.abc import Sequence from typing import Union import sqlalchemy as sa -import sqlmodel +from alembic import op from sqlalchemy.dialects import postgresql -import app.api.common.models.custom_types -from alembic import op +import app.api.file_storage.models.storage as file_storage_storage # revision identifiers, used by Alembic. revision: str = "33b00b31e537" @@ -34,9 +34,9 @@ def upgrade() -> None: "material", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("description", sa.String(500), nullable=True), + sa.Column("source", sa.String(50), nullable=True), sa.Column("density_kg_m3", sa.Float(), nullable=True), sa.Column("is_crm", sa.Boolean(), nullable=True), sa.Column("id", sa.Integer(), nullable=False), @@ -47,7 +47,7 @@ def upgrade() -> None: "newslettersubscriber", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sa.String(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("is_confirmed", sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint("id"), @@ -57,9 +57,9 @@ def upgrade() -> None: "organization", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("location", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("location", sa.String(50), nullable=True), + sa.Column("description", sa.String(500), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("owner_id", sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(["owner_id"], ["user.id"], name="fk_organization_owner", use_alter=True), @@ -70,8 +70,8 @@ def upgrade() -> None: "producttype", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("description", sa.String(500), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), ) @@ -80,8 +80,8 @@ def upgrade() -> None: "taxonomy", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("description", sa.String(500), nullable=True), sa.Column( "domains", postgresql.ARRAY( @@ -89,7 +89,7 @@ def upgrade() -> None: ), nullable=True, ), - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column("source", sa.String(50), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), ) @@ -97,14 +97,14 @@ def upgrade() -> None: op.create_table( "user", sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False), sa.Column("is_superuser", sa.Boolean(), nullable=False), sa.Column("is_verified", sa.Boolean(), nullable=False), sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("username", sa.String(), nullable=True), sa.Column("organization_id", sa.Uuid(), nullable=True), sa.Column( "organization_role", @@ -120,12 +120,12 @@ def upgrade() -> None: "camera", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("description", sa.String(500), nullable=True), + sa.Column("url", sa.String(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("encrypted_api_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("encrypted_auth_headers", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("encrypted_api_key", sa.String(), nullable=False), + sa.Column("encrypted_auth_headers", sa.String(), nullable=True), sa.Column("owner_id", sa.Uuid(), nullable=False), sa.ForeignKeyConstraint( ["owner_id"], @@ -138,9 +138,9 @@ def upgrade() -> None: "category", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=250), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("external_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("name", sa.String(250), nullable=False), + sa.Column("description", sa.String(500), nullable=True), + sa.Column("external_id", sa.String(), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.Column("supercategory_id", sa.Integer(), nullable=True), sa.Column("taxonomy_id", sa.Integer(), nullable=False), @@ -161,12 +161,12 @@ def upgrade() -> None: sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("user_id", sa.Uuid(), nullable=False), - sa.Column("oauth_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("access_token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("oauth_name", sa.String(), nullable=False), + sa.Column("access_token", sa.String(), nullable=False), sa.Column("expires_at", sa.Integer(), nullable=True), - sa.Column("refresh_token", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("account_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("account_email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("refresh_token", sa.String(), nullable=True), + sa.Column("account_id", sa.String(), nullable=False), + sa.Column("account_email", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["user_id"], ["user.id"], @@ -179,11 +179,11 @@ def upgrade() -> None: "product", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("model", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("dismantling_notes", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("description", sa.String(500), nullable=True), + sa.Column("brand", sa.String(100), nullable=True), + sa.Column("model", sa.String(100), nullable=True), + sa.Column("dismantling_notes", sa.String(500), nullable=True), sa.Column("dismantling_time_start", sa.TIMESTAMP(timezone=True), nullable=False), sa.Column("dismantling_time_end", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column("id", sa.Integer(), nullable=False), @@ -238,10 +238,10 @@ def upgrade() -> None: "file", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("description", sa.String(500), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("file", app.api.file_storage.models.custom_types.FileType(), nullable=False), + sa.Column("filename", sa.String(), nullable=False), + sa.Column("file", file_storage_storage.FileType(), nullable=False), sa.Column( "parent_type", postgresql.ENUM("PRODUCT", "PRODUCT_TYPE", "MATERIAL", name="fileparenttype", create_type=False), @@ -268,11 +268,11 @@ def upgrade() -> None: "image", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("description", sa.String(500), nullable=True), sa.Column("image_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("file", app.api.file_storage.models.custom_types.ImageType(), nullable=False), + sa.Column("filename", sa.String(), nullable=False), + sa.Column("file", file_storage_storage.ImageType(), nullable=False), sa.Column( "parent_type", postgresql.ENUM("PRODUCT", "PRODUCT_TYPE", "MATERIAL", name="imageparenttype", create_type=False), @@ -337,9 +337,9 @@ def upgrade() -> None: "video", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("url", sa.String(), nullable=False), + sa.Column("title", sa.String(100), nullable=True), + sa.Column("description", sa.String(500), nullable=True), sa.Column("video_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.Column("product_id", sa.Integer(), nullable=False), diff --git a/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py b/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py new file mode 100644 index 00000000..41d4e684 --- /dev/null +++ b/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py @@ -0,0 +1,32 @@ +"""Add last_login tracking fields to user model + +Revision ID: 4c248b3004c6 +Revises: 84d2f72dccc7 +Create Date: 2026-02-17 16:41:13.956150 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4c248b3004c6" +down_revision: str | None = "84d2f72dccc7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("user", sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True)) + op.add_column("user", sa.Column("last_login_ip", sa.String(45), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "last_login_ip") + op.drop_column("user", "last_login_at") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/4d379aaa416a_add_user_stats_cache.py b/backend/alembic/versions/4d379aaa416a_add_user_stats_cache.py new file mode 100644 index 00000000..d556b715 --- /dev/null +++ b/backend/alembic/versions/4d379aaa416a_add_user_stats_cache.py @@ -0,0 +1,36 @@ +"""add_user_stats_cache + +Revision ID: 4d379aaa416a +Revises: 6891389e6660 +Create Date: 2026-04-14 12:01:46.778801 + +""" + +# spell-checker: ignore astext + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "4d379aaa416a" +down_revision: str | None = "6891389e6660" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user", sa.Column("stats_cache", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "stats_cache") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py b/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py index a5cb94a2..e6ddb61e 100644 --- a/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py +++ b/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py @@ -10,9 +10,6 @@ from typing import Union import sqlalchemy as sa -import sqlmodel - -import app.api.common.models.custom_types from alembic import op # revision identifiers, used by Alembic. @@ -28,63 +25,63 @@ def upgrade() -> None: "camera", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "material", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "material", "source", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=True, ) op.alter_column( "organization", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "organization", "location", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=True, ) op.alter_column( "product", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "producttype", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "taxonomy", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.String(100), existing_nullable=False, ) op.alter_column( "taxonomy", "source", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=500), + type_=sa.String(500), existing_nullable=True, ) # ### end Alembic commands ### @@ -95,63 +92,63 @@ def downgrade() -> None: op.alter_column( "taxonomy", "source", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=500), + existing_type=sa.String(500), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "taxonomy", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "producttype", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "product", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "organization", "location", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "organization", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "material", "source", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "material", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "camera", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sa.String(100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) diff --git a/backend/alembic/versions/6891389e6660_remove_relay_last_seen_at_from_camera.py b/backend/alembic/versions/6891389e6660_remove_relay_last_seen_at_from_camera.py new file mode 100644 index 00000000..dc546a6e --- /dev/null +++ b/backend/alembic/versions/6891389e6660_remove_relay_last_seen_at_from_camera.py @@ -0,0 +1,35 @@ +"""Remove relay_last_seen_at from Camera + +Revision ID: 6891389e6660 +Revises: d9ffb53c12c0 +Create Date: 2026-04-13 18:26:16.817503 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "6891389e6660" +down_revision: str | None = "d9ffb53c12c0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("camera", "relay_last_seen_at") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "camera", + sa.Column("relay_last_seen_at", postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + ) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/6f2b9e4a1c3d_add_recording_session_backstop.py b/backend/alembic/versions/6f2b9e4a1c3d_add_recording_session_backstop.py new file mode 100644 index 00000000..9deabdde --- /dev/null +++ b/backend/alembic/versions/6f2b9e4a1c3d_add_recording_session_backstop.py @@ -0,0 +1,41 @@ +"""add recording session backstop + +Revision ID: 6f2b9e4a1c3d +Revises: 4d379aaa416a +Create Date: 2026-04-15 13:10:00.000000 + +""" +# spell-checker: ignore astext, ondelete + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "6f2b9e4a1c3d" +down_revision: str | None = "4d379aaa416a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "recording_session", + sa.Column("camera_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("stream_url", sa.String(), nullable=False), + sa.Column("broadcast_key", sa.String(), nullable=False), + sa.Column("video_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.ForeignKeyConstraint(["camera_id"], ["camera.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("camera_id"), + ) + + +def downgrade() -> None: + op.drop_table("recording_session") diff --git a/backend/alembic/versions/70e78d937e0b_add_websocket_connection_to_rpi_cams.py b/backend/alembic/versions/70e78d937e0b_add_websocket_connection_to_rpi_cams.py new file mode 100644 index 00000000..4d8b1190 --- /dev/null +++ b/backend/alembic/versions/70e78d937e0b_add_websocket_connection_to_rpi_cams.py @@ -0,0 +1,44 @@ +"""Add websocket connection to RPI-cams + +Revision ID: 70e78d937e0b +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-07 15:14:18.076123 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "70e78d937e0b" +down_revision: str | None = "a1b2c3d4e5f6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum("HTTP", "WEBSOCKET", name="connectionmode").create(op.get_bind()) + op.add_column( + "camera", + sa.Column( + "connection_mode", + postgresql.ENUM("HTTP", "WEBSOCKET", name="connectionmode", create_type=False), + server_default="HTTP", + nullable=False, + ), + ) + op.alter_column("camera", "url", existing_type=sa.VARCHAR(), nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("camera", "url", existing_type=sa.VARCHAR(), nullable=False) + op.drop_column("camera", "connection_mode") + sa.Enum("HTTP", "WEBSOCKET", name="connectionmode").drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py b/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py new file mode 100644 index 00000000..16d22255 --- /dev/null +++ b/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py @@ -0,0 +1,47 @@ +"""Simplify Circularity_properties model + +Revision ID: 84d2f72dccc7 +Revises: 0faa2fa19f62 +Create Date: 2025-11-27 12:01:32.413795 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "84d2f72dccc7" +down_revision: str | None = "0faa2fa19f62" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "circularityproperties", "recyclability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + op.alter_column( + "circularityproperties", "repairability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + op.alter_column( + "circularityproperties", "remanufacturability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "circularityproperties", "remanufacturability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + op.alter_column( + "circularityproperties", "repairability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + op.alter_column( + "circularityproperties", "recyclability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py b/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py index 56d25512..cb42bc68 100644 --- a/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py +++ b/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py @@ -10,9 +10,6 @@ from typing import Union import sqlalchemy as sa -import sqlmodel - -import app.api.common.models.custom_types from alembic import op # revision identifiers, used by Alembic. @@ -24,7 +21,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column("taxonomy", sa.Column("version", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True)) + op.add_column("taxonomy", sa.Column("version", sa.String(50), nullable=True)) # ### end Alembic commands ### diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_tsvector_search_to_material_producttype_category.py b/backend/alembic/versions/a1b2c3d4e5f6_add_tsvector_search_to_material_producttype_category.py new file mode 100644 index 00000000..7605bbc2 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_tsvector_search_to_material_producttype_category.py @@ -0,0 +1,84 @@ +"""Add tsvector full-text search and trigram indexes to material, producttype, and category tables + +Revision ID: a1b2c3d4e5f6 +Revises: f3a8c2d1e5b7 +Create Date: 2026-03-30 00:00:00.000000 + +""" + +# spell-checker: ignore trgm + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: str | None = "f3a8c2d1e5b7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # pg_trgm was already enabled by the product migration; guard with IF NOT EXISTS. + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + # ── material ────────────────────────────────────────────────────────────── + op.execute(""" + ALTER TABLE material + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(name, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce(source, '') + ) + ) STORED + """) + op.execute("CREATE INDEX material_search_vector_idx ON material USING GIN (search_vector)") + op.execute("CREATE INDEX material_name_trgm_idx ON material USING GIN (name gin_trgm_ops)") + + # ── producttype ─────────────────────────────────────────────────────────── + op.execute(""" + ALTER TABLE producttype + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(name, '') || ' ' || + coalesce(description, '') + ) + ) STORED + """) + op.execute("CREATE INDEX producttype_search_vector_idx ON producttype USING GIN (search_vector)") + op.execute("CREATE INDEX producttype_name_trgm_idx ON producttype USING GIN (name gin_trgm_ops)") + + # ── category ────────────────────────────────────────────────────────────── + op.execute(""" + ALTER TABLE category + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(name, '') || ' ' || + coalesce(description, '') + ) + ) STORED + """) + op.execute("CREATE INDEX category_search_vector_idx ON category USING GIN (search_vector)") + op.execute("CREATE INDEX category_name_trgm_idx ON category USING GIN (name gin_trgm_ops)") + + +def downgrade() -> None: + # category + op.execute("DROP INDEX IF EXISTS category_name_trgm_idx") + op.execute("DROP INDEX IF EXISTS category_search_vector_idx") + op.execute("ALTER TABLE category DROP COLUMN IF EXISTS search_vector") + + # producttype + op.execute("DROP INDEX IF EXISTS producttype_name_trgm_idx") + op.execute("DROP INDEX IF EXISTS producttype_search_vector_idx") + op.execute("ALTER TABLE producttype DROP COLUMN IF EXISTS search_vector") + + # material + op.execute("DROP INDEX IF EXISTS material_name_trgm_idx") + op.execute("DROP INDEX IF EXISTS material_search_vector_idx") + op.execute("ALTER TABLE material DROP COLUMN IF EXISTS search_vector") diff --git a/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py b/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py new file mode 100644 index 00000000..1bbd25e5 --- /dev/null +++ b/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py @@ -0,0 +1,51 @@ +"""Add basic circularity_properties model + +Revision ID: b43d157d07f1 +Revises: 95cc94317b69 +Create Date: 2025-11-17 13:30:07.435637 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b43d157d07f1" +down_revision: str | None = "95cc94317b69" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "circularityproperties", + sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("recyclability_observation", sa.String(500), nullable=False), + sa.Column("recyclability_comment", sa.String(100), nullable=True), + sa.Column("recyclability_reference", sa.String(100), nullable=True), + sa.Column("repairability_observation", sa.String(500), nullable=False), + sa.Column("repairability_comment", sa.String(100), nullable=True), + sa.Column("repairability_reference", sa.String(100), nullable=True), + sa.Column("remanufacturability_observation", sa.String(500), nullable=False), + sa.Column("remanufacturability_comment", sa.String(100), nullable=True), + sa.Column("remanufacturability_reference", sa.String(100), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["product_id"], + ["product.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("circularityproperties") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b7c1d2e3f4a5_inline_properties_onto_product.py b/backend/alembic/versions/b7c1d2e3f4a5_inline_properties_onto_product.py new file mode 100644 index 00000000..00244467 --- /dev/null +++ b/backend/alembic/versions/b7c1d2e3f4a5_inline_properties_onto_product.py @@ -0,0 +1,146 @@ +"""Inline physical and circularity properties onto product table + +Revision ID: b7c1d2e3f4a5 +Revises: e3d54054d34a +Create Date: 2026-04-08 12:00:00.000000 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b7c1d2e3f4a5" +down_revision: str | None = "e3d54054d34a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 1. Add physical property columns to the product table + op.add_column("product", sa.Column("weight_g", sa.Float(), nullable=True)) + op.add_column("product", sa.Column("height_cm", sa.Float(), nullable=True)) + op.add_column("product", sa.Column("width_cm", sa.Float(), nullable=True)) + op.add_column("product", sa.Column("depth_cm", sa.Float(), nullable=True)) + + # 2. Add circularity property columns to the product table + op.add_column("product", sa.Column("recyclability_observation", sa.String(length=500), nullable=True)) + op.add_column("product", sa.Column("recyclability_comment", sa.String(length=100), nullable=True)) + op.add_column("product", sa.Column("recyclability_reference", sa.String(length=100), nullable=True)) + op.add_column("product", sa.Column("repairability_observation", sa.String(length=500), nullable=True)) + op.add_column("product", sa.Column("repairability_comment", sa.String(length=100), nullable=True)) + op.add_column("product", sa.Column("repairability_reference", sa.String(length=100), nullable=True)) + op.add_column("product", sa.Column("remanufacturability_observation", sa.String(length=500), nullable=True)) + op.add_column("product", sa.Column("remanufacturability_comment", sa.String(length=100), nullable=True)) + op.add_column("product", sa.Column("remanufacturability_reference", sa.String(length=100), nullable=True)) + + # 3. Copy data from physicalproperties to product + op.execute(""" + UPDATE product + SET weight_g = pp.weight_g, + height_cm = pp.height_cm, + width_cm = pp.width_cm, + depth_cm = pp.depth_cm + FROM physicalproperties pp + WHERE pp.product_id = product.id + """) + + # 4. Copy data from circularityproperties to product + op.execute(""" + UPDATE product + SET recyclability_observation = cp.recyclability_observation, + recyclability_comment = cp.recyclability_comment, + recyclability_reference = cp.recyclability_reference, + repairability_observation = cp.repairability_observation, + repairability_comment = cp.repairability_comment, + repairability_reference = cp.repairability_reference, + remanufacturability_observation = cp.remanufacturability_observation, + remanufacturability_comment = cp.remanufacturability_comment, + remanufacturability_reference = cp.remanufacturability_reference + FROM circularityproperties cp + WHERE cp.product_id = product.id + """) + + # 5. Drop old tables + op.drop_table("physicalproperties") + op.drop_table("circularityproperties") + + +def downgrade() -> None: + # Re-create the property tables + op.create_table( + "physicalproperties", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("weight_g", sa.Float(), nullable=True), + sa.Column("height_cm", sa.Float(), nullable=True), + sa.Column("width_cm", sa.Float(), nullable=True), + sa.Column("depth_cm", sa.Float(), nullable=True), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["product_id"], ["product.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "circularityproperties", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("recyclability_observation", sa.String(length=500), nullable=True), + sa.Column("recyclability_comment", sa.String(length=100), nullable=True), + sa.Column("recyclability_reference", sa.String(length=100), nullable=True), + sa.Column("repairability_observation", sa.String(length=500), nullable=True), + sa.Column("repairability_comment", sa.String(length=100), nullable=True), + sa.Column("repairability_reference", sa.String(length=100), nullable=True), + sa.Column("remanufacturability_observation", sa.String(length=500), nullable=True), + sa.Column("remanufacturability_comment", sa.String(length=100), nullable=True), + sa.Column("remanufacturability_reference", sa.String(length=100), nullable=True), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["product_id"], ["product.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + # Copy data back + op.execute(""" + INSERT INTO physicalproperties (weight_g, height_cm, width_cm, depth_cm, product_id) + SELECT weight_g, height_cm, width_cm, depth_cm, id + FROM product + WHERE weight_g IS NOT NULL OR height_cm IS NOT NULL + OR width_cm IS NOT NULL OR depth_cm IS NOT NULL + """) + + op.execute(""" + INSERT INTO circularityproperties ( + recyclability_observation, recyclability_comment, recyclability_reference, + repairability_observation, repairability_comment, repairability_reference, + remanufacturability_observation, remanufacturability_comment, remanufacturability_reference, + product_id + ) + SELECT + recyclability_observation, recyclability_comment, recyclability_reference, + repairability_observation, repairability_comment, repairability_reference, + remanufacturability_observation, remanufacturability_comment, remanufacturability_reference, + id + FROM product + WHERE recyclability_observation IS NOT NULL OR recyclability_comment IS NOT NULL + OR repairability_observation IS NOT NULL OR remanufacturability_observation IS NOT NULL + """) + + # Drop inlined columns + op.drop_column("product", "weight_g") + op.drop_column("product", "height_cm") + op.drop_column("product", "width_cm") + op.drop_column("product", "depth_cm") + op.drop_column("product", "recyclability_observation") + op.drop_column("product", "recyclability_comment") + op.drop_column("product", "recyclability_reference") + op.drop_column("product", "repairability_observation") + op.drop_column("product", "repairability_comment") + op.drop_column("product", "repairability_reference") + op.drop_column("product", "remanufacturability_observation") + op.drop_column("product", "remanufacturability_comment") + op.drop_column("product", "remanufacturability_reference") diff --git a/backend/alembic/versions/c4d5e6f7a8b9_consolidate_media_parent_fks.py b/backend/alembic/versions/c4d5e6f7a8b9_consolidate_media_parent_fks.py new file mode 100644 index 00000000..323d8c09 --- /dev/null +++ b/backend/alembic/versions/c4d5e6f7a8b9_consolidate_media_parent_fks.py @@ -0,0 +1,76 @@ +"""Consolidate File/Image parent FK columns into generic parent_id + +Replace the three nullable foreign-key columns (product_id, material_id, +product_type_id) on ``file`` and ``image`` with a single ``parent_id`` +integer column. Referential integrity moves to the application layer; +a composite index on (parent_type, parent_id) preserves query performance. + +Revision ID: c4d5e6f7a8b9 +Revises: b7c1d2e3f4a5 +Create Date: 2026-04-08 14:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c4d5e6f7a8b9" +down_revision: str | None = "b7c1d2e3f4a5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for table in ("file", "image"): + # 1. Add parent_id (nullable initially so we can populate it) + op.add_column(table, sa.Column("parent_id", sa.Integer(), nullable=True)) + + # 2. Populate parent_id from whichever FK column is set (per-table literals, no f-strings) + op.execute(sa.text("UPDATE file SET parent_id = COALESCE(product_id, material_id, product_type_id)")) + op.execute(sa.text("UPDATE image SET parent_id = COALESCE(product_id, material_id, product_type_id)")) + + for table in ("file", "image"): + # 3. Make parent_id NOT NULL + op.alter_column(table, "parent_id", nullable=False) + + # 4. Drop old FK constraints then columns + op.drop_constraint(f"{table}_product_id_fkey", table, type_="foreignkey") + op.drop_constraint(f"{table}_material_id_fkey", table, type_="foreignkey") + op.drop_constraint(f"{table}_product_type_id_fkey", table, type_="foreignkey") + op.drop_column(table, "product_id") + op.drop_column(table, "material_id") + op.drop_column(table, "product_type_id") + + # 5. Add composite index for efficient parent lookups + op.create_index(f"ix_{table}_parent_type_parent_id", table, ["parent_type", "parent_id"]) + + +def downgrade() -> None: + for table in ("file", "image"): + # 1. Drop composite index + op.drop_index(f"ix_{table}_parent_type_parent_id", table_name=table) + + # 2. Re-add old FK columns (nullable) + op.add_column(table, sa.Column("product_id", sa.Integer(), nullable=True)) + op.add_column(table, sa.Column("material_id", sa.Integer(), nullable=True)) + op.add_column(table, sa.Column("product_type_id", sa.Integer(), nullable=True)) + + # 3. Populate old FK columns from parent_id + parent_type (per-table literals, no f-strings) + op.execute(sa.text("UPDATE file SET product_id = parent_id WHERE parent_type = 'PRODUCT'")) + op.execute(sa.text("UPDATE file SET material_id = parent_id WHERE parent_type = 'MATERIAL'")) + op.execute(sa.text("UPDATE file SET product_type_id = parent_id WHERE parent_type = 'PRODUCT_TYPE'")) + op.execute(sa.text("UPDATE image SET product_id = parent_id WHERE parent_type = 'PRODUCT'")) + op.execute(sa.text("UPDATE image SET material_id = parent_id WHERE parent_type = 'MATERIAL'")) + op.execute(sa.text("UPDATE image SET product_type_id = parent_id WHERE parent_type = 'PRODUCT_TYPE'")) + + for table in ("file", "image"): + # 4. Re-add FK constraints + op.create_foreign_key(f"{table}_product_id_fkey", table, "product", ["product_id"], ["id"]) + op.create_foreign_key(f"{table}_material_id_fkey", table, "material", ["material_id"], ["id"]) + op.create_foreign_key(f"{table}_product_type_id_fkey", table, "producttype", ["product_type_id"], ["id"]) + + # 5. Drop parent_id + op.drop_column(table, "parent_id") diff --git a/backend/alembic/versions/cb66f26a9893_fix_nullable_unit_and_domains.py b/backend/alembic/versions/cb66f26a9893_fix_nullable_unit_and_domains.py new file mode 100644 index 00000000..0ea34d28 --- /dev/null +++ b/backend/alembic/versions/cb66f26a9893_fix_nullable_unit_and_domains.py @@ -0,0 +1,51 @@ +"""Fix nullable drift on materialproductlink.unit and taxonomy.domains + +Both columns are declared non-nullable in the ORM models but were left +nullable in the DB after the SQLModel -> SQLAlchemy 2.0 migration. + +Revision ID: cb66f26a9893 +Revises: c4d5e6f7a8b9 +Create Date: 2026-04-09 00:20:30.902700 + +""" + +from collections.abc import Sequence + +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "cb66f26a9893" +down_revision: str | None = "c4d5e6f7a8b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column( + "materialproductlink", + "unit", + existing_type=postgresql.ENUM("KILOGRAM", "GRAM", "METER", "CENTIMETER", name="unit"), + nullable=False, + ) + op.alter_column( + "taxonomy", + "domains", + existing_type=postgresql.ARRAY(postgresql.ENUM("MATERIALS", "PRODUCTS", "OTHER", name="taxonomydomain")), + nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "taxonomy", + "domains", + existing_type=postgresql.ARRAY(postgresql.ENUM("MATERIALS", "PRODUCTS", "OTHER", name="taxonomydomain")), + nullable=True, + ) + op.alter_column( + "materialproductlink", + "unit", + existing_type=postgresql.ENUM("KILOGRAM", "GRAM", "METER", "CENTIMETER", name="unit"), + nullable=True, + ) diff --git a/backend/alembic/versions/d9ffb53c12c0_simplify_rpi_cam_device_auth.py b/backend/alembic/versions/d9ffb53c12c0_simplify_rpi_cam_device_auth.py new file mode 100644 index 00000000..ec3e7047 --- /dev/null +++ b/backend/alembic/versions/d9ffb53c12c0_simplify_rpi_cam_device_auth.py @@ -0,0 +1,68 @@ +"""simplify rpi cam device auth + +Revision ID: d9ffb53c12c0 +Revises: cb66f26a9893 +Create Date: 2026-04-13 16:34:01.731636 + +""" +# spell-checker: ignore astext, cameracredentialstatus + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "d9ffb53c12c0" +down_revision: str | None = "cb66f26a9893" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Existing RPi camera rows used legacy shared-key HTTP/WebSocket credentials + # that cannot be transformed into asymmetric device credentials. + op.execute("DELETE FROM camera") + + sa.Enum("ACTIVE", "REVOKED", name="cameracredentialstatus").create(op.get_bind()) + op.add_column("camera", sa.Column("relay_public_key_jwk", postgresql.JSONB(astext_type=sa.Text()), nullable=False)) + op.add_column("camera", sa.Column("relay_key_id", sa.String(length=64), nullable=False)) + op.add_column( + "camera", + sa.Column( + "relay_credential_status", + postgresql.ENUM("ACTIVE", "REVOKED", name="cameracredentialstatus", create_type=False), + server_default="ACTIVE", + nullable=False, + ), + ) + op.add_column("camera", sa.Column("relay_last_seen_at", sa.DateTime(timezone=True), nullable=True)) + op.drop_column("camera", "url") + op.drop_column("camera", "encrypted_auth_headers") + op.drop_column("camera", "encrypted_api_key") + op.drop_column("camera", "connection_mode") + sa.Enum("HTTP", "WEBSOCKET", name="connectionmode").drop(op.get_bind()) + + +def downgrade() -> None: + op.execute("DELETE FROM camera") + + sa.Enum("HTTP", "WEBSOCKET", name="connectionmode").create(op.get_bind()) + op.add_column( + "camera", + sa.Column( + "connection_mode", + postgresql.ENUM("HTTP", "WEBSOCKET", name="connectionmode", create_type=False), + server_default=sa.text("'HTTP'::connectionmode"), + nullable=False, + ), + ) + op.add_column("camera", sa.Column("encrypted_api_key", sa.VARCHAR(), nullable=False)) + op.add_column("camera", sa.Column("encrypted_auth_headers", sa.VARCHAR(), nullable=True)) + op.add_column("camera", sa.Column("url", sa.VARCHAR(), nullable=True)) + op.drop_column("camera", "relay_last_seen_at") + op.drop_column("camera", "relay_credential_status") + op.drop_column("camera", "relay_key_id") + op.drop_column("camera", "relay_public_key_jwk") + sa.Enum("ACTIVE", "REVOKED", name="cameracredentialstatus").drop(op.get_bind()) diff --git a/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py b/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py new file mode 100644 index 00000000..18ad46e8 --- /dev/null +++ b/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py @@ -0,0 +1,29 @@ +"""add_oauth_account_uniqueness_constraint + +Revision ID: da288fbcf15e +Revises: 4c248b3004c6 +Create Date: 2026-03-17 14:22:56.702224 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "da288fbcf15e" +down_revision: str | None = "4c248b3004c6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint("uq_oauth_account_identity", "oauthaccount", ["oauth_name", "account_id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_oauth_account_identity", "oauthaccount", type_="unique") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/e3d54054d34a_add_user_preferences.py b/backend/alembic/versions/e3d54054d34a_add_user_preferences.py new file mode 100644 index 00000000..36f4fb75 --- /dev/null +++ b/backend/alembic/versions/e3d54054d34a_add_user_preferences.py @@ -0,0 +1,35 @@ +"""Add user preferences + +Revision ID: e3d54054d34a +Revises: 70e78d937e0b +Create Date: 2026-04-08 03:57:06.451232 + +""" +# spell-checker: ignore astext + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "e3d54054d34a" +down_revision: str | None = "70e78d937e0b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user", sa.Column("preferences", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "preferences") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py b/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py new file mode 100644 index 00000000..c472197d --- /dev/null +++ b/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py @@ -0,0 +1,54 @@ +"""Add full-text search (tsvector) and trigram fuzzy search indexes to product table + +Revision ID: f3a8c2d1e5b7 +Revises: da288fbcf15e +Create Date: 2026-03-22 00:00:00.000000 + +""" +# spell-checker: ignore trgm + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f3a8c2d1e5b7" +down_revision: str | None = "da288fbcf15e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Enable pg_trgm extension for trigram fuzzy matching + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + # Add a stored generated tsvector column covering name, description, brand, model. + # GENERATED ALWAYS AS STORED means Postgres maintains this automatically on insert/update. + op.execute(""" + ALTER TABLE product + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(name, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce(brand, '') || ' ' || + coalesce(model, '') + ) + ) STORED + """) + + # GIN index for full-text search (tsvector @@ tsquery) + op.execute("CREATE INDEX product_search_vector_idx ON product USING GIN (search_vector)") + + # GIN trigram indexes for fuzzy matching on name and brand + # (these are the fields users are most likely to mis-spell) + op.execute("CREATE INDEX product_name_trgm_idx ON product USING GIN (name gin_trgm_ops)") + op.execute("CREATE INDEX product_brand_trgm_idx ON product USING GIN (brand gin_trgm_ops)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS product_brand_trgm_idx") + op.execute("DROP INDEX IF EXISTS product_name_trgm_idx") + op.execute("DROP INDEX IF EXISTS product_search_vector_idx") + op.execute("ALTER TABLE product DROP COLUMN IF EXISTS search_vector") + # Note: we intentionally leave pg_trgm installed; other tables may rely on it diff --git a/backend/app/__version__.py b/backend/app/__version__.py index 2613a415..a8f81f8b 100644 --- a/backend/app/__version__.py +++ b/backend/app/__version__.py @@ -1,3 +1,3 @@ """For versioning the FastAPI app.""" -version = "0.1.0" +version = "0.2.0" diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py deleted file mode 100644 index 180309fd..00000000 --- a/backend/app/api/admin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Admin panel package.""" diff --git a/backend/app/api/admin/auth.py b/backend/app/api/admin/auth.py deleted file mode 100644 index 9371707a..00000000 --- a/backend/app/api/admin/auth.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Authentication backend for the SQLAdmin interface, based on FastAPI-Users authentication backend.""" - -import json -from typing import Literal - -from fastapi import Response, status -from fastapi.responses import RedirectResponse -from sqladmin.authentication import AuthenticationBackend -from sqlalchemy.ext.asyncio import async_sessionmaker -from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.requests import Request - -from app.api.admin.config import settings as admin_settings -from app.api.auth.config import settings as auth_settings -from app.api.auth.routers.frontend import router as frontend_auth_router -from app.api.auth.services.user_manager import cookie_transport, get_jwt_strategy -from app.api.auth.utils.context_managers import get_chained_async_user_manager_context -from app.core.database import async_engine - -async_session_generator = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) - -# TODO: Redirect all backend login systems (admin panel, swagger docs, API landing page) to frontend login system -main_login_page_redirect_path = ( - f"{frontend_auth_router.url_path_for('login_page')}?next={admin_settings.admin_base_url}" -) - - -class AdminAuth(AuthenticationBackend): - """Authentication backend for the SQLAdmin interface, using FastAPI-Users.""" - - async def login(self, request: Request) -> bool: # noqa: ARG002 # Signature expected by the SQLAdmin implementation - """Placeholder logout function. - - Login is handled by the authenticate method, which redirects to the main API login page. - """ - return True - - async def logout(self, request: Request) -> bool: # noqa: ARG002 # Signature expected by the SQLAdmin implementation - """Placeholder logout function. - - Logout requires unsetting a cookie, which is not possible in the standard SQLAdmin logout function, - which is excepted to return a boolean. - Instead, the default logout route is overridden by the custom route below. - """ - return True - - async def authenticate(self, request: Request) -> RedirectResponse | Response | Literal[True]: - token = request.cookies.get(cookie_transport.cookie_name) - if not token: - return RedirectResponse(url=main_login_page_redirect_path) - async with get_chained_async_user_manager_context() as user_manager: - user = await get_jwt_strategy().read_token(token=token, user_manager=user_manager) - if user is None: - return RedirectResponse(url=main_login_page_redirect_path) - if not user.is_superuser: - return Response( - json.dumps({"detail": "You do not have permission to access this resource."}), - status_code=status.HTTP_403_FORBIDDEN, - media_type="application/json", - ) - - return True - - -def get_authentication_backend() -> AdminAuth: - """Get the authentication backend for the SQLAdmin interface.""" - return AdminAuth(secret_key=auth_settings.fastapi_users_secret) - - -async def logout_override(request: Request) -> RedirectResponse: # noqa: ARG001 # Signature expected by the SQLAdmin implementation - """Override of the default admin dashboard logout route to unset the authentication cookie.""" - response = RedirectResponse(url=frontend_auth_router.url_path_for("index"), status_code=302) - response.delete_cookie( - key=cookie_transport.cookie_name, domain=cookie_transport.cookie_domain, path=cookie_transport.cookie_path - ) - return response diff --git a/backend/app/api/admin/config.py b/backend/app/api/admin/config.py deleted file mode 100644 index 7ed84166..00000000 --- a/backend/app/api/admin/config.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Configuration for the admin module.""" - -from pydantic_settings import BaseSettings - - -class AdminSettings(BaseSettings): - """Settings class to store settings related to admin components.""" - - admin_base_url: str = "/admin/dashboard" # The base url of the SQLadmin interface - - -# Create a settings instance that can be imported throughout the app -settings = AdminSettings() diff --git a/backend/app/api/admin/main.py b/backend/app/api/admin/main.py deleted file mode 100644 index df5aa675..00000000 --- a/backend/app/api/admin/main.py +++ /dev/null @@ -1,65 +0,0 @@ -"""SQLAdmin module for the FastAPI app.""" - -from fastapi import FastAPI -from sqladmin import Admin -from sqlalchemy import Engine -from sqlalchemy.ext.asyncio.engine import AsyncEngine -from starlette.applications import Starlette -from starlette.routing import Mount, Route - -from app.api.admin.auth import get_authentication_backend, logout_override -from app.api.admin.config import settings -from app.api.admin.models import ( - CategoryAdmin, - ImageAdmin, - MaterialAdmin, - MaterialProductLinkAdmin, - ProductAdmin, - ProductTypeAdmin, - TaxonomyAdmin, - UserAdmin, - VideoAdmin, -) - - -def init_admin(app: FastAPI, engine: Engine | AsyncEngine) -> Admin: - """Initialize the SQLAdmin interface for the FastAPI app. - - Args: - app (FastAPI): Main FastAPI application instance - engine (Engine | AsyncEngine): SQLAlchemy database engine, sync or async - """ - admin = Admin(app, engine, authentication_backend=get_authentication_backend(), base_url=settings.admin_base_url) - - # HACK: Override SQLAdmin logout route to allow cookie-based auth - for route in admin.app.routes: - # Find the mounted SQLAdmin app - if isinstance(route, Mount) and route.path == settings.admin_base_url and isinstance(route.app, Starlette): - for subroute in route.app.routes: - # Find the logout subroute and replace it with the custom override to allow cookie-based auth - if isinstance(subroute, Route) and subroute.name == "logout": - route.routes.remove(subroute) - route.app.add_route( - subroute.path, - logout_override, - methods=list(subroute.methods) if subroute.methods is not None else None, - name="logout", - ) - break - break - - # Add Background Data views to Admin interface - admin.add_view(CategoryAdmin) - admin.add_view(MaterialAdmin) - admin.add_view(ProductTypeAdmin) - admin.add_view(TaxonomyAdmin) - # Add Data Collection views to Admin interface - admin.add_view(MaterialProductLinkAdmin) - admin.add_view(ImageAdmin) - admin.add_view(ProductAdmin) - admin.add_view(VideoAdmin) - - # Add other admin views - admin.add_view(UserAdmin) - - return admin diff --git a/backend/app/api/admin/models.py b/backend/app/api/admin/models.py deleted file mode 100644 index f0efff66..00000000 --- a/backend/app/api/admin/models.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Models for the admin module.""" - -import uuid -from collections.abc import Callable, Sequence -from pathlib import Path -from typing import Any, ClassVar - -from anyio import to_thread -from markupsafe import Markup -from sqladmin import ModelView -from sqladmin._types import MODEL_ATTR -from starlette.datastructures import UploadFile -from starlette.requests import Request -from wtforms import ValidationError -from wtforms.fields import FileField -from wtforms.form import Form -from wtforms.validators import InputRequired - -from app.api.auth.models import User -from app.api.background_data.models import Category, Material, ProductType, Taxonomy -from app.api.common.models.associations import MaterialProductLink -from app.api.data_collection.models import Product -from app.api.file_storage.models.models import Image, Video - -### Constants ### -ALLOWED_IMAGE_EXTENSIONS: set[str] = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tiff", ".webp"} - - -### Form Validators ### -class FileSizeLimit: - """WTForms validator to limit the file size of a FileField.""" - - def __init__(self, max_size_mb: int, message: str | None = None) -> None: - self.max_size_mb = max_size_mb - self.message = message or f"File size must be under {self.max_size_mb} MB." - - def __call__(self, form: Form, field: FileField): # noqa: ARG002 # WTForms uses this signature - if isinstance(field.data, UploadFile) and field.data.size and field.data.size > self.max_size_mb * 1024 * 1024: - raise ValidationError(self.message) - - -class FileTypeValidator: - """WTForms validator to limit the file type of a FileField.""" - - def __init__(self, allowed_extensions: set[str], message: str | None = None): - self.allowed_extensions = allowed_extensions - self.message = message or f"Allowed file types: {', '.join(self.allowed_extensions)}." - - def __call__(self, form: Form, field: FileField): # noqa: ARG002 # WTForms uses this signature - if isinstance(field.data, UploadFile) and field.data.filename: - file_ext = Path(field.data.filename).suffix.lower() - if file_ext not in self.allowed_extensions: - raise ValidationError(self.message) - - -### Linking Models ### -class MaterialProductLinkAdmin(ModelView, model=MaterialProductLink): - """Admin view for Material-Product links.""" - - name = "Material-Product Link" - name_plural = "Material-Product Links" - icon = "fa-solid fa-link" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["material", "product", "quantity", "unit"] - - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "material": lambda m, _: Markup('{}').format(m.material_id, m.material), - "product": lambda m, _: Markup('{}').format(m.product_id, m.product), - } - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["material.name", "product.name"] - - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["quantity", "unit"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = [*column_list, "created_at", "updated_at"] - - -### Background Models ### -class CategoryAdmin(ModelView, model=Category): - """Admin view for Category model.""" - - name = "Category" - name_plural = "Categories" - icon = "fa-solid fa-list" - category = "Background Data" - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "taxonomy_id"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "taxonomy_id"] - - -class TaxonomyAdmin(ModelView, model=Taxonomy): - """Admin view for Taxonomy model.""" - - name = "Taxonomy" - name_plural = "Taxonomies" - icon = "fa-solid fa-sitemap" - category = "Background Data" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "domain"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "domain"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name"] - - -class MaterialAdmin(ModelView, model=Material): - """Admin view for Material model.""" - - name = "Material" - name_plural = "Materials" - icon = "fa-solid fa-cubes" - category = "Background Data" - - column_labels: ClassVar[dict[MODEL_ATTR, str]] = { - "density_kg_m3": "Density (kg/m³)", - "is_crm": "Is CRM", - } - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "description", - "is_crm", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "is_crm"] - - -class ProductTypeAdmin(ModelView, model=ProductType): - """Admin view for ProductType model.""" - - name = "Product Type" - name_plural = "Product Types" - icon = "fa-solid fa-tag" - category = "Background Data" - - column_labels: ClassVar[dict[MODEL_ATTR, str]] = { - "lifespan_yr": "Lifespan (years)", - } - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "description"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name"] - - -### Product Models ### -class ProductAdmin(ModelView, model=Product): - """Admin view for Product model.""" - - name = "Product" - name_plural = "Products" - icon = "fa-solid fa-box" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "type", - "description", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "product_type_id", - ] - - -### Data Collection Models ### -class VideoAdmin(ModelView, model=Video): - """Admin view for Video model.""" - - name = "Video" - name_plural = "Videos" - icon = "fa-solid fa-video" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "url", "description", "product", "created_at"] - - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "url": lambda m, _: Markup('{}').format(m.url, m.url), - "product": lambda m, _: Markup('{}').format(m.product_id, m.product) - if m.product - else "", - "created_at": lambda m, _: m.created_at.strftime("%Y-%m-%d %H:%M") if m.created_at else "", - } - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["description", "url"] - - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "created_at"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = [*column_list, "updated_at"] - - -### User Models ### -class UserAdmin(ModelView, model=User): - """Admin view for User model.""" - - name = "User" - name_plural = "Users" - icon = "fa-solid fa-user" - category = "Users" - - # User CRUD should be handled by the auth module - can_create = False - can_edit = False - can_delete = False - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "email", - "username", - "organization", - "is_active", - "is_superuser", - "is_verified", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["email", "organization"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["email", "organization"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = column_list - - -### File Storage Models ### -class ImageAdmin(ModelView, model=Image): - """Admin view for Image model.""" - - # TODO: Use Image schema logic instead of duplicating it here - # TODO: Add a method to download the original file (should take it from the filename but rename it to original_name) - - name = "Image" - name_plural = "Images" - icon = "fa-solid fa-camera" - category = "Data Collection" - - # Display settings - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "description", - "filename", - "created_at", - "updated_at", - "image_preview", - ] - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = column_list - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "created_at": lambda model, _: model.created_at.strftime("%Y-%m-%d %H:%M:%S") if model.created_at else "", - "updated_at": lambda model, _: model.updated_at.strftime("%Y-%m-%d %H:%M:%S") if model.updated_at else "", - "image_preview": lambda model, _: model.image_preview(100), - } - column_formatters_detail: ClassVar[dict[MODEL_ATTR, Callable]] = column_formatters - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "description", - "filename", - "created_at", - "updated_at", - ] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = column_searchable_list - - # Create and edit settings - form_columns: ClassVar[Sequence[MODEL_ATTR]] = [ - "description", - "file", - ] - - form_args: ClassVar[dict[str, Any]] = { - "file": { - "validators": [ - InputRequired(), - FileSizeLimit(max_size_mb=10), - FileTypeValidator(allowed_extensions=ALLOWED_IMAGE_EXTENSIONS), - ], - } - } - - def _delete_image_file(self, image_path: Path) -> None: - """Delete the image file from the filesystem if it exists.""" - if image_path.exists(): - image_path.unlink() - - def handle_model_change(self, data: dict[str, Any], model: Image, is_created: bool) -> None: # noqa: FBT001 # Wtforms uses this signature - def new_image_uploaded(data: dict[str, Any]) -> bool: - """Check if a new image is present in form data.""" - return isinstance(data.get("file"), UploadFile) and data["file"].size - - if new_image_uploaded(data): - model.filename = data["file"].filename # Set the filename to the original filename - data["file"].filename = f"{uuid.uuid4()}{Path(model.filename).suffix}" # Store the file to a unique path - - if not is_created and model.file: # If the model is being edited and it has an existing image - if new_image_uploaded(data): - self._delete_image_file(Path(model.file.path)) - else: - data.pop("file", None) # Keep existing image if no new one uploaded - - def handle_model_delete(self, model: Image) -> None: - if model.file: - self._delete_image_file(model.file.path) - - async def on_model_change(self, data: dict[str, Any], model: Image, is_created: bool, request: Request) -> None: # noqa: ARG002, FBT001 # Wtforms uses this signature - """SQLAdmin expects on_model_change to be asynchronous. This method handles the synchronous model change.""" - await to_thread.run_sync(self.handle_model_change, data, model, is_created) - - async def after_model_delete(self, model: Image, request: Request) -> None: # noqa: ARG002 # Wtforms uses this signature - await to_thread.run_sync(lambda: self._delete_image_file(Path(model.file.path)) if model.file.path else None) diff --git a/backend/app/api/auth/config.py b/backend/app/api/auth/config.py index e412c4fd..8b3dd668 100644 --- a/backend/app/api/auth/config.py +++ b/backend/app/api/auth/config.py @@ -1,56 +1,149 @@ """Configuration for the auth module.""" -from pathlib import Path +from dataclasses import dataclass +from functools import cached_property -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import EmailStr, Field, NameEmail, SecretStr, TypeAdapter, model_validator -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[3]).resolve() +from app.core.config.models import Environment +from app.core.constants import DAY, HOUR, MINUTE, MONTH +from app.core.env import RelabBaseSettings, is_production_like_environment +NAME_EMAIL_ADAPTER = TypeAdapter(NameEmail) -class AuthSettings(BaseSettings): + +def parse_name_email(value: str, *, fallback: str = "") -> NameEmail | None: + """Parse a configured email string, optionally falling back to another value.""" + raw_value = value.strip() or fallback.strip() + if not raw_value: + return None + return NAME_EMAIL_ADAPTER.validate_python(raw_value) + + +@dataclass(frozen=True, slots=True) +class ResolvedEmailSettings: + """Resolved auth email settings shared by email utilities.""" + + username: str + password: SecretStr + host: str + port: int + sender: NameEmail | None + reply_to: NameEmail | None + + def recipient(self, email: EmailStr | str) -> NameEmail: + """Return a parsed recipient address.""" + return NAME_EMAIL_ADAPTER.validate_python(str(email)) + + +class AuthSettings(RelabBaseSettings): """Settings class to store settings related to auth components.""" + environment: Environment = Environment.DEV + # Authentication settings - fastapi_users_secret: str = "" - newsletter_secret: str = "" + fastapi_users_secret: SecretStr = SecretStr("") + newsletter_secret: SecretStr = SecretStr("") # OAuth settings - google_oauth_client_id: str = "" - google_oauth_client_secret: str = "" - github_oauth_client_id: str = "" - github_oauth_client_secret: str = "" + google_oauth_client_id: SecretStr = SecretStr("") + google_oauth_client_secret: SecretStr = SecretStr("") + github_oauth_client_id: SecretStr = SecretStr("") + github_oauth_client_secret: SecretStr = SecretStr("") + + # OAuth frontend redirect hardening + # NOTE: Origin validation reuses the same normalized frontend URLs and dev-only regex as CORS. + + # Optional path allowlist. When empty, any path on an allowed origin is accepted. + oauth_allowed_redirect_paths: list[str] = Field(default_factory=list) + # Optional exact allowlist for native deep-link callbacks (scheme://host/path, no query/fragment). + oauth_allowed_native_redirect_uris: list[str] = Field(default_factory=list) # Settings used to configure the email server for sending emails from the app. email_host: str = "" email_port: int = 587 # Default SMTP port for TLS email_username: str = "" - email_password: str = "" + email_password: SecretStr = SecretStr("") email_from: str = "" email_reply_to: str = "" - # Initialize the settings configuration from the .env file - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") + # Time to live for access (login) and verification tokens + access_token_ttl_seconds: int = 15 * MINUTE # 15 minutes (Redis token lifetime) + oauth_state_token_ttl_seconds: int = 10 * MINUTE # 10 minutes + reset_password_token_ttl_seconds: int = HOUR # 1 hour + verification_token_ttl_seconds: int = DAY # 1 day + newsletter_unsubscription_token_ttl_seconds: int = MONTH # 30 days - # Set default values for email settings if not provided - if not email_from: - email_from = email_username - if not email_reply_to: - email_reply_to = email_username + # Auth settings - Refresh tokens and sessions + refresh_token_expire_days: int = 30 # 30 days for long-lived refresh tokens + session_id_length: int = 32 - # Time to live for access (login) and verification tokens - access_token_ttl_seconds: int = 60 * 60 * 3 # 3 hours - reset_password_token_ttl_seconds: int = 60 * 60 # 1 hour - verification_token_ttl_seconds: int = 60 * 60 * 24 # 1 day - newsletter_unsubscription_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 7 days + # Auth settings - Rate limiting + rate_limit_login_attempts_per_minute: int = 3 + rate_limit_register_attempts_per_hour: int = 5 + rate_limit_verify_attempts_per_hour: int = 3 + rate_limit_password_reset_attempts_per_hour: int = 3 # Youtube API settings - youtube_api_scopes: list[str] = [ - "https://www.googleapis.com/auth/youtube", - "https://www.googleapis.com/auth/youtube.force-ssl", - "https://www.googleapis.com/auth/youtube.readonly", - "https://www.googleapis.com/auth/youtube.upload", - ] + youtube_api_scopes: list[str] = Field( + default_factory=lambda: [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + ] + ) + + @cached_property + def email(self) -> ResolvedEmailSettings: + """Return resolved email settings with shared fallback logic applied once.""" + sender = parse_name_email(self.email_from, fallback=self.email_username) + return ResolvedEmailSettings( + username=self.email_username, + password=self.email_password, + host=self.email_host, + port=self.email_port, + sender=sender, + reply_to=parse_name_email(self.email_reply_to, fallback=self.email_from or self.email_username) or sender, + ) + + @model_validator(mode="after") + def validate_production_auth_settings(self) -> AuthSettings: + """Fail fast when production-like auth settings are incomplete.""" + if not is_production_like_environment(self.environment.value): + return self + + errors: list[str] = [] + required_secrets = { + "FASTAPI_USERS_SECRET": self.fastapi_users_secret.get_secret_value(), + "NEWSLETTER_SECRET": self.newsletter_secret.get_secret_value(), + "GOOGLE_OAUTH_CLIENT_ID": self.google_oauth_client_id.get_secret_value(), + "GOOGLE_OAUTH_CLIENT_SECRET": self.google_oauth_client_secret.get_secret_value(), + "GITHUB_OAUTH_CLIENT_ID": self.github_oauth_client_id.get_secret_value(), + "GITHUB_OAUTH_CLIENT_SECRET": self.github_oauth_client_secret.get_secret_value(), + "EMAIL_PASSWORD": self.email_password.get_secret_value(), + } + required_strings = { + "EMAIL_HOST": self.email_host, + "EMAIL_USERNAME": self.email_username, + "EMAIL_FROM": self.email_from, + "EMAIL_REPLY_TO": self.email_reply_to, + } + + for name, value in required_secrets.items(): + if not value: + errors.append(f"{name} must not be empty in production/staging") + + for name, value in required_strings.items(): + if not value: + errors.append(f"{name} must not be empty in production/staging") + + if errors: + formatted = "\n - ".join(errors) + msg = f"Auth settings validation failed:\n - {formatted}" + raise ValueError(msg) + + return self # Create a settings instance that can be imported throughout the app diff --git a/backend/app/api/auth/crud/__init__.py b/backend/app/api/auth/crud/__init__.py index f809fbe5..3f63ff63 100644 --- a/backend/app/api/auth/crud/__init__.py +++ b/backend/app/api/auth/crud/__init__.py @@ -5,29 +5,29 @@ delete_organization_as_owner, force_delete_organization, get_organization_members, - get_user_organization, + get_organizations, leave_organization, update_user_organization, user_join_organization, ) from .users import ( add_user_role_in_organization_after_registration, - create_user_override, get_user_by_username, update_user_override, + validate_user_create, ) __all__ = [ "add_user_role_in_organization_after_registration", "create_organization", - "create_user_override", "delete_organization_as_owner", "force_delete_organization", "get_organization_members", + "get_organizations", "get_user_by_username", - "get_user_organization", "leave_organization", "update_user_organization", "update_user_override", "user_join_organization", + "validate_user_create", ] diff --git a/backend/app/api/auth/crud/organizations.py b/backend/app/api/auth/crud/organizations.py index 8d0d6e51..b7e86c59 100644 --- a/backend/app/api/auth/crud/organizations.py +++ b/backend/app/api/auth/crud/organizations.py @@ -1,25 +1,144 @@ """CRUD operations for organizations.""" -from pydantic import UUID4 +from typing import TYPE_CHECKING, cast + +from pydantic import UUID4, BaseModel +from sqlalchemy import Select, delete, inspect, select from sqlalchemy.exc import IntegrityError -from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import NO_VALUE from app.api.auth.exceptions import ( AlreadyMemberError, OrganizationHasMembersError, - OrganizationNameExistsError, UserDoesNotOwnOrgError, UserHasNoOrgError, UserIsNotMemberError, UserOwnsOrgError, + handle_organization_integrity_error, ) from app.api.auth.models import Organization, OrganizationRole, User from app.api.auth.schemas import OrganizationCreate, OrganizationUpdate -from app.api.common.crud.base import get_model_by_id -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit +from app.api.common.crud.query import require_model +from app.api.common.exceptions import InternalServerError + +if TYPE_CHECKING: + from fastapi_filter.contrib.sqlalchemy import Filter + from fastapi_pagination import Page ### Constants ### -UNIQUE_VIOLATION_PG_CODE = "23505" +OWNER_ID_FIELD = "owner_id" + + +def _loaded_user_organization(user: User) -> Organization | None: + """Return a loaded organization relationship without triggering async lazy loading.""" + loaded_value = inspect(user).attrs.organization.loaded_value + if loaded_value is NO_VALUE: + return None + return cast("Organization | None", loaded_value) + + +def _organization_statement( + *, + loaders: set[str] | None = None, + filters: Filter | None = None, + read_schema: type[BaseModel] | None = None, +) -> Select[tuple[Organization]]: + """Build the shared organization-listing query.""" + statement: Select[tuple[Organization]] = select(Organization) + statement = apply_filter(statement, Organization, filters) + return cast( + "Select[tuple[Organization]]", + apply_loader_profile(statement, Organization, loaders, read_schema=read_schema), + ) + + +def _organization_members_statement(organization_id: UUID4) -> Select[tuple[User]]: + """Build the organization-members query.""" + return select(User).where(User.organization_id == organization_id) + + +async def get_organization( + db: AsyncSession, + organization_id: UUID4, + *, + loaders: set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> Organization: + """Load one organization with optional relationships.""" + return await require_model(db, Organization, organization_id, loaders=loaders, read_schema=read_schema) + + +async def _load_org_for_transfer(db: AsyncSession, organization_id: UUID4) -> Organization: + """Load an organization with members and owner for ownership transfer flows.""" + organization = await get_organization(db, organization_id, loaders={"members", "owner"}) + await db.refresh(organization, attribute_names=["members", "owner"]) + return organization + + +async def _require_joinable_current_owner_org(db: AsyncSession, user: User) -> Organization: + """Load the organization currently owned by the user during join flows.""" + db_organization = _loaded_user_organization(user) + if db_organization is None or db_organization.id != user.organization_id: + if user.organization_id is None: + err_msg = "Owned organization must exist before loading it during join flow." + raise InternalServerError(details=err_msg, log_message=err_msg) + return await get_organization( + db, + user.organization_id, + loaders={"members"}, + ) + await db.refresh(db_organization, attribute_names=["members"]) + return db_organization + + +async def _delete_empty_owned_organization_for_join(db: AsyncSession, user: User) -> None: + """Delete a user's current organization when they are its last remaining owner/member.""" + if user.organization_id is None: + err_msg = "Owned organization must exist before deleting it during join flow." + raise InternalServerError(details=err_msg, log_message=err_msg) + + db_organization = await _require_joinable_current_owner_org(db, user) + if len(db_organization.members) > 1: + raise UserOwnsOrgError( + details=" You cannot join another organization until you transfer ownership or remove all members." + ) + + user.organization_id = None + user.organization_role = None + user.organization = None + db.add(user) + await db.flush() + await db.execute(delete(Organization).where(Organization.id == db_organization.id)) + + +def _require_transfer_member(db_organization: Organization, transfer_owner_id: UUID4) -> User: + """Return the transfer target when it is already an organization member.""" + new_owner = next((member for member in db_organization.members if member.id == transfer_owner_id), None) + if new_owner is None: + raise UserIsNotMemberError( + organization_id=db_organization.id, + details="Ownership can only be transferred to an existing member.", + ) + return new_owner + + +def _apply_organization_updates(db_organization: Organization, organization_in: OrganizationUpdate) -> None: + """Apply non-ownership organization updates.""" + for key, value in organization_in.model_dump(exclude_unset=True, exclude={OWNER_ID_FIELD}).items(): + setattr(db_organization, key, value) + + +def _transfer_organization_ownership(db_organization: Organization, *, new_owner: User) -> None: + """Transfer ownership from the current owner to an existing member.""" + current_owner = db_organization.owner + current_owner.organization_role = OrganizationRole.MEMBER + new_owner.organization_role = OrganizationRole.OWNER + db_organization.owner_id = new_owner.id ## Create Organization ## @@ -27,6 +146,9 @@ async def create_organization(db: AsyncSession, organization: OrganizationCreate """Create a new organization in the database.""" if owner.organization_id: raise AlreadyMemberError(details="Leave your current organization before creating a new one.") + if owner.id is None: + err_msg = "Organization owner must have a persisted ID." + raise InternalServerError(details=err_msg, log_message=err_msg) # Create organization db_organization = Organization( @@ -41,25 +163,35 @@ async def create_organization(db: AsyncSession, organization: OrganizationCreate db.add(db_organization) await db.flush() except IntegrityError as e: - # TODO: Reuse this in general exception handling - if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: - raise OrganizationNameExistsError from e - err_msg = f"Error creating organization: {e}" - raise RuntimeError(err_msg) from e + handle_organization_integrity_error(e, "creating") db.add(owner) - await db.commit() - await db.refresh(db_organization) - - return db_organization + return await commit_and_refresh(db, db_organization, add_before_commit=False) ## Read Organization ## -async def get_user_organization(user: User) -> Organization: - """Get the organization of a user, optionally including related models.""" - if not user.organization: - raise UserHasNoOrgError - return user.organization +async def get_organizations( + db: AsyncSession, + *, + loaders: set[str] | None = None, + filters: Filter | None = None, + read_schema: type[BaseModel] | None = None, +) -> Page[Organization]: + """Get organizations with optional filtering, relationships, and pagination.""" + statement = _organization_statement(loaders=loaders, filters=filters, read_schema=read_schema) + return cast("Page[Organization]", await paginate_select(db, statement, model=Organization)) + + +async def page_organization_members( + db: AsyncSession, + organization_id: UUID4, + *, + read_schema: type[BaseModel] | None = None, +) -> Page[User]: + """Get organization members in a paginated response.""" + statement = _organization_members_statement(organization_id) + statement = cast("Select[tuple[User]]", apply_loader_profile(statement, User, read_schema=read_schema)) + return cast("Page[User]", await paginate_select(db, statement, model=User)) ## Update Organization ## @@ -67,24 +199,23 @@ async def update_user_organization( db: AsyncSession, db_organization: Organization, organization_in: OrganizationUpdate ) -> Organization: """Update an existing organization in the database.""" - # Update organization data - db_organization.sqlmodel_update(organization_in.model_dump(exclude_unset=True)) + transfer_owner_id = organization_in.owner_id if OWNER_ID_FIELD in organization_in.model_fields_set else None + if transfer_owner_id is not None: + db_organization = await _load_org_for_transfer(db, db_organization.id) + + _apply_organization_updates(db_organization, organization_in) + + if transfer_owner_id is not None and transfer_owner_id != db_organization.owner_id: + new_owner = _require_transfer_member(db_organization, transfer_owner_id) + _transfer_organization_ownership(db_organization, new_owner=new_owner) try: db.add(db_organization) await db.flush() except IntegrityError as e: - # TODO: Reuse this in general exception handling - if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: - raise OrganizationNameExistsError from e - err_msg = f"Error updating organization: {e}" - raise RuntimeError(err_msg) from e + handle_organization_integrity_error(e, "updating") - # Save to database - await db.commit() - await db.refresh(db_organization) - - return db_organization + return await commit_and_refresh(db, db_organization, add_before_commit=False) ## Delete Organization ## @@ -98,16 +229,14 @@ async def delete_organization_as_owner(db: AsyncSession, owner: User) -> None: if len(db_organization.members) > 1: raise OrganizationHasMembersError - await db.delete(db_organization) - await db.commit() + await delete_and_commit(db, db_organization) async def force_delete_organization(db: AsyncSession, organization_id: UUID4) -> None: """Force delete a organization from the database.""" - db_organization = await db_get_model_with_id_if_it_exists(db, Organization, organization_id) + db_organization = await get_organization(db, organization_id) - await db.delete(db_organization) - await db.commit() + await delete_and_commit(db, db_organization) ## Organization member CRUD operations ## @@ -118,34 +247,42 @@ async def user_join_organization( ) -> User: """Add user to organization as member.""" # Check if user already owns an organization - # TODO: Implement logic for owners to delegate ownership, or delete organization if it has no members if user.organization_id: if user.organization_role == OrganizationRole.OWNER: - raise UserOwnsOrgError( - details=" You cannot join another organization until you transfer ownership or remove all members." - ) - raise AlreadyMemberError(details="Leave your current organization before joining a new one.") + await _delete_empty_owned_organization_for_join(db, user) + else: + raise AlreadyMemberError(details="Leave your current organization before joining a new one.") # Update user user.organization_id = organization.id user.organization_role = OrganizationRole.MEMBER + user.organization = organization db.add(user) - await db.commit() - await db.refresh(organization) + await commit_and_refresh(db, organization, add_before_commit=False) return user -async def get_organization_members(db: AsyncSession, organization_id: UUID4, user: User) -> list[User]: +async def get_organization_members( + db: AsyncSession, + organization_id: UUID4, + user: User, + *, + paginate: bool = False, + read_schema: type[BaseModel] | None = None, +) -> list[User] | Page[User]: """Get organization members if user is a member or superuser.""" # Verify user is member or superuser if not user.is_superuser and user.organization_id != organization_id: raise UserIsNotMemberError - organization = await get_model_by_id(db, Organization, organization_id, include_relationships={"members"}) + if paginate: + await get_organization(db, organization_id) + return await page_organization_members(db, organization_id, read_schema=read_schema) + + organization = await get_organization(db, organization_id, loaders={"members"}) - # TODO: Add pagination when there are many members return organization.members diff --git a/backend/app/api/auth/crud/users.py b/backend/app/api/auth/crud/users.py index 5961c18f..f7aae184 100644 --- a/backend/app/api/auth/crud/users.py +++ b/backend/app/api/auth/crud/users.py @@ -1,12 +1,14 @@ """Custom CRUD operations for the User model, on top of the standard FastAPI-Users implementation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from fastapi import Request -from fastapi_users.db import BaseUserDatabase -from pydantic import UUID4, EmailStr, ValidationError -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession +from pydantic import EmailStr, ValidationError +from sqlalchemy import exists, select -from app.api.auth.exceptions import UserNameAlreadyExistsError +from app.api.auth.exceptions import DisposableEmailError, UserNameAlreadyExistsError from app.api.auth.models import Organization, OrganizationRole, User from app.api.auth.schemas import ( OrganizationCreate, @@ -14,23 +16,31 @@ UserCreateWithOrganization, UserUpdate, ) -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.common.crud.query import require_model + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.services.email_checker import EmailChecker + from app.api.auth.services.user_database import UserDatabaseAsync ## Create User ## -async def create_user_override( - user_db: BaseUserDatabase[User, UUID4], user_create: UserCreate | UserCreateWithOrganization +async def validate_user_create( + user_db: UserDatabaseAsync, + user_create: UserCreate | UserCreateWithOrganization, + email_checker: EmailChecker | None = None, ) -> UserCreate: """Override of base user creation with additional username uniqueness check. Meant for use within the on_after_register event in FastAPI-Users UserManager. """ - # TODO: Fix type errors in this method and implement custom UserNameAlreadyExists error in FastAPI-Users + if email_checker and await email_checker.is_disposable(user_create.email): + raise DisposableEmailError(email=user_create.email) if user_create.username is not None: - query = select(User).where(User.username == user_create.username) - existing_username = await user_db.session.execute(query) - if existing_username.unique().scalar_one_or_none(): + query = select(exists().where(User.username == user_create.username)) + if (await user_db.session.execute(query)).scalar_one(): raise UserNameAlreadyExistsError(user_create.username) if isinstance(user_create, UserCreateWithOrganization): @@ -46,34 +56,36 @@ async def create_user_override( elif user_create.organization_id: # Validate organization ID (will raise ValueError if not found) - await db_get_model_with_id_if_it_exists(user_db.session, Organization, user_create.organization_id) + await require_model(user_db.session, Organization, user_create.organization_id) return user_create async def add_user_role_in_organization_after_registration( - user_db: BaseUserDatabase[User, UUID4], - user: User, - registration_request: Request, + user_db: UserDatabaseAsync, user: User, registration_request: Request ) -> User: """Add user to an organization after registration. Meant for use within the on_after_register event in FastAPI-Users UserManager. - Validation of organization data is performed in create_user_override. + Validation of organization data is performed in validate_user_create. """ user_create_data = await registration_request.json() + if organization_data := user_create_data.get("organization"): # Create organization organization = Organization(**organization_data, owner_id=user.id) user_db.session.add(organization) await user_db.session.flush() + # Set user as organization owner user.organization_id = organization.id user.organization_role = OrganizationRole.OWNER + elif organization_id := user_create_data.get("organization_id"): # User was added to an existing organization user.organization_id = organization_id user.organization_role = OrganizationRole.MEMBER + else: return user @@ -84,34 +96,33 @@ async def add_user_role_in_organization_after_registration( ## Read User ## -async def get_user_by_username( - session: AsyncSession, - username: str, -) -> User: +async def get_user_by_username(session: AsyncSession, username: str) -> User: """Get a user by their username.""" statement = select(User).where(User.username == username) - if not (user := (await session.exec(statement)).one_or_none()): + + if not (user := (await session.execute(statement)).scalars().unique().one_or_none()): err_msg: EmailStr = f"User not found with username: {username}" + raise ValueError(err_msg) return user ## Update User ## -async def update_user_override( - user_db: BaseUserDatabase[User, UUID4], - user: User, - user_update: UserUpdate, -) -> UserUpdate: +async def update_user_override(user_db: UserDatabaseAsync, user: User, user_update: UserUpdate) -> UserUpdate: """Override base user update with organization validation.""" if user_update.username is not None: # Check username uniqueness - query = select(User).where(and_(User.username == user_update.username, User.id != user.id)) - existing_username = await user_db.session.execute(query) - if existing_username.scalar_one_or_none(): + query = select(exists().where((User.username == user_update.username) & (User.id != user.id))) + if (await user_db.session.execute(query)).scalar_one(): raise UserNameAlreadyExistsError(user_update.username) if user_update.organization_id is not None: # Validate organization exists - await db_get_model_with_id_if_it_exists(user_db.session, Organization, user_update.organization_id) + await require_model(user_db.session, Organization, user_update.organization_id) + + # Merge preferences (shallow) instead of replacing the whole dict + if user_update.preferences is not None: + merged = {**(user.preferences or {}), **user_update.preferences} + user_update.preferences = merged return user_update diff --git a/backend/app/api/auth/dependencies.py b/backend/app/api/auth/dependencies.py index 1420c307..1819f83e 100644 --- a/backend/app/api/auth/dependencies.py +++ b/backend/app/api/auth/dependencies.py @@ -4,11 +4,14 @@ from fastapi import Depends, Security from pydantic import UUID4 +from sqlalchemy import inspect +from sqlalchemy.orm.attributes import NO_VALUE -from app.api.auth.exceptions import UserDoesNotOwnOrgError, UserIsNotMemberError +from app.api.auth.exceptions import UserDoesNotOwnOrgError, UserHasNoOrgError from app.api.auth.models import Organization, OrganizationRole, User -from app.api.auth.services.user_manager import UserManager, fastapi_user_manager, get_user_manager -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.auth.services.user_database import UserDatabaseAsync +from app.api.auth.services.user_manager import UserManager, fastapi_user_manager, get_user_db, get_user_manager +from app.api.common.crud.query import require_model from app.api.common.routers.dependencies import AsyncSessionDep # Dependencies @@ -18,6 +21,7 @@ optional_current_active_user = fastapi_user_manager.current_user(optional=True) # Annotated dependency types. For example usage, see the `authenticated_route` function in the auth.routers module. +UserDBDep = Annotated[UserDatabaseAsync[User, UUID4], Depends(get_user_db)] UserManagerDep = Annotated[UserManager, Depends(get_user_manager)] CurrentActiveUserDep = Annotated[User, Security(current_active_user)] CurrentActiveVerifiedUserDep = Annotated[User, Security(current_active_verified_user)] @@ -25,42 +29,52 @@ OptionalCurrentActiveUserDep = Annotated[User | None, Security(optional_current_active_user)] -# Organizations +def _loaded_user_organization(current_user: User) -> Organization | None: + """Return a loaded organization relationship without triggering async lazy loading.""" + loaded_value = inspect(current_user).attrs.organization.loaded_value + if loaded_value is NO_VALUE: + return None + return loaded_value -async def get_org_by_id( - organization_id: UUID4, +async def get_current_user_organization( + current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep, ) -> Organization: - """Get a valid organization by ID.""" - return await db_get_model_with_id_if_it_exists(session, Organization, organization_id) - + """Return the current user's organization or raise a stable not-found error.""" + if current_user.organization_id is None: + raise UserHasNoOrgError(user_id=current_user.id) -async def get_org_by_id_as_owner( - organization_id: UUID4, - current_user: CurrentActiveVerifiedUserDep, -) -> Organization: - """Dependency function to retrieve an organization by ID and ensure it's owned by the current user.""" - if ( - current_user.organization - and current_user.organization_id == organization_id - and current_user.organization_role == OrganizationRole.OWNER - ): - return current_user.organization + organization = _loaded_user_organization(current_user) + if organization is not None: + return organization - raise UserDoesNotOwnOrgError + return await require_model( + session, + Organization, + current_user.organization_id, + ) -async def get_org_by_id_as_member( - organization_id: UUID4, +async def get_current_user_owned_organization( current_user: CurrentActiveVerifiedUserDep, + session: AsyncSessionDep, ) -> Organization: - """Dependency function to retrieve an organization by ID and ensure the current user is a member.""" - if current_user.organization and current_user.organization_id == organization_id: - return current_user.organization - raise UserIsNotMemberError + """Return the current user's organization when they are its owner.""" + if current_user.organization_role != OrganizationRole.OWNER or current_user.organization_id is None: + raise UserDoesNotOwnOrgError(user_id=current_user.id) + + organization = _loaded_user_organization(current_user) + if organization is not None: + return organization + + return await require_model( + session, + Organization, + current_user.organization_id, + loaders={"members", "owner"}, + ) -OrgByID = Annotated[Organization, Depends(get_org_by_id)] -OrgAsOwner = Annotated[Organization, Depends(get_org_by_id_as_owner)] -OrgAsMember = Annotated[Organization, Depends(get_org_by_id_as_member)] +CurrentUserOrgDep = Annotated[Organization, Depends(get_current_user_organization)] +CurrentUserOwnedOrgDep = Annotated[Organization, Depends(get_current_user_owned_organization)] diff --git a/backend/app/api/auth/examples.py b/backend/app/api/auth/examples.py new file mode 100644 index 00000000..fd7bd01e --- /dev/null +++ b/backend/app/api/auth/examples.py @@ -0,0 +1,125 @@ +"""Centralized OpenAPI examples for auth schemas and routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +ORGANIZATION_CREATE_EXAMPLES = [ + { + "name": "Reverse Engineering Lab", + "location": "Leiden", + "description": "Research group for product teardown and circularity analysis", + } +] + +USER_CREATE_EXAMPLES = [ + { + "email": "user@example.com", + "password": "fake_password", + "username": "username", + "organization_id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + } +] + +USER_CREATE_WITH_ORGANIZATION_EXAMPLES = [ + { + "email": "user@example.com", + "password": "fake_password", + "username": "username", + "organization": { + "name": "organization", + "location": "location", + "description": "description", + }, + } +] + +USER_READ_EXAMPLES = [ + { + "id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "user@example.com", + "is_active": True, + "is_superuser": False, + "is_verified": True, + "username": "username", + } +] + +USER_UPDATE_EXAMPLES = [ + { + "password": "newpassword", + "email": "user@example.com", + "is_active": True, + "is_superuser": True, + "is_verified": True, + "username": "username", + "organization_id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + } +] + +REFRESH_TOKEN_REQUEST_EXAMPLES = [ + { + "refresh_token": "refresh-token-from-login", + } +] + +REFRESH_TOKEN_RESPONSE_EXAMPLES = [ + { + "access_token": "new-jwt-access-token", + "refresh_token": "rotated-refresh-token", + "token_type": "bearer", + "expires_in": 3600, + } +] + +USER_INCLUDE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + none=openapi_example([]), + products=openapi_example(["products"]), + all=openapi_example(["products", "organization"]), +) + +ORGANIZATION_INCLUDE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + none=openapi_example([]), + all=openapi_example(["owner", "members"]), +) + +ADMIN_USERS_RESPONSE_EXAMPLES = openapi_examples( + basic=openapi_example( + [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "username": "alice", + "email": "alice@example.com", + "is_active": True, + "is_superuser": False, + "is_verified": True, + } + ], + summary="Users without relationships", + ), + with_organization=openapi_example( + [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "username": "alice", + "email": "alice@example.com", + "is_active": True, + "is_superuser": False, + "is_verified": True, + "organization": { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "name": "University of Example", + "location": "Example City", + "description": "Example organization", + }, + } + ], + summary="Users with organization", + ), +) diff --git a/backend/app/api/auth/exceptions.py b/backend/app/api/auth/exceptions.py index d03bdefd..23b3682a 100644 --- a/backend/app/api/auth/exceptions.py +++ b/backend/app/api/auth/exceptions.py @@ -1,31 +1,37 @@ -"""Custom exceptions for user and organization operations.""" +"""Custom exceptions for authentication, user, and organization operations.""" -from fastapi import status +from fastapi import HTTPException, status +from fastapi_users.router.common import ErrorCode from pydantic import UUID4 - -from app.api.common.exceptions import APIError +from sqlalchemy.exc import IntegrityError + +from app.api.common.exceptions import ( + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + UnauthorizedError, +) +from app.api.common.models.base import get_model_label from app.api.common.models.custom_types import IDT, MT -class AuthCRUDError(APIError): +class AuthCRUDError(Exception): """Base class for custom authentication CRUD exceptions.""" -class UserNameAlreadyExistsError(AuthCRUDError): +class UserNameAlreadyExistsError(ConflictError, AuthCRUDError): """Raised when a username is already taken.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, username: str): msg = f"Username '{username}' is already taken." super().__init__(msg) -class AlreadyMemberError(AuthCRUDError): +class AlreadyMemberError(ConflictError, AuthCRUDError): """Raised when a user already belongs to an organization.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} already belongs to an organization" @@ -35,11 +41,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserOwnsOrgError(AuthCRUDError): +class UserOwnsOrgError(ConflictError, AuthCRUDError): """Raised when a user already owns an organization.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = (f"User with ID {user_id} owns an organization" if user_id else "You own an organization") + ( f": {details}" if details else "" @@ -48,11 +52,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserHasNoOrgError(AuthCRUDError): +class UserHasNoOrgError(NotFoundError, AuthCRUDError): """Raised when a user does not belong to any organization.""" - http_status_code = status.HTTP_404_NOT_FOUND - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} does not belong to an organization" @@ -62,11 +64,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserIsNotMemberError(AuthCRUDError): +class UserIsNotMemberError(ForbiddenError, AuthCRUDError): """Raised when a user does not belong to an organization.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__( self, user_id: UUID4 | None = None, organization_id: UUID4 | None = None, details: str | None = None ) -> None: @@ -78,11 +78,9 @@ def __init__( super().__init__(msg) -class UserDoesNotOwnOrgError(AuthCRUDError): +class UserDoesNotOwnOrgError(ForbiddenError, AuthCRUDError): """Raised when a user does not own an organization.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} does not own an organization" if user_id else "You do not own an organization" @@ -90,11 +88,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class OrganizationHasMembersError(AuthCRUDError): +class OrganizationHasMembersError(ConflictError, AuthCRUDError): """Raised when an organization has members and cannot be deleted.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, organization_id: UUID4 | None = None) -> None: msg = ( f"Organization {' with ID ' + str(organization_id) if organization_id else ''}" @@ -104,25 +100,183 @@ def __init__(self, organization_id: UUID4 | None = None) -> None: super().__init__(msg) -class OrganizationNameExistsError(AuthCRUDError): +class OrganizationNameExistsError(ConflictError, AuthCRUDError): """Raised when an organization with the same name already exists.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, msg: str = "Organization with this name already exists") -> None: super().__init__(msg) -class UserOwnershipError(APIError): +class UserOwnershipError(ForbiddenError): """Exception raised when a user does not own the specified model.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__( self, model_type: type[MT], model_id: IDT, user_id: UUID4, ) -> None: - model_name = model_type.get_api_model_name().name_capital + model_name = get_model_label(model_type) super().__init__(message=(f"User {user_id} does not own {model_name} with ID {model_id}.")) + + +class DisposableEmailError(BadRequestError, AuthCRUDError): + """Raised when a disposable email address is used.""" + + def __init__(self, email: str) -> None: + msg = f"The email address '{email}' is from a disposable email provider, which is not allowed." + super().__init__(msg) + + +class InvalidOAuthProviderError(BadRequestError): + """Raised when an unsupported OAuth provider is requested.""" + + def __init__(self, provider: str) -> None: + super().__init__(f"Invalid OAuth provider: {provider}.") + + +class OAuthAccountNotLinkedError(NotFoundError): + """Raised when the current user has no linked OAuth account for the provider.""" + + def __init__(self, provider: str) -> None: + super().__init__(f"OAuth account not linked for provider: {provider}.") + + +class RefreshTokenError(UnauthorizedError): + """Base class for refresh token authentication failures.""" + + +class RefreshTokenNotFoundError(RefreshTokenError): + """Raised when no refresh token is present in the request.""" + + def __init__(self) -> None: + super().__init__("Refresh token not found") + + +class RefreshTokenInvalidError(RefreshTokenError): + """Raised when a refresh token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired refresh token") + + +class RefreshTokenRevokedError(RefreshTokenError): + """Raised when a refresh token has already been revoked.""" + + def __init__(self) -> None: + super().__init__("Token has been revoked") + + +class RefreshTokenUserInactiveError(RefreshTokenError): + """Raised when the refresh token resolves to a missing or inactive user.""" + + def __init__(self) -> None: + super().__init__("User not found or inactive") + + +class OAuthHTTPError(HTTPException): + """Base class for OAuth flow errors that intentionally preserve FastAPI HTTPException payloads.""" + + def __init__(self, detail: str | ErrorCode, status_code: int = status.HTTP_400_BAD_REQUEST) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class OAuthStateDecodeError(OAuthHTTPError): + """Raised when an OAuth state token cannot be decoded.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.ACCESS_TOKEN_DECODE_ERROR) + + +class OAuthStateExpiredError(OAuthHTTPError): + """Raised when an OAuth state token has expired.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.ACCESS_TOKEN_ALREADY_EXPIRED) + + +class OAuthInvalidStateError(OAuthHTTPError): + """Raised when OAuth CSRF state validation fails.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_INVALID_STATE) + + +class OAuthInvalidRedirectURIError(OAuthHTTPError): + """Raised when a frontend OAuth redirect URI is not allowlisted.""" + + def __init__(self) -> None: + super().__init__("Invalid redirect_uri") + + +class OAuthEmailUnavailableError(OAuthHTTPError): + """Raised when the OAuth provider does not return an email address.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL) + + +class OAuthUserAlreadyExistsHTTPError(OAuthHTTPError): + """Raised when an OAuth login collides with an existing unlinked user.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_USER_ALREADY_EXISTS) + + +class OAuthInactiveUserHTTPError(OAuthHTTPError): + """Raised when an OAuth-authenticated user is inactive.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.LOGIN_BAD_CREDENTIALS) + + +class OAuthAccountAlreadyLinkedError(OAuthHTTPError): + """Raised when an OAuth provider account is already linked to another user.""" + + def __init__(self) -> None: + super().__init__("This account is already linked to another user.") + + +class RegistrationHTTPError(HTTPException): + """Base class for registration-route HTTP errors with stable string details.""" + + def __init__(self, detail: str, status_code: int) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class RegistrationUserAlreadyExistsHTTPError(RegistrationHTTPError): + """Raised when a registration email is already in use.""" + + def __init__(self) -> None: + super().__init__(detail="A user with this email already exists", status_code=status.HTTP_409_CONFLICT) + + +class RegistrationInvalidPasswordHTTPError(RegistrationHTTPError): + """Raised when password validation fails during registration.""" + + def __init__(self, reason: str) -> None: + super().__init__( + detail=f"Password validation failed: {reason}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + +class RegistrationUnexpectedHTTPError(RegistrationHTTPError): + """Raised when an unexpected registration failure occurs.""" + + def __init__(self) -> None: + super().__init__( + detail="An unexpected error occurred during registration", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +UNIQUE_VIOLATION_PG_CODE = "23505" + + +def handle_organization_integrity_error(e: IntegrityError, action: str) -> None: + """Handle integrity errors when creating or updating an organization, and raise appropriate exceptions.""" + if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: + raise OrganizationNameExistsError from e + err_msg = f"Error {action} organization: {e}" + raise InternalServerError(details=err_msg, log_message=err_msg) from e diff --git a/backend/app/api/auth/filters.py b/backend/app/api/auth/filters.py index eb6f47d6..912a252c 100644 --- a/backend/app/api/auth/filters.py +++ b/backend/app/api/auth/filters.py @@ -1,12 +1,14 @@ """Fastapi-filter schemas for filtering User and Organization models.""" -from typing import ClassVar +from typing import TYPE_CHECKING -from fastapi_filter import FilterDepends, with_prefix from fastapi_filter.contrib.sqlalchemy import Filter from app.api.auth.models import Organization, User +if TYPE_CHECKING: + from typing import ClassVar + class UserFilter(Filter): """FastAPI-filter class for User filtering.""" @@ -18,15 +20,18 @@ class UserFilter(Filter): is_superuser: bool | None = None is_verified: bool | None = None - search_model_fields: ClassVar[list[str]] = [ - "email", - "username", - "organization", - ] + search: str | None = None + + class Constants(Filter.Constants): + """Constants for UserFilter.""" - class Constants(Filter.Constants): # noqa: D106 # Standard FastAPI-filter class model = User + search_model_fields: ClassVar[list[str]] = [ + "email", + "username", + ] + class OrganizationFilter(Filter): """FastAPI-filter class for Organization filtering.""" @@ -35,24 +40,17 @@ class OrganizationFilter(Filter): location__ilike: str | None = None description__ilike: str | None = None - search_model_fields: ClassVar[list[str]] = [ - "name", - "location", - "description", - ] - - class Constants(Filter.Constants): # noqa: D106 # Standard FastAPI-filter class - model = Organization - + search: str | None = None -class UserFilterWithRelationships(UserFilter): - """FastAPI-filter class for User filtering with relationships.""" + order_by: list[str] | None = None - organization: UserFilter | None = FilterDepends(with_prefix("owner", UserFilter)) + class Constants(Filter.Constants): + """Constants for OrganizationFilter.""" + model = Organization -class OrganizationFilterWithRelationships(OrganizationFilter): - """FastAPI-filter class for Organization filtering with relationships.""" - - owner: UserFilter | None = FilterDepends(with_prefix("owner", UserFilter)) - members: UserFilter | None = FilterDepends(with_prefix("users", UserFilter)) + search_model_fields: ClassVar[list[str]] = [ + "name", + "location", + "description", + ] diff --git a/backend/app/api/auth/models.py b/backend/app/api/auth/models.py index 46acc523..27a67118 100644 --- a/backend/app/api/auth/models.py +++ b/backend/app/api/auth/models.py @@ -1,66 +1,94 @@ """Database models related to platform users.""" import uuid -from enum import Enum -from functools import cached_property -from typing import TYPE_CHECKING, Annotated, Optional +from datetime import datetime # noqa: TC003 # Used at runtime for ORM mapped annotations +from enum import StrEnum +from typing import Any # noqa: TC003 # Used at runtime for ORM mapped annotations -from fastapi_users_db_sqlmodel import SQLModelBaseOAuthAccount, SQLModelBaseUserDB -from pydantic import UUID4, BaseModel, ConfigDict, StringConstraints +from pydantic import BaseModel +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint from sqlalchemy import Enum as SAEnum -from sqlalchemy import ForeignKey -from sqlmodel import Column, Field, Relationship +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.api.common.models.base import CustomBase, CustomBaseBare, TimeStampMixinBare +from app.api.auth.services.user_database import BaseOAuthAccountDB, BaseUserDB +from app.api.common.models.base import Base, TimeStampMixinBare -if TYPE_CHECKING: - from app.api.data_collection.models import Product +# Note: Keeping auth models together avoids circular imports in SQLAlchemy/Pydantic schema building. -# TODO: Refactor into separate files for each model. -# This is tricky due to circular imports and the way SQLAlchemy and Pydantic handle schema building. ### Enums ### -class OrganizationRole(str, Enum): +class OrganizationRole(StrEnum): """Enum for organization roles.""" OWNER = "owner" MEMBER = "member" -### User Model ### +### Pydantic base schemas (shared with schemas.py) ### class UserBase(BaseModel): - """Base schema for user data.""" + """Base schema for user data. Used by Pydantic schemas only, not ORM.""" - username: Annotated[ - str | None, - StringConstraints(strip_whitespace=True, pattern=r"^[\w]+$"), # Allows only letters, numbers, and underscores - ] = Field(index=True, unique=True, default=None) + username: str | None = None - model_config = ConfigDict(use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config = {"use_enum_values": True} -class User(UserBase, CustomBaseBare, TimeStampMixinBare, SQLModelBaseUserDB, table=True): +class OrganizationBase(BaseModel): + """Base schema for organization data. Used by Pydantic schemas only, not ORM.""" + + name: str + location: str | None = None + description: str | None = None + + +class User(BaseUserDB, TimeStampMixinBare): """Database model for platform users.""" + # Override __tablename__ from base (both set "user", this is explicit) + __tablename__ = "user" + + username: Mapped[str | None] = mapped_column(String(50), index=True, unique=True, default=None) + + # Login tracking + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + last_login_ip: Mapped[str | None] = mapped_column(String(45), default=None) + + # Flexible user preferences (UI settings, feature toggles, etc.) + preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}", default=dict) + + # Pre-computed statistics (product count, total weight, top categories, etc.) + stats_cache: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}", default=dict) + # One-to-many relationship with OAuthAccount - oauth_accounts: list["OAuthAccount"] = Relationship( + oauth_accounts: Mapped[list[OAuthAccount]] = relationship( back_populates="user", - sa_relationship_kwargs={"lazy": "joined"}, # Required because of FastAPI-Users OAuth implementation + lazy="joined", # Required because of FastAPI-Users OAuth implementation + foreign_keys="[OAuthAccount.user_id]", ) - products: list["Product"] = Relationship(back_populates="owner") # Many-to-one relationship with Organization - organization_id: UUID4 | None = Field( + organization_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("organization.id", use_alter=True, name="fk_user_organization"), default=None, - sa_column=Column(ForeignKey("organization.id", use_alter=True, name="fk_user_organization"), nullable=True), ) - organization: Optional["Organization"] = Relationship( - back_populates="members", sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[User.organization_id]"} + organization: Mapped[Organization | None] = relationship( + back_populates="members", + lazy="selectin", + foreign_keys="[User.organization_id]", ) - organization_role: OrganizationRole | None = Field(default=None, sa_column=Column(SAEnum(OrganizationRole))) + organization_role: Mapped[OrganizationRole | None] = mapped_column(SAEnum(OrganizationRole), default=None) - @cached_property + # One-to-one relationship with owned Organization + owned_organization: Mapped[Organization | None] = relationship( + back_populates="owner", + uselist=False, + foreign_keys="[Organization.owner_id]", + ) + + @property def is_organization_owner(self) -> bool: + """Check if the user is an organization owner.""" return self.organization_role == OrganizationRole.OWNER def __str__(self) -> str: @@ -68,43 +96,49 @@ def __str__(self) -> str: ### OAuthAccount Model ### -class OAuthAccount(SQLModelBaseOAuthAccount, CustomBaseBare, TimeStampMixinBare, table=True): - """Database model for OAuth accounts. Note that the main implementation is in the base class.""" +class OAuthAccount(BaseOAuthAccountDB, TimeStampMixinBare): + """Database model for OAuth accounts.""" - # Many-to-one relationship with User - user: User = Relationship(back_populates="oauth_accounts") + __tablename__ = "oauthaccount" + # Redefine user_id to ensure the ForeignKey survives mixin inheritance. + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id"), nullable=False) -### Organization Model ### -class OrganizationBase(CustomBase): - """Base schema for organization data.""" + # Many-to-one relationship with User + user: Mapped[User] = relationship( + back_populates="oauth_accounts", + foreign_keys="[OAuthAccount.user_id]", + ) - name: str = Field(index=True, unique=True, min_length=2, max_length=100) - location: str | None = Field(default=None, max_length=100) - description: str | None = Field(default=None, max_length=500) + __table_args__ = (UniqueConstraint("oauth_name", "account_id", name="uq_oauth_account_identity"),) -class Organization(OrganizationBase, TimeStampMixinBare, table=True): +### Organization Model ### +class Organization(TimeStampMixinBare, Base): """Database model for organizations.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + __tablename__ = "organization" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(100), index=True, unique=True) + location: Mapped[str | None] = mapped_column(String(100), default=None) + description: Mapped[str | None] = mapped_column(String(500), default=None) # One-to-one relationship with owner User - owner_id: UUID4 = Field( - sa_column=Column(ForeignKey("user.id", use_alter=True, name="fk_organization_owner"), nullable=False), + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("user.id", use_alter=True, name="fk_organization_owner"), nullable=False ) - owner: User = Relationship( - back_populates="organization", - sa_relationship_kwargs={"primaryjoin": "Organization.owner_id == User.id", "foreign_keys": "[User.id]"}, + owner: Mapped[User] = relationship( + back_populates="owned_organization", + uselist=False, + foreign_keys="[Organization.owner_id]", + post_update=True, ) # One-to-many relationship with member Users - members: list["User"] = Relationship( + members: Mapped[list[User]] = relationship( back_populates="organization", - sa_relationship_kwargs={ - "primaryjoin": "Organization.id == User.organization_id", - "foreign_keys": "[User.organization_id]", - }, + foreign_keys="[User.organization_id]", ) def __str__(self) -> str: diff --git a/backend/app/api/auth/resources/disposable_email_domains.txt b/backend/app/api/auth/resources/disposable_email_domains.txt new file mode 100644 index 00000000..272ab445 --- /dev/null +++ b/backend/app/api/auth/resources/disposable_email_domains.txt @@ -0,0 +1,72201 @@ +# Curated local fallback for disposable email validation. +# Refresh from upstream with: `just refresh-disposable-email-domains` +0-00.usa.cc +0-30-24.com +0-attorney.com +0-mail.com +00-tv.com +00.msk.ru +00.pe +00000000000.pro +000777.info +00082cc.com +000email.com +001.igg.biz +001gmail.com +002gmail.com +002r.com +002t.com +003271.com +0033.pl +0039.cf +0039.ga +0039.gq +0039.ml +003j.com +004k.com +0058.ru +006j.com +006o.com +007game.ru +007gmail.com +008gmail.com +009gmail.com +00b2bcr51qv59xst2.cf +00b2bcr51qv59xst2.ga +00b2bcr51qv59xst2.gq +00b2bcr51qv59xst2.ml +00b2bcr51qv59xst2.tk +00g0.com +00jac.com +00sh.cf +01022.hk +010gmail.com +01130.hk +011gmail.com +0123.website +01234.space +012gmail.com +017gmail.com +01852990.ga +01911.ru +0199934.com +019gmail.com +01bktwi2lzvg05.cf +01bktwi2lzvg05.ga +01bktwi2lzvg05.gq +01bktwi2lzvg05.ml +01bktwi2lzvg05.tk +01g.cloud +01gmail.com +01hosting.biz +01trends.com +02.pl +020gmail.com +020yiren.com +020zlgc.com +022gmail.com +023gmail.com +024024.cf +02466.cf +02466.ga +02466.gq +02466.ml +025gmail.com +027168.com +029gmail.com +02gmail.com +02hotmail.com +03-genkzmail.ga +030gmail.com +0317123.cn +031839.com +031gmail.com +032gmail.com +0335g.com +036gmail.com +039gmail.com +03gmail.com +0411cs.com +041gmail.com +042gmail.com +043gmail.com +045692.xyz +047gmail.com +04gmail.com +050gmail.com +0530fk.com +0543sh.com +057gmail.com +058gmail.com +0597797341.website +059gmail.com +05gmail.com +05hotmail.com +060gmail.com +0623456.com +062e.com +062gmail.com +063gmail.com +065gmail.com +0662dq.com +066gmail.com +067gmail.com +068gmail.com +06gmail.com +07-izvestiya.ru +07157.com +071gmail.com +0731tz.com +07819.cf +07819.ga +07819.gq +07819.ml +07819.tk +078gmail.com +079gmail.com +079i080nhj.info +07gmail.com +0800br.ml +080mail.com +0815.ru +0815.su +0845.ru +0854445.com +086gmail.com +087gmail.com +089563.quest +089gmail.com +08gmail.com +090gmail.com +091gmail.com +092gmail.com +0934445.com +093gmail.com +095gmail.com +096gmail.com +097gmail.com +098gmail.com +099gmail.com +09gmail.com +09ojsdhad.info +09stees.online +0accounts.com +0ak.org +0an.ru +0box.eu +0box.net +0cd.cn +0celot.com +0cindcywrokv.cf +0cindcywrokv.ga +0cindcywrokv.gq +0cindcywrokv.ml +0cindcywrokv.tk +0clickemail.com +0clock.net +0clock.org +0costofivf.com +0cv23qjrvmcpt.cf +0cv23qjrvmcpt.ga +0cv23qjrvmcpt.gq +0cv23qjrvmcpt.ml +0cv23qjrvmcpt.tk +0d00.com +0ehtkltu0sgd.ga +0ehtkltu0sgd.ml +0ehtkltu0sgd.tk +0eml.com +0f590da1.bounceme.net +0fru8te0xkgfptti.cf +0fru8te0xkgfptti.ga +0fru8te0xkgfptti.gq +0fru8te0xkgfptti.ml +0fru8te0xkgfptti.tk +0gyenlcce.dropmail.me +0h26le75d.pl +0hboy.com +0hcow.com +0hdear.com +0hio.net +0hio.org +0hio0ak.com +0hiolce.com +0hioln.com +0ils.net +0ils.org +0ioi.net +0jralz2qipvmr3n.ga +0jralz2qipvmr3n.ml +0jralz2qipvmr3n.tk +0jylaegwalss9m6ilvq.cf +0jylaegwalss9m6ilvq.ga +0jylaegwalss9m6ilvq.gq +0jylaegwalss9m6ilvq.ml +0jylaegwalss9m6ilvq.tk +0kok.net +0kok.org +0ld0ak.com +0ld0x.com +0live.org +0ll2au4c8.pl +0mel.com +0mfs0mxufjpcfc.cf +0mfs0mxufjpcfc.ga +0mfs0mxufjpcfc.gq +0mfs0mxufjpcfc.ml +0mfs0mxufjpcfc.tk +0mixmail.info +0n0ff.net +0n24.com +0nb9zti01sgz8u2a.cf +0nb9zti01sgz8u2a.ga +0nb9zti01sgz8u2a.gq +0nb9zti01sgz8u2a.ml +0nb9zti01sgz8u2a.tk +0nce.net +0ne.lv +0ne0ak.com +0ne0ut.com +0nedrive.cf +0nedrive.ga +0nedrive.gq +0nedrive.ml +0nedrive.tk +0nelce.com +0nes.net +0nes.org +0nly.org +0nrg.com +0oxgvfdufyydergd.cf +0oxgvfdufyydergd.ga +0oxgvfdufyydergd.gq +0oxgvfdufyydergd.ml +0oxgvfdufyydergd.tk +0pppp.com +0r0wfuwfteqwmbt.cf +0r0wfuwfteqwmbt.ga +0r0wfuwfteqwmbt.gq +0r0wfuwfteqwmbt.ml +0r0wfuwfteqwmbt.tk +0ranges.com +0rdered.com +0rdering.com +0regon.net +0regon.org +0rg.fr +0sg.net +0sx.ru +0tinak9zyvf.cf +0tinak9zyvf.ga +0tinak9zyvf.gq +0tinak9zyvf.ml +0tinak9zyvf.tk +0tires.com +0to6oiry4ghhscmlokt.cf +0to6oiry4ghhscmlokt.ga +0to6oiry4ghhscmlokt.gq +0to6oiry4ghhscmlokt.ml +0to6oiry4ghhscmlokt.tk +0u.ro +0ulook.com +0utln.com +0uxpgdvol9n.cf +0uxpgdvol9n.ga +0uxpgdvol9n.gq +0uxpgdvol9n.ml +0uxpgdvol9n.tk +0v.ro +0vomphqb.emlhub.com +0w.ro +0wn3d.pl +0wnd.net +0wnd.org +0wos8czt469.ga +0wos8czt469.gq +0wos8czt469.tk +0x00.name +0x000.cf +0x000.ga +0x000.gq +0x000.ml +0x01.gq +0x01.tk +0x02.cf +0x02.ga +0x02.gq +0x02.ml +0x02.tk +0x03.cf +0x03.ga +0x03.gq +0x03.ml +0x03.tk +0x207.info +0xmiikee.com +0za7vhxzpkd.cf +0za7vhxzpkd.ga +0za7vhxzpkd.gq +0za7vhxzpkd.ml +0za7vhxzpkd.tk +0zc7eznv3rsiswlohu.cf +0zc7eznv3rsiswlohu.ml +0zc7eznv3rsiswlohu.tk +0zspgifzbo.cf +0zspgifzbo.ga +0zspgifzbo.gq +0zspgifzbo.ml +0zspgifzbo.tk +1-3-3-7.net +1-8.biz +1-box.ru +1-million-rubley.xyz +1-second-mail.site +1-tm.com +1-up.cf +1-up.ga +1-up.gq +1-up.ml +1-up.tk +1.atm-mi.cf +1.atm-mi.ga +1.atm-mi.gq +1.atm-mi.ml +1.atm-mi.tk +1.batikbantul.com +1.bestsitetoday.website +1.emaile.org +1.emailfake.ml +1.fackme.gq +1.kerl.cf +1.spymail.one +1.supere.ml +10-minute-mail.com +10-minute-mail.de +10-minuten-mail.de +10-tube.ru +10.dns-cloud.net +10.laste.ml +10000websites.miasta.pl +1000gay.com +1000gmail.com +1000kti.xyz +1000mail.com +1000mail.tk +1000rebates.stream +1000rub.com +1000xbetslots.xyz +1001gmail.com +100bet.online +100gmail.com +100hot.ru +100kkk.ru +100kti.xyz +100lat.com.pl +100likers.com +100lvl.com +100m.hl.cninfo.net +100pet.ru +100ss.ru +100tb-porno.ru +100vesov24.ru +100xbit.com +10100.ml +1012.com +1012gmail.com +101gmail.com +101livemail.top +101peoplesearches.com +101pl.us +101price.co +1020pay.com +102gmail.com +1050.gq +1056windtreetrace.com +105gmail.com +105kg.ru +106gmail.com +107punto7.com +1092df.com +10963rip1.mimimail.me +10bir.com +10dk.email +10dkmail.net +10host.top +10inbox.online +10launcheds.com +10m.email +10m.in +10mail.com +10mail.info +10mail.org +10mail.tk +10mail.xyz +10mails.net +10mi.org +10minemail.com +10minemail.net +10minmail.de +10minut.com.pl +10minut.xyz +10minute-email.com +10minute.cf +10minuteemails.com +10minutemail.be +10minutemail.cf +10minutemail.co.uk +10minutemail.co.za +10minutemail.com +10minutemail.de +10minutemail.ga +10minutemail.gq +10minutemail.info +10minutemail.ml +10minutemail.net +10minutemail.nl +10minutemail.org +10minutemail.pl +10minutemail.pro +10minutemail.ru +10minutemail.us +10minutemailbox.com +10minutemails.in +10minutenemail.de +10minutenmail.xyz +10minutesemail.net +10minutesmail.com +10minutesmail.fr +10minutesmail.net +10minutesmail.ru +10minutesmail.us +10minutetempemail.com +10minutmail.pl +10mt.cc +10pmdesign.com +10vpn.info +10x.es +10x10-bet.com +10x9.com +11-32.cf +11-32.ga +11-32.gq +11-32.ml +11-32.tk +110202.com +110mail.net +1111.ru +11111.ru +111222.pl +11163.com +111gmail.com +111vt.com +112288211.com +112gmail.com +112oank.com +113gmail.com +114207.com +114gmail.com +115200.xyz +115gmail.com +115mail.net +116.vn +116gmail.com +117.yyolf.net +11852dbmobbil.emlhub.com +1195dbmobbil.emlhub.com +119mail.com +11a-klass.ru +11b-klass.ru +11booting.com +11cows.com +11fortune.com +11gmail.com +11jac.com +11lu.org +11thhourgospelgroup.com +11top.xyz +11xz.com +11yahoo.com +12-znakov.ru +12.dropmail.me +1200b.com +120181311.xyz +120gmail.com +120mail.com +1212gmail.com +1213gmail.com +121gmail.com +1221locust.com +122444.xyz +122gmail.com +123-m.com +123.com +123.dns-cloud.net +123.emlhub.com +1231254.com +123321asedad.info +12345gmail.com +1234gmail.com +1234yahoo.com +1236456.com +123amateucam.com +123anddone.com +123box.org +123clone.com +123coupons.com +123gmail.com +123hummer.com +123mail.ml +123mails.org +123market.com +123moviesfree.one +123moviesonline.club +123salesreps.com +123tech.site +12499aaa.com +124gmail.com +125-jahre-kudamm.de +12503dbmobbil.emlhub.com +125gmail.com +125hour.online +126.com.com +126.com.org +126sell.com +127.life +127gmail.com +128gmail.com +129.in +129aastersisyii.info +129gmail.com +12ab.info +12bclass.us +12blogwonders.com +12h.click +12hosting.net +12houremail.com +12hourmail.com +12minutemail.com +12minutemail.net +12monthsloan1.co.uk +12search.com +12shoe.com +12storage.com +12ur8rat.pl +12wqeza.com +130gmail.com +1313gmail.com +131ochman.emlhub.com +1337.care +1337.email +1337.no +133mail.cn +134gmail.com +1369.ru +138gmail.com +139gmail.com +13dk.net +13fishing.ru +13gmail.com +13hotmail.com +13sasytkgb0qobwxat.cf +13sasytkgb0qobwxat.ga +13sasytkgb0qobwxat.gq +13sasytkgb0qobwxat.ml +13sasytkgb0qobwxat.tk +13yahoo.com +14-8000.ru +140gmail.com +140unichars.com +141gmail.com +143gmail.com +1444.us +144gmail.com +145gmail.com +146gmail.com +147.cl +147gmail.com +1490wntj.com +149gmail.com +14club.org.uk +14gmail.com +14n.co.uk +14p.in +14yahoo.com +1500klass.ru +150bc.com +150gmail.com +15140dbmobbil.emlhub.com +151gmail.com +152gmail.com +15375dbmobbil.emlhub.com +153gmail.com +154884.com +154gmail.com +155gmail.com +156gmail.com +157gmail.com +15963.fr.nf +15gmail.com +15qm-mail.red +15qm.com +161gg161.com +161gmail.com +163.com.com +163.com.org +163fy.com +164gmail.com +164qq.com +1655mail.com +166gmail.com +1676.ru +167gmail.com +167mail.com +1688daogou.com +168cyg.com +168gmail.com +16983dbmobbil.emlhub.com +16gmail.com +16ik7egctrkxpn9okr.ga +16ik7egctrkxpn9okr.ml +16ik7egctrkxpn9okr.tk +16yahoo.com +1701host.com +170gmail.com +171646.app +171gmail.com +17200rip1.mimimail.me +172tuan.com +174gmail.com +1758indianway.com +175gmail.com +175vip.xyz +17601dbmobbil.emlhub.com +1766258.com +176gmail.com +178gmail.com +179bet.club +179gmail.com +17gmail.com +17hotmail.com +17tgo.com +17tgy.com +17upay.com +17yahoo.com +18-19.cf +18-19.ga +18-19.gq +18-19.ml +18-19.tk +18-9-2.cf +18-9-2.ga +18-9-2.gq +18-9-2.ml +18-9-2.tk +1800-americas.info +1800banks.com +1800endo.net +1820mail.vip +182100.ru +182gmail.com +183carlton.changeip.net +183gmail.com +185gmail.com +1866sailobx.com +186gmail.com +186site.com +1871188.net +18767dbmobbil.emlhub.com +187gmail.com +18822ochman.emlhub.com +188gmail.com +189.email +1895photography.com +189gmail.com +18a8q82bc.pl +18am.ru +18chiks.com +18dewa.fun +18dewa.live +18gmail.com +18ladies.com +1909.com +190gmail.com +19162ochman.emlhub.com +1919-2009ch.pl +191mariobet.com +1944gmail.com +194gmail.com +1950gmail.com +1953gmail.com +1956gmail.com +1957gmail.com +1959gmail.com +1960gmail.com +1961.com +1961gmail.com +1962.com +1963gmail.com +1964.com +1964gmail.com +1969.com +1969gmail.com +196gmail.com +1970.com +1970gmail.com +1974gmail.com +1975gmail.com +1978.com +1978gmail.com +1979gmail.com +1980gmail.com +1981gmail.com +1981pc.com +1982gmail.com +1983gmail.com +1984gmail.com +1985abc.com +1985gmail.com +1985ken.net +1986gmail.com +1987.com +1987gmail.com +1988gmail.com +1989gmail.com +198funds.com +198gmail.com +1990gmail.com +19917ochman.emlhub.com +1991gmail.com +19922.cf +19922.ga +19922.gq +19922.ml +1992gmail.com +1993gmail.com +1994gmail.com +1995gmail.com +1996a.lol +1996gmail.com +1997gmail.com +1998gmail.com +1999gmail.com +199cases.com +199gmail.com +19gmail.com +19quotes.com +19yahoo.com +1a-flashgames.info +1ac.xyz +1adir.com +1afbwqtl8bcimxioz.cf +1afbwqtl8bcimxioz.ga +1afbwqtl8bcimxioz.gq +1afbwqtl8bcimxioz.ml +1afbwqtl8bcimxioz.tk +1amsleep.xyz +1ank6cw.gmina.pl +1aolmail.com +1asdasd.com +1automovers.info +1ayj8yi7lpiksxawav.cf +1ayj8yi7lpiksxawav.ga +1ayj8yi7lpiksxawav.gq +1ayj8yi7lpiksxawav.ml +1ayj8yi7lpiksxawav.tk +1bahisno1.com +1bedpage.com +1bi.email-temp.com +1blackmoon.com +1blueymail.gq +1c-spec.ru +1ce.us +1chsdjk7f.pl +1chuan.com +1clck2.com +1click-me.info +1cmmit.ru +1cocosmail.co.cc +1cw1mszn.pl +1datingintheusa.com +1dds23.com +1dmedical.com +1dne.com +1drive.cf +1drive.ga +1drive.gq +1e72.com +1e80.com +1errz9femsvhqao6.cf +1errz9femsvhqao6.ga +1errz9femsvhqao6.gq +1errz9femsvhqao6.ml +1errz9femsvhqao6.tk +1euqhmw9xmzn.cf +1euqhmw9xmzn.ga +1euqhmw9xmzn.gq +1euqhmw9xmzn.ml +1euqhmw9xmzn.tk +1f3t.com +1f4.xyz +1forthemoney.com +1fsdfdsfsdf.tk +1gatwickaccommodation.info +1gfb3h.spymail.one +1gmail.com +1googlemail.com +1heizi.com +1hermesbirkin0.com +1hmoxs72qd.cf +1hmoxs72qd.ga +1hmoxs72qd.ml +1hmoxs72qd.tk +1hotmail.co.uk +1hotmail.com +1hours.com +1hsoagca2euowj3ktc.ga +1hsoagca2euowj3ktc.gq +1hsoagca2euowj3ktc.ml +1hsoagca2euowj3ktc.tk +1ima.no +1intimshop.ru +1jypg93t.orge.pl +1ki.co +1lifeproducts.com +1liqu1d.gq +1load-fiiliiies.ru +1lp7j.us +1lv.in +1mail.ml +1mail.site +1mail.uk.to +1maschio.site +1milliondollars.xyz +1mojadieta.ru +1moresurvey.com +1mspkvfntkn9vxs1oit.cf +1mspkvfntkn9vxs1oit.ga +1mspkvfntkn9vxs1oit.gq +1mspkvfntkn9vxs1oit.ml +1mspkvfntkn9vxs1oit.tk +1nnex.com +1nom.org +1nppx7ykw.pl +1nut.com +1oh1.com +1om.co +1ouboutinshoes.com +1ouisvuitton1.com +1ouisvuittonborseit.com +1ouisvuittonfr.com +1pad.de +1penceauction.co.uk +1petra.website +1qpatglchm1.cf +1qpatglchm1.ga +1qpatglchm1.gq +1qpatglchm1.ml +1qpatglchm1.tk +1qwezaa.com +1rentcar.top +1rererer.ru +1resep.art +1riladg.mil.pl +1rmgqwfno8wplt.cf +1rmgqwfno8wplt.ga +1rmgqwfno8wplt.gq +1rmgqwfno8wplt.ml +1rmgqwfno8wplt.tk +1rnydobtxcgijcfgl.cf +1rnydobtxcgijcfgl.ga +1rnydobtxcgijcfgl.gq +1rnydobtxcgijcfgl.ml +1rnydobtxcgijcfgl.tk +1rumk9woxp1.pl +1rzk1ufcirxtg.ga +1rzk1ufcirxtg.ml +1rzk1ufcirxtg.tk +1rzpdv6y4a5cf5rcmxg.cf +1rzpdv6y4a5cf5rcmxg.ga +1rzpdv6y4a5cf5rcmxg.gq +1rzpdv6y4a5cf5rcmxg.ml +1rzpdv6y4a5cf5rcmxg.tk +1s.fr +1s1uasxaqhm9.cf +1s1uasxaqhm9.ga +1s1uasxaqhm9.gq +1s1uasxaqhm9.ml +1s1uasxaqhm9.tk +1sad.com +1sec.site +1secmail.com +1secmail.net +1secmail.org +1secmail.ru +1secmail.space +1secmail.website +1secmail.xyz +1shivom.com +1sj2003.com +1slate.com +1smail.top +1smailbr.top +1spcziorgtfpqdo.cf +1spcziorgtfpqdo.ga +1spcziorgtfpqdo.gq +1spcziorgtfpqdo.ml +1spcziorgtfpqdo.tk +1ss.noip.me +1st-forms.com +1stbest.info +1stcallsecurity.com +1stdibs.icu +1stdomainresource.com +1stimmobilien.eu +1stpatrol.info +1sworld.com +1sydney.net +1syn.info +1t-ml.com +1thecity.biz +1tmail.club +1tmail.ltd +1tml.com +1to1mail.org +1trick.net +1trionclub.com +1turkeyfarmlane.com +1tware.com +1up.orangotango.gq +1upserve.com +1uscare.com +1usemail.com +1usweb.com +1vitsitoufficiale.com +1vsitoit.com +1vtvga6.orge.pl +1vvb.ru +1webmail.gdn +1webmail.info +1webmail.net +1webmail.xyz +1website.net +1x1zsv9or.pl +1xbkbet.com +1xkfe3oimup4gpuop.cf +1xkfe3oimup4gpuop.ga +1xkfe3oimup4gpuop.gq +1xkfe3oimup4gpuop.ml +1xkfe3oimup4gpuop.tk +1xp.fr +1xrecruit.online +1xstabka.ru +1xy86py.top +1zhuan.com +1zl.org +1zxzhoonfaia3.cf +1zxzhoonfaia3.ga +1zxzhoonfaia3.gq +1zxzhoonfaia3.ml +1zxzhoonfaia3.tk +2-attorney.com +2-bee.tk +2-ch.space +2-l.net +2.batikbantul.com +2.emailfake.ml +2.fackme.gq +2.kerl.cf +2.safemail.cf +2.safemail.tk +2.sexymail.ooo +2.spymail.one +2.tebwinsoi.ooo +2.vvsmail.com +2.yomail.info +20-20pathways.com +20.dns-cloud.net +20.gov +2000-plus.pl +2000gmail.com +2000rebates.stream +2001gmail.com +2002gmail.com +2003gmail.com +2004gmail.com +200555.com +2005gmail.com +2006gmail.com +2007gmail.com +20080rip1.mimimail.me +2008firecode.info +2008gmail.com +2008radiochat.info +2009gmail.com +200cai.com +200gmail.com +2010gmail.com +2010tour.info +2011cleanermail.info +2011gmail.com +2011rollover.info +2012-2016.ru +2012ajanda.com +2012burberryhandbagsjp.com +2012casquebeatsbydre.info +2012moncleroutletjacketssale.com +2012nflnews.com +2012pandoracharms.net +2013-ddrvers.ru +2013-lloadboxxx.ru +2013cheapnikeairjordan.org +2013dietsfromoz.com +2013fitflopoutlet.com +2013longchamppaschere.com +2013louboutinoutlets.com +2013mercurialshoeusa.com +2013nikeairmaxv.com +2013spmd.ru +2014gmail.com +2014mail.ru +2016gmail.com +2017gmail.com +2018-12-23.ga +2018gmail.com +2019gmail.com +2019x.cf +2019x.ga +2019x.gq +2019x.ml +2019y.cf +2019y.ga +2019y.gq +2019y.ml +2019z.cf +2019z.ga +2019z.gq +2019z.ml +2019z.tk +201gmail.com +2020.gimal.com +2020gmail.com +202qs.com +20433dbmobbil.emlhub.com +204gmail.com +2050.com +20520.com +20529dbmobbil.emlhub.com +206214.com +206896.com +206gmail.com +2084-antiutopia.ru +208gmail.com +2094445.com +20boxme.org +20email.eu +20email.it +20mail.eu +20mail.in +20mail.it +20minute.email +20minutemail.com +20minutemail.it +20minutesmail.com +20mm.eu +20twelvedubstep.com +2100.com +210gmail.com +210ms.com +211619.xyz +211gmail.com +2120001.net +2121gmail.com +212gmail.com +212staff.com +214.pl +2147h.com +2166ddf0-db94-460d-9558-191e0a3b86c0.ml +2166tow6.mil.pl +216gmail.com +21871dbmobbil.emlhub.com +218gmail.com +21999ochman.emlhub.com +219gmail.com +21daysugardetoxreview.org +21email4now.info +21hotmail.com +21jag.com +21lr12.cf +21mail.xyz +21yearsofblood.com +22-bet.org +2200freefonts.com +220gmail.com +220w.net +221gmail.com +2222gmail.com +222gmail.com +22332ochman.emlhub.com +223gmail.com +224gmail.com +225522.ml +2266av.com +22794.com +227gmail.com +227r7.anonbox.net +22856dbmobbil.emlhub.com +22ffnrxk11oog.cf +22ffnrxk11oog.ga +22ffnrxk11oog.gq +22ffnrxk11oog.tk +22ikb.anonbox.net +22jharots.com +22meds.com +22office.com +22ov17gzgebhrl.cf +22ov17gzgebhrl.gq +22ov17gzgebhrl.ml +22ov17gzgebhrl.tk +22zollmonitor.com +23-february-posdrav.ru +231gmail.com +2323bryanstreet.com +2323gmail.com +232gmail.com +23343dbmobbil.emlhub.com +2336900.com +234.pl +234asdadsxz.info +234gmail.com +235francisco.com +235gmail.com +237bets.com +23864ochman.emlhub.com +238gmail.com +239gmail.com +23fanofknives.com +23hotmail.com +23sfeqazx.com +23thingstodoxz.com +23w.com +24-7-demolition-adelaide.com +24-7-fencer-brisbane.com +24-7-plumber-brisbane.com +24-7-retaining-walls-brisbane.com +242gmail.com +24423dbmobbil.emlhub.com +24591dbmobbil.emlhub.com +245gmail.com +246gmail.com +246hltwog9utrzsfmj.cf +246hltwog9utrzsfmj.ga +246hltwog9utrzsfmj.gq +246hltwog9utrzsfmj.ml +246hltwog9utrzsfmj.tk +24779rip1.mimimail.me +247demo.online +247gmail.com +247jockey.com +247mail.xyz +247web.net +2488682.ru +248gmail.com +24cable.ru +24cheapdrugsonline.ru +24ddw6hy4ltg.cf +24ddw6hy4ltg.ga +24ddw6hy4ltg.gq +24ddw6hy4ltg.ml +24ddw6hy4ltg.tk +24facet.com +24faw.com +24fitness.ru +24fm.org +24gmail.com +24hbanner.com +24hhost.cc +24hinbox.com +24hotesl.com +24hour.email +24hourfitness.com +24hourloans.us +24hourmail.com +24hourmail.net +24hrcabling.com +24hrsofsales.com +24hrsshipping.com +24hschool.xyz +24mail.chacuo.net +24mail.top +24mail.xyz +24mailpro.top +24meds.com +24news24.ru +24prm.ru +24rumen.com +24sm.tech +24vlk.xyz +24volcano.net +24x7daily.com +250hz.com +252gmail.com +253gmail.com +25400rip1.mimimail.me +2554445.com +255gmail.com +256gmail.com +25703ochman.emlhub.com +25827ochman.emlhub.com +25891rip1.mimimail.me +258gmail.com +259gmail.com +25gmail.com +25mails.com +25sas.help +25tr4.anonbox.net +25u.com +26004ochman.emlhub.com +26175ochman.emlhub.com +262gmail.com +26422dbmobbil.emlhub.com +265ne.com +266gmail.com +268gmail.com +26cl5.anonbox.net +26evbkf6n.aid.pl +26gmail.com +26llxdhttjb.cf +26llxdhttjb.ga +26llxdhttjb.gq +26llxdhttjb.ml +26llxdhttjb.tk +26pg.com +26wq2.anonbox.net +26yahoo.com +273gmail.com +274gmail.com +27554ochman.emlhub.com +275gmail.com +27gmail.com +27hotesl.com +27yahoo.com +28088rip1.mimimail.me +2820666hyby.com +28685ochman.emlhub.com +28719ochman.emlhub.com +28798dbmobbil.emlhub.com +288gmail.com +289gmail.com +28c1122.com +28gmail.com +28hotmail.com +28musicbaran.us +28onnae92bleuiennc1.cf +28onnae92bleuiennc1.ga +28onnae92bleuiennc1.gq +28onnae92bleuiennc1.ml +28onnae92bleuiennc1.tk +28woman.com +290gmail.com +291.usa.cc +2911.net +29296819.xyz +2929ochman.emlhub.com +292gmail.com +293gmail.com +295gmail.com +29770ochman.emlhub.com +29830ochman.emlhub.com +2990303.ru +299gmail.com +29gmail.com +29hotmail.com +29wrzesnia.pl +29yahoo.com +2aitycnhnno6.cf +2aitycnhnno6.ga +2aitycnhnno6.gq +2aitycnhnno6.ml +2aitycnhnno6.tk +2all.xyz +2and2mail.tk +2anime.org +2anom.com +2b9s.dev +2bedbluewaters.com +2brutus.com +2ch.coms.hk +2ch.daemon.asia +2ch.orgs.hk +2chmail.net +2cny2bstqhouldn.cf +2cny2bstqhouldn.ga +2cny2bstqhouldn.gq +2cny2bstqhouldn.ml +2cny2bstqhouldn.tk +2commaconsulting.com +2coolchops.info +2cor9.com +2csfreight.com +2ctech.net +2d-art.ru +2damaxagency.com +2dbt.com +2detox.com +2dffn.anonbox.net +2dfmail.ga +2dfmail.ml +2dfmail.tk +2dollopsofautism.com +2dsectv.ru +2edgklfs9o5i.cf +2edgklfs9o5i.ga +2edgklfs9o5i.gq +2edgklfs9o5i.ml +2edgklfs9o5i.tk +2emailock.com +2emea.com +2eq8eaj32sxi.cf +2eq8eaj32sxi.ga +2eq8eaj32sxi.gq +2eq8eaj32sxi.ml +2eq8eaj32sxi.tk +2ether.net +2ez6l4oxx.pl +2f2tisxv.bij.pl +2fdgdfgdfgdf.tk +2filmshd.online +2fmm5.anonbox.net +2gear.ru +2gep2ipnuno4oc.cf +2gep2ipnuno4oc.ga +2gep2ipnuno4oc.gq +2gep2ipnuno4oc.ml +2gep2ipnuno4oc.tk +2go-mail.com +2gsdg.anonbox.net +2gufaxhuzqt2g1h.cf +2gufaxhuzqt2g1h.ga +2gufaxhuzqt2g1h.gq +2gufaxhuzqt2g1h.ml +2gufaxhuzqt2g1h.tk +2gurmana.ru +2guysservinglawn.com +2hand.xyz +2hermesbirkin0.com +2hgw666.com +2hotmail.com +2iikwltxabbkofa.cf +2iikwltxabbkofa.ga +2iikwltxabbkofa.gq +2iikwltxabbkofa.ml +2insp.com +2iuzngbdujnf3e.cf +2iuzngbdujnf3e.ga +2iuzngbdujnf3e.gq +2iuzngbdujnf3e.ml +2iuzngbdujnf3e.tk +2k18.mailr.eu +2kcr.win +2kpda46zg.ml +2kratom.com +2kwebserverus.info +2la.info +2leg.com +2listen.ru +2lmu3.anonbox.net +2lug.com +2lyvui3rlbx9.cf +2lyvui3rlbx9.ga +2lyvui3rlbx9.gq +2lyvui3rlbx9.ml +2mail.com +2mailcloud.com +2mailfree.shop +2mailnext.com +2mailnext.top +2mcyy.anonbox.net +2mik.com +2minstory.com +2morr2.com +2nd-mail.xyz +2nd.world +2ndamendmenttactical.com +2ndchancesyouthservices.com +2nf.org +2nnex.com +2o3ffrm7pm.cf +2o3ffrm7pm.ga +2o3ffrm7pm.gq +2o3ffrm7pm.ml +2o3ffrm7pm.tk +2odem.com +2oqqouxuruvik6zzw9.cf +2oqqouxuruvik6zzw9.ga +2oqqouxuruvik6zzw9.gq +2oqqouxuruvik6zzw9.ml +2oqqouxuruvik6zzw9.tk +2p-mail.com +2p.pl +2p7u8ukr6pksiu.cf +2p7u8ukr6pksiu.ga +2p7u8ukr6pksiu.gq +2p7u8ukr6pksiu.ml +2p7u8ukr6pksiu.tk +2pair.com +2pays.ru +2pbfp.anonbox.net +2prong.com +2ptech.info +2qyz2.anonbox.net +2rna.com +2sbcglobal.net +2sea.org +2sea.xyz +2sharp.com +2sisf.anonbox.net +2skjqy.pl +2tl2qamiivskdcz.cf +2tl2qamiivskdcz.ga +2tl2qamiivskdcz.gq +2tl2qamiivskdcz.ml +2tl2qamiivskdcz.tk +2umail.org +2ursxg0dbka.cf +2ursxg0dbka.ga +2ursxg0dbka.gq +2ursxg0dbka.ml +2ursxg0dbka.tk +2v3vjqapd6itot8g4z.cf +2v3vjqapd6itot8g4z.ga +2v3vjqapd6itot8g4z.gq +2v3vjqapd6itot8g4z.ml +2v3vjqapd6itot8g4z.tk +2var.com +2viewerl.com +2vznqascgnfgvwogy.cf +2vznqascgnfgvwogy.ga +2vznqascgnfgvwogy.gq +2vznqascgnfgvwogy.ml +2vznqascgnfgvwogy.tk +2wc.info +2web.com.pl +2wjxak4a4te.cf +2wjxak4a4te.ga +2wjxak4a4te.gq +2wjxak4a4te.ml +2wjxak4a4te.tk +2wled.anonbox.net +2wm3yhacf4fvts.ga +2wm3yhacf4fvts.gq +2wm3yhacf4fvts.ml +2wm3yhacf4fvts.tk +2world.pl +2wslhost.com +2xd.ru +2xqgun.dropmail.me +2xxx.com +2yh6uz.bee.pl +2yigoqolrmfjoh.gq +2yigoqolrmfjoh.ml +2yigoqolrmfjoh.tk +2young4u.ru +2zozbzcohz3sde.cf +2zozbzcohz3sde.gq +2zozbzcohz3sde.ml +2zozbzcohz3sde.tk +2zpph1mgg70hhub.cf +2zpph1mgg70hhub.ga +2zpph1mgg70hhub.gq +2zpph1mgg70hhub.ml +2zpph1mgg70hhub.tk +3-attorney.com +3-debt.com +3.batikbantul.com +3.emailfake.com +3.emailfake.ml +3.fackme.gq +3.kerl.cf +3.spymail.one +3.vvsmail.com +30.dns-cloud.net +300-lukoil.ru +300book.info +300gmail.com +301er.com +301gmail.com +301url.info +30253rip1.mimimail.me +3027a.com +302gmail.com +303.ai +303030.ru +303gmail.com +30409dbmobbil.emlhub.com +304333.xyz +304gmail.com +3055.com +305gmail.com +3060.nl +307gmail.com +308980.com +308gmail.com +309gmail.com +30daycycle.com +30daygoldmine.com +30daystothinreview.org +30gmail.com +30it.ru +30mail.ir +30minutemail.com +30minutenmail.eu +30minutesmail.com +30rip.ru +30secondsmile-review.info +30wave.com +310gmail.com +3126.com +312gmail.com +314gmail.com +315gmail.com +318gmail.com +318tuan.com +31gmail.com +31k.it +31lossweibox.com +31yahoo.com +32.biz +3202.com +321-email.com +321dasdjioadoi.info +321gmail.com +322capital.xyz +32526rip1.mimimail.me +325designcentre.xyz +326herry.com +327designexperts.xyz +32857dbmobbil.emlhub.com +328herry.com +328hetty.com +329store.xyz +329wo.com +32core.live +32gmail.com +32inchledtvreviews.com +32y.ru +32yahoo.com +330gmail.com +331main.com +333.igg.biz +333gmail.com +333uh.com +333vk.com +334343.xyz +3344.online +334gmail.com +335gmail.com +336gmail.com +337gmail.com +338gmail.com +33gmail.com +33m.co +33mail.com +341gmail.com +342gmail.com +34328ochman.emlhub.com +343gmail.com +34412rip1.mimimail.me +344gmail.com +344vip31.com +345.pl +345gmail.com +345v345t34t.cf +345v345t34t.ga +345v345t34t.gq +345v345t34t.ml +345v345t34t.tk +346gmail.com +347gmail.com +348es7arsy2.cf +348es7arsy2.ga +348es7arsy2.gq +348es7arsy2.ml +348es7arsy2.tk +34gmail.com +34rf6y.as +34rfwef2sdf.co.pl +34rutor.site +350gmail.com +350qs.com +351gmail.com +351qs.com +353gmail.com +356gmail.com +357merry.com +35gmail.com +35yuan.com +360.associates +360.band +360.bargains +360.black +360.camp +360.catering +360.church +360.clinic +360.contractors +360.dance +360.delivery +360.directory +360.education +360.equipment +360.exposed +360.express +360.forsale +360.furniture +360.gives +360.hosting +360.industries +360.institute +360.irish +360.limo +360.markets +360.melbourne +360.monster +360.moscow +360.museum +360.navy +360.partners +360.pics +360.recipes +360.soccer +360.study +360.surgery +360.tires +360.toys +360.vet +360discountgames.info +360gmail.com +360onefirm.com +360shopat.com +360spel.se +360wellnessuk.com +360yu.site +36125ochman.emlhub.com +362332.com +362gmail.com +363.net +364.pl +364gmail.com +3657she.com +365jjs.com +365live7m.com +365me.info +3675.mooo.com +36805rip1.mimimail.me +368herry.com +368hetty.com +369gmail.com +369hetty.com +36gmail.com +36poker.ru +36ru.com +372gmail.com +374gmail.com +374kj.com +377gmail.com +3782wqk.targi.pl +37892dbmobbil.emlhub.com +37gmail.com +380gmail.com +381gmail.com +383gmail.com +38498ochman.emlhub.com +38528.com +385619.com +385gmail.com +386gmail.com +386herry.com +386hetty.com +38797rip1.mimimail.me +389production.com +38gmail.com +38yahoo.com +390gmail.com +391881.com +392gmail.com +3942hg.com +3946hg.com +394gmail.com +396hetty.com +398gmail.com +39gmail.com +39hotmail.com +39p.ru +3a88.dev +3agg8gojyj.ga +3agg8gojyj.gq +3agg8gojyj.ml +3arn.net +3bez.com +3bo1grwl36e9q.cf +3bo1grwl36e9q.ga +3bo1grwl36e9q.gq +3bo1grwl36e9q.ml +3bo1grwl36e9q.tk +3c0zpnrhdv78n.ga +3c0zpnrhdv78n.gq +3c0zpnrhdv78n.ml +3c0zpnrhdv78n.tk +3c168.com +3ce5jbjog.pl +3d-films.ru +3d-live.ru +3d-painting.com +3d180.com +3d4o.com +3darchitekci.com.pl +3dautomobiles.com +3db7.xyz +3dboxer.com +3dheadsets.net +3dhome26.ru +3dhor.com +3diifwl.mil.pl +3dinews.com +3dkai.com +3dlab.tech +3dmail.top +3dmasti.com +3dnevvs.ru +3drc.com +3drugs.com +3dsculpter.com +3dsculpter.net +3dsgateway.eu +3dwg.com +3dwstudios.net +3etvi1zbiuv9n.cf +3etvi1zbiuv9n.ga +3etvi1zbiuv9n.gq +3etvi1zbiuv9n.ml +3etvi1zbiuv9n.tk +3ew.usa.cc +3fdn.com +3fhjcewk.pl +3fsv.site +3fy1rcwevwm4y.cf +3fy1rcwevwm4y.ga +3fy1rcwevwm4y.gq +3fy1rcwevwm4y.ml +3fy1rcwevwm4y.tk +3g.lol +3g24.pl +3g2bpbxdrbyieuv9n.cf +3g2bpbxdrbyieuv9n.ga +3g2bpbxdrbyieuv9n.gq +3g2bpbxdrbyieuv9n.ml +3g2bpbxdrbyieuv9n.tk +3gauto.co.uk +3gk2yftgot.cf +3gk2yftgot.ga +3gk2yftgot.gq +3gk2yftgot.ml +3gk2yftgot.tk +3gmtlalvfggbl3mxm.cf +3gmtlalvfggbl3mxm.ga +3gmtlalvfggbl3mxm.gq +3gmtlalvfggbl3mxm.ml +3gmtlalvfggbl3mxm.tk +3gz6v.anonbox.net +3h5gdraa.xzzy.info +3h73.com +3hackers.com +3hermesbirkin0.com +3hqjp.anonbox.net +3j4rnelenwrlvni1t.ga +3j4rnelenwrlvni1t.gq +3j4rnelenwrlvni1t.ml +3j4rnelenwrlvni1t.tk +3jcsx.anonbox.net +3kbyueliyjkrfhsg.ga +3kbyueliyjkrfhsg.gq +3kbyueliyjkrfhsg.ml +3kbyueliyjkrfhsg.tk +3ker23i7vpgxt2hp.cf +3ker23i7vpgxt2hp.ga +3ker23i7vpgxt2hp.gq +3ker23i7vpgxt2hp.ml +3ker23i7vpgxt2hp.tk +3kh990rrox.cf +3kh990rrox.ml +3kh990rrox.tk +3kk43.com +3knloiai.mil.pl +3kqvns1s1ft7kenhdv8.cf +3kqvns1s1ft7kenhdv8.ga +3kqvns1s1ft7kenhdv8.gq +3kqvns1s1ft7kenhdv8.ml +3kqvns1s1ft7kenhdv8.tk +3krtqc2fr7e.cf +3krtqc2fr7e.ga +3krtqc2fr7e.gq +3krtqc2fr7e.ml +3krtqc2fr7e.tk +3l6.com +3littlemiracles.com +3m4i1s.pl +3m73.com +3mail.ga +3mail.gq +3mail.rocks +3mailapp.net +3mi.org +3million3.com +3mir4osvd.pl +3mkz.com +3monthloanseveryday.co.uk +3mx.biz +3nixmail.com +3ntongm4il.ga +3ntxtrts3g4eko.cf +3ntxtrts3g4eko.ga +3ntxtrts3g4eko.gq +3ntxtrts3g4eko.ml +3ntxtrts3g4eko.tk +3nyyn.anonbox.net +3obxa.anonbox.net +3pleasantgentlemen.com +3pscsr94r3dct1a7.cf +3pscsr94r3dct1a7.ga +3pscsr94r3dct1a7.gq +3pscsr94r3dct1a7.ml +3pscsr94r3dct1a7.tk +3pxsport.com +3pzj6.anonbox.net +3qp6a6d.media.pl +3qpplo4avtreo4k.cf +3qpplo4avtreo4k.ga +3qpplo4avtreo4k.gq +3qpplo4avtreo4k.ml +3qpplo4avtreo4k.tk +3raspberryketonemonster.com +3sh7h.anonbox.net +3skzlr.site +3ssfif.pl +3starhotelsinamsterdam.com +3steam.digital +3suisses-3pagen.com +3trtretgfrfe.tk +3url.xyz +3utasmqjcv.cf +3utasmqjcv.ga +3utasmqjcv.gq +3utasmqjcv.ml +3utasmqjcv.tk +3utilities.com +3wmnivgb8ng6d.cf +3wmnivgb8ng6d.ga +3wmnivgb8ng6d.gq +3wmnivgb8ng6d.ml +3wmnivgb8ng6d.tk +3wxoiia16pb9ck4o.cf +3wxoiia16pb9ck4o.ga +3wxoiia16pb9ck4o.ml +3wxoiia16pb9ck4o.tk +3x0ex1x2yx0.cf +3x0ex1x2yx0.ga +3x0ex1x2yx0.gq +3x0ex1x2yx0.ml +3x0ex1x2yx0.tk +3x2uo.anonbox.net +3xk.xyz +3xophlbc5k3s2d6tb.cf +3xophlbc5k3s2d6tb.ga +3xophlbc5k3s2d6tb.gq +3xophlbc5k3s2d6tb.ml +3xophlbc5k3s2d6tb.tk +3xpl0it.vip +3xu.studio +3zumchngf2t.cf +3zumchngf2t.ga +3zumchngf2t.gq +3zumchngf2t.ml +3zumchngf2t.tk +4-boy.com +4-credit.com +4-debt.com +4-n.us +4.batikbantul.com +4.emailfake.ml +4.fackme.gq +40.volvo-xc.ml +40.volvo-xc.tk +4006444444.com +4006633333.com +4006677777.com +40095dbmobbil.emlhub.com +400gmail.com +401202.xyz +401gmail.com +402gmail.com +40494ochman.emlhub.com +404box.com +4057.com +4059.com +405gmail.com +4092ochman.emlhub.com +40daikonkatsu-kisarazusi.xyz +411gmail.com +411reversedirectory.com +41282ochman.emlhub.com +4131ochman.emlhub.com +41347dbmobbil.emlhub.com +413gmail.com +41520dbmobbil.emlhub.com +416gmail.com +417gmail.com +418.dk +4188019.com +41903ochman.emlhub.com +41gmail.com +41plusphotography.xyz +41uno.com +41uno.net +41v1relaxn.com +420blaze.it +420gmail.com +420pure.com +42143dbmobbil.emlhub.com +423gmail.com +424gmail.com +425gmail.com +425inc.com +427gmail.com +428gmail.com +42gmail.com +42o.org +42web.io +430gmail.com +432gmail.com +43324ochman.emlhub.com +435gmail.com +43691rip1.mimimail.me +436gmail.com +439gmail.com +43adsdzxcz.info +43dayone.xyz +43fe4.anonbox.net +43gmail.com +43nsx.anonbox.net +43sdvs.com +43yahoo.com +43zblo.com +43zen.pl +44000dbmobbil.emlhub.com +440gmail.com +442gmail.com +443gmail.com +4444gmail.com +4445jinsha.com +4445n.com +4445v.com +44556677.igg.biz +445t6454545ty4.cf +445t6454545ty4.ga +445t6454545ty4.gq +445t6454545ty4.ml +445t6454545ty4.tk +447gmail.com +448gmail.com +44994dbmobbil.emlhub.com +449gmail.com +44gmail.com +450gmail.com +451gmail.com +453gmail.com +4545.a.hostable.me +45460703.xyz +45505ochman.emlhub.com +45537ochman.emlhub.com +455gmail.com +456.dns-cloud.net +45656753.xyz +456b4564.cf +456b4564.ga +456b4564.gq +456b4564.ml +456b4564ev4.ga +456b4564ev4.gq +456b4564ev4.ml +456b4564ev4.tk +456gmail.com +4580.com +459gmail.com +45hotesl.com +45it.ru +45kti.xyz +45up.com +460gmail.com +46149dbmobbil.emlhub.com +465gmail.com +466453.usa.cc +466gmail.com +467gmail.com +467uph4b5eezvbzdx.cf +467uph4b5eezvbzdx.ga +467uph4b5eezvbzdx.gq +467uph4b5eezvbzdx.ml +46917ochman.emlhub.com +46beton.ru +46designhotel.xyz +46gmail.com +46lclee29x6m02kz.cf +46lclee29x6m02kz.ga +46lclee29x6m02kz.gq +46lclee29x6m02kz.ml +46lclee29x6m02kz.tk +46yzk.anonbox.net +471gmail.com +473gmail.com +474gmail.com +475829487mail.net +475gmail.com +4785541001882360.com +47bmt.com +47gmail.com +47hotmail.com +47t.de +47tiger.site +47yahoo.com +47zen.pl +48031dbmobbil.emlhub.com +481gmail.com +484.pl +48548ochman.emlhub.com +486gmail.com +487.nut.cc +487gmail.com +488gmail.com +4899w.com +48dz.com +48gmail.com +48hr.email +48m.info +48plusclub.xyz +48yahoo.com +4900.com +4906dbmobbil.emlhub.com +490gmail.com +491gmail.com +495metrov.ru +49648ochman.emlhub.com +499gmail.com +49com.com +49designone.xyz +49ersproteamshop.com +49erssuperbowlproshop.com +49ersuperbowlshop.com +49gmail.com +49qoyzl.aid.pl +49xq.com +4afih.anonbox.net +4alphapro.com +4b5yt45b4.cf +4b5yt45b4.ga +4b5yt45b4.gq +4b5yt45b4.ml +4b5yt45b4.tk +4bettergolf.com +4blogers.com +4bver2tkysutf.cf +4bver2tkysutf.ga +4bver2tkysutf.gq +4bver2tkysutf.ml +4bver2tkysutf.tk +4bvm5o8wc.pl +4c1jydiuy.pl +4c5kzxhdbozk1sxeww.cf +4c5kzxhdbozk1sxeww.gq +4c5kzxhdbozk1sxeww.ml +4c5kzxhdbozk1sxeww.tk +4cheaplaptops.com +4chnan.org +4cjd2.anonbox.net +4ddhn.anonbox.net +4dentalsolutions.com +4diabetes.ru +4dmacan.org +4dpondok.biz +4drad.com +4dubc.anonbox.net +4easyemail.com +4eofbxcphifsma.cf +4eofbxcphifsma.ga +4eofbxcphifsma.gq +4eofbxcphifsma.ml +4eofbxcphifsma.tk +4fda.club +4fdfff3ef.com +4fdvnfdrtf.com +4fly.ga +4fly.ml +4fou.com +4free.li +4freemail.org +4funpedia.com +4gei7vonq5buvdvsd8y.cf +4gei7vonq5buvdvsd8y.ga +4gei7vonq5buvdvsd8y.gq +4gei7vonq5buvdvsd8y.ml +4gei7vonq5buvdvsd8y.tk +4gfdsgfdgfd.tk +4gmail.com +4gwpencfprnmehx.cf +4gwpencfprnmehx.ga +4gwpencfprnmehx.gq +4gwpencfprnmehx.ml +4gwpencfprnmehx.tk +4hd8zutuircto.cf +4hd8zutuircto.ga +4hd8zutuircto.gq +4hd8zutuircto.ml +4hd8zutuircto.tk +4hsxniz4fpiuwoma.ga +4hsxniz4fpiuwoma.ml +4hsxniz4fpiuwoma.tk +4iftt.anonbox.net +4ijn7.anonbox.net +4invision.com +4k5.net +4kd.ru +4kmovie.ru +4kqk58d4y.pl +4kweb.com +4mail.cf +4mail.ga +4mail.top +4mispc8ou3helz3sjh.cf +4mispc8ou3helz3sjh.ga +4mispc8ou3helz3sjh.gq +4mispc8ou3helz3sjh.ml +4mispc8ou3helz3sjh.tk +4mnjr.anonbox.net +4mnsuaaluts.cf +4mnsuaaluts.ga +4mnsuaaluts.gq +4mnsuaaluts.ml +4mnsuaaluts.tk +4mnvi.ru +4mobile.pw +4more.lv +4movierulzfree.com +4mrns.anonbox.net +4mwgfceokw83x1y7o.cf +4mwgfceokw83x1y7o.ga +4mwgfceokw83x1y7o.gq +4mwgfceokw83x1y7o.ml +4mwgfceokw83x1y7o.tk +4na3.pl +4nextmail.com +4nmv.ru +4nyvq.anonbox.net +4ocmmk87.pl +4of671adx.pl +4ofqb4hq.pc.pl +4oi.ru +4orty.com +4ozqi.us +4padpnhp5hs7k5no.cf +4padpnhp5hs7k5no.ga +4padpnhp5hs7k5no.gq +4padpnhp5hs7k5no.ml +4padpnhp5hs7k5no.tk +4pass.tk +4pet.ro +4pkr15vtrpwha.cf +4pkr15vtrpwha.ga +4pkr15vtrpwha.gq +4pkr15vtrpwha.ml +4pkr15vtrpwha.tk +4prkrmmail.net +4pu.com +4qmail.com +4red.ru +4rfv6qn1jwvl.cf +4rfv6qn1jwvl.ga +4rfv6qn1jwvl.gq +4rfv6qn1jwvl.ml +4rfv6qn1jwvl.tk +4save.net +4senditnow.com +4serial.com +4shizzleyo.com +4shots.club +4simpleemail.com +4softsite.info +4starmaids.com +4stroy.info +4stroy.pl +4struga.com +4su.one +4suf6rohbfglzrlte.cf +4suf6rohbfglzrlte.ga +4suf6rohbfglzrlte.gq +4suf6rohbfglzrlte.ml +4suf6rohbfglzrlte.tk +4sumki.org.ua +4tb.host +4timesover.com +4tmail.com +4tmail.net +4tphy5m.pl +4ttmail.com +4ufo.info +4up3vtaxujpdm2.cf +4up3vtaxujpdm2.ga +4up3vtaxujpdm2.gq +4up3vtaxujpdm2.ml +4up3vtaxujpdm2.tk +4vlasti.net +4vq19hhmxgaruka.cf +4vq19hhmxgaruka.ga +4vq19hhmxgaruka.gq +4vq19hhmxgaruka.ml +4vq19hhmxgaruka.tk +4w.io +4warding.com +4warding.net +4warding.org +4wide.fun +4wristbands.com +4x10.ru +4x4-team-usm.pl +4x4man.com +4x4n.ru +4x5aecxibj4.cf +4x5aecxibj4.ga +4x5aecxibj4.gq +4x5aecxibj4.ml +4x5aecxibj4.tk +4xmail.net +4xmail.org +4xo3i.anonbox.net +4xoay.com +4xzotgbunzq.cf +4xzotgbunzq.ga +4xzotgbunzq.gq +4xzotgbunzq.ml +4xzotgbunzq.tk +4you.de +4ywzd.xyz +4zbt9rqmvqf.cf +4zbt9rqmvqf.ga +4zbt9rqmvqf.gq +4zbt9rqmvqf.ml +4zbt9rqmvqf.tk +4ze1hnq6jjok.cf +4ze1hnq6jjok.ga +4ze1hnq6jjok.gq +4ze1hnq6jjok.ml +4ze1hnq6jjok.tk +4zhens.info +4zm1fjk8hpn.cf +4zm1fjk8hpn.ga +4zm1fjk8hpn.gq +4zm1fjk8hpn.ml +4zm1fjk8hpn.tk +5-attorney.com +5-mail.info +5.emailfake.ml +5.emailfreedom.ml +5.fackme.gq +500-0-501.ru +500.mg +50000t.com +50000z.com +500loan-payday.com +500obyavlenii.ru +501gmail.com +502gmail.com +504333.xyz +504gmail.com +505gmail.com +506gmail.com +507gmail.com +508gmail.com +509journey.com +50c0bnui7wh.cf +50c0bnui7wh.ga +50c0bnui7wh.gq +50c0bnui7wh.ml +50c0bnui7wh.tk +50e.info +50gmail.com +50mad.com +50mb.ml +50offsale.com +50sale.club +50saleclub.com +50set.ru +51.com +510520.org +510gmail.com +510md.com +510sc.com +511gmail.com +512gmail.com +514gmail.com +517dnf.com +517gmail.com +519art.com +51icq.com +51jel.com +51jiaju.net +51kyb.com +51store.ru +51ttkx.com +51vic.com +51xh.fun +51xoyo.com +5200001.top +5202011.com +5202012.com +520gmail.com +521gmail.com +5225b4d0pi3627q9.privatewhois.net +522gmail.com +523gmail.com +524446913.xyz +524gmail.com +52571dbmobbil.emlhub.com +5258nnn.com +5258v.com +525gmail.com +525kou.com +526gmail.com +528gmail.com +529qs.com +52gmail.com +52mails.com +52subg.org +52tbao.com +52tour.com +530run.com +535gmail.com +536gmail.com +53gmail.com +53vtbcwxf91gcar.cf +53vtbcwxf91gcar.ga +53vtbcwxf91gcar.gq +53vtbcwxf91gcar.ml +53vtbcwxf91gcar.tk +53w7r.anonbox.net +53yahoo.com +54.kro.kr +54.mk +540gmail.com +541gmail.com +54377dbmobbil.emlhub.com +543dsadsdawq.info +545gmail.com +547gmail.com +549gmail.com +54artistry.com +54gmail.com +54np.club +54tiljt6dz9tcdryc2g.cf +54tiljt6dz9tcdryc2g.ga +54tiljt6dz9tcdryc2g.gq +54tiljt6dz9tcdryc2g.ml +54tiljt6dz9tcdryc2g.tk +550gmail.com +551gmail.com +553gmail.com +5555gmail.com +555888.icu +5558ochman.emlhub.com +555gmail.com +555ur.com +5566178.com +5566528.com +556gmail.com +558-33.com +558qd0.spymail.one +559ai.com +55gmail.com +55hosting.net +55hotmail.com +55yahoo.com +56049ochman.emlhub.com +560gmail.com +5634445.com +5635dbmobbil.emlhub.com +563gmail.com +56598ochman.emlhub.com +565gmail.com +566dh.com +566gmail.com +56787.com +567gmail.com +567map.xyz +56818dbmobbil.emlhub.com +568gmail.com +56910ochman.emlhub.com +569gmail.com +56gmail.com +570gmail.com +5712dbmobbil.emlhub.com +5717.ru +573gmail.com +574gmail.com +57571ochman.emlhub.com +575gmail.com +57646ochman.emlhub.com +576gmail.com +577gmail.com +578gmail.com +57gdz.anonbox.net +57gmail.com +57hotmail.com +57up.com +57yahoo.com +580gmail.com +581gmail.com +58425dbmobbil.emlhub.com +58626ochman.emlhub.com +58669ochman.emlhub.com +587gmail.com +588-11.net +58803dbmobbil.emlhub.com +588gmail.com +5897f.com +58992ochman.emlhub.com +58as.com +58gmail.com +58h.de +58hotmail.com +58k.ru +58yahoo.com +590gmail.com +594gmail.com +594qs.com +595gmail.com +59776ochman.emlhub.com +597j.com +59gmail.com +59o.net +59solo.com +5a58wijv3fxctgputir.cf +5a58wijv3fxctgputir.ga +5a58wijv3fxctgputir.gq +5a58wijv3fxctgputir.ml +5a58wijv3fxctgputir.tk +5acmkg8cgud5ky.cf +5acmkg8cgud5ky.ga +5acmkg8cgud5ky.gq +5acmkg8cgud5ky.ml +5acmkg8cgud5ky.tk +5am5ung.cf +5am5ung.ga +5am5ung.gq +5am5ung.ml +5am5ung.tk +5auto.anonbox.net +5awtm.anonbox.net +5biya2otdnpkd7llam.cf +5biya2otdnpkd7llam.ga +5biya2otdnpkd7llam.gq +5biya2otdnpkd7llam.ml +5btxankuqtlmpg5.cf +5btxankuqtlmpg5.ga +5btxankuqtlmpg5.gq +5btxankuqtlmpg5.ml +5btxankuqtlmpg5.tk +5cbc.com +5conto.com +5ddgrmk3f2dxcoqa3.cf +5ddgrmk3f2dxcoqa3.ga +5ddgrmk3f2dxcoqa3.gq +5ddgrmk3f2dxcoqa3.ml +5ddgrmk3f2dxcoqa3.tk +5dsmartstore.com +5e5y.uglyas.com +5ej7f.anonbox.net +5el5nhjf.pl +5esnu.anonbox.net +5fhu5.anonbox.net +5fingershoesoutlet.com +5gags.com +5ghgfhfghfgh.tk +5gmail.com +5gr6v4inzp8l.cf +5gr6v4inzp8l.ga +5gr6v4inzp8l.gq +5gr6v4inzp8l.ml +5gramos.com +5hcc9hnrpqpe.cf +5hcc9hnrpqpe.ga +5hcc9hnrpqpe.gq +5hcc9hnrpqpe.ml +5hcc9hnrpqpe.tk +5hfmczghlkmuiduha8t.cf +5hfmczghlkmuiduha8t.ga +5hfmczghlkmuiduha8t.gq +5hfmczghlkmuiduha8t.ml +5hfmczghlkmuiduha8t.tk +5iznnnr6sabq0b6.cf +5iznnnr6sabq0b6.ga +5iznnnr6sabq0b6.gq +5iznnnr6sabq0b6.ml +5iznnnr6sabq0b6.tk +5j.emlpro.com +5jir9r4j.pl +5july.org +5jzwl.anonbox.net +5k2u.com +5ketonemastery.com +5kratom.com +5letterwordsfinder.com +5mail.cf +5mail.ga +5mail.xyz +5mails.xyz +5minutemail.net +5minutetrip.com +5music.info +5music.top +5n3i2.anonbox.net +5nqkxprvoctdc0.cf +5nqkxprvoctdc0.ga +5nqkxprvoctdc0.gq +5nqkxprvoctdc0.ml +5nqkxprvoctdc0.tk +5osjrktwc5pzxzn.cf +5osjrktwc5pzxzn.ga +5osjrktwc5pzxzn.gq +5osjrktwc5pzxzn.ml +5osjrktwc5pzxzn.tk +5ouhkf8v4vr6ii1fh.cf +5ouhkf8v4vr6ii1fh.ga +5ouhkf8v4vr6ii1fh.gq +5ouhkf8v4vr6ii1fh.ml +5ouhkf8v4vr6ii1fh.tk +5oz.ru +5quq5vbtzswx.cf +5quq5vbtzswx.ga +5quq5vbtzswx.gq +5quq5vbtzswx.ml +5quq5vbtzswx.tk +5qzaa.anonbox.net +5r6atirlv.pl +5rk4a.anonbox.net +5rof.cf +5rw6o.anonbox.net +5se17.com +5se24.com +5se30.com +5se43.com +5se46.com +5se48.com +5se50.com +5se56.com +5se57.com +5se63.com +5se68.com +5se79.com +5se81.com +5se85.com +5semail.com +5so1mammwlf8c.cf +5so1mammwlf8c.ga +5so1mammwlf8c.gq +5so1mammwlf8c.ml +5so1mammwlf8c.tk +5starimport.com +5steps-site.ru +5sun.net +5sword.com +5t7b3.anonbox.net +5tb-pix.ru +5tb-video.ru +5tb.in +5u4nms.us +5ubo.com +5uet4izbel.cf +5uet4izbel.ga +5uet4izbel.gq +5uet4izbel.ml +5uet4izbel.tk +5vcxwmwtq62t5.cf +5vcxwmwtq62t5.ga +5vcxwmwtq62t5.gq +5vcxwmwtq62t5.ml +5vcxwmwtq62t5.tk +5vib.com +5vlimcrvbyurmmllcw0.cf +5vlimcrvbyurmmllcw0.ga +5vlimcrvbyurmmllcw0.gq +5vlimcrvbyurmmllcw0.ml +5vlimcrvbyurmmllcw0.tk +5x25.com +5y5u.com +5yaochu.top +5yg2o.anonbox.net +5yi9xi9.mil.pl +5yk.idea-makers.tk +5ymail.com +5ymail.me +5ytff56753kkk.cf +5ytff56753kkk.ga +5ytff56753kkk.gq +5ytff56753kkk.ml +5ytff56753kkk.tk +6-6-6.cf +6-6-6.ga +6-6-6.igg.biz +6-6-6.ml +6-6-6.nut.cc +6-6-6.usa.cc +6-attorney.com +6-debt.com +6.emailfake.ml +6.fackme.gq +60-minuten-mail.de +60.volvo-xc.ml +60.volvo-xc.tk +600pro.com +60236.monster +602gmail.com +603gmail.com +60504ochman.emlhub.com +605gmail.com +608gmail.com +60901ochman.emlhub.com +60939dbmobbil.emlhub.com +60986dbmobbil.emlhub.com +609k23.pl +60dayworkoutdvd.info +60gmail.com +60minutemail.com +60paydayloans.co.uk +61185ochman.emlhub.com +611gmail.com +613gmail.com +61662dbmobbil.emlhub.com +61992ochman.emlhub.com +619gmail.com +619va2h8.info +61gmail.com +61yahoo.com +620gmail.com +622gmail.com +623gmail.com +624gmail.com +625gmail.com +626gmail.com +627gmail.com +62814ochman.emlhub.com +628gmail.com +62933ochman.emlhub.com +62crv.anonbox.net +62gmail.com +62it.ru +62pwo.anonbox.net +631gmail.com +634gmail.com +638gmail.com +63956ochman.emlhub.com +639gmail.com +63gmail.com +63hotmail.com +63taw.anonbox.net +640gmail.com +641gmail.com +644gmail.com +645gmail.com +646973706f7361626c656768.de +646gmail.com +64702dbmobbil.emlhub.com +648gmail.com +649gmail.com +64ge.com +64gmail.com +64hotmail.com +650dialup.com +651gmail.com +652gmail.com +6530508.com +654gmail.com +655gmail.com +656gmail.com +657gmail.com +65927rip1.mimimail.me +65gmail.com +65nryny6y7.cf +65nryny6y7.ga +65nryny6y7.gq +65nryny6y7.ml +65nryny6y7.tk +65uwtobxcok66.cf +65uwtobxcok66.ga +65uwtobxcok66.gq +65uwtobxcok66.ml +65uwtobxcok66.tk +65yahoo.com +65yxw.anonbox.net +65zblo.com +65zen.pl +6624445.com +663gmail.com +665gmail.com +666-evil.com +666-satan.cf +666-satan.ga +666-satan.gq +666-satan.ml +666-satan.tk +66651ochman.emlhub.com +6666gmail.com +6667988.com +6668288.com +666866ll.com +6669188.com +666gmail.com +666mai.com +666zagrusssski.ru +667gmail.com +66887ochman.emlhub.com +668fmail.com +668gmail.com +6690288.com +6690588.com +6695288.com +66hotmail.com +66tower.com +66uuff.com +66zxt.anonbox.net +671gmail.com +672643.net +672gmail.com +673gmail.com +675gmail.com +675hosting.com +675hosting.net +675hosting.org +676gmail.com +67804dbmobbil.emlhub.com +67832.cf +67832.ga +67832.ml +67832.tk +6789658.com +67899vip.com +6789v.com +678gmail.com +678nu.com +67azck3y6zgtxfoybdm.cf +67azck3y6zgtxfoybdm.ga +67azck3y6zgtxfoybdm.gq +67azck3y6zgtxfoybdm.ml +67azck3y6zgtxfoybdm.tk +67b.online +67gmail.com +67rzpjb2im3fuehh9gp.cf +67rzpjb2im3fuehh9gp.ga +67rzpjb2im3fuehh9gp.gq +67rzpjb2im3fuehh9gp.ml +67rzpjb2im3fuehh9gp.tk +67xxzwhzv5fr.cf +67xxzwhzv5fr.ga +67xxzwhzv5fr.gq +67xxzwhzv5fr.tk +681mail.com +682653.com +68283dbmobbil.emlhub.com +68372rip1.mimimail.me +683gmail.com +684gmail.com +684hh.com +68721.buzz +687gmail.com +68826dbmobbil.emlhub.com +688as.org +68961dbmobbil.emlhub.com +689gmail.com +68azpqh.pl +68gmail.com +68mail.com +68mail.sbs +68yahoo.com +69-ew.tk +69059dbmobbil.emlhub.com +69161dbmobbil.emlhub.com +693gmail.com +694gmail.com +69531ochman.emlhub.com +6965666.com +696902.xyz +6969gmail.com +697av.com +697gmail.com +698054.com +698264.com +698309.com +698424.com +698425.com +698497.com +698549.com +698742.com +698gmail.com +699gmail.com +69gmail.com +69postix.info +69t03rpsl4.cf +69t03rpsl4.ga +69t03rpsl4.gq +69t03rpsl4.ml +69t03rpsl4.tk +69z.com +6a24bzvvu.pl +6a81fostts.cf +6a81fostts.ga +6a81fostts.gq +6a81fostts.ml +6a81fostts.tk +6brmwv.cf +6brmwv.ga +6brmwv.gq +6brmwv.ml +6brmwv.tk +6cq9epnn.edu.pl +6dy.store +6ed9cit4qpxrcngbq.cf +6ed9cit4qpxrcngbq.ga +6ed9cit4qpxrcngbq.gq +6ed9cit4qpxrcngbq.ml +6ed9cit4qpxrcngbq.tk +6ekk.com +6elkf86.pl +6en9mail2.ga +6eng-zma1lz.ga +6eogvwbma.pl +6f.pl +6fkxw.anonbox.net +6fw22.anonbox.net +6fzmz.anonbox.net +6hermesbirkin0.com +6hjgjhgkilkj.tk +6ip.us +6jjnz.anonbox.net +6kg8ddf6mtlyzzi5mm.cf +6kg8ddf6mtlyzzi5mm.ga +6kg8ddf6mtlyzzi5mm.gq +6kg8ddf6mtlyzzi5mm.ml +6kg8ddf6mtlyzzi5mm.tk +6kratom.com +6lhp5tembvpl.cf +6lhp5tembvpl.ga +6lhp5tembvpl.gq +6lhp5tembvpl.ml +6lhp5tembvpl.tk +6mail.cc +6mail.cf +6mail.ga +6mail.ml +6mail.top +6mails.com +6monthscarinsurance.co.uk +6n9.net +6nns09jw.bee.pl +6ox.com +6paq.com +6pr4k.anonbox.net +6q70sdpgjzm2irltn.cf +6q70sdpgjzm2irltn.ga +6q70sdpgjzm2irltn.gq +6q70sdpgjzm2irltn.ml +6q70sdpgjzm2irltn.tk +6qssmefkx.pl +6qstz1fsm8hquzz.cf +6qstz1fsm8hquzz.ga +6qstz1fsm8hquzz.gq +6qstz1fsm8hquzz.ml +6qstz1fsm8hquzz.tk +6qwkvhcedxo85fni.cf +6qwkvhcedxo85fni.ga +6qwkvhcedxo85fni.gq +6qwkvhcedxo85fni.ml +6qwkvhcedxo85fni.tk +6ra8wqulh.pl +6rbex.anonbox.net +6rndtguzgeajcce.cf +6rndtguzgeajcce.ga +6rndtguzgeajcce.gq +6rndtguzgeajcce.ml +6rndtguzgeajcce.tk +6rrtk52.mil.pl +6s5z.com +6scwis5lamcv.gq +6snja.anonbox.net +6somok.ru +6tbeq.anonbox.net +6tumdl.site +6twkd1jggp9emimfya8.cf +6twkd1jggp9emimfya8.ga +6twkd1jggp9emimfya8.gq +6twkd1jggp9emimfya8.ml +6twkd1jggp9emimfya8.tk +6ugzob6xpyzwt.cf +6ugzob6xpyzwt.ga +6ugzob6xpyzwt.gq +6ugzob6xpyzwt.ml +6ugzob6xpyzwt.tk +6url.com +6uydh.anonbox.net +6v9haqno4e.cf +6v9haqno4e.ga +6v9haqno4e.gq +6v9haqno4e.ml +6v9haqno4e.tk +6vgflujwsc.cf +6vgflujwsc.ga +6vgflujwsc.gq +6vgflujwsc.ml +6xf64.anonbox.net +6xtx.com +7-attorney.com +7.emailfake.ml +7.fackme.gq +700gmail.com +70160ochman.emlhub.com +701gmail.com +702gmail.com +703xanmf2tk5lny.cf +703xanmf2tk5lny.ga +703xanmf2tk5lny.gq +703xanmf2tk5lny.ml +703xanmf2tk5lny.tk +70445ochman.emlhub.com +706gmail.com +707gmail.com +70843rip1.mimimail.me +708gmail.com +708ugg-boots.com +70gmail.com +70k6ylzl2aumii.cf +70k6ylzl2aumii.ga +70k6ylzl2aumii.gq +70k6ylzl2aumii.ml +70k6ylzl2aumii.tk +710gmail.com +7119.net +71343dbmobbil.emlhub.com +713705.xyz +713gmail.com +715gmail.com +716gmail.com +71999rip1.mimimail.me +719gmail.com +719x.com +71btdutk.blogrtui.ru +71compete.com +71gmail.com +71hotmail.com +71yahoo.com +7204445.com +720gmail.com +721gmail.com +723gmail.com +724sky.mobi +726xhknin96v9oxdqa.cf +726xhknin96v9oxdqa.gq +726xhknin96v9oxdqa.ml +726xhknin96v9oxdqa.tk +727ec.es +727gmail.com +72897ochman.emlhub.com +728gmail.com +72gmail.com +72w.com +730gmail.com +73225rip1.mimimail.me +733gmail.com +735gmail.com +738gmail.com +739gmail.com +73dg6.anonbox.net +73gmail.com +73up.com +73wire.com +73xk2p39p.pl +73yahoo.com +743gmail.com +745gmail.com +747gmail.com +74gmail.com +74hotmail.com +74jw.com +74zblo.com +75058dbmobbil.emlhub.com +755gmail.com +7567fdcvvghw2.cf +7567fdcvvghw2.ga +7567fdcvvghw2.gq +7567fdcvvghw2.ml +7567fdcvvghw2.tk +756gmail.com +7579dbmobbil.emlhub.com +758gmail.com +759b136.com +75gmail.com +75happy.com +75hosting.com +75hosting.net +75hosting.org +75vjt.anonbox.net +75yahoo.com +760gmail.com +765gmail.com +76657766.com +766gmail.com +767gmail.com +768gmail.com +76gmail.com +76hotmail.com +76jdafbnde38cd.cf +76jdafbnde38cd.ga +76jdafbnde38cd.gq +76jdafbnde38cd.ml +76jdafbnde38cd.tk +76up.com +76yahoo.com +77009dbmobbil.emlhub.com +770gmail.com +77161dbmobbil.emlhub.com +7728ccc.com +77333rip1.mimimail.me +7752050.ru +77684rip1.mimimail.me +776gmail.com +777-university.ru +777.net.cn +777fortune.com +777gmail.com +777score-mv.com +777slots-online.com +779gmail.com +77ahgaz.shop +77hotmail.com +77mail.xyz +77q8m.com +77yahoo.com +7814445.com +78186ochman.emlhub.com +782gmail.com +783gmail.com +784666.net +784gmail.com +785gmail.com +786gambling.com +786gmail.com +787gmail.com +787y849s.bij.pl +789.dns-cloud.net +789.tips +789456123mail.ml +7899w.top +789gmail.com +789movies.com +78gmail.com +790gmail.com +792646.com +792c.lol +794gmail.com +798gmail.com +79966.xyz +799fu.com +799gmail.com +79gmail.com +79mail.com +7ag83mwrabz.ga +7ag83mwrabz.ml +7ag83mwrabz.tk +7aw.ru +7bafilmy.ru +7be.org +7bhmsthext.cf +7bhmsthext.ga +7bhmsthext.gq +7bhmsthext.ml +7bhmsthext.tk +7bhtm0suwklftwx7.cf +7bhtm0suwklftwx7.ga +7bhtm0suwklftwx7.gq +7bhtm0suwklftwx7.ml +7bhtm0suwklftwx7.tk +7d7ebci63.pl +7days-printing.com +7ddf32e.info +7dmail.com +7gmail.com +7go.info +7gpvegspglb8x8bczws.cf +7gpvegspglb8x8bczws.ga +7gpvegspglb8x8bczws.gq +7gpvegspglb8x8bczws.ml +7gpvegspglb8x8bczws.tk +7gr.pl +7hotmail.com +7ihd9vh6.edu.pl +7ijabi.com +7j7cf.anonbox.net +7kawan.web.id +7klm5.anonbox.net +7kratom.com +7kuiqff4ay.cf +7kuiqff4ay.ga +7kuiqff4ay.gq +7kuiqff4ay.ml +7kuiqff4ay.tk +7m3aq2e9chlicm.cf +7m3aq2e9chlicm.ga +7m3aq2e9chlicm.gq +7m3aq2e9chlicm.ml +7m3aq2e9chlicm.tk +7magazinov.ru +7mail.ga +7mail.io +7mail.ml +7mail.xyz +7mail7.com +7med24.co.uk +7mn6v.anonbox.net +7msof.anonbox.net +7nation.com +7nglhuzdtv.cf +7nglhuzdtv.ga +7nglhuzdtv.gq +7nglhuzdtv.ml +7nglhuzdtv.tk +7novels.com +7nxwl.anonbox.net +7oicpwgcc8trzcvvfww.cf +7oicpwgcc8trzcvvfww.ga +7oicpwgcc8trzcvvfww.gq +7oicpwgcc8trzcvvfww.ml +7oicpwgcc8trzcvvfww.tk +7opp2romngiww8vto.cf +7opp2romngiww8vto.ga +7opp2romngiww8vto.gq +7opp2romngiww8vto.ml +7opp2romngiww8vto.tk +7p6kz0omk2kb6fs8lst.cf +7p6kz0omk2kb6fs8lst.ga +7p6kz0omk2kb6fs8lst.gq +7p6kz0omk2kb6fs8lst.ml +7p6kz0omk2kb6fs8lst.tk +7paqd.anonbox.net +7pccf.cf +7pccf.ga +7pccf.gq +7pccf.ml +7pccf.tk +7pdqpb96.pl +7qdkg.anonbox.net +7qrtbew5cigi.cf +7qrtbew5cigi.ga +7qrtbew5cigi.gq +7qrtbew5cigi.ml +7qrtbew5cigi.tk +7rent.top +7rtay.info +7rv.es +7rx24.com +7seatercarsz.com +7startruckdrivingschool.com +7t6bp.anonbox.net +7tags.com +7thpeggroup.com +7tiqqxsfmd2qx5.cf +7tiqqxsfmd2qx5.ga +7tiqqxsfmd2qx5.gq +7tiqqxsfmd2qx5.ml +7tiqqxsfmd2qx5.tk +7tsrslgtclz.pl +7tul.com +7twlev.bij.pl +7u7rdldlbvcnklclnpx.cf +7u7rdldlbvcnklclnpx.ga +7u7rdldlbvcnklclnpx.gq +7u7rdldlbvcnklclnpx.ml +7u7rdldlbvcnklclnpx.tk +7uy35p.cf +7uy35p.ga +7uy35p.gq +7uy35p.ml +7uy35p.tk +7vcntir8vyufqzuqvri.cf +7vcntir8vyufqzuqvri.ga +7vcntir8vyufqzuqvri.gq +7vcntir8vyufqzuqvri.ml +7vcntir8vyufqzuqvri.tk +7vfdo.anonbox.net +7wd45do5l.pl +7wdse.anonbox.net +7wjej.anonbox.net +7wv5l.anonbox.net +7wzctlngbx6fawlv.cf +7wzctlngbx6fawlv.ga +7wzctlngbx6fawlv.gq +7wzctlngbx6fawlv.ml +7wzctlngbx6fawlv.tk +7xnk9kv.pl +7ymail.com +7zm2n.anonbox.net +8-mail.com +8.dnsabr.com +8.emailfake.ml +8.fackme.gq +8.thepieter.com +800gmail.com +800hotspots.info +800sacramento.tk +803gmail.com +804m66.pl +806.flu.cc +80600.net +80658ochman.emlhub.com +80665.com +806gmail.com +807gmail.com +808app.com +808gmail.com +80923dbmobbil.emlhub.com +80gmail.com +80pu.info +80r0zc5fxpmuwczzxl.cf +80r0zc5fxpmuwczzxl.ga +80r0zc5fxpmuwczzxl.gq +80r0zc5fxpmuwczzxl.ml +80r0zc5fxpmuwczzxl.tk +80ro.eu +80zooiwpz1nglieuad8.cf +80zooiwpz1nglieuad8.ga +80zooiwpz1nglieuad8.gq +80zooiwpz1nglieuad8.ml +80zooiwpz1nglieuad8.tk +810gmail.com +81122ochman.emlhub.com +811gmail.com +8127ep.com +813uu.com +81519gcu.orge.pl +8159rip1.mimimail.me +816206.com +816mail.com +816qs.com +817gmail.com +818gmail.com +8191.at +81939dbmobbil.emlhub.com +819978f0-0b0f-11e2-892e-0800200c9a66.com +819gmail.com +81gmail.com +81mail.com +82094dbmobbil.emlhub.com +820gmail.com +821gmail.com +821mail.com +823gmail.com +82514rip1.mimimail.me +825gmail.com +825mail.com +8260613.com +8264513.com +827gmail.com +8290.com +82c8.com +82j2we.pl +83096ochman.emlhub.com +830gmail.com +832group.com +833gmail.com +833tomhale.club +834gmail.com +8352p.com +8357399.com +835gmail.com +835qs.com +8363199.com +839776.xyz +83998ochman.emlhub.com +839gmail.com +83gd90qriawwf.cf +83gd90qriawwf.ga +83gd90qriawwf.gq +83gd90qriawwf.ml +83gd90qriawwf.tk +83gmail.com +840gmail.com +841gmail.com +842gmail.com +845276.com +845297.com +845418.com +845gmail.com +847331.com +847gmail.com +848gmail.com +84927dbmobbil.emlhub.com +8498rip1.mimimail.me +849gmail.com +84gmail.com +84hotmail.com +84mce5gufev8.cf +84mce5gufev8.ga +84mce5gufev8.gq +84mce5gufev8.ml +84mce5gufev8.tk +84rhilv8mm3xut2.cf +84rhilv8mm3xut2.ga +84rhilv8mm3xut2.gq +84rhilv8mm3xut2.ml +84rhilv8mm3xut2.tk +84yahoo.com +850gmail.com +852gmail.com +8539927.com +853gmail.com +854gmail.com +855gmail.com +857gmail.com +859gmail.com +85gmail.com +8601ochman.emlhub.com +860gmail.com +86443ochman.emlhub.com +866303.com +868757.com +86911dbmobbil.emlhub.com +86cnb.space +86d14866fx.ml +86gmail.com +86x6.com +871gmail.com +8723891.com +873gmail.com +874gmail.com +876gmail.com +87708b.com +87gjgsdre2sv.cf +87gjgsdre2sv.ga +87gjgsdre2sv.gq +87gjgsdre2sv.ml +87gjgsdre2sv.tk +87gmail.com +87mmwdtf63b.cf +87mmwdtf63b.ga +87mmwdtf63b.gq +87mmwdtf63b.ml +87mmwdtf63b.tk +87yhasdasdmail.ru +8808go.com +880gmail.com +8815.fun +88155.xyz +881gmail.com +882117711.com +882117722.com +882117733.com +882119900.com +882119911.com +88365.xyz +88388.org +8844shop.com +8848.net +885gmail.com +887gmail.com +888.dns-cloud.net +888.gen.in +888008.xyz +8883229.com +8883236.com +8883372.com +8883919.com +8883936.com +8888gmail.com +888gmail.com +888tron.net +888z5.cf +888z5.ga +888z5.gq +888z5.ml +888z5.tk +88979ochman.emlhub.com +88998.com +88av.net +88chaye.com +88clean.pro +88cloud.cc +88cot.info +88hotmail.com +88urtyzty.pl +890gmail.com +891175.com +891gmail.com +8929rip1.mimimail.me +892gmail.com +893gmail.com +894gmail.com +8974ochman.emlhub.com +899079.com +89db.com +89ghferrq.com +89gmail.com +89yliughdo89tly.com +8chan.co +8e6d9wk7a19vedntm35.cf +8e6d9wk7a19vedntm35.ga +8e6d9wk7a19vedntm35.gq +8e6d9wk7a19vedntm35.ml +8email.com +8eoqovels2mxnxzwn7a.cf +8eoqovels2mxnxzwn7a.ga +8eoqovels2mxnxzwn7a.gq +8eoqovels2mxnxzwn7a.ml +8eoqovels2mxnxzwn7a.tk +8estcommunity.org +8ev9nir3ilwuw95zp.cf +8ev9nir3ilwuw95zp.ga +8ev9nir3ilwuw95zp.gq +8ev9nir3ilwuw95zp.ml +8ev9nir3ilwuw95zp.tk +8ffn7qixgk3vq4z.cf +8ffn7qixgk3vq4z.ga +8ffn7qixgk3vq4z.gq +8ffn7qixgk3vq4z.ml +8ffn7qixgk3vq4z.tk +8fuur0zzvo8otsk.cf +8fuur0zzvo8otsk.ga +8fuur0zzvo8otsk.gq +8fuur0zzvo8otsk.ml +8fuur0zzvo8otsk.tk +8gnkb3b.sos.pl +8hadrm28w.pl +8hermesbirkin0.com +8hfzqpstkqux.cf +8hfzqpstkqux.ga +8hfzqpstkqux.gq +8hfzqpstkqux.ml +8hfzqpstkqux.tk +8hj3rdieaek.cf +8hj3rdieaek.ga +8hj3rdieaek.gq +8hj3rdieaek.ml +8hj3rdieaek.tk +8i7.net +8imefdzddci.cf +8imefdzddci.ga +8imefdzddci.gq +8imefdzddci.ml +8imefdzddci.tk +8kcpfcer6keqqm.cf +8kcpfcer6keqqm.ml +8kcpfcer6keqqm.tk +8klddrkdxoibtasn3g.cf +8klddrkdxoibtasn3g.ga +8klddrkdxoibtasn3g.gq +8klddrkdxoibtasn3g.ml +8klddrkdxoibtasn3g.tk +8liffwp16.pl +8m1t.com +8mail.cf +8mail.com +8mail.ga +8mail.ml +8mailpro.com +8mnqpys1n.pl +8mtz.com +8oboi80bcv1.cf +8oboi80bcv1.ga +8oboi80bcv1.gq +8oivvg.dropmail.me +8ouyuy5.ce.ms +8pc2ztkr6.pl +8pukcddnthjql.cf +8pukcddnthjql.ga +8pukcddnthjql.gq +8pukcddnthjql.ml +8pukcddnthjql.tk +8pyda.us +8qdw3jexxncwd.cf +8qdw3jexxncwd.ga +8qdw3jexxncwd.gq +8qdw3jexxncwd.ml +8qdw3jexxncwd.tk +8qwh37kibb6ut7.cf +8qwh37kibb6ut7.ga +8qwh37kibb6ut7.gq +8qwh37kibb6ut7.ml +8qwh37kibb6ut7.tk +8rskf3xpyq.cf +8rskf3xpyq.ga +8rskf3xpyq.gq +8rskf3xpyq.ml +8rskf3xpyq.tk +8t0sznngp6aowxsrj.cf +8t0sznngp6aowxsrj.ga +8t0sznngp6aowxsrj.gq +8t0sznngp6aowxsrj.ml +8t0sznngp6aowxsrj.tk +8u4e3qqbu.pl +8up0.spymail.one +8usmwuqxh1s1pw.cf +8usmwuqxh1s1pw.ga +8usmwuqxh1s1pw.gq +8usmwuqxh1s1pw.ml +8usmwuqxh1s1pw.tk +8verxcdkrfal61pfag.cf +8verxcdkrfal61pfag.ga +8verxcdkrfal61pfag.gq +8verxcdkrfal61pfag.ml +8verxcdkrfal61pfag.tk +8wehgc2atizw.cf +8wehgc2atizw.ga +8wehgc2atizw.gq +8wehgc2atizw.ml +8wehgc2atizw.tk +8wkkrizxpphbm3c.cf +8wkkrizxpphbm3c.ga +8wkkrizxpphbm3c.gq +8wkkrizxpphbm3c.ml +8wkkrizxpphbm3c.tk +8wwxmcyntfrf.cf +8wwxmcyntfrf.ga +8wwxmcyntfrf.gq +8wwxmcyntfrf.ml +8xcdzvxgnfztticc.cf +8xcdzvxgnfztticc.ga +8xcdzvxgnfztticc.gq +8xcdzvxgnfztticc.tk +8xyz8.dynu.net +8ythwpz.pl +8zbpmvhxvue.cf +8zbpmvhxvue.ga +8zbpmvhxvue.gq +8zbpmvhxvue.ml +8zbpmvhxvue.tk +9.emailfake.ml +9.fackme.gq +90.volvo-xc.ml +90.volvo-xc.tk +900k.es +902gmail.com +90385ochman.emlhub.com +905gmail.com +906gmail.com +908997.com +908gmail.com +909gmail.com +90gmail.com +91000.com +9111rip1.mimimail.me +911gmail.com +913gmail.com +914258.ga +916gmail.com +91792dbmobbil.emlhub.com +91gmail.com +91gxflclub.info +91sedh.xyz +91tanhua.top +920gmail.com +9227uu.com +92280ochman.emlhub.com +922gmail.com +92470rip1.mimimail.me +925gmail.com +926tao.com +928gmail.com +929.be +92ff.xyz +930gmail.com +9310.ru +93281ochman.emlhub.com +933j.com +935gmail.com +936gmail.com +93707rip1.mimimail.me +93779dbmobbil.emlhub.com +937gmail.com +939gmail.com +93gmail.com +93k0ldakr6uzqe.cf +93k0ldakr6uzqe.ga +93k0ldakr6uzqe.gq +93k0ldakr6uzqe.ml +93k0ldakr6uzqe.tk +93re.com +940qs.com +942gmail.com +943gmail.com +944gmail.com +945gmail.com +9462dbmobbil.emlhub.com +94b5.ga +94gmail.com +94hotmail.com +94jo.com +94xtyktqtgsw7c7ljxx.co.cc +950gmail.com +951gmail.com +95218ochman.emlhub.com +957gmail.com +958gmail.com +95978dbmobbil.emlhub.com +959gmail.com +95gmail.com +95ta.com +961.dog +963gmail.com +9666z.com +9670ochman.emlhub.com +96826ochman.emlhub.com +96895ochman.emlhub.com +9696.eu +96gmail.com +96hotmail.com +97138e.xyz +971gmail.com +9722.us +973gmail.com +974gmail.com +975gmail.com +97gmail.com +97so1ubz7g5unsqgt6.cf +97so1ubz7g5unsqgt6.ga +97so1ubz7g5unsqgt6.gq +97so1ubz7g5unsqgt6.ml +97so1ubz7g5unsqgt6.tk +980gmail.com +98118ochman.emlhub.com +98266rip1.mimimail.me +98591ochman.emlhub.com +985box.com +985gmail.com +986gmail.com +987gmail.com +98865ochman.emlhub.com +9889927.com +988gmail.com +989192.com +9899w.top +989gmail.com +98gmail.com +98mail.xyz +98usd.com +98yahoo.com +99-brand.com +99.com +990.net +99011rip1.mimimail.me +990ys.com +991-sh.top +991gmail.com +99236.xyz +99371ochman.emlhub.com +99399.xyz +994gmail.com +9950dbmobbil.emlhub.com +996a.lol +996gmail.com +999bjw.com +999intheshade.net +99alternatives.com +99cows.com +99depressionlists.com +99email.xyz +99experts.com +99gamil.com +99hacks.us +99hotmail.com +99mail.cf +99marks.com +99mimpi.com +99pg.group +99price.co +99pubblicita.com +99publicita.com +99situs.online +99x99.com +9ate.com +9azw9lpz.emlhub.com +9co.de +9cvlhwqrdivi04.cf +9cvlhwqrdivi04.ga +9cvlhwqrdivi04.gq +9cvlhwqrdivi04.ml +9cvlhwqrdivi04.tk +9daqunfzk4x0elwf5k.cf +9daqunfzk4x0elwf5k.ga +9daqunfzk4x0elwf5k.gq +9daqunfzk4x0elwf5k.ml +9daqunfzk4x0elwf5k.tk +9ebrklpoy3h.cf +9ebrklpoy3h.ga +9ebrklpoy3h.gq +9ebrklpoy3h.ml +9ebrklpoy3h.tk +9email.com +9en6mail2.ga +9et1spj7br1ugxrlaa3.cf +9et1spj7br1ugxrlaa3.ga +9et1spj7br1ugxrlaa3.gq +9et1spj7br1ugxrlaa3.ml +9et1spj7br1ugxrlaa3.tk +9fdy8vi.mil.pl +9gals.com +9jw5zdja5nu.pl +9k27djbip0.cf +9k27djbip0.ga +9k27djbip0.gq +9k27djbip0.ml +9k27djbip0.tk +9kfifc2x.pl +9klsh2kz9.pl +9mail.cf +9mail.shop +9mail9.cf +9maja.pl +9me.site +9monsters.com +9mot.ru +9nteria.pl +9o04xk8chf7iaspralb.cf +9o04xk8chf7iaspralb.ga +9o04xk8chf7iaspralb.gq +9o04xk8chf7iaspralb.ml +9oul.com +9ox.net +9q.ro +9q402.com +9q8eriqhxvep50vuh3.cf +9q8eriqhxvep50vuh3.ga +9q8eriqhxvep50vuh3.gq +9q8eriqhxvep50vuh3.ml +9q8eriqhxvep50vuh3.tk +9rok.info +9rtkerditoy.info +9rtn5qjmug.cf +9rtn5qjmug.ga +9rtn5qjmug.gq +9rtn5qjmug.ml +9rtn5qjmug.tk +9skcqddzppe4.cf +9skcqddzppe4.ga +9skcqddzppe4.gq +9skcqddzppe4.ml +9skcqddzppe4.tk +9spokesqa.mailinator.com +9t7xuzoxmnwhw.cf +9t7xuzoxmnwhw.ga +9t7xuzoxmnwhw.gq +9t7xuzoxmnwhw.ml +9t7xuzoxmnwhw.tk +9times.club +9times.pro +9toplay.com +9ufveewn5bc6kqzm.cf +9ufveewn5bc6kqzm.ga +9ufveewn5bc6kqzm.gq +9ufveewn5bc6kqzm.ml +9ufveewn5bc6kqzm.tk +9w93z8ul4e.cf +9w93z8ul4e.ga +9w93z8ul4e.gq +9w93z8ul4e.ml +9w93z8ul4e.tk +9xmail.xyz +9y222.app +9ya.de +9yc4hw.us +9ziqmkpzz3aif.cf +9ziqmkpzz3aif.ga +9ziqmkpzz3aif.gq +9ziqmkpzz3aif.ml +9ziqmkpzz3aif.tk +9zjz7suyl.pl +a-action.ru +a-b.co.za +a-bc.net +a-ge.ru +a-germandu.de +a-glittering-gem-is-not-enough.top +a-kinofilm.ru +a-l-e-x.net +a-mule.cf +a-mule.ga +a-mule.gq +a-mule.ml +a-mule.tk +a-nd.info +a-ng.ga +a-rodadmitssteroids.in +a-sound.ru +a-spy.xyz +a-t-english.com +a-vot-i-ya.net +a.a.fbmail.usa.cc +a.asiamail.website +a.b.c.dropmail.me +a.b.c.emlpro.com +a.b.c.emltmp.com +a.b.c.laste.ml +a.barbiedreamhouse.club +a.beardtrimmer.club +a.bestwrinklecreamnow.com +a.betr.co +a.bettermail.website +a.blatnet.com +a.com +a.dropmail.me +a.flour.icu +a.fm.cloudns.nz +a.garciniacambogia.directory +a.gsamail.website +a.gsasearchengineranker.pw +a.gsasearchengineranker.site +a.gsasearchengineranker.space +a.gsasearchengineranker.top +a.gsasearchengineranker.xyz +a.gsaverifiedlist.download +a.hido.tech +a.kerl.gq +a.kwtest.io +a.mailcker.com +a.marksypark.com +a.martinandgang.com +a.mediaplayer.website +a.mylittlepony.website +a.ouijaboard.club +a.poisedtoshrike.com +a.polosburberry.com +a.rdmail.online +a.sach.ir +a.safe-mail.gq +a.teemail.in +a.uditt.cf +a.uhdtv.website +a.virtualmail.website +a.vztc.com +a.waterpurifier.club +a.wxnw.net +a.yertxenor.tk +a.zeemail.xyz +a0.igg.biz +a02sjv3e4e8jk4liat.cf +a02sjv3e4e8jk4liat.ga +a02sjv3e4e8jk4liat.gq +a02sjv3e4e8jk4liat.ml +a02sjv3e4e8jk4liat.tk +a0f7ukc.com +a0reklama.pl +a1.usa.cc +a10mail.com +a1aemail.win +a1b2.cf +a1b2.cloudns.ph +a1b2.gq +a1b2.ml +a1b31.xyz +a1plumbjax.com +a1zsdz2xc1d2a3sac12.com +a2.flu.cc +a23.buzz +a24hourpharmacy.com +a2mail.com +a2qp.com +a2zculinary.com +a3.bigpurses.org +a333yuio.uni.cc +a3auto.com +a3ho7tlmfjxxgy4.cf +a3ho7tlmfjxxgy4.ga +a3ho7tlmfjxxgy4.gq +a3ho7tlmfjxxgy4.ml +a3ho7tlmfjxxgy4.tk +a40.com +a41odgz7jh.com +a41odgz7jh.com.com +a45.in +a458a534na4.cf +a4h4wtikqcamsg.cf +a4h4wtikqcamsg.ga +a4h4wtikqcamsg.gq +a4hk3s5ntw1fisgam.cf +a4hk3s5ntw1fisgam.ga +a4hk3s5ntw1fisgam.gq +a4hk3s5ntw1fisgam.ml +a4hk3s5ntw1fisgam.tk +a4rpeoila5ekgoux.cf +a4rpeoila5ekgoux.ga +a4rpeoila5ekgoux.gq +a4rpeoila5ekgoux.ml +a4rpeoila5ekgoux.tk +a4zerwak0d.cf +a4zerwak0d.ga +a4zerwak0d.gq +a4zerwak0d.ml +a4zerwak0d.tk +a53qgfpde.pl +a54pd15op.com +a5m9aorfccfofd.cf +a5m9aorfccfofd.ga +a5m9aorfccfofd.gq +a5m9aorfccfofd.ml +a6a.nl +a6lrssupliskva8tbrm.cf +a6lrssupliskva8tbrm.ga +a6lrssupliskva8tbrm.gq +a6lrssupliskva8tbrm.ml +a6lrssupliskva8tbrm.tk +a6mail.net +a78tuztfsh.cf +a78tuztfsh.ga +a78tuztfsh.gq +a78tuztfsh.ml +a78tuztfsh.tk +a7996.com +a84doctor.com +a8bl0wo1g5.xorg.pl +a90906.com +a99999.ce.ms +a9jcqnufsawccmtj.cf +a9jcqnufsawccmtj.ga +a9jcqnufsawccmtj.gq +a9jcqnufsawccmtj.ml +a9jcqnufsawccmtj.tk +aa.da.mail-temp.com +aa.dropmail.me +aa.emltmp.com +aa.laste.ml +aa0318.com +aa5j3uktdeb2gknqx99.ga +aa5j3uktdeb2gknqx99.ml +aa5j3uktdeb2gknqx99.tk +aa5zy64.com +aaa-chemicals.com +aaa117.com +aaa4.pl +aaa5.pl +aaa6.pl +aaaaa1.pl +aaaaa2.pl +aaaaa3.pl +aaaaa4.pl +aaaaa5.pl +aaaaa6.pl +aaaaa7.pl +aaaaa8.pl +aaaaa9.pl +aaaaaaa.de +aaaaaaaaa.com +aaabboya00.store +aaaf.ru +aaafdz.mailpwr.com +aaamail.online +aaanime.net +aaaw45e.com +aababes.com +aabagfdgks.net +aabamian.site +aabbt.com +aabop.tk +aabx.laste.ml +aacr.com +aacxb.xyz +aad.yomail.info +aad9qcuezeb2e0b.cf +aad9qcuezeb2e0b.ga +aad9qcuezeb2e0b.gq +aad9qcuezeb2e0b.ml +aad9qcuezeb2e0b.tk +aaddweb.com +aadidassoccershoes.com +aae.freeml.net +aaeton.emailind.com +aaewr.com +aafddz.ltd +aagijim.site +aahs.co.pl +aakk.de +aakk.link +aakkmail.com +aalianz.com +aaliyah.sydnie.livemailbox.top +aall.de +aallaa.org +aalna.org +aalone.xyz +aals.co.pl +aalyaa.com +aamail.co +aamail.com +aamanah.cf +aaorsi.com +aaphace.ml +aaphace1.ga +aaphace2.cf +aaphace3.ml +aaphace4.ga +aaphace5.cf +aaphace6.ml +aaphace7.ga +aaphace8.cf +aaphace9.ml +aaquib.cf +aaqwe.ru +aaqwe.store +aar.emailind.com +aard.org.uk +aargau.emailind.com +aargonar.emailind.com +aaronboydarts.com +aaronlittles.com +aarons-cause.org +aaronson.cf +aaronson1.onedumb.com +aaronson2.qpoe.com +aaronson3.sendsmtp.com +aaronson6.authorizeddns.org +aaronwolford.com +aarrowdev.us +aarway.com +aasf.emlhub.com +aasgashashashajh.cf +aasgashashashajh.ga +aasgashashashajh.gq +aashapuraenterprise.com +aaskin.fr +aasso.com +aateam.pl +aatgmail.com +aayt.freeml.net +aazita.xyz +aazkan.com +aazzn.com +ab-coaster.info +ab-volvo.cf +ab-volvo.ga +ab-volvo.gq +ab-volvo.ml +ab-volvo.tk +ab.emlhub.com +ab0.igg.biz +ab1.pl +abaarian.emailind.com +ababmail.ga +abacuswe.us +abafar.emailind.com +abagael.best +abakiss.com +aballar.com +abandonmail.com +abanksat.us +abaok.com +abaot.com +abar.emailind.com +abarth.ga +abarth.gq +abarth.tk +abasem.ml +abatido.com +abaxmail.com +abb.dns-cloud.net +abb.dnsabr.com +abba.co.pl +abbaji.emailind.com +abbelt.com +abbeyrose.info +abboidsh.online +abboudsh.site +abbuzz.com +abc-payday-loans.co.uk +abc.yopmail.com +abc1.ch +abc1.emltmp.com +abc12235.mailpwr.com +abc13441.mailpwr.com +abc14808.mailpwr.com +abc1519.mailpwr.com +abc17900.mailpwr.com +abc18106.mailpwr.com +abc18992.spymail.one +abc1918.xyz +abc20043.mailpwr.com +abc2018.ru +abc20688.mailpwr.com +abc25247.spymail.one +abc25388.mailpwr.com +abc25907.mailpwr.com +abc26601.mailpwr.com +abc32351.mailpwr.com +abc36625.mailpwr.com +abc37657.mailpwr.com +abc39938.spymail.one +abc44097.mailpwr.com +abc4510.spymail.one +abc47530.mailpwr.com +abc49393.mailpwr.com +abc51411.mailpwr.com +abc58591.mailpwr.com +abc60945.mailpwr.com +abc63564.spymail.one +abc63874.mailpwr.com +abc65641.emlhub.com +abc65774.mailpwr.com +abc68650.dropmail.me +abc68993.dropmail.me +abc69616.emlpro.com +abc69749.mailpwr.com +abc73823.mailpwr.com +abc76582.mailpwr.com +abc80069.mailpwr.com +abc83007.mailpwr.com +abc87180.mailpwr.com +abc90933.mailpwr.com +abc93991.mailpwr.com +abc94459.emlpro.com +abc96544.spymail.one +abc97585.mailpwr.com +abc97975.mailpwr.com +abcda.tech +abcday.net +abcdef1234abc.ml +abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.com +abciarum.info +abcm.mimimail.me +abcmail.email +abcmail.men +abcnetworkingu.pl +abcpaydayloans.co.uk +abcremonty.com.pl +abcsport.xyz +abctoto.live +abcv.info +abcx.dropmail.me +abcz.info.tm +abdcart.shop +abdgoalys.store +abdiell.xyz +abdulah.xyz +abdullaaaa.online +abdullah.ch +abegegr0hl.cf +abegegr0hl.ga +abegegr0hl.gq +abegegr0hl.ml +abegegr0hl.tk +abem.info +abendkleidergunstig.net +abendschoen.com +abenzymes.us +abercrombieepascheresyffr.info +abercrombiefitch-shop.com +abercrombiefitch-store.com +abercrombiefpacherfr.com +abercrombiepascherefrance.fr +abercrombieppascher.com +abercrombiesalejp.com +aberfeldy.pl +abevw.com +abg.nikeshoesoutletforsale.com +abg0i9jbyd.cf +abg0i9jbyd.ga +abg0i9jbyd.gq +abg0i9jbyd.ml +abg0i9jbyd.tk +abh.lol +abhean.emailind.com +abiasa.online +abibal.site +abicontrols.com +abidot.me +abigail11halligan.ga +abigail69.sexy +abigailbatchelder.com +abikmail.com +abilify.site +abilityskillup.info +abilitywe.us +abimillepattes.com +abincol.com +abingtongroup.com +abisheka.cf +abista.space +abject.cfd +ablacja-nie-zawsze.info +ablacja-nie-zawsze.info.pl +ably.co.pl +abmoney.xyz +abmr.waw.pl +abnamro.usa.cc +abnovel.com +abo-free.fr.nf +abogadanotariapr.com +abogados-divorcio.info +aboh913i2.pl +abol.gq +abonc.com +abooday.top +abookb.site +aborega1.com +abos.co.pl +abosoltan.me +abot5fiilie.ru +abot5zagruz.ru +abot8fffile.ru +abouse.space +about.com-posted.org +about.oldoutnewin.com +about.poisedtoshrike.com +about27.com +aboutbeautifulgallopinghorsesinthegreenpasture.online +aboutbothann.org +aboutfitness.net +above-rh.com +abovewe.us +abqenvironmentalstory.org +abqkravku4x36unnhgu9.co.cc +abrauto.com +abreutravel.com +abri.co.pl +abridon.emailind.com +abrighterfutureday.com +abroadedu.ru +abscessedtoothhomeremedy.com +absensidikjari.com +abshc.com +absit.emailind.com +absolutelyecigs.com +absolutesuccess.win +absolutewe.us +absolution-la.com +absorbacher.xyz +absorbenty.pl +absorblovebed.com +absorbuj.pl +abstraction-is-often-one-floor-above-you.top +abstruses.com +abstruses.net +absunflowers.com +abt90bet.net +abtw.de +abtx.emlpro.com +abudat.com +abunasser.online +abunasser.site +abundantwe.us +abunprodvors.xyz +abuseipdb.ru +abuselist.com +abusemail.de +abuser.eu +abut.co.pl +abvent.com +abwesend.de +abyan.art +abybuy.com +abyis.com +abynelil.wiki +abyssemail.com +abyssmail.com +abz101.mooo.com +ac-malin.fr.nf +ac-nation.club +ac20mail.in +ac3d64b9a4n07.cf +ac3d64b9a4n07.ga +ac3d64b9a4n07.gq +ac3d64b9a4n07.tk +ac895.cf +ac895.ga +ac895.gq +ac895.ml +ac9fqq0qh6ucct.cf +ac9fqq0qh6ucct.ga +ac9fqq0qh6ucct.gq +ac9fqq0qh6ucct.ml +ac9fqq0qh6ucct.tk +aca5.com +acaciaa.top +academail.net +academic.edu.rs +academiccommunity.com +academmail.info +academybankmw.com +academywe.us +acadteh.ru +acai-berry.es +acaihelp.com +acampadaparis.com +acanadianpharmacy.com +acasabianca.com +acc1s.com +acc1s.net +acc2t9qnrt.cf +acc2t9qnrt.ga +acc2t9qnrt.gq +acc2t9qnrt.ml +acc2t9qnrt.tk +accademiadiscanto.org +accclone.com +accebay.site +acceleratedps.com +acceleratewe.us +accent.home.pl +accentri.com +accentslandscapes.com +accentwe.us +acceptbadcredit.ru +acceptmail.net +acceptwe.us +accesorii.info +access.com-posted.org +access995.com +accesschicago.net +accessecurity.com +accesshigh.win +accesslivingllc.net +accessmedia.it +accessori.ru +accessoriesjewelry.co.cc +acciobit.net +accionambiente.org +acclaimwe.us +accmt-servicefundsprefer.com +accnw.com +accordcomm.com +accordmail.net +accordwe.us +accountanten.com +accountantruth.cf +accounting11-tw.org +accountingdegree101.com +accountingintaylor.com +accountrainbow.email +accountrainbow.store +accounts-login.ga +accountsadtracker.com +accountscenter.support +accountsite.me +accountsiteku.tech +accpremium.ga +accreditedwe.us +acctw.net +accuracyis.com +accuranker.tech +accuratecomp.com +accurateto.com +accurbrinue.biz +accutaneonlinesure.com +ace-mail.net +ace.ace.gy +ace333.info +acebabe.com +aced.co.pl +acedby.com +acem2021.com +acemail.info +acembine.site +acentni.com +acentri.com +acequickloans.co.uk +acer-servisi.com +acetesz.com +acetonic.info +acfddy.ltd +acgapp.hk +acgmetals.com +achatairjordansfrance.com +achatairjordansfrshop.com +achatjordansfrshop.com +achatz.ga +ache.co.pl +acheterairmaxs.com +achetertshirt.com +achievementwe.us +achievewe.us +achillesinvestments.com +achterhoekrp.online +achuevo.ru +achy.co.pl +aciclovir.ru.com +acidalia.ml +acidlsdpyshop.com +acidlsdshop.com +acidrefluxdiseasecure.com +acike.com +acissupersecretmail.ml +acklewinet.store +acklink.com +acl.freeml.net +acmail.com +acmeco.tk +acmenet.org +acmet.com +acmilanbangilan.cf +acmimail.com +acname.com +acnatu.com +acne.co.pl +acne.com +acnebrufolirime43.eu +acnec.com +acnemethods.com +acnenomorereviewed.info +acneproduction.com +acnonline.com +acnrnidnrd.ga +acofmail.com +aconnectioninc.com +acontenle.eu +acoporthope.org +acornautism.com +acornsbristol.com +acornwe.us +acoukr.pw +acousticlive.net +acpeak.com +acqm38bmz5atkh3.cf +acqm38bmz5atkh3.ga +acqm38bmz5atkh3.gq +acqm38bmz5atkh3.ml +acqm38bmz5atkh3.tk +acquaintance70.tk +acres.asia +acrewgame.com +acribush.site +acrilicoemosasco.ml +acrilicosemosasco.ml +acrilworld.ml +acroexch.us +acrossgracealley.com +acroyoga.fun +acroyogabook.com +acroyogadance.academy +acroyogadance.coach +acrylicchairs.org +acrylicwe.us +acs.net +acsisa.net +acsstudent.com +act4trees.com +acta.co.pl +actarus.infos.st +acting-guide.info +actitz.site +activacs.com +activatewe.us +active.au-burn.net +activehealthsystems.com +activesniper.com +activestore.xyz +activilla.com +activities.works +activitysports.ru +activitywe.us +acton-plumber-w3.co.uk +actor.ruimz.com +actrses.com +acts.co.pl +actualizaweb.com +actuallyhere.com +acu.yomail.info +acuarun.com +acucre.com +acuitywe.us +acumendart-forcepeace-darter.com +acumenwe.us +acuntco.com +acupuncturenews.org +acuxi.com +acv.fyi +acvina.com +acx-edu.com +acyclovir-buy.com +acyl.co.pl +acys.de +ad-seo.com +ad2linx.org +ada-duit.ga +ada-janda.ga +adacalabuig.com +adachiu.me +adacplastics.com +adadad.com +adadad.uk +adadass.cf +adadass.ga +adadass.gq +adadass.ml +adadass.tk +adadfaf.tech +adalah.dev +adallasnews.com +adalowongan.com +adamastore.co +adambra.com +adamcoloradofitness.com +adamholtphotography.net +adamsarchitects.com +adamtraffic.com +adann.xyz +adaov.com +adapdev.com +adapromo.com +adaptempire.site +adaptivesensors.co.uk +adaptivewe.us +adaptwe.us +adaromania.com +adarsa.me +adarsh.cf +adarshgoel.me +adasd.cc +adasfe.com +adashev.ru +adastars333.com +adastralflying.com +adax.site +adazmail.com +adb3s.com +adbet.co +adcloud.us +adcoolmedia.com +add3000.pp.ua +add6site.tk +addcom.de +addictingtrailers.com +addictionisbad.com +addidas-group.com +addimail.top +addisonn.xyz +additionaledu.ru +additive.center +addmails.com +addressunlock.com +addrin.uk +addthis.site +addtocurrentlist.com +addyoubooks.com +adeany.com +adeata.com +adec.name +adeha.com +adek.orge.pl +adel.asia +adelaide.bike +adelaideoutsideblinds.com.au +adelechic.shop +adelinabubulina.com +adelpia.net +adengo.ru +adenose.info +adentaltechnician.com +adeptwe.us +aderispharm.com +adesktop.com +adfilter.org +adfly.comx.cf +adfskj.com +adg.spymail.one +adgento.com +adgloselche.esmtp.biz +adgome.com +adhamabonaser.space +adheaminn.xyz +adhong.com +adhreez.xyz +adhya.xyz +adidas-fitness.eu +adidas-porsche-design-shoes.com +adidas.servepics.com +adidasasoccershoes.com +adidasshoesshop.com +adidasto.com +adifferentlooktaxservices.com +adil.pl +adilub.com +adios.email +adiosbaby.com +adipex7z.com +adiq.eu +adisabeautysalon.com +adit.co.pl +aditus.info +adivava.com +adj.emltmp.com +adjun.info +adkchecking.com +adkcontracting.com +adleep.org +adlinks.org +admadvice.com +admail.com +admarz.com +admin-ru.ru +admin4cloud.net +administraplus.delivery +administrativo.world +adminzoom.com +admiralwe.us +admiraq.site +admissiontostudyukraine.com +admlinc.com +admmo.com +admt0121.com +admt01211.com +admt01212.com +admt01213.com +adn3t.com +adnc7mcvmqj0qrb.cf +adnc7mcvmqj0qrb.ga +adnc7mcvmqj0qrb.gq +adnc7mcvmqj0qrb.ml +adnc7mcvmqj0qrb.tk +ado888.biz +adobeccepdm.com +adolf-hitler.cf +adolf-hitler.ga +adolf-hitler.gq +adolf-hitler.ml +adolfhitlerspeeches.com +adoms.site +adonisgoldenratioreviews.info +adoniswe.us +adoppo.com +adorable.org +adoratus.buzz +adosnan.com +adpings.com +adpmfxh0ta29xp8.cf +adpmfxh0ta29xp8.ga +adpmfxh0ta29xp8.gq +adpmfxh0ta29xp8.ml +adpmfxh0ta29xp8.tk +adpostingjob.com +adprofjub.tk +adprojnante.xyz +adpromot.net +adpugh.org +adpurl.com +adrais.com +adramail.com +adrespocztowy.pl +adresse.biz.st +adresse.infos.st +adresseemailtemporaire.com +adrewire.com +adriana.evelin.kyoto-webmail.top +adrianneblackvideo.com +adrianou.gq +adrinks.ru +adriveriep.com +adrmwn.me +adroh.com +adroit.asia +ads24h.top +adsas.com +adsbruh.com +adsd.org +adsensekorea.com +adsfafgas.cloud +adsgiare.vn +adshine.click +adsordering.com +adspecials.us +adstam.com +adstellara.com +adstreet.es +adsvn.me +adtemps.org +adtika.online +adtolls.com +adubandar.com +adubandar69.com +adubiz.info +aduhsakit.ga +adukmail.com +adulktrsvp.com +adult-biz-forum.com +adult-db.net +adult-free.info +adult-work.info +adultbabybottles.com +adultcamzlive.com +adultchat67.uni.cc +adultesex.net +adultfacebookinfo.info +adultfriendclubs.com +adultmagsfinder.info +adulttoy20117.co.tv +adulttoys.com +adultvidlite.com +aduski.info +adv.spymail.one +adva.net +advancedwebstrategiesinc.com +advantagesofsocialnetworking.com +advantagewe.us +advantimal.com +advantimals.com +advantimo.com +advarm.com +advdesignss.info +adventurewe.us +adventwe.us +adverstudio.com +advertence.com +advertforyou.info +advertiseall.com +advertisingmarketingfuture.info +advertmix85.xyz +advew.com +advextreme.com +advidsstudio.co +advisorwe.us +advitise.com +advitize.com +adviva-odsz.com +advlogisticsgroup.com +advocatewe.us +advogadoespecializado.com +advokats.info +advorta.com +advoter.cc +adwaterandstir.com +adwb.emltmp.com +adwordsopus.com +adx-telecom.com +ady12.design +adye.spymail.one +adza.cc +adze.co.pl +adzillastudio.com +ae-mail.pl +ae.freeml.net +ae.laste.ml +ae.pureskn.com +aeacides.info +aeai.com +aebfish.com +aecmedya.com +aed-cbdoil.com +aed5lzkevb.cf +aed5lzkevb.ga +aed5lzkevb.gq +aed5lzkevb.ml +aed5lzkevb.tk +aeerso.space +aegde.com +aegia.net +aegis-conference.eu +aegiscorp.net +aegiswe.us +aegoneinsurance.cf +aeh.dropmail.me +aeimpu.es +aeissy.com +ael.freeml.net +aeliatinos.com +aelo.es +aelove.us +aelup.com +aemail.xyz +aemail4u.com +aengar.ml +aenikaufa.com +aenmail.net +aenmglcgki.ga +aenomail.com +aenomail.online +aenomail.xyz +aenterprise.ru +aeon.tk +aeonpsi.com +aeorder.us +aeorierewrewt.co.tv +aepc2022.org +aer.emlhub.com +aerectiledysfunction.com +aergaqq.cloud +aergargearg.tech +aeri.ml +aero-files.net +aero.ilawa.pl +aero1.co.tv +aero2.co.tv +aerobicaerobic.info +aerobicservice.com +aerochart.co.uk +aerodynamicer.store +aeroponics.edu +aeroport78.co.tv +aeroshack.com +aerosp.com +aeroxboy.com +aersm.com +aerteur73.co.tv +aertewurtiorie.co.cc +aesamedayloans.co.uk +aesel.me +aeshopshop.xyz +aesopsfables.net +aestabbetting.xyz +aestrony6.com +aestyria.com +aet.freeml.net +aethermails.com +aethiops.com +aetorieutur.tk +aeu.yomail.info +aev333.cz.cc +aevtpet.com +aewh.info +aewituerit893.co.cc +aewn.info +aewutyrweot.co.tv +aewy.info +aexa.info +aexd.com +aexd.info +aexf.info +aexg.info +aexk.ru +aexw.info +aexy.info +aeyl.com +aeyq.info +aeze0qhwergah70.cf +aeze0qhwergah70.ga +aeze0qhwergah70.gq +aeze0qhwergah70.ml +aeze0qhwergah70.tk +aezl.info +aezz.emlhub.com +af.dropmail.me +af2przusu74mjzlkzuk.cf +af2przusu74mjzlkzuk.ga +af2przusu74mjzlkzuk.gq +af2przusu74mjzlkzuk.ml +af2przusu74mjzlkzuk.tk +afandi.baby +afandi.digital +afaracuspurcatiidintara.com +afarek.com +afat1loaadz.ru +afat2fiilie.ru +afat3sagruz.ru +afat9faiili.ru +afatt3fiilie.ru +afatt7faiili.ru +afbj.emlpro.com +afcgroup40.com +afeeyah.store +afenmail.com +aferin.site +aff-marketing-company.info +affcats.com +affecting.org +afferro-mining.com +affgame.com +affgrinder.com +affilialogy.com +affiliate-marketing2012.com +affiliate-nebenjob.info +affiliatedwe.us +affiliatehustle.com +affiliatenova.com +affiliateseeking.biz +affiliatesonline.info +affiliatez.net +affilikingz.de +affinitywe.us +affliatemagz.com +afflictionmc.com +affluentwe.us +affogatgaroth.com +affordable55apartments.com +affordableroofcare.com +affordablescrapbook.com +affordablespecs.online +affordablevisitors.com +affordablevoiceguy.com +affordablewe.us +affricca.com +afg-lca.com +afganbaba.com +afi-tic.es +afia.pro +afiliadoaprendiz.com +afilliyanlizlik.xyz +afisha.biz.ua +afishaonline.info +aflam06.com +aflamyclub.com +afluidbear.cc +afmail.com +afmail.xyz +afopmail.com +aforyzmy.biz +afp.blatnet.com +afp.lakemneadows.com +afpeterg.com +afr564646emails.com +afractalreality.com +afranceattraction.com +afre676007mails.com +afre67677mails.com +afreecatvve.com +afremails.com +africanamerican-hairstyles.org +africanmails.com +africanmangoactives.com +africanprospectors.com +africatimes.xyz +afriend.fun +afriendship.ru +afro.com-posted.org +afrobacon.com +afrocelts.us +afroprides.com +afsaf.com +afse-gh.top +afsf.de +afsp.emltmp.com +afteir.com +after.lakemneadows.com +afteraffair.com +aftercorporation.com +aftereight.pl +afterhourswe.us +afternea.sbs +afternic.com +afterpeg.com +afterspace.net +afterthediagnosisthebook.com +aftnfeyuwtzm.cf +aftnfeyuwtzm.ga +aftnfeyuwtzm.gq +aftnfeyuwtzm.ml +aftnfeyuwtzm.tk +aftttrwwza.com +afun.com +afunthingtodo.com +afuture.date +afw.fr.nf +afyonbilgisayar.xyz +ag.us.to +ag02dnk.slask.pl +ag163.top +ag95.cf +ag95.ga +ag95.gq +ag95.ml +ag95.tk +aga.emlpro.com +agafx.com +agagmail.com +agallagher.id +agamail.com +agapetus.info +agar.co.pl +agartstudio.com.pl +agaseo.com +agasolution.me +agave.buzz +agcd.com +agdrtv.com +agedlist.com +agedmail.com +agelesspx.com +agemail.com +agenbola.com +agenbola9.com +agencabo.com +agencjaatrakcji.pl +agencjainteraktywna.com +agencjareklamowanestor.pl +agencynet.us +agendawe.us +agendka.mielno.pl +agenimc6.com +agenra.com +agenresmipokeridn.com +agent.blatnet.com +agent.cowsnbullz.com +agent.lakemneadows.com +agent.makingdomes.com +agent.oldoutnewin.com +agent.ploooop.com +agent.poisedtoshrike.com +agent.warboardplace.com +agentogelasia.com +agentshipping.com +agentsosmed.com +agentwithstyle.com +agenzieinvestigativetorino.it +ageokfc.com +agesong.com +agfdgks.com +agger.ro +agget5fiilie.ru +agget6fiilie.ru +agget6loaadz.ru +aggrandized673jc.online +agh-rip.com +agha.co.pl +agibdd.ru +agilecoding.com +agilekz.com +agilewe.us +agilityforeigntrade.com +aginfolink.com +agistore.co +agitprops.de +agiuse.com +aglobetony.pl +agma.co.pl +agmail.com +agmial.com +agmoney.xyz +agnitumhost.net +agnxbhpzizxgt1vp.cf +agnxbhpzizxgt1vp.ga +agnxbhpzizxgt1vp.gq +agnxbhpzizxgt1vp.ml +agnxbhpzizxgt1vp.tk +agoda.lk +agoravai.tk +agorawe.us +agp.edu.pl +agpb.com +agpforum.com +agpoker99.uno +agramas.cf +agramas.ml +agreeone.ga +agreetoshop.com +agri.agriturismopavi.it +agri.com-posted.org +agriokss.com +agristyleapparel.us +agrofort.com +agrolaw.ru +agrolivana.com +agromgt.com +agrostor.com +agrostroy1.site +agsmechanicalinc.com +agtt.net +agtx.net +aguablancasbr.com +aguamail.com +aguamexico.com.mx +aguardhome.com +aguarios1000.com.mx +aguastinacos.com +ague.co.pl +aguide.site +agung001.com +agung002.com +agustaa.top +agustasportswear.com +agustusmp3.xyz +agwbyfaaskcq.cf +agwbyfaaskcq.ga +agwbyfaaskcq.gq +agwbyfaaskcq.ml +agwbyfaaskcq.tk +agxazvn.pl +agxngcxklmahntob.cf +agxngcxklmahntob.ga +agxngcxklmahntob.gq +agxngcxklmahntob.ml +agxngcxklmahntob.tk +ahaappy0faiili.ru +ahajusthere.com +ahakista.emailind.com +ahanim.com +ahappycfffile.ru +ahardrestart.com +ahbtv.mom +ahbz.xyz +ahcsolicitors.co.uk +ahd.emlhub.com +ahdrone.com +aheadwe.us +ahem.email +ahgae-crews.us.to +ahghtgnn.xyz +ahgk.spymail.one +ahgnmedhew.cloud +ahhmail.info +ahhos.com +ahhtee.com +ahieh.com +ahihi.site +ahihimail.com +ahilleos.com +ahimail.sbs +ahjmemdjed.cloud +ahjvgcg.com +ahk.jp +ahketevfn4zx4zwka.cf +ahketevfn4zx4zwka.ga +ahketevfn4zx4zwka.gq +ahketevfn4zx4zwka.ml +ahketevfn4zx4zwka.tk +ahlifb.com +ahmadahmad.cloud +ahmadhamed.cloud +ahmadidik.cf +ahmadidik.ga +ahmadidik.gq +ahmadidik.ml +ahmadmohsen.shop +ahmadmohsen2.shop +ahmadne.cloud +ahmail.xyz +ahmed-nahed12.website +ahmed211.cloud +ahmed805171.cloud +ahmedassaf2003.site +ahmedggasj14.cloud +ahmedggeg100.cloud +ahmedggsg741.cloud +ahmedggslfja180.cloud +ahmedkhlef.com +ahmednaidal.tech +ahmednjjar.store +ahmedsafo.cloud +ahmesdfpo.tech +ahmnnedtfs.fun +ahmosalahgood.fun +ahnmednrh.shop +ahnnmedmehd.cloud +ahoj.co.uk +ahojmail.pl +ahomesolution.com +ahomework.ru +ahoo.com.ar +ahoora-band.com +ahopmail.com +ahouse.top +ahoxavccj.pl +ahq.yomail.info +ahrixthinh.net +ahrr59qtdff98asg5k.cf +ahrr59qtdff98asg5k.ga +ahrr59qtdff98asg5k.gq +ahrr59qtdff98asg5k.ml +ahrr59qtdff98asg5k.tk +ahsb.de +ahsozph.tm.pl +ahtubabar.ru +ahvin.com +ahyars.site +ai.aax.cloudns.asia +ai.hsfz.info +ai.vcss.eu.org +ai4trade.info +ai6188.com +aiadvertising.xyz +aiafhg.com +aiauction.xyz +aiaustralia.xyz +aicanada.xyz +aicasino.xyz +aichou.org +aiclbd.com +aicogz.com +aicts.com +aiczcn.us +aide.co.pl +aiduisoi3456ta.tk +aidweightloss.co.uk +aiebka.com +aieen.com +aifmhymvug7n4.ga +aifmhymvug7n4.gq +aifmhymvug7n4.ml +aifmhymvug7n4.tk +aigptplus.co +aihent.com +aihtnb.com +aihualiu.com +aiindia.xyz +aiiots.net +aij.freeml.net +aijuice.net +aikoreaedu.com +aikq.de +aikunkun.com +aikusy.com +ailem.info +ailicke.com +ailiking.com +ailme.pw +ailoki.com +ailtex.com +aimamhunter.host +aimboss.ru +aimodel.xyz +aims.co.pl +aimserv.com +ainbz.com +aing.tech +ains.co.pl +ainumedia.xyz +aioneclick.com +aiot.aiphone.eu.org +aiot.creo.site +aiot.creou.dev +aiot.dmtc.dev +aiot.ptcu.dev +aiot.vuforia.us +aiot.ze.cx +aiphotoeditor.io +aiphotoenhancer.me +aipmail.ga +aips.store +aipuma.com +aiqisp.com +aiqoe.com +air-blog.com +air-bubble.bedzin.pl +air-inbox.com +air-maxshoesonline.com +air.stream +air2token.com +airadding.com +airaf.site +aircapitol.net +aircargomax.us +aircolehaan.com +airconditionermaxsale.us +airconditioningservicetampafl.com +aircourriel.com +airebook.com +airfareswipe.com +airfiltersmax.us +airforceonebuy.net +airforceonesbuy.com +airg.app +airhue.com +airideas.us +airj0ranpascher.com +airj0ranpascher2.com +airjodanpasfranceshoes.com +airjodansshoespascherefr.com +airjoranpasachere.com +airjordan-france-1.com +airjordanacheter.com +airjordanafrance.com +airjordanapascher.com +airjordanapascherfrance.com +airjordanaustraliasale.com +airjordancchaussure.com +airjordaneenlignefr.com +airjordanffemme.com +airjordanfranceeee.com +airjordannpascherr.com +airjordannsoldes.com +airjordanochaussure.com +airjordanoutletcenter.us +airjordanoutletclub.us +airjordanoutletdesign.us +airjordanoutletgroup.us +airjordanoutlethomes.us +airjordanoutletinc.us +airjordanoutletmall.us +airjordanoutletonline.us +airjordanoutletshop.us +airjordanoutletsite.us +airjordanoutletstore.us +airjordanoutletusa.us +airjordanoutletwork.us +airjordanpaschefr.com +airjordanpascher1.com +airjordanpaschereshoes.com +airjordanpascherjordana.com +airjordanpaschermagasinn.com +airjordanpascherrfr.com +airjordanpascherrr.com +airjordanpascherrssoldes.com +airjordanpaschersfr.com +airjordanpaschersoldesjordanfr.com +airjordanpasschemagasin.com +airjordanpasscher.com +airjordanretro2013.org +airjordanscollection.com +airjordanshoesfrfrancepascher.com +airjordansofficiellefrshop.com +airjordanspascher1.com +airjordansshoes2014.com +airjordansstocker.com +airknox.com +airmail.fun +airmail.nz +airmail.tech +airmail.top +airmailbox.website +airmailhub.com +airmails.info +airmax-sale2013club.us +airmax1s.com +airmaxdesignusa.us +airmaxgroupusa.us +airmaxhomessale2013.us +airmaxnlinesaleinc.us +airmaxonlineoutlet.us +airmaxonlinesaleinc.us +airmaxpower.us +airmaxprooutlet2013.us +airmaxrealtythesale.us +airmaxsaleonlineblog.us +airmaxschuhev.com +airmaxsde.com +airmaxshoessite.com +airmaxshopnike.us +airmaxslocker.com +airmaxsmart.com +airmaxsneaker.us +airmaxspascherfrance.com +airmaxsproshop.com +airmaxsstocker.com +airmaxstoresale2013.us +airmaxstyles.com +airmaxtn1-90paschers.com +airmaxtnmagasin.com +airmaxukproshop.com +airmighty.net +airmo.net +airn.co.pl +airold.net +airon116.su +airparkmax.us +airplane2.com +airplay.elk.pl +airportlimoneworleans.com +airpriority.com +airpurifiermax.us +airriveroutlet.us +airshowmax.us +airsi.de +airsoftshooters.com +airsport.top +airsuspension.com +airsworld.net +airtravelmaxblog.us +airturbine.pl +airuc.com +airwayy.us +airweldon.com +airxr.ru +ais.freeml.net +aisaelectronics.com +aisezu.com +aishastore.net +aisj.com +aisports.xyz +aistis.xyz +aitecleco.com +aituvip.com +aiuepd.com +aiuq.dropmail.me +aiv.pl +aivtxkvmzl29cm4gr.cf +aivtxkvmzl29cm4gr.ga +aivtxkvmzl29cm4gr.gq +aivtxkvmzl29cm4gr.ml +aivtxkvmzl29cm4gr.tk +aiwanlab.com +aiworldx.com +aiwozhongguo.office.gy +aixind.com +aixne.com +aixnv.com +aiy.spymail.one +aizennsasuke.cf +aizennsasuke.ga +aizennsasuke.gq +aizennsasuke.ml +aizennsasuke.tk +aj.yomail.info +ajabdshown.com +ajarnow.com +ajaxapp.net +ajaxdesign.org +ajbsoftware.com +ajeeb.email +ajengkartika.art +ajeroportvakansii20126.co.tv +ajfldkvmek.com +ajfm.spymail.one +ajgyuijh.shop +aji.kr +ajiagustian.com +ajiezvandel.site +ajinimoto.me +ajjdf.com +ajllogistik.com +ajmail.com +ajobabroad.ru +ajobfind.ru +ajoxmail.com +ajp.emlhub.com +ajpapa.net +ajrf.in +ajruqjxdj.pl +ajrvnkes.xyz +ajsd.de +aju.onlysext.com +ajustementsain.club +ajx.laste.ml +ak.mintemail.com +aka2.pl +akaan.emailind.com +akademiyauspexa.xyz +akae.dropmail.me +akainventorysystem.com +akakumo.com +akaliy.com +akamaiedge.gq +akamail.com +akamaized.cf +akamaized.ga +akamaized.gq +akamarkharris.com +akanshabhatia.com +akapost.com +akapple.com +akara-ise.com +akash9.gq +akazq33.cn +akb007.com +akbip.com +akbqvkffqefksf.cf +akbqvkffqefksf.ga +akbqvkffqefksf.gq +akbqvkffqefksf.ml +akbqvkffqefksf.tk +akcebetuyelik1.club +akcesoria-dolazienki.pl +akcesoria-telefoniczne.pl +akd-k.icu +akedits.com +akee.co.pl +akekee.com +akerd.com +aketospring.biz +akfioixtf.pl +akgaf.orge.pl +akgaming.com +akgq701.com +akhirluvia.biz +akhmadi.cf +akhost.trade +akhpremium.site +aki.spymail.one +akihiro84.downloadism.top +akilliusak.network +akina.pl +akinesis.info +akinozilkree.click +akiol555.vv.cc +akiowrertutrrewa.co.tv +akira4d.info +akirapowered.com +akirbs.cloud +akixpres.com +akjewelery-kr.info +akk.ro +akkecuwa.ga +aklqo.com +akmail.com +akmail.in +akmaila.org +akmandken.tk +akmra.com +akmtop.com +akoe.yomail.info +akoption.com +akorde.al +akramed.ru +akryn4rbbm8v.cf +akryn4rbbm8v.ga +akryn4rbbm8v.gq +akryn4rbbm8v.tk +aksarat.eu +aksarayorospulari.xyz +aksearches.com +aksesorisa.com +aksipalestina.biz.id +aktantekten.shop +aktiefmail.nl +aktifanadhevi.biz +aktifbil.com +aktifplastik.com +akuadalah.dev +akufry.cf +akufry.ga +akufry.gq +akufry.ml +akufry.tk +akugu.com +akula012.vv.cc +akumulatorysamochodowe.com +akumulatoryszczecin.top +akunamatata.site +akunhd.com +akunku.shop +akunku.xyz +akunlama.com +akunnerft.engineer +akunprm.com +akunvipku.com +akunyd.com +akunzoom.com +akusara.online +akusayyangkamusangat.ga +akusayyangkamusangat.ml +akusayyangkamusangat.tk +akustyka2012.pl +akutamvan.com +akuudahlelah.com +akvaristlerdunyasi.com +akxpert.com +akxugua0hbednc.cf +akxugua0hbednc.ga +akxugua0hbednc.gq +akxugua0hbednc.ml +akxugua0hbednc.tk +akyildizkahve.com +akza.yomail.info +akzwayynl.pl +al-qaeda.us +al.freeml.net +alabama-get.loan +alabama-nedv.ru +alabamawheelchair.com +alabapestenoi.com +aladeen.org +alain-ducasserecipe.site +alainazaisvoyance.com +alaki.ga +alalal.com +alalkamalalka.gq +alalkamalalka.tk +alamal.asia +alamedanet.net +alanadi.xyz +alankxp.com +alannahtriggs.ga +alanwilliams2008.com +alapage.ru +alappuzhanews.com +alarabi24.com +alaret.ru +alarmsunrise.ml +alarmsysteem.online +alarmydoowectv.com +alaska-nedv.ru +alaskaquote.com +alasse.tech +alassemohmed.fun +alatajaib.com +alb-gaming.com +albamail.ga +alban-nedv.ru +albarulo.com +albaspecials.com +albayan-magazine.net +albedolab.com +albico.su +albill.com +albionwe.us +albos.in +albtelecom.com +alburov.com +albvid.org +alc.emlpro.com +alchemywe.us +alchiter.ga +alcody.com +alcohol-rehab-costs.com +alcoholetn.com +alcoholicsanonymoushotline.com +alcyonoid.info +alda.com +aldemimea.xyz +aldephia.net +aldeyaa.ae +aldineisd.com +aldivy.emailind.com +ale35anner.ga +aleagustina724.cf +aleaisyah710.ml +aleamanda606.cf +aleanna704.cf +aleanwisa439.cf +alebutar-butar369.cf +alec.co.pl +alectronik.com +aledestrya671.tk +aledrioroots.youdontcare.com +alee.co.pl +aleelma686.ml +aleen.emailind.com +aleepapalae.gq +alefachria854.ml +alefika98.ga +alegrabrasil.com +alegracia623.cf +alegradijital.com +aleh.de +aleherlin351.tk +aleitar.com +alekikhmah967.tk +alemalakra.com +alemaureen164.ga +alemeutia520.cf +alenina729.tk +aleno.com +alenoor903.tk +alenovita373.tk +aleomailo.com +aleqodriyah730.ga +aleramici.eu +alerioncharleston.com +alerionventures.info +alerionventures.org +alerionventures.us +alertslit.top +alesapto153.ga +aleshiami275.ml +alessi9093.co.cc +alessia1818.site +alesulalah854.tk +alesuperaustostrada.eu +aletar.ga +aletar.tk +aletasya616.ml +alethea.top +alex.dynamailbox.com +alexa-ranks.com +alexadomain.info +alexandreleclercq.com +alexandria.fund +alexapisces.co.uk +alexapisces.com +alexapisces.uk +alexbox.online +alexbrowne.info +alexbtz.com +alexcabrera.net +alexcruz.tk +alexdrivers00.ru +alexdrivers2013.ru +alexecristina.com +alexida.com +alexpeattie.com +alf.laste.ml +alfa-romeo.cf +alfa-romeo.ga +alfa-romeo.gq +alfa-romeo.ml +alfa.papa.wollomail.top +alfa.tricks.pw +alfaceti.com +alfacontabilidadebrasil.com +alfamailr.org +alfaomega24.ru +alfapaper.ru +alfarab1f4rh4t.online +alfaromeo.igg.biz +alfaromeo147.cf +alfaromeo147.gq +alfaromeo147.ml +alfaromeo147.tk +alfasigma.spithamail.top +alfresco.app +alfursanwinchtorescuecarsincairo.xyz +alga.co.pl +algeria-nedv.ru +algerie-culture.com +algicidal.info +algobot.one +algobot.org +algomau.ga +algreen.com +alhamadealmeria.com +aliannedal.tech +alianzati.com +aliases.tk +aliasnetworks.info +aliaswe.us +alibabao.club +alibabor.com +aliban.org +alibestdeal.com +alibirelax.ru +aliblue.top +alibrs.com +alibto.com +alic.info +alicdh.com +alicemail.link +alicemchard.com +aliclaim.click +alidioa.tk +aliefeince.com +alientex.com +alienware13.com +aliex.co +aliex.us +aliexchangevn.com +alif.co.pl +alifestyle.ru +aligamel.com +alightmotion.id +alightmotion.top +aligreen.top +aligroup.uk +alihkan.com +alikmotion.com +alilen.pw +alilike.us +alilomalyshariki.ru +alilot-web.com +alilot.com +alimail.bid +alimaseh.space +alimunjaya.xyz +alina-schiesser.ch +alinalinn.com +alindropromo.com +aline9.com +alinedal.cloud +alinzx.com +alioka759.vv.cc +alione.top +aliorbaank.pl +aliorder.pro +aliorder.ru +alired.top +alis.crabdance.com +alisaaliya.istanbul-imap.top +alisaol.com +alisiarininta.art +alisoftued.com +alisongamel.com +alisree.com +alistantravellinert.com +alitma.com +alittle.website +alivance.com +alivewe.us +aliwegwpvd.ga +aliwegwpvd.gq +aliwegwpvd.ml +aliwegwpvd.tk +aliwhite.top +alizaa4.shop +alizof.com +alkila-lo.com +alkila-lo.net +alkoholeupominki.pl +alkomat24h.pl +alky.co.pl +all-about-cars.co.tv +all-about-health-and-wellness.com +all-cats.ru +all-file.site +all-knowledge.ru +all-mail.net +all-store24.ru +all.cowsnbullz.com +all.droidpic.com +all.emailies.com +all.lakemneadows.com +all.marksypark.com +all.ploooop.com +all4mail.cn.pn +all4me.info +all4oneseo.com +allabilarskrotas.se +allaboutdogstraining.com +allaboutebay2012.com +allaboutemarketing.info +allaboutlabyrinths.com +allaboutword.com +allaccesswe.us +alladyn.unixstorm.org +allairjordanoutlet.us +allairmaxsaleoutlet.us +allamericanmiss.com +allamericanwe.us +allanimal.ru +allanjosephbatac.com +allapparel.biz +allaroundwe.us +allartworld.com +allbest-games.ru +allbest.site +allbigsales.com +allboutiques.com +allcheapjzv.ml +allchristianlouboutinshoesusa.us +allclown.com +alldao.org +alldavirdaresinithesjy.com +alldelhiescort.com +alldirectbuy.com +alldotted.com +alldrys.com +alledoewservices.com +alleen.site +allegiancewe.us +allegrowe.us +allemailyou.com +allemaling.com +allemojikeyboard.com +allen.nom.za +allenelectric.com +allenrothclosetorganizer.com +allerguxfpoq.com +allergypeanut.com +allesgutezumgeburtstag.info +allfactory.com +allfamus.com +allfolk.ru +allfreemail.net +allfrree.xyz +allgaiermogensen.com +allgamemods.name +allgoodwe.us +allhostguide.com +alliancefenceco.com +alliancetraining.com +alliancewe.us +allinonewe.us +alliscasual.org.ua +allkemerovo.ru +allmailserver.com +allmarkshare.info +allmmogames.com +allmp3stars.com +allmtr.com +allnet.org +allnewsblog.ru +allofthem.net +alloggia.de +allopurinol-online.com +alloutwe.us +allowed.org +alloywe.us +allpaydayloans.info +allpickuplines.info +allpisaim.shop +allpotatoes.ml +allpronetve.ml +allprowe.us +allreview4u.com +allroundawesome.com +allroundnews.com +allsaintscatholicschool.org +allseasonswe.us +allsets.xyz +allsoftreviews.com +allsportsinc.net +allsquaregolf.com +allstarwe.us +allsuperinfo.com +alltekia.com +alltell.net +alltempmail.com +allthegoodnamesaretaken.org +allthetimeyoudisappear.com +allthingswoodworking.com +alltopmail.com +alltopmovies.biz +alltrozmail.club +allukschools.com +allumhall.co.uk +allurewe.us +allute.com +allwebemails.com +ally.co.pl +allyourcheats.com +allyours.xyz +almail.com +almail.top +almajedy.com +almanara.info +almasa.asia +almatips.com +almaxen.com +almaz-beauty.ru +alme.co.pl +almiswelfare.org +almondwe.us +almooshamm.website +almostfamous.it +almubaroktigaraksa.com +alnewcar.co.uk +aloalo.store +aloaloweb.online +aloha.emlpro.com +alohagroup808.com +alohagroup808.net +alohaziom.pl +alohomora.biz +aloimail.com +alonecmw.com +alonetry.com +alonzo1121.club +alonzos-end-of-career.online +alook.com +alormbf88nd.cf +alormbf88nd.ga +alormbf88nd.gq +alormbf88nd.ml +alormbf88nd.tk +alosp.com +alosttexan.com +alotivi.com +alovobasweer.co.tv +aloxy.ga +aloxy.ml +alpegui.com +alpenjodel.de +alpersadikan.sbs +alph.wtf +alpha-jewelry.com +alpha-lamp.ru +alpha-web.net +alpha.uniform.livemailbox.top +alphabeticallysa.site +alphaconquista.com +alphafrau.de +alphaneutron.com +alphaomegahealth.com +alphaomegawe.us +alphaphalpha74.com +alphark.xyz +alphatheblog.com +alphaupsilon.thefreemail.top +alphax.fr.nf +alphonsebathrick.com +alpinewe.us +alqy5wctzmjjzbeeb7s.cf +alqy5wctzmjjzbeeb7s.ga +alqy5wctzmjjzbeeb7s.gq +alqy5wctzmjjzbeeb7s.ml +alqy5wctzmjjzbeeb7s.tk +alreval.com +alrmail.com +alrr.com +alsadeqoun.com +alsfw5.bee.pl +alsheim.no-ip.org +also.oldoutnewin.com +alsoai.live +alsoai.online +alsoai.shop +alsoai.site +alsoai.store +altaddress.com +altaddress.net +altaddress.org +altairwe.us +altamed.com +altamontespringspools.com +altamotors.com +altcen.com +altdesign.info +altecnet.gr +altel.net +alterego.life +altern.biz +alternativesa.shop +alternavox.net +altersa.site +althkend.com +although-soft-sharp-nothing.xyz +altinbasaknesriyat.com +altincasino.club +altitudewe.us +altmail.top +altmails.com +altnewshindi.com +altonamobilehomes.com +altpano.com +altq.freeml.net +altrans.fr.nf +altrmed.ru +altuswe.us +altwow.ru +alufelgenprs.de +aluimport.com +aluminum-rails.com +alumix.cf +alumnimp3.xyz +alumnioffer.com +alumnismfk.com +alunord.com +alunord.pl +alvaxio.com +alvemi.cf +alves.fr.nf +alvinneo.com +alviory.net +alvisani.com +alwaysmail.minemail.in +alwernia.co.pl +alwmail.site +alykpa.biz.st +alyssa.allie.wollomail.top +alysz.com +alyxgod.rf.gd +alzhelpnow.com +alzy.mailpwr.com +am-am.su +am-dv.ru +am.emlpro.com +am.freeml.net +am2g.com +ama-trade.de +ama-trans.de +ama.laste.ml +amadaferig.org +amadamus.com +amadeuswe.us +amail.club +amail.com +amail.gq +amail.men +amail.work +amail1.com +amail3.com +amail4.me +amaill.ml +amailr.net +amanda-uroda.pl +amandabeatrice.com +amankro.com +amantapkun.com +amarkbo.com +amatblog.eu +amateur69.info +amateurbondagesex.com +amateurspot.net +amatriceporno.eu +amav.ro +amazeautism.com +amazetips.com +amazingbagsuk.info +amazingchristmasgiftideas.com +amazinggift.life +amazinghandbagsoutlet.info +amazingly.online +amazingmaroc.com +amazingrem.uni.me +amazingself.net +amazon-aws-us.com +amazon-aws.org +amazon.coms.hk +amazonshopbuy.com +amazonshopsite.com +ambarbeauty.com +ambassadorwe.us +ambaththoor.com +amberofoka.org +amberwe.us +ambiancewe.us +ambientiusa.com +ambilqq.com +ambitiouswe.us +ambutaek.pro +ambwd.com +amcret.com +amdepholdings.xyz +amdlr.xyz +amdma.com +amdxgybwyy.pl +ameica.com +ameitech.net +amelabs.com +ameliachoi.com +amentionq.com +ameraldmail.com +ameramortgage.com +amercydas.com +america-sp.com.br +american-closeouts.com +american-image.com +americanawe.us +americancivichub.com +americangraphicboard.com +americantechit.com +americanwindowsglassrepair.com +americasbestwe.us +americasmorningnews.mobi +americaswe.us +americasyoulikeit.com +ameriech.net +amerilinkmail.com +amerimetromarketing.com +amerinetgate.com +ameriteh.net +amertech.net +amerusa.online +ametitas.com +amex-online.ga +amex-online.gq +amex-online.ml +amex-online.tk +amex409.monster +ameyprice.com +amfm.de +amg-recycle.com +amgens.com +amhar.asia +amharem.katowice.pl +amharow.cieszyn.pl +amicuswe.us +amid.co.pl +amidevous.tk +amiga-life.ru +amigoconsults.social +amigowe.us +amik.pro +amiksingh.com +amilegit.com +amimail.com +amimu.com +amin.co.pl +aminating.com +amindhab.ga +amindhab.gq +aminois.ga +aminoprimereview.info +aminudin.me +amiralty.com +amiramov.ru +amirdark.click +amirei.com +amirhsvip.ir +amiri.net +amiriindustries.com +amistaff.com +amitywe.us +aml.dropmail.me +aml.emlpro.com +ammafortech.site +ammazzatempo.com +amnesictampicobrush.org +amokqidwvb630.ga +amoksystems.com +amongth.com +amoniteas.com +amonscietl.site +amorazone.lat +amoria.lat +amorlink.lat +amovies.in +amoxicillincaamoxil.com +amoxilonlineatonce.com +amozix.com +ampasinc.com +ampdial.com +amphist.com +amphynode.com +ampicillin.website +ampicillinpills.net +ampim.com +ampivory.com +amplewallet.com +amplewe.us +amplifiedwe.us +amplifywe.us +amplindia.com +ampoules-economie-energie.fr +amprb.com +ampswipe.com +ampsylike.com +amreis.com +ams.emlpro.com +amsalebridesmaid.com +amseller.ru +amsgkmzvhc6.cf +amsgkmzvhc6.ga +amsgkmzvhc6.gq +amsgkmzvhc6.tk +amsspecialist.com +amt3security.com +amtex.com.mx +amthuc24.net +amthucvn.net +amtibiff.tk +amule.cf +amule.ga +amule.gq +amule.ml +amxyy.com +amymary.us +amyotonic.info +amysink.com +amyxrolest.com +amzgs.com +amzpe.ga +amzpe.tk +amzz.tk +an-jay.engineer +an-uong.net +an.cowsnbullz.com +an.id.au +an.martinandgang.com +an.ploooop.com +an0n.host +an0nz.store +anabells.xyz +anabolicscreworiginal.com +anacronym.info +anaf.com +anafentos.com +anahiem.com +anakjalanan.ga +anakjembutad.cf +anakjembutad.ga +anakjembutad.gq +anakjembutad.ml +anakjembutad.tk +anal.accesscam.org +anal.com +analabeevers.site +analenfo111.eu +analogekameras.com +analogwe.us +analysan.ru +analysiswe.us +analyticalwe.us +analyticauto.com +analyticswe.us +analyticwe.us +analyzerly.com +anandafaturrahman.art +anansou.com +anaploxo.cf +anaploxo.ga +anaploxo.gq +anaploxo.ml +anaploxo.tk +anappfor.com +anappthat.com +anaptanium.com +anarac.com +anasdet.site +anatolygroup.com +anawalls.com +anayelizavalacitycouncil.com +anayikt.cf +anayikt.ga +anayikt.gq +anayikt.ml +anbinhnet.com +ancc.us +ancestralfields.com +ancewa.com +anchrisbaton.acmetoy.com +anchukatie.com +anchukattie.com +anchukaty.com +anchukatyfarms.com +ancientart.co +ancientbank.com +ancok.my.id +ancreator.com +and.celebrities-duels.com +and.lakemneadows.com +and.marksypark.com +and.oldoutnewin.com +and.ploooop.com +and.poisedtoshrike.com +andalanglobal.app +andbitcoins.com +ander.us +anderbeck.se +andersonelectricnw.com +andetne.win +andhani.ml +andiamoainnovare.eu +andinews.com +andlos77.shop +andoni-luis-aduriz.art +andoniluisaduriz.art +andorem.com +andorra-nedv.ru +andre-chiang.art +andreagilardi.me +andreams.ru +andreasveei.site +andreay.codes +andrechiang.art +andreicutie.com +andreihusanu.ro +andreshampel.com +andrewm.art +andrewmurphy.org +andreych4.host +android-quartet.com +android.lava.mineweb.in +androidevolutions.com +androidinstagram.org +androidmobile.mobi +androidsapps.co +androidworld.tw +andry.de +andsee.org +andthen.us +andy1mail.host +andyes.net +andynugraha.net +andysairsoft.com +andyyxc45.biz +aneaproducciones.com +aneka-resep.art +anemiom.kobierzyce.pl +anemon11.shop +anesorensen.me +aneuch.info +aneup.site +anew-news.ru +anfg.spymail.one +anga.spymail.one +angedly.site +angel-leon.art +angelabacks.com +angelandcurve.com +angelareedfox.com +angeleslid.com +angelicablog.com +angelinthemist.com +angelinway.icu +angelleon.art +angelsluxuries.com +angelsoflahore.com +angesti.tech +angewy.com +angga.team +angielski.edu +angielskie.synonimy.com +angieplease.com +angiiidayyy.click +anginn.site +angioblast.info +angka69.com +angkahoki.club +angkajitu.site +angksoeas.club +angleda.icu +angmail.com +angola-nedv.ru +angoplengop.cf +angry.favbat.com +angrybirdsforpc.info +angularcheilitisguide.info +angushof.de +anh123.ga +anhalim.me +anhaysuka.com +anheakao.xyz +anhhungrom47.xyz +anhmaybietchoi.com +anhthu.org +anhudsb.com +anhvip9999.com +anhxyz.ml +ani24.de +anibym.gniezno.pl +anidaw.com +anilahwillhite.store +animail.net +animalads.co.uk +animalavianhospital.com +animalextract.com +animalkingdo.com +animalrescueprofessional.com +animalright21.com +animalsneakers.com +animalspiritnetwork.com +animalwallpaper.site +animation-studios.com +animatorzywarszawa.pl +animeappeal.com +animekiksazz.com +animeru.tv +animeslatinos.com +animesos.com +animevostorg.com +animeworld1.cf +animex98.com +anio.site +aniplay.xyz +anique.pro +aniross.com +anit.ro +anitadarkvideos.net +aniub.com +anjay.id +anjaybgo.com +anjayy.pw +anjelo-travel.social +anjing.cool +anjingkokditolak.cf +anjingkokditolak.ga +anjingkokditolak.gq +anjingkokditolak.ml +anjingkokditolak.tk +anjon.com +ankankan.com +ankarapdr.com +ankercoal.com +anketka.de +ankoninc.pw +ankplacing.com +ankt.de +anlocc.com +anlubi.com +anm.laste.ml +anmail.com +anmail.xyz +anmlvapors.com +anmmo2024.com +anna-tut.ru +annabismail.com +annabless.co.cc +annafathir.cf +annalisenadia.london-mail.top +annalusi.cf +annamike.org +annanakal.ga +annapayday.net +annarahimah.ml +annasblog.info +annavogue.shop +annazahra.cf +anncoates.shop +anncool.shop +anncool.site +annd.us +anneholdenlcsw.com +annesdiary.com +annettebruhn.dk +annetteturow.com +annidis.com +anniversarygiftideasnow.com +anno90.nl +annoor.us +annuaire-seotons.com +annualcred8treport.com +annuallyix.com +annuityassistance.com +ano-mail.net +anom.xyz +anomail.com +anomail.us +anomgo.com +anon-mail.de +anon.leemail.me +anon.subdavis.com +anonbox.net +anonemailbox.com +anongirl.com +anonimailer.com +anonimous-email.bid +anonimousemail.bid +anonimousemail.trade +anonimousemail.website +anonimousemail.win +anonimsirketmail.online +anonimsirketmail.xyz +anonmail.top +anonmail.xyz +anonmails.de +anonpop.com +anonym0us.net +anonymail.dk +anonymbox.com +anonymize.com +anonymized.org +anonymous-email.net +anonymousfeedback.net +anonymousmail.org +anonymousness.com +anonymousspeech.com +anonymstermail.com +anoshtar.tech +another-1drivvers.ru +another-temp-mail.com +anotherblast2013.com +anotherdomaincyka.tk +anotherway.me +anotherwinters.site +anpolitics.ru +anquandx.com +anruma.site +ansaldo.cf +ansaldo.ga +ansaldo.gq +ansaldo.ml +ansaldobreda.cf +ansaldobreda.ga +ansaldobreda.gq +ansaldobreda.ml +ansaldobreda.tk +ansbanks.ru +anschool.ru +anselme.edu +anserva.cf +ansgjypcd.pl +ansibleemail.com +ansley27.spicysallads.com +ansomesa.com +anstravel.ru +answerauto.ru +answers.blatnet.com +answers.ploooop.com +answers.xyz +answersfortrivia.ml +answersworld.ru +antade.xyz +antalyaescortkizlar.com +antamdesign.site +antamo.com +antawii.com +antegame.com +anterin.online +anthagine.cf +anthagine.ga +anthagine.gq +anthagine.ml +anthemazrealestate.com +antherdihen.eu +anthony-junkmail.com +anthropologycommunity.com +anti-ronflement.info +antiageingsecrets.net +antiaginggames.com +antiagingserumreview.net +antibioticgeneric.com +anticaosteriavalpolicella.com +anticheatpd.com +antichef.com +antichef.net +antichef.org +antidrinker.com +antigua-nedv.ru +antiguabars.com +antilopa.site +antimalware360.co.uk +antiminer.website +antiprocessee.xyz +antiquerestorationwork.com +antiquestores.us +antireg.com +antireg.ru +antisnoringdevicesupdate.com +antispam.de +antispam.fr.nf +antispam.rf.gd +antispam24.de +antispammail.de +antistream.cf +antistream.ga +antistream.gq +antistream.ml +antistream.tk +antiviruswiz.com +antkander.com +antonietta1818.site +antonrichardson.com +antonveneta.cf +antonveneta.ga +antonveneta.gq +antonveneta.ml +antonveneta.tk +antsdo.com +antykoncepcjabytom.pl +antylichwa.pl +antywirusyonline.pl +anuan.tk +anuefa.com +anultrasoundtechnician.com +anunciacos.net +anuong24h.info +anuong360.com +anuonghanoi.net +anut7gcs.atm.pl +anwarb.com +anwintersport.ru +anx.laste.ml +anxietydisorders.biz +anxietyeliminators.com +anxietymeter.com +anxmalls.com +any-gsm-network.top +any.pink +any.ploooop.com +anyalias.com +anyett.com +anyopoly.com +anypen.accountant +anypng.com +anypsd.com +anyqx.com +anysilo.com +anythms.site +anytimejob.ru +anywhere.pw +anzeigenschleuder.com +anzy.xyz +ao.emlhub.com +ao.emlpro.com +ao.emltmp.com +ao4ffqty.com +ao5.gallery +aoahomes.com +aoaib.com +aoaks.com +aoalelgl64shf.ga +aob.emltmp.com +aocdoha.com +aocw4.com +aoeiualk36g.ml +aoeuhtns.com +aogmoney.xyz +aogservices.com +aoi.laste.ml +aol.edu +aol.vo.uk +aolimail.com +aolinemail.cf +aolinemail.ga +aoll.com +aolmail.fun +aolmail.pw +aolmate.com +aolo.com +aoltimewarner.cf +aoltimewarner.ga +aoltimewarner.gq +aoltimewarner.ml +aoltimewarner.tk +aolx.com +aomail.xyz +aomaomm.com +aomejl.pl +aomien.com +aomrock.com +aomvnab.pl +aonbola.biz +aonbola.club +aonbola.org +aonbola.store +aonibn.com +aooe.dropmail.me +aopconsultants.com +aosdeag.com +aosod.com +aotp.emlpro.com +ap.maildin.com +apachan.site +apagitu.biz.tm +apagitu.chickenkiller.com +apakahandasiap.com +apalo.tk +apaname.com +apartmentsba.com +apaylofinance.com +apaymail.com +apcleaningjservice.org +apcm29te8vgxwrcqq.cf +apcm29te8vgxwrcqq.ga +apcm29te8vgxwrcqq.gq +apcm29te8vgxwrcqq.ml +apcm29te8vgxwrcqq.tk +apcode.com +apd.yomail.info +apdiv.com +apebkxcqxbtk.cf +apebkxcqxbtk.ga +apebkxcqxbtk.gq +apebkxcqxbtk.ml +apel88.com +apemail.com +apemail.in +apepic.com +aperiol.com +apexhearthealth.com +apexmail.ru +apexsilver.com +apfelkorps.de +aphlog.com +aphm.com +api.cowsnbullz.com +api.emailies.com +api.lakemneadows.com +api.ploooop.com +apidiwo1qa.com +apifan.com +apilasansor.com +apimail.com +apistudio.ru +apixy.sbs +apkdownloadbox.com +apklitestore.com +apkmd.com +apkshake.com +apleo.com +aplikacje.com +aplo.me +apluson.xyz +apmp.info +apn.emltmp.com +apn7.com +apnastreet.com +apnj.yomail.info +apocaw.com +apocztaz.com.pl +apoimail.com +apoimail.net +apolishxa.com +apolitions.xyz +apollosclouds.com +apolymerfp.com +apophalypse.com +apostv.com +apotekberjalan.com +apotekerid.com +apotekmu.net +apown.com +apoyrwyr.gq +apozemail.com +app-expert.com +app-inc-vol.ml +app-lex-acc.com +app-mailer.com +app.blatnet.com +app.lakemneadows.com +app.marksypark.com +app.ploooop.com +app.poisedtoshrike.com +appakin.com +apparls.com +appbotbsxddf.com +appc.se +appdev.science +appdollars.com +appefforts.com +appfund.biz +appguidelab.com +appinventor.nl +appixie.com +appl3.cf +appl3.ga +appl3.gq +appl3.ml +appl3.tk +apple-account.app +apple-web.tk +apple.dnsabr.com +appleaccount.app +appledress.net +applefix.ru +applegift.xyz +appleparcel.com +appleseedrlty.com +applianceremoval.ca +applianceserviceshouston.com +appliedphytogenetics.com +applphone.ru +apply4more.com +applynow0.com +applytome.com +appmail.top +appmail.uk +appmail24.com +appmailer.org +appmailer.site +appmaillist.com +appmfc.tk +appmingle.com +appmobile-documentneedtoupload.com +appnode.xyz +appnowl.ml +appnox.com +appolicestate.org +appremiums.pro +apprendrelepiano.com +approich.com +approve-thankgenerous.com +approvedinstructor.com +apps.dj +appsfy.com +appsmail.me +appsmail.tech +appsmail.us +apptalker.com +apptip.net +apptonic.tech +apptova.com +appxapi.com +appxilo.com +appxoly.tk +appzily.com +apqw.info +apra.info +apraizr.com +apranakikitoto.pw +apreom.site +aprice.co +apriles.ru +aprilmovo.com +aprilsoundbaitshop.com +aprimail.com +aprinta.com +apriver.ru +aproangler.com +aproinc.com +aprosti.ru +aprte.com +aprutana.ru +apssdc.ml +aptaweightlosshelpok.live +aptcha.com +aptee.me +apteka-medyczna.waw.pl +aptel.org +aptronix.com +aputmail.com +apuymail.com +apxby.com +aqamail.com +aqav.mailpwr.com +aqazstnvw1v.cf +aqazstnvw1v.ga +aqazstnvw1v.gq +aqazstnvw1v.ml +aqazstnvw1v.tk +aqb.dropmail.me +aqgi0vyb98izymp.cf +aqgi0vyb98izymp.ga +aqgi0vyb98izymp.gq +aqgi0vyb98izymp.ml +aqgi0vyb98izymp.tk +aqi.emltmp.com +aqkg.emltmp.com +aqmail.xyz +aqmar.ga +aqomail.com +aqpm.app +aqqb.emlpro.com +aqqn.dropmail.me +aqqor.com +aquafria.org +aquaguide.ru +aquainspiration.com +aquanautsdive.com +aquaponicssupplies.club +aquarianageastrology.com +aquarians.co.uk +aquarius74.org +aquarix.tk +aquashieldroofingcorporate.com +aquavante.com +aquilateam.com +aqumad.com +aqumail.com +aqwee.online +aqweeks.com +ar.emltmp.com +ar.laste.ml +ar.szcdn.pl +ar0dc0qrkla.cf +ar0dc0qrkla.ga +ar0dc0qrkla.gq +ar0dc0qrkla.ml +ar0dc0qrkla.tk +ar6j5llqj.pl +arabdemocracy.info +arablawyer.services +arabsalim.com +arak.ml +arakcarpet.ir +aramail.com +aramamotor.net +aramask.com +aramidth.com +aranelab.com +araniera.net +aranjis.com +arapgege.app +arapgege.tech +arapps.me +arasempire.com +arashkarimzadeh.com +arasj.net +aravites.com +arbdigital.com +arbvc.com +arcadein.com +arcadespecialist.com +arcb.site +arcedia.co.uk +arcelormittal-construction.pl +arcengineering.com +archanybook.site +archanybooks.site +archanyfile.site +archanylib.site +archanylibrary.site +archawesomebooks.site +archeage-gold.co.uk +archeage-gold.de +archeage-gold.us +archeagegoldshop.com +archex.pl +archfinancial.com +archfreefile.site +archfreelib.site +archfreshbook.site +archfreshbooks.site +archfreshfiles.site +archfreshlibrary.site +archfreshtext.site +archgoodlib.site +archgoodtext.site +archildrens.com +archine.online +architecture101.com +architektwarszawaa.pl +archivewest.com +archivision.pl +archnicebook.site +archnicetext.site +archrarefile.site +archrarefiles.site +archrarelib.site +archraretext.site +arcleti.com +arcompus.net +arcticfoxtrust.tk +arcticside.com +arcu.site +ardavin.ir +ardexamerica.com +ardsp.shop +arduino.hk +area-thinking.de +areamoney.us +arearugsdeals.com +areastate.biz +areastate.us +areaway.us +aregods.com +aremania.cf +aremanita.cf +arenahiveai.com +arenamq.com +arenda-s-vykupom.info +arenda-yamoburakrana.ru +arensus.com +areosur.com +ares.edu.pl +aresanob.cf +aresanob.ga +aresanob.gq +aresanob.ml +aresanob.tk +aresting.com +areswebstudio.com +aretacollege.com +arewethere.host +arewhich.com +areyouthere.org +arfamed.com +argand.nl +argentin-nedv.ru +argenttrading.com +argentumcore.site +arhshtab.ru +arhx1qkhnsirq.cf +arhx1qkhnsirq.ga +arhx1qkhnsirq.gq +arhx1qkhnsirq.ml +arhx1qkhnsirq.tk +ariana.keeley.wollomail.top +ariasexy.tk +ariaz.jetzt +aribeth.ru +aridasarip.ru +arido.ir +ariefganteng.site +arigo.site +ariking.com +arimidex.website +arimlog.co.uk +ariotri.tech +arisecreation.com +aristino.co.uk +aristockphoto.com +ariston.ml +arizona-nedv.ru +arizonaapr.com +arizonablogging.com +arizonachem.com +arkaliv.com +arkansasquote.com +arkanzas-nedv.ru +arkatech.ml +arknet.tech +arkonnide.cf +arkotronic.pl +arkritepress.com +arktico.com +arktive.com +arlenedunkley-wood.co.uk +arlinc.org +armabet23.com +armablog.com +armada4d.com +armada4d.net +armail.com +armail.in +armandwii.me +armatny.augustow.pl +armcams.com +armdoadout.store +armenik.ru +armiasrodek.pl +armind.com +armormail.net +armoux.ml +armp-rdc.cd +armsfat.com +armss.site +armstrongbuildings.com +army.gov +armyan-nedv.ru +armylaw.ru +armyspy.com +arnaudlallement.art +arnend.com +arnet.com +arno.fi +arnoldohollingermail.org +aro.stargard.pl +arockee.com +aromat-best.ru +aromavapes.co.uk +aron.us +arormail.com +arowmail.com +arpahosting.com +arpizol.com +arqsis.com +arr.laste.ml +arrai.org +arrance.freshbreadcrumbs.com +arrangeditems.website +array.cowsnbullz.com +array.lakemneadows.com +array.oldoutnewin.com +array.poisedtoshrike.com +arrels.info +arristm502g.com +arrivalsib.com +arroisijewellery.com +arschloch.com +arseente.site +arsenals.live +arshopshop.xyz +arss.me +arstudioart.com +art-design-communication.com +art-en-ligne.pro +art-hawk.net +art-spire.com +art2427.com +artaho.net +artamebel.ru +artan.fr +artbellrules.info +artbygarymize.com +artdrip.com +artemmel.info +arteol.pl +artflowerscorp.com +artgmilos.de +artgulin.com +arthritisxpert.com +arthurgerex.network +arthursbox.com +articlearistrocat.info +articlebase.net +articlebigshot.info +articlechief.info +articlejaw.com +articlemagnate.info +articlemogul.info +articlenag.com +articlenewsflasher.com +articlerose.com +articles4women.com +articlesearchenginemarketing.com +articleslive191.com +articlespinning.club +articleswebsite.net +articletarget.com +articlewicked.com +articlewritingguidelines.info +articmine.com +articulate.cf +artificialbelligerence.com +artificialintelligence.productions +artificialintelligenceseo.com +artikasaridevi.art +artinterpretation.org +artisanbooth.com +artistsignal.com +artiviodesign.com +artix.ga +artlover.shop +artmail.icu +artman-conception.com +artmedinaeyecare.net +artmez.com +artmix.net.pl +artmweb.pl +artnames-cubism.online +artofboss.com +artofhypnosis.net +artquery.info +artropad.net +arts-3d.net +arttica.com +arturremonty.pl +artvara.com +artwitra.pl +artworkincluded.com +artworkltk.com +artykuly-na-temat.pl +artykuly.net.pl +artzeppelin.com +aruanimeporni20104.cz.cc +arudi.ru +aruguy20103.co.tv +arugy.com +arumail.com +arumibachsin.art +aruqmail.com +arur01.tk +arurgitu.gq +arurimport.ml +aruxprem.web.id +arvato-community.de +arvestloanbalanceeraser.com +arxxwalls.com +aryagate.net +arybebekganteng.cf +arybebekganteng.ga +arybebekganteng.gq +arybebekganteng.ml +arybebekganteng.tk +aryi.xyz +aryl.com +arylabs.co +arypro.tk +arysc.ooo +arzettibilbina.art +arzmail.com +as.blatnet.com +as.cowsnbullz.com +as.onlysext.com +as.poisedtoshrike.com +as01.cf +as10.ddnsfree.com +asa-dea.com +asaafo333.shop +asaama.shop +asadfat333.shop +asahi.cf +asahi.ga +asana.biz +asapbox.com +asapcctv.com +asaption.com +asaroad.com +asas1.co.tv +asasaaaf77.site +asb-mail.info +asbakpinuh.club +asbcglobal.net +asbeauty.com +asbestoslawyersguide.com +ascad-pp.ru +ascalus.com +ascaz.net +ascendanttech.com +ascendventures.cf +aschenbrandt.net +asciibinder.net +ascotairporlinks.co.uk +ascotairporltinks.co.uk +ascotairportlinks.co.uk +ascotchauffeurs.co.uk +ascqwcxz.com +asculpture.ru +ascvzxcwx.com +ascwcxax.com +asd.dropmail.me +asd.freeml.net +asd323.com +asd654.uboxi.com +asda.pl +asdadw.com +asdas.xyz +asdascxz-sadasdcx.icu +asdasd.co +asdasd.nl +asdasd.ru +asdasd1231.info +asdasdasd.com +asdasdd.com +asdasdfds.com +asdasdsa.com +asdasdweqee.com +asdawqa.com +asdbwegweq.xyz +asddddmail.org +asdeqwqborex.com +asdewqrf.com +asdf.pl +asdfadf.com +asdfads.com +asdfasd.co +asdfasdf.co +asdfasdfmail.com +asdfasdfmail.net +asdfghmail.com +asdfjkl.com +asdfmail.net +asdfmailk.com +asdfooff.org +asdfsdf.co +asdfsdfjrmail.com +asdfsdfjrmail.net +asdhf.com +asdhgsad.com +asdjioj31223.info +asdjjrmaikl.com +asdjmail.org +asdkjasd.laste.ml +asdkwasasasaa.ce.ms +asdogksd.com +asdooeemail.com +asdooeemail.net +asdq.emlhub.com +asdqwe001.site +asdqwe2025.shop +asdqwee213.info +asdqwevfsd.com +asdr.com +asdrxzaa.com +asdsd.co +asdua.com +asdversd.com +asdvewq.com +asdz2xc1d23sac12.com +asdz2xc1d2a3sac12.com +asdz2xc1d2sac12.com +asdz2xcd2sac12.com +asdzxcd2sac12.com +asdzxcdsac1.com +aseall.com +asedr.store +aseewr1tryhtu.co.cc +aseq.com +aseriales.ru +aserookadion.uni.cc +aserrpp.com +asertol1.co.tv +ases.info +aseur.com +asewrggerrra.ce.ms +aseyreirtiruyewire.co.tv +aseztakwholesale.com +asfalio.com +asfasf.com +asfasfas.com +asfdasd.com +asfdd-ff.top +asfedass.uni.me +asffhdjkjads5.cloud +asgaccse-pt.cf +asgaccse-pt.ga +asgaccse-pt.gq +asgaccse-pt.ml +asgaccse-pt.tk +asgaf.com +asgardia-space.tk +asgasgasgasggasg.ga +asgasgasgasggasg.ml +asgasghashashas.cf +asgasghashashas.ga +asgasghashashas.gq +asgasghashashas.ml +asghashasdhasjhashag.ml +asgictex.xyz +asgus.com +ashansa.live +ashbge.online +ashford-plumbers.co.uk +ashik2in.com +ashina.men +ashiro.biz +ashishsingla.com +ashleyandrew.com +ashleyesse.com +ashleywisemanfitness.com +ashotmail.com +ashun.ml +asi72.ru +asia-me.review +asia-pasifikacces.com +asia.dnsabr.com +asiadnsabr.com +asiahot.jp +asian-handicap.org.uk +asianbeauty.app +asianeggdonor.info +asianflushtips.info +asiangangsta.site +asianmeditations.ru +asianpkr88.info +asiapmail.club +asiapoker389.com +asiaqq8.com +asiarap.usa.cc +asiavpn.me +asicshoesmall.com +asicsonshop.org +asicsrunningsale.com +asicsshoes.com +asicsshoes005.com +asicsshoesforsale.com +asicsshoeskutu.com +asicsshoesonsale.com +asicsshoessale.com +asicsshoessite.net +asicsshoesworld.com +asifboot.com +asik2in.biz +asik2in.com +asiki2in.com +asikmainbola.com +asikmainbola.org +asimarif.com +asimark.com +asistx.net +ask-bo.co.uk +ask-mail.com +ask-zuraya.com.au +askandhire700.info +askddoor.org +askdrbob.com +askedkrax.com +askerpoints.com +askian-mail.com +asklexi.com +askman.tk +askmantutivie.com +askot.org +askpirate.com +asl13.cf +asl13.ga +asl13.gq +asl13.ml +asl13.tk +aslana.xyz +asleepity.com +asli.cloud +aslibayar.com +asls.ml +asm.snapwet.com +asmail.com +asmailproject.info +asmailz1.pl +asmm5.com +asmwebsitesi.info +asn.services +asndassbs.space +asnieceila.xyz +asnl.yomail.info +asnx.dropmail.me +asoes.tk +asoflex.com +asokevli.xyz +asomasom001.site +asooemail.com +asooemail.net +asopenhrs.com +asorent.com +asors.org +asosk.tk +asouses.ru +asperorotutmail.com +aspfitting.com +asportsa.ru +aspotgmail.org +ass.pp.ua +assa.pl +assaf2003.site +assaf7720250025.site +assafassaf700.site +assafsh1778.shop +assafshar111.shop +assayplate.com +assecurity.com +assetscoin.com +associazionearia.org +assomail.com +assospirlanta.shop +asspoo.com +assrec.com +asss.mailerokdf.com +asssaf.site +assscczxzw.website +assuranceconst.com +assuranceprops.fun +assurancespourmoi.eu +assureplan.info +assurmail.net +astaad.xyz +astaghfirulloh.cf +astaghfirulloh.ga +astaghfirulloh.gq +astaghfirulloh.ml +astanca.pl +astarmax.com +astegol.com +asteraavia.ru +asterhostingg.com +astermebel.com.pl +asterrestaurant.com +astheiss.gr +astimei.com +astipa.com +astonut.cf +astonut.ga +astonut.ml +astonut.tk +astonvpshostelx.com +astorcollegiate.com +astoredu.com +astraeusairlines.xyz +astralcars.com +astramail.ml +astrevoyance.com +astrial.su +astridtiar.art +astrinurdin.art +astrkkd.org.ua +astro4d.com +astro4d.net +astroempires.info +astrofox.pw +astrolo.ga +astrolo.tk +astrology.host +astroo.tk +astropharm.com +astropink.com +astroscreen.org +astrotogel.net +astrowave.ru +astrthelabel.xyz +astutegames.com +astxixi.com +astyx.fun +asu.mx +asu.su +asu.wiki +asub1.bace.wroclaw.pl +asubtlejm.com +asuflex.com +asuk.com +asurad.com +asurfacesz.com +asvascx.com +asvqwzxcac.com +aswatna-eg.net +aswellas.emltmp.com +aswertyuifwe.cz.cc +asyabahis51.com +asza.ga +at.blatnet.com +at.cowsnbullz.com +at.hm +at.laste.ml +at.ploooop.com +at0mik.org +atar-dinami.com +atarax-hydroxyzine.com +atasehirsuit.com +atasibotak.shop +atausa.org +atch.com +atcuxffg.shop +ateampc.com +atebin.com +atech5.com +ateculeal.info +ateez.org +ateh.su +atelier-x.com +atemail.com +ateng.ml +atengtom.cf +atenk99.ml +atenolol.website +atesli.net +atest.com +atfshminm.pl +ath.dropmail.me +atharroi.gq +athdn.com +athem.com +athenaplus.com +athens5.com +athensmemorygardens.com +athleticsupplement.xyz +athodyd.com +athohn.site +athomewealth.net +athoo.com +athoscapacitacao.com +atinjo.com +atinto.co +atinvestment.pl +atisecuritysystems.us +atj.yomail.info +atka.info +atlantafalconsproteamshop.com +atlantaquote.com +atlantawatercloset.com +atlantaweb-design.com +atlanticyu.com +atletico.ga +atlteknet.com +atm-mi.cf +atm-mi.ga +atm-mi.gq +atm-mi.ml +atm-mi.tk +atmodule.com +atmospheremaxhomes.us +atnextmail.com +atolyezen.com +atoyot.cf +atoyot.ga +atoyot.gq +atoyot.ml +atoyot.tk +atozbangladesh.com +atozcashsystem.net +atozconference.com +atozshare.com +atpm.us +atrais-kredits24.com +atrakcje-na-impreze.pl +atrakcje-nestor.pl +atrakcjedladziecii.pl +atrakcjenaimprezki.pl +atrakcjenawesele.pl +atrakcyjneimprezki.pl +atrezje.radom.pl +atriummanagment.com +atriushealth.info +atsw.de +att-warner.cf +att-warner.ga +att-warner.gq +att-warner.ml +att-warner.tk +attack11.com +attake0fffile.ru +attax.site +attb.com +attckdigital.com +attefs.site +attemptify.com +attention.support +attfreak.cloud +atticus-finch.es +attn.net +attnetwork.com +attobas.ml +attompt.com +attractionmarketing.net.nz +atucotejo.com +aturos.ink +atux.de +atuyutyruti.ce.ms +atvclub.msk.ru +atwankbe3wcnngp.ga +atwankbe3wcnngp.ml +atwankbe3wcnngp.tk +atwellpublishing.com +atx.emltmp.com +atxcrunner.com +atyc.laste.ml +au-1.top +au-b1.top +au-c1.top +au1688x.us +auan.dropmail.me +aub.emlpro.com +aubady.com +aubootfans.co.uk +aubootfans.com +aubootsoutlet.co.uk +auboutdesreves.com +aubreyequine.com +auchandirekt.pl +audi-r8.cf +audi-r8.ga +audi-r8.gq +audi-r8.ml +audi-r8.tk +audi-tt.cf +audi-tt.ga +audi-tt.gq +audi-tt.ml +audi-tt.tk +audi.igg.biz +audience.emlhub.com +audince.com +audio.now.im +audioalarm.de +audiobookmonster.com +audiobrush.com +audiocore.online +audioequipmentstores.info +audioswitch.info +audiovenik.info +audoscale.net +audrey11reveley.ga +audrianaputri.com +audytowo.pl +audytwfirmie.pl +auelite.ru +auessaysonline.com +auey1wtgcnucwr.cf +auey1wtgcnucwr.ga +auey1wtgcnucwr.gq +auey1wtgcnucwr.ml +auey1wtgcnucwr.tk +aufu.de +augmentationtechnology.com +augmentedrealitysmartglasses.site +augmentin4u.com +augstusproductions.com +auguridibuonapasqua.info +auguryans.ru +augustone.ru +auhit.com +aui.emltmp.com +auj.emlpro.com +auloc.com +aum.spymail.one +aumails.us +aumentarpenis.net +aumento-de-mama.es +aumx.mimimail.me +aunmodon.com +aunv.mailpwr.com +auoi53la.ga +auoie.com +auolethtgsra.uni.cc +auon.org +aupvs.com +auraence.com +auraity.com +auralfix.com +auraness.com +auraqq.com +aureliajobs.com +aureliosot.website +aurelstyle.ru +aures-autoparts.com +auroraalcoholrehab.com +auroracontrol.com +auroraheroinrehab.com +aurorapacking.ru +aurresources.com +aus.schwarzmail.ga +ausclan.com +ausdance.org +ausdocjobs.com +ausdoctors.info +ausgefallen.info +auslank.com +auspb.com +auspecialist.net +ausracer.com +aussie.finance +aussie.loan +aussieboat.loan +aussiebulkdiscounting.com +aussiecampertrailer.loan +aussiecampertrailer.loans +aussiecar.loans +aussiecaravan.loan +aussiegroups.com +aussieknives.club +aussielesiure.loans +aussiematureclub.com +aussiepersonal.loan +aussiepersonal.loans +aussiesmut.com +austbikemart.com +austimail.com +austinbell.name +austincar.club +austincocainerehab.com +austinelectronics.net +austingambles.org +austinheroinrehab.com +austinnelson.online +austinopiaterehab.com +austinpainassociates.com +austinpoel.site +austinquote.com +austinsherman.me +austinveterinarycenter.net +australiaasicsgel.com +australiadirect.xyz +australiamining.xyz +australiandoctorplus.com +australianfinefood.com +australianlegaljobs.com +australianmail.gdn +australianwinenews.com +australiasunglassesonline.net +austria.nhadautuphuquoc.com +austriasocial.com +austriayoga.com +austrycastillo.com +autaogloszenia.pl +autarchy.academy +autdent.com +auth.legal +auth.page +auth2fa.com +authensimilate.com +authentic-guccipurses.com +authenticawakeningadvanced.com +authenticchanelsbags.com +authenticpayments.net +authenticsportsshop.com +author24.su +authoritycelebrity.com +authorityhost.com +authorityredirect.com +authorityvip.com +authoritywave.com +authorizedoffr.com +authorizes.me +authormail.lavaweb.in +authorship.com +authose.site +authout.site +auti.st +autisminfo.com +autisticsociety.info +autisticsymptoms.com +autlok.com +autlook.com +autlook.es +autluok.com +auto-consilidation-settlements.com +auto-correlator.biz +auto-glass-houston.com +auto-lab.com.pl +auto-mobille.com +auto-zapchast.info +auto411jobs.xyz +autoaa317.xyz +autoairjordanoutlet.us +autobodyspecials.com +autobroker.tv +autocardesign.site +autocereafter.xyz +autocoverage.ru +autodienstleistungen.de +autognz.com +autogradka.pl +autograph34.ru +autohotline.us +autoimmunedisorderblog.info +autoinsurancesanantonio.xyz +autoketban.online +autoknowledge.ru +autolicious.info +autoloan.org +autoloans.org +autoloans.us +autoloansonline.us +automark.com +automatedpersonnel.com +automaticforextrader.info +automisly.org +automizely.info +automizelymail.info +automizly.net +autommo.net +automobilerugs.com +automotique.tech +automotivesort.com +autoodzaraz.com.pl +autoodzaraz.pl +autoonlineairmax.us +autoplusinsurance.world +autopro24.de +autorapide.com +autoretrote.site +autorobotica.com +autosdis.ru +autosfromus.com +autoshake.ru +autosouvenir39.ru +autosportgallery.com +autospozarica.com +autosseminuevos.org +autostupino.ru +autotalon.info +autotest.ml +autotwollow.com +autowb.com +autoxugiare.com +autoxugiare.net +autozestanow.pl +autre.fr.nf +auw88.com +auweek.net +auxifyboosting.ga +auxiliated.xyz +auxille.com +auximail.com +av.emlpro.com +av.jp +av636.com +avaba.ru +avabots.com +available-home.com +availablemail.igg.biz +availablewibowo.biz +avainternational.com +avaliaboards.com +avalins.com +avalonminer.cloud +avalonrx.com +avalonyouth.com +avanafilprime.com +avangard-kapital.ru +avangard.ru.com +avantageexpress.ca +avaphpnet.com +avaphpnet.net +avashost.com +avast.ml +avasts.net +avastu.com +avcc.tk +ave-kingdom.com +avelani.com +avengersfanboygirlongirl.com +avenuebb.com +avenuesilver.com +aver.com +averdov.com +averedlest.monster +averite.com +aversale.com +avery.jocelyn.thefreemail.top +avery.regina.miami-mail.top +averyhart.com +avganrmkfd.pl +avia-sex.com +avia-tonic.fr +aviani.com +aviatorrayban.com +avidapro.com +avidblur.com +avidts.net +aviestas.space +avikd.tk +avinsurance2018.top +avio.cf +avio.ga +avio.gq +avio.ml +avioaero.cf +avioaero.ga +avioaero.gq +avioaero.ml +avioaero.tk +avipred.app +avito-save.online +avkdubai.com +avkwinkel.nl +avl.dropmail.me +avlnch.store +avls.pt +avmail.xyz +avobitekc.com +avocadorecipesforyou.com +avoi.emltmp.com +avomail.org +avonco.site +avoncons.store +avonforlady.ru +avonkin.com +avorybonds.com +avotron.com +avp1brunupzs8ipef.cf +avp1brunupzs8ipef.ga +avp1brunupzs8ipef.gq +avp1brunupzs8ipef.ml +avp1brunupzs8ipef.tk +avr.ze.cx +avr1.org +avslenjlu.pl +avstria-nedv.ru +avtobym.ru +avtolev.com +avtomationline.net +avtopark.men +avtoshtorka.ru +avtovukup.ru +avucon.com +avuimkgtbgccejft901.cf +avuimkgtbgccejft901.ga +avuimkgtbgccejft901.gq +avuimkgtbgccejft901.ml +avuimkgtbgccejft901.tk +avuiqtrdnk.ga +avulos.com +avumail.com +avvisassi.ml +avvmail.com +avxrja.com +avzl.com +avzong.com +aw.kikwet.com +awa.pics +awahal0vk1o7gbyzf0.cf +awahal0vk1o7gbyzf0.ga +awahal0vk1o7gbyzf0.gq +awahal0vk1o7gbyzf0.ml +awahal0vk1o7gbyzf0.tk +awakmedia.com +awanhitamwoy.fun +awatum.de +awaves.com +awawawaw.me +awbleqll.xyz +awca.eu +awcu.yomail.info +awdawd.com +awdrt.com +awdrt.net +awdrt.org +aweather.ru +aweightlossguide.com +awemail.com +awemail.top +awep.net +awesome.reviews +awesome47.com +awesome4you.ru +awesomebikejp.com +awesomecatfile.site +awesomecatfiles.site +awesomecattext.site +awesomedirbook.site +awesomedirbooks.site +awesomedirfiles.site +awesomedirtext.site +awesomeemail.com +awesomefreshstuff.site +awesomefreshtext.site +awesomelibbook.site +awesomelibfile.site +awesomelibfiles.site +awesomelibtext.site +awesomelibtexts.site +awesomelistbook.site +awesomelistbooks.site +awesomelistfile.site +awesomelisttexts.site +awesomenewbooks.site +awesomenewfile.site +awesomenewfiles.site +awesomenewstuff.site +awesomenewtext.site +awesomeofferings.com +awesomereviews.com +awesomesaucemail.org +awesomespotbook.site +awesomespotbooks.site +awesomespotfile.site +awesomespotfiles.site +awesomespottext.site +awesomewellbeing.com +awewallet.com +awez.icu +awg5.com +awgarstone.com +awig.emlpro.com +awiki.org +awinceo.com +awiners.com +awionka.info +awloywro.co.cc +awmail.com +awme.com +awml.emlpro.com +awngqe4qb3qvuohvuh.cf +awngqe4qb3qvuohvuh.ga +awngqe4qb3qvuohvuh.gq +awngqe4qb3qvuohvuh.ml +awngqe4qb3qvuohvuh.tk +awnspeeds.com +awomal.com +awp.emlhub.com +awrp3laot.cf +aws.cadx.edu.pl +aws.creo.site +aws910.com +awsoo.com +awspe.ga +awspe.tk +awsubs.host +awsupplyk.com +awumail.com +awv.yomail.info +awyn.emlhub.com +awzf.dropmail.me +awzg.office.gy +ax80mail.com +axatech.tech +axaxmail.com +axcess.com +axcradio.com +axemail.com +axeprim.eu +axerflow.com +axerflow.org +axie.ml +axiemeta.fun +axisbank.co +axiz.digital +axiz.org +axizmaxtech.cf +axkleinfa.com +axlinesid.bio +axlinesid.site +axlu.ga +axlugames.cf +axmail.com +axmluf8osv0h.cf +axmluf8osv0h.ga +axmluf8osv0h.gq +axmluf8osv0h.ml +axmluf8osv0h.tk +axmodine.tk +axmz.freeml.net +axnjyhf.top +axon7zte.com +axonbxifqx.ga +axpmydyeab.ga +axsup.net +axtonic.me +axulus.gq +axuwv6wnveqhwilbzer.cf +axuwv6wnveqhwilbzer.ga +axuwv6wnveqhwilbzer.gq +axuwv6wnveqhwilbzer.ml +axuwv6wnveqhwilbzer.tk +axv.dropmail.me +axv.emltmp.com +axwel.in +axza.com +ay.spymail.one +ay33rs.flu.cc +ayabozz.com +ayag.com +ayah.com +ayahseviana.io +ayakamail.cf +ayalamail.men +ayamluli.space +ayanuska.site +ayazmarket.network +ayberkys.tk +ayblieufuav.cf +ayblieufuav.ga +ayblieufuav.gq +ayblieufuav.ml +ayblieufuav.tk +aybukeaycaturna.shop +ayecapta.in +ayfoto.com +ayimail.com +ayizkufailhjr.cf +ayizkufailhjr.ga +ayizkufailhjr.gq +ayizkufailhjr.ml +ayizkufailhjr.tk +aymail.xyz +aympatico.ca +ayohave.fun +ayomail.com +ayonge.tech +ayongopi.org +ayotech.com +ayoushuckb.store +ayqtellsu.com +ayron-shirli.ru +aysegulsobac.cfd +ayshpale.club +ayshpale.online +ayshpale.xyz +ayudyahpasha.art +ayuh.myvnc.com +ayulaksmi.art +ayumail.com +ayurvedamassagen.de +ayurvedayogashram.com +aywq.com +ayyd.freeml.net +ayzah.com +az.com +az.usto.in +azacavesuite.com +azacmail.com +azaloptions.com +azame.pw +azart-player.ru +azazazatashkent.tk +azclip.net +azcomputerworks.com +azduan.com +aze.kwtest.io +azehiaxeech.ru +azel.xyz +azemail.com +azeqsd.fr.nf +azer-nedv.ru +azeriom.com +azest.us +azfvbwa.pl +azhirock.com +azhour.fr +azhq.com +aziamail.com +azithromaxozz.com +azithromaxww.com +aziu.com +azjuggalos.com +azkankitchen.shop +azmeil.tk +azna.ga +aznayra.co.tv +azne.spymail.one +azon-review.com +azooma.ru +azooo1000.shop +azosmail.com +azote.cf +azote.ga +azote.gq +azpuma.com +azq.laste.ml +azqas.com +azrmail.com +azrvdvazg.pl +azsdz2xc1d2a3sac12.com +azsy.spymail.one +azteen.com +azulaomarine.com +azulejoslowcost.es +azumail.com +azure.cloudns.asia +azurebfh.me +azureexplained.com +azuregiare.com +azures.live +azuretechtalk.net +azurny.mazowsze.pl +azusagawa.ml +azuxyre.com +azwaa.site +azwab.site +azwac.site +azwad.site +azwae.site +azwaf.site +azwag.site +azwah.site +azwai.site +azwaj.site +azwak.site +azwal.site +azwam.site +azwao.site +azwap.site +azwaq.site +azwas.site +azwat.site +azwau.site +azwav.site +azwaw.site +azwax.site +azway.site +azwaz.site +azwb.site +azwc.site +azwd.site +azwe.site +azwea.site +azwec.site +azwed.site +azwee.site +azwef.site +azweg.site +azweh.site +azwei.site +azwej.site +azwek.site +azwel.site +azwem.site +azwen.site +azweo.site +azwep.site +azweq.site +azwer.site +azwes.site +azwet.site +azweu.site +azwev.site +azwg.site +azwh.site +azwi.site +azwj.site +azwk.site +azwl.site +azwm.site +azwn.site +azwo.site +azwp.site +azwq.site +azws.site +azwt.site +azwu.site +azwv.site +azww.site +azwx.site +azwz.site +azxddgvcy.pl +azxf.com +azxhzkohzjwvt6lcx.cf +azxhzkohzjwvt6lcx.ga +azxhzkohzjwvt6lcx.gq +azxhzkohzjwvt6lcx.ml +azxhzkohzjwvt6lcx.tk +azzancoffee.com +azzzzuhjc10151.spymail.one +azzzzuhjc10370.emlpro.com +azzzzuhjc11020.freeml.net +azzzzuhjc12248.spymail.one +azzzzuhjc12776.dropmail.me +azzzzuhjc14299.mailpwr.com +azzzzuhjc15404.spymail.one +azzzzuhjc15973.laste.ml +azzzzuhjc17418.mimimail.me +azzzzuhjc18221.spymail.one +azzzzuhjc18532.mailpwr.com +azzzzuhjc1892.spymail.one +azzzzuhjc19715.mimimail.me +azzzzuhjc20638.emlhub.com +azzzzuhjc21093.mailpwr.com +azzzzuhjc23151.laste.ml +azzzzuhjc23828.spymail.one +azzzzuhjc24084.spymail.one +azzzzuhjc25412.dropmail.me +azzzzuhjc26044.dropmail.me +azzzzuhjc26244.dropmail.me +azzzzuhjc27093.spymail.one +azzzzuhjc27104.mimimail.me +azzzzuhjc27190.spymail.one +azzzzuhjc27929.emltmp.com +azzzzuhjc28176.dropmail.me +azzzzuhjc28885.freeml.net +azzzzuhjc29851.mimimail.me +azzzzuhjc30101.spymail.one +azzzzuhjc32352.emlpro.com +azzzzuhjc32822.laste.ml +azzzzuhjc33515.mailpwr.com +azzzzuhjc33733.spymail.one +azzzzuhjc37605.emlhub.com +azzzzuhjc38012.freeml.net +azzzzuhjc38225.laste.ml +azzzzuhjc38359.freeml.net +azzzzuhjc39549.laste.ml +azzzzuhjc41586.spymail.one +azzzzuhjc4316.spymail.one +azzzzuhjc43651.freeml.net +azzzzuhjc45866.dropmail.me +azzzzuhjc46377.emltmp.com +azzzzuhjc4686.dropmail.me +azzzzuhjc46996.spymail.one +azzzzuhjc47179.dropmail.me +azzzzuhjc47383.spymail.one +azzzzuhjc52279.emlhub.com +azzzzuhjc52503.laste.ml +azzzzuhjc53245.mailpwr.com +azzzzuhjc5390.mailpwr.com +azzzzuhjc5408.emltmp.com +azzzzuhjc55193.freeml.net +azzzzuhjc57045.freeml.net +azzzzuhjc58810.emlpro.com +azzzzuhjc60560.spymail.one +azzzzuhjc613.laste.ml +azzzzuhjc61472.freeml.net +azzzzuhjc61531.dropmail.me +azzzzuhjc61532.emlhub.com +azzzzuhjc62255.emlhub.com +azzzzuhjc64496.dropmail.me +azzzzuhjc66133.dropmail.me +azzzzuhjc66591.emltmp.com +azzzzuhjc68142.emlhub.com +azzzzuhjc70003.spymail.one +azzzzuhjc70832.spymail.one +azzzzuhjc70902.freeml.net +azzzzuhjc71456.emlpro.com +azzzzuhjc72020.mailpwr.com +azzzzuhjc73335.laste.ml +azzzzuhjc73589.emlpro.com +azzzzuhjc73987.freeml.net +azzzzuhjc75385.mailpwr.com +azzzzuhjc75878.mimimail.me +azzzzuhjc76597.spymail.one +azzzzuhjc7729.mimimail.me +azzzzuhjc77340.mailpwr.com +azzzzuhjc77680.emlhub.com +azzzzuhjc78345.spymail.one +azzzzuhjc78750.emlpro.com +azzzzuhjc79218.emlpro.com +azzzzuhjc82993.laste.ml +azzzzuhjc83404.mimimail.me +azzzzuhjc83429.spymail.one +azzzzuhjc83928.laste.ml +azzzzuhjc84820.emlhub.com +azzzzuhjc84898.spymail.one +azzzzuhjc86989.emlhub.com +azzzzuhjc87306.emlpro.com +azzzzuhjc89569.mimimail.me +azzzzuhjc89688.dropmail.me +azzzzuhjc89860.dropmail.me +azzzzuhjc90594.mailpwr.com +azzzzuhjc91753.spymail.one +azzzzuhjc91777.mimimail.me +azzzzuhjc91938.spymail.one +azzzzuhjc93461.mailpwr.com +azzzzuhjc95033.dropmail.me +azzzzuhjc95412.mailpwr.com +azzzzuhjc95652.emlpro.com +azzzzuhjc95814.mimimail.me +azzzzuhjc9598.emltmp.com +azzzzuhjc96269.emlhub.com +azzzzuhjc96389.mailpwr.com +azzzzuhjc96936.freeml.net +azzzzuhjc98695.emltmp.com +azzzzuhjc98930.emlhub.com +azzzzuhjc99952.laste.ml +b-geamuritermopan-p.com +b-geamuritermopane-p.com +b-have.com +b-preturitermopane-p.com +b-preturitermopane.com +b-sky-b.cf +b-sky-b.ga +b-sky-b.gq +b-sky-b.ml +b-sky-b.tk +b-termopanepreturi-p.com +b-time117.com +b.barbiedreamhouse.club +b.bestwrinklecreamnow.com +b.bettermail.website +b.captchaeu.info +b.coloncleanse.club +b.cr.cloudns.asia +b.dogclothing.store +b.fastmail.website +b.garciniacambogia.directory +b.gsasearchengineranker.pw +b.gsasearchengineranker.site +b.gsasearchengineranker.space +b.gsasearchengineranker.top +b.gsasearchengineranker.xyz +b.kerl.gq +b.mediaplayer.website +b.ouijaboard.club +b.polosburberry.com +b.reed.to +b.royal-syrup.tk +b.smelly.cc +b.teemail.in +b.uhdtv.website +b.virtualmail.website +b.waterpurifier.club +b.wp-viralclick.com +b.yertxenor.tk +b.yourmail.website +b.zeemail.xyz +b0.nut.cc +b057bf.pl +b1gmail.epicgamer.org +b1of96u.com +b1p5xtrngklaukff.cf +b1p5xtrngklaukff.ga +b1p5xtrngklaukff.gq +b1p5xtrngklaukff.tk +b24u2.anonbox.net +b26im.anonbox.net +b2avg.anonbox.net +b2b4business.com +b2bf6.anonbox.net +b2bmail.bid +b2bmail.download +b2bmail.men +b2bmail.stream +b2bmail.trade +b2bmail.website +b2bx.net +b2cmail.de +b2email.win +b2g6anmfxkt2t.cf +b2g6anmfxkt2t.ga +b2g6anmfxkt2t.gq +b2g6anmfxkt2t.ml +b2g6anmfxkt2t.tk +b2hg2.anonbox.net +b2ieu.anonbox.net +b2npv.anonbox.net +b2ntj.anonbox.net +b2uvj.anonbox.net +b2xta.anonbox.net +b34fdweffir.net +b35gm.anonbox.net +b3a2p.anonbox.net +b3ade.anonbox.net +b3apn.anonbox.net +b3cev.anonbox.net +b3j3z.anonbox.net +b3j7f.anonbox.net +b3nxdx6dhq.cf +b3nxdx6dhq.ga +b3nxdx6dhq.gq +b3nxdx6dhq.ml +b3ou3.anonbox.net +b3sikk.com +b3uz5.anonbox.net +b3vea.anonbox.net +b3zig.anonbox.net +b3zz7.anonbox.net +b43bx.anonbox.net +b43xu.anonbox.net +b44tz.anonbox.net +b4bhw.anonbox.net +b4ekh.anonbox.net +b4fdg.anonbox.net +b4feg.anonbox.net +b4frx.anonbox.net +b4hh3.anonbox.net +b4jlg.anonbox.net +b4piu.anonbox.net +b4pp7.anonbox.net +b4ry5.anonbox.net +b4rzx.anonbox.net +b4s4q.anonbox.net +b4sf2.anonbox.net +b4uc7.anonbox.net +b4urd.anonbox.net +b4vv7.anonbox.net +b4xex.anonbox.net +b534b.anonbox.net +b55b56.cf +b55b56.ga +b55b56.gq +b55b56.ml +b55b56.tk +b5c53.anonbox.net +b5eb6.anonbox.net +b5eyv.anonbox.net +b5kir.anonbox.net +b5msd.anonbox.net +b5nr6.anonbox.net +b5r2z.anonbox.net +b5r5wsdr6.pl +b5safaria.com +b5umv.anonbox.net +b5vf2.anonbox.net +b5xlu.anonbox.net +b5yvo.anonbox.net +b5zuw.anonbox.net +b602mq.pl +b64pc.anonbox.net +b66x4.anonbox.net +b67ht.anonbox.net +b6avk.anonbox.net +b6bpj.anonbox.net +b6c4g.anonbox.net +b6faz.anonbox.net +b6fm5.anonbox.net +b6kgo.anonbox.net +b6lgf.anonbox.net +b6muz.anonbox.net +b6o7vt32yz.cf +b6o7vt32yz.ga +b6o7vt32yz.gq +b6o7vt32yz.ml +b6o7vt32yz.tk +b6ped.anonbox.net +b6pni.anonbox.net +b6quk.anonbox.net +b6vscarmen.com +b6xh2n3p7ywli01.cf +b6xh2n3p7ywli01.ga +b6xh2n3p7ywli01.gq +b6xufbtfpqco.cf +b6xufbtfpqco.ga +b6xufbtfpqco.gq +b6xufbtfpqco.ml +b6xufbtfpqco.tk +b6zel.anonbox.net +b6zui.anonbox.net +b76h7.anonbox.net +b77zz.anonbox.net +b7agx.anonbox.net +b7aqs.anonbox.net +b7ba4ef3a8f6.ga +b7fk3.anonbox.net +b7m5c.anonbox.net +b7nsd.anonbox.net +b7nse.anonbox.net +b7rra.anonbox.net +b7s.ru +b7s42.anonbox.net +b7sn3xrm.my.id +b7t98zhdrtsckm.ga +b7t98zhdrtsckm.ml +b7t98zhdrtsckm.tk +b7tpg.anonbox.net +b7yug.anonbox.net +b83gritty1eoavex.cf +b83gritty1eoavex.ga +b83gritty1eoavex.gq +b83gritty1eoavex.ml +b83gritty1eoavex.tk +b857tghh.buzz +b9adiv5a1ecqabrpg.cf +b9adiv5a1ecqabrpg.ga +b9adiv5a1ecqabrpg.gq +b9adiv5a1ecqabrpg.ml +b9adiv5a1ecqabrpg.tk +b9x45v1m.com +b9x45v1m.com.com +ba-ca.com +ba3iz.anonbox.net +ba3nh.anonbox.net +ba76r.anonbox.net +ba7hj.anonbox.net +baalism.info +baang.co.uk +baanr.com +baasdomains.info +baatz33.universallightkeys.com +bab72.anonbox.net +bababox.info +baban.ml +babaratomaria.com +babassu.info +babau.cf +babau.flu.cc +babau.ga +babau.gq +babau.igg.biz +babau.ml +babau.mywire.org +babau.nut.cc +babau.usa.cc +babayigithukuk.xyz +babbien.com +babblehorde.site +babe-idol.com +babe-store.com +babehealth.ru +babei-idol.com +babesstore.com +babiczka.az.pl +babimost.co.pl +babinski.info +babirousa.ml +babirusa.info +babiszoni.pl +bablace.com +babraja.kutno.pl +babroc.az.pl +babski.az.pl +babssaito.com +babssaito.net +babtisa.com +baby-mat.com +baby.blatnet.com +baby.inblazingluck.com +baby.lakemneadows.com +baby.makingdomes.com +baby.marksypark.com +babya.site +babyandkidsfashion.com +babyb1og.ru +babybaby.info +babycounter.com +babyfriendly.app +babyk.gq +babylissshoponline.org +babylissstore.com +babyloaf.name +babylonize.com +babymails.com +babymattress.me +babymoose.info +babynamesonly.com +babyrezensionen.com +babyroomdecorations.net +babyrousa.info +babysheets.com +babysneedshoes.com +babyteeth.club +babytrainers.info +babyvideoemail.com +babywalker.me +babywalzgutschein.com +bac24.de +bacaberitabola.com +bacai70.net +bacaki.com +bacapedia.web.id +baceu.anonbox.net +bacfonline.org +bacfy.anonbox.net +bacharg.com +bachelorette.com +bacheloretteparty.com +bachelorpartyprank.info +bachelors.ml +bachkhoatoancau.com +bachnam.net +bachoa.xyz +bachus-dava.com +bacinj.com +back-replace-happy-speech.xyz +back.blatnet.com +back.inblazingluck.com +back.lakemneadows.com +back.marksypark.com +back.oldoutnewin.com +back2barack.info +back2bsback.com +backalleybowling.info +backalleydesigns.org +backbone.works +backdropcheck.xyz +backfensu.tk +backflip.cf +backilnge.com +backlesslady.com +backlesslady.net +backlink.mygbiz.com +backlinkaufbauservice.de +backlinkcity.info +backlinkhorsepower.com +backlinks.we.bs +backlinkscheduler.com +backlinkservice.me +backlinkskopen.net +backlinksparser.com +backmail.ml +backpackestore.com +backpainadvice.info +backtobasicsbluegrass.com +backva.com +backwis.com +backyardduty.com +backyardfood.com +backyardgardenblog.com +bacninhmail.us +baconporker.com +baconpro.network +baconsoi.tk +bacria.com +bacria.net +bact.site +bacteroidmail.com +bacti.org +bad.emltmp.com +badabingfor1.com +badaboommail.xyz +badamm.us +badassmail.com +badatorreadorr.com +badaxitem.host +badazzvapez.com +badboygirlpowa.com +badce.com +badcreditloans.elang.org +badcreditloanss.co.uk +badfat.com +badfist.com +badger.tel +badgerland.eu +badgettingnurdsusa.com +badhus.org +badixort.eu +badknech.ml +badlion.co.uk +badmili.com +badnewsol.com +badoo.live +badoop.com +badopsec.lol +badpotato.tk +badred.pw +badumtssboy.com +badumtxolo.com +badungmail.cf +badutquinza.com +badutstore.com +badwyn.biz +bae-systems.tk +baebaebox.com +baebies.com +baegibagy.com +baf4j.anonbox.net +bafanglaicai.sbs +bafilm.site +bafrem3456ails.com +bafrx.anonbox.net +bag2.ga +bag2.gq +bagam-nedv.ru +bagelmaniamiami.com +bagfdgks.com +bagfdgks.net +baghehonar.art +bagikanlagi.com +bagislan.org +bagivideos.com +bagonew.com +bagonsalejp.com +bagoutletjp.com +bagpaclag.com +bagscheaplvvuitton.com +bagscheaps.org +bagscoachoutleonlinestore.com +bagsguccisaleukonliness.co.uk +bagslouisvuitton2012.com +bagso.bond +bagsofficer.info +bagsonline-store.com +bagsshopjp.com +bagx.site +bahannes.network +bahiablogs.online +bahiscasinoparayatirma.xyz +bahoo.biz.st +bahv5.anonbox.net +bai47.com +baicmotormyanmar.com +baidoxe.com +baidubaidu123123.info +baikal-autotravel.ru +bailbondsdirect.com +baileprofessional.xyz +bainesbathrooms.co.uk +baireselfie.net +baitify.com +baixeeteste.tk +bajardepesoahora.org +bajarpeso24.es +bajatyoutube.com +bajery-na-imprezy.pl +bajerydladzieci.pl +bajerynaimprezy.pl +bajyma.ru +bakamail.info +bakar.bid +bakarmadu.xyz +bakecakecake.com +bakerhughs.com +bakertaylor.com +bakkenoil.org +bakolfb.live +bakso.rocks +bakulanaws.com +bakulcod.club +bal.emlpro.com +balabush.ru +balacavaloldoi.com +balaket.com +balanc3r.com +balancedcannabis.com +balangi.ga +balaway.com +balaways.com +balawo.com +balderdash.org +baldmama.de +baldpapa.de +balenciagabag.co.uk +balesmotel.com +balibestresorts.com +balimeloveyoulongtime.com +balincs.com +balladothris.pw +ballaghma.monster +ballaratsteinerprep.in +ballenas.info +ballground.ml +ballistika.site +ballmails.xyz +ballman05.ml +ballsofsteel.net +ballustra.net.pl +ballyfinance.com +ballysale.com +balm.com +baloszyce-elektroluminescencja-nadpilicki.top +balsasquerida.com +baltey.com +baltimore2.freeddns.com +baltimore4.ygto.com +baltimorechildrensbusinessfair.com +balutemp.email +balwo.anonbox.net +balzola.eu +bambase.com +bambee.tk +bambibaby.shop +bambis.cat +bamcs3.com +bamibi.com +baminsja.tk +bamjamz.com +bamsrad.com +bamulatos.net +banaboende.cd +banad.me +bananacc.site +bananadream.site +bananamail.org +bananamails.info +bananashakem.com +banancaocap.com +banashbrand.com +banclonetiktok.com +band-freier.de +bandai.nom.co +bandamn.ru +bandar389a.com +bandariety.xyz +bandcalledmill.com +bandmuflikhun.biz +bandon-cheese.name +bandon.name +bandoncheese.name +bandoncoastfoods.name +bandonscheese.name +bandonscheese.us +bandsoap.com +bandspeed.com +bandtoo.com +banetc.com +banfit.site +bangalorefoodfete.com +bangban.uk +bangeer-55sw.xyz +bangi.anonbox.net +bangilan.ga +bangilan.ml +bangkeju.fun +bangkok-mega.com +bangkok9sonoma.com +bangkokremovals.net +bangladesh-nedv.ru +banglamusic.co +banglanatok.co +bangsat.in +banhang14.com +banhbeovodich.vn +banhga.cf +banhga.ga +banhga.ml +banit.club +banit.me +banjarworo.ga +banjarworo.ml +banjarworocity.cf +bank-konstancin.pl +bank-lokata.edu.pl +bank-opros1.ru +bankaccountexpert.tk +bankcommon.com +bankingresources.xyz +bankinnepal.com +bankionline.info +bankofamericsaaa.com +bankoff.me +bankofpalestine.club +bankofthecarolinas.com +bankowepromocje.com.pl +bankparibas.pl +bankpln.com +bankpravo.ru +bankrobbersindicators.com +bankrotbankodin.xyz +bankrupt1.com +bankruptcycopies.com +banks-review.ru +banmailco.site +bannedpls.online +banner4traffic.com +bannerstandpros.com +banquyen.xyz +banricc.xyz +banten.me +bantenvpn.live +banubadaeraceva.com +banyansprings.com +bao160.com +baobaosport.com +baobaosport.xyz +baocaothue.store +baomat.ml +baomoi.site +baothoitrang.org +baphled.com +bapok.best +baptistedufour.xyz +bapu.gq +bapu.ml +bapumoj.cf +bapumoj.ga +bapumoj.gq +bapumoj.ml +bapumoj.tk +bapumojo.ga +baracudapoolcleaner.com +barafa.gs +barajasmail.bid +barakademin.se +baramail.com +barbabas.space +barbados-nedv.ru +barbarra-com.pl +barbarrianking.com +barbieoyungamesoyna.com +barca.my.id +barcakana.tk +barcalovers.club +barcin.co.pl +barcinohomes.ru +barclays-plc.cf +barclays-plc.ga +barclays-plc.gq +barclays-plc.ml +barclays-plc.tk +bardecor.ru +bards.net +barecases.com +bareck.net +bareed.ws +bareface.social +barefooted.com +bargainthc.com +bargay.space +baridasari.ru +bariswc.com +barkingdogs.de +barkingspidertx.com +barkito.se +barkochicomail.com +barnebas.space +barnesandnoble-couponcodes.com +barneu.com +barny.space +barnyarthartakar.com +baroedaksaws.website +barooko.com +barosuefoarteprost.com +barping.asia +barplane.com +barrabravaz.com +barretodrums.com +barrhq.com +barrieevans.co.uk +barrindia.com +barryogorman.com +barrypov.com +barryspov.com +barsikvtumane.cf +bartdevos.be +bartholemy.space +bartholomeo.space +bartholomeus.space +bartolemo.space +bartoparcadecabinet.com +baruchcc.edu +baruu.anonbox.net +barzan.mielno.pl +basakgidapetrol.com +base-all.ru +base-weight.com +base.blatnet.com +base.cowsnbullz.com +base.lakemneadows.com +baseballboycott.com +basebuykey.com +basedify.com +basefixzone.com +baseflowpro.com +basegenai.com +baselwesam.site +basemindway.com +baseny-mat.com.pl +baseon.click +basepathgrid.com +baserelief.ru +basgoo.com +bashmak.info +bashnya.info +basic-colo.com +basic.cowsnbullz.com +basic.droidpic.com +basic.lakemneadows.com +basic.oldoutnewin.com +basic.poisedtoshrike.com +basic.popautomated.com +basicbusinessinfo.com +basicinstinct.com.us +basiclaw.ru +basicmail.host +basicskillssheep.com +basingbase.com +basius.club +basketball.group +basketball2in.com +basketballcite.com +basketballontv.com +basketballvoice.com +basketinfo.net +basketth.com +baskinoco.ru +basscode.org +basssi.today +bastamail.cf +bastauop.info +bastore.co +bastwisp.ru +basurtest55ckr.tk +basy.cf +batanik-mir.ru +batches.info +batdongsanhatinh.org +batesmail.men +bath-slime.com +bathandbodyworksoutlettest.org +bathedandinfused.com +bathroomsbristol.com +bathworks.info +batikmpo.top +batlmamad.gq +batpat.it +batpeer.site +battelknight.pl +batterydoctor.online +battey.me +battleperks.com +battpackblac.ml +battpackblac.tk +battricks.com +bau-peler.business +bau-peler.com +bauchtanzkunst.info +bauff.anonbox.net +bauimail.ga +baumhotels.de +bauscn.com +bauwerke-online.com +baver.com +baxidy.com +baxima.com +baxomale.ht.cx +baxymfyz.pl +bayanarkadas.info +bayarea.net +baybabes.com +baycollege.com +baylead.com +bayrjnf.pl +bayshore.edu +baytrilfordogs.org +bayxs.com +bazaaboom.com +bazaarcovers.com +bazaarsoftware.com +bazarop.com +bazatek.com +bazavashdom.info +bazi1399.site +bazmool.com +bazoocam.co +bazookagoldtrap.com +bazreno.com +bazybgumui.pl +bb-system.pl +bb1197.com +bb2.ru +bb28.dev +bb2v7.anonbox.net +bb7fk.anonbox.net +bb99.lol +bba24.de +bbabyswing.com +bbacademy.es +bbadcreditloan.com +bbasky.us +bbb.hexsite.pl +bbbbongp.com +bbbbyyzz.info +bbbest.com +bbblanket.com +bbcbbc.com +bbclogistics.org +bbcok.com +bbcs.me +bbcvnews.com +bbd.emlhub.com +bbdd.info +bbdoifs.com +bbdownz.com +bbe54.anonbox.net +bbestssafd.com +bbetweenj.com +bbf.emlhub.com +bbhost.us +bbi2q.anonbox.net +bbibbaibbobbatyt.cf +bbibbaibbobbatyt.gq +bbitf.com +bbitj.com +bbitq.com +bbjnc.anonbox.net +bbka.lol +bbkgi.anonbox.net +bbkk7.anonbox.net +bblounge.co.za +bblt.yomail.info +bbmail.win +bbmtl.anonbox.net +bbnhbgv.com +bbograiz.com +bbokki12.com +bbomaaaar.ml +bbomaaaar.tk +bbox.com +bboygarage.com +bboys.fr.nf +bboysd.com +bbpsc.anonbox.net +bbq59.xyz +bbqlight.com +bbreghodogx83cuh.ml +bbrsr.anonbox.net +bbs.edu +bbsaili.com +bbse195.com +bbsmoodle.com +bbsqk.anonbox.net +bbswordiwc.com +bbt.dropmail.me +bbtop.com +bbtspage.com +bbugblanket.com +bburberryoutletufficialeit.com +bbvapr.com +bbw.monster +bbwcu.anonbox.net +bbxvp.anonbox.net +bbysn.anonbox.net +bbzr4.anonbox.net +bc.dropmail.me +bc3rv.anonbox.net +bc4mails.com +bc6mm.anonbox.net +bc6yl.anonbox.net +bcampbelleo.com +bcaoo.com +bcaplay.vip +bcarriedxl.com +bcast.store +bcast.ws +bcb.ro +bcbgblog.org +bccenterprises.com +bcchain.com +bccplease.com +bccstudent.me +bccto.cc +bccto.me +bcdmail.date +bcg-adwokaci.pl +bch5z.anonbox.net +bchaa.anonbox.net +bchatz.ga +bcir.mimimail.me +bcle.de +bcm.edu.pl +bcma.freeml.net +bcnwalk.com +bcoat.anonbox.net +bcompiled3.com +bcooperation.cloud +bcooq.com +bcp33.anonbox.net +bcpfm.com +bcqh.dropmail.me +bcrhc.anonbox.net +bcsbm.com +bcssi.com +bcuae.anonbox.net +bcuv.emlhub.com +bcuxp.anonbox.net +bcvm.de +bcw.spymail.one +bcwb7.anonbox.net +bcxaiws58b1sa03dz.cf +bcxaiws58b1sa03dz.ga +bcxaiws58b1sa03dz.gq +bcxaiws58b1sa03dz.ml +bcxaiws58b1sa03dz.tk +bcxv3.anonbox.net +bcxym.anonbox.net +bczwy6j7q.pl +bd.dns-cloud.net +bd.if.ua +bd.nestla.com +bd3xm.anonbox.net +bd6rt.anonbox.net +bd7u7.anonbox.net +bda.freeml.net +bdaov.anonbox.net +bdas.com +bdc4u.anonbox.net +bdc76.anonbox.net +bdci.website +bdf343rhe.de +bdgdn.anonbox.net +bdgstr.com +bdiversemd.com +bdk2x.anonbox.net +bdm.ovh +bdmuzic.pw +bdnets.com +bdoindia.co.in +bdpmedia.com +bdredemptionservices.com +bdrfoe.store +bds-hado.com +bdshar.com +bdsm-community.ch +bdtf4.anonbox.net +bdttp.anonbox.net +bduix.anonbox.net +bdvsthpev.pl +bdvy.com +bdww7.anonbox.net +bdx.spymail.one +bdxrc.anonbox.net +bdxsg.anonbox.net +bdybj.anonbox.net +bdydy.anonbox.net +bdyii.anonbox.net +bdyn5.anonbox.net +bdyrh.anonbox.net +bdzj.laste.ml +bdzu2.anonbox.net +be-breathtaking.net +be-mail.xyz +be.emlpro.com +be.ploooop.com +be.popautomated.com +beach-homes.com +beach.favbat.com +beachbodysucces.net +beaconmessenger.com +beall-cpa.com +beameagle.top +bean.farm +beanchukaty.com +beanieinfo.com +beaniemania.net +beanlignt.com +beaplumbereducationok.sale +bearan.online +bearegone.pro +beareospace.com +bearmels.life +bearmels.live +bearmels.online +bearpaint.com +bearsarefuzzy.com +beastmagic.com +beastmail.space +beastpanda.com +beastrapleaks.blogspot.com +beatdirectai.com +beatelse.com +beatoff.com +beats-rock.com +beatsaheadphones.com +beatsbudredrkk.com +beatsbydre18.com +beatsbydredk.com +beatsdr-dreheadphones.com +beatsdre.info +beatsdydr.com +beatskicks.com +beatsportsbetting.com +beaufortschool.org +beautibus.com +beautifulhair.info +beautifulinteriors.info +beautifulonez.com +beautifulsmile.info +beautty.online +beauty-pro.info +beautycareklin.xyz +beautyfashionnews.com +beautyiwona.pl +beautyjewelery.com +beautykz.store +beautynewsforyou.com +beautyothers.ru +beautypromdresses.net +beautypromdresses.org +beautyshine.club +beautyskincarefinder.com +beautytesterin.de +beautywelldress.com +beautywelldress.org +beaverboob.info +beaverbreast.info +beaverhooters.info +beaverknokers.info +beavertits.info +beazelas.monster +beazleycompany.com +beba.icu +bebarefoot.com +bebasmovie.com +bebekpenyet.buzz +bebekurap.xyz +bebemeuescumpfoc.com +beben.xyz +becamanus.site +because.cowsnbullz.com +because.lakemneadows.com +because.marksypark.com +because.oldoutnewin.com +becausethenight.cf +becausethenight.ml +becausethenight.tk +becaxklo.info +becdk.anonbox.net +bechtac.pomorze.pl +beck-it.net +beckmotors.com +beckygri.pw +becommigh.site +beconfidential.com +beconfidential.net +bedatsky.agencja-csk.pl +bedbathandbeyond-couponcodes.com +beddly.com +bedestin11.top +bedisplaysa.com +bedk.com +bedmail.top +bedroomsod.com +bedstyle2015.com +bedul.net +bedulsenpai.net +beeae.com +beecabs.com +beechatz.ga +beechatzz.ga +beed.ml +beef2o.com +beefance.com +beefmilk.com +beefnomination.info +beekv.anonbox.net +beelsil.com +beenfiles.com +beenhi.one +beeplush.com +beerolympics.se +beetlejuices.xyz +beeviee.cf +beeviee.ga +beeviee.gq +beeviee1.cf +beeviee1.ga +beeviee1.gq +beeviee1.ml +beeviee1.tk +beezom.buzz +befn.yomail.info +befotey.com +begance.xyz +beganment.com +begemail.com +beginnergeek.net +begism.site +begisobaka.cf +begisobaka.gq +begisobaka.ml +begivverh.xyz +begnthp.tk +begoz.com +beh3q.anonbox.net +behaviorsupportsolutions.com +behax.net +beheks.ml +behiyesamoglu.cfd +behka.anonbox.net +bei.kr +beibilga.ga +beibiza.es +beihoffer.com +beijinhuixin.com +beinger.me +beingyourbest.org +beins.info +beiop.com +bekasi.me +bel.kr +belajaryuk.me +belalbelalw.cloud +belamail.org +belan.website +belanjaonlineku.web.id +belarus-nedv.ru +belarussian-fashion.eu +belastingdienst.pw +belaya-orhideya.ru +belchan.tk +belediyeevleri2noluasm.com +belence.cf +belence.ga +belence.gq +belence.ml +belence.tk +belgia-nedv.ru +belgianairways.com +belgrado.shop +belibeli.shop +belicloud.net +beliefnet.com +belieti.com +believesex.com +believesrq.com +beligummail.com +belisatu.net +beliz-nedv.ru +belksouth.net +bellaitaliavalencia.com +bellanotte.cf +bellaora.com +bellatoengineers.com +bellavanireview.net +belldouth.net +belleairjordanoutlet.us +belleairmaxingthe.us +bellebele.click +bellenuits.com +bellesbabyboutique.com +belligerentmail.top +bellingham-ma.us +belljonestax.com +bellsourh.net +belmed.uno +belmed.xyz +belmontfinancial.com +belog.digital +belongestival.xyz +belqa.com +belspb.ru +belt.io +beltng.com +beltpin.com +beluckygame.com +belugateam.info +belujah.com +belvedereliverpool.com +bem.freeml.net +bem75.anonbox.net +bemali.life +bemersky.com +bemone.com +bemony.com +bemvip.xyz +ben10benten.com +benandrose.com +benature.tv +benchjacken.info +benchsbeauty.info +bendbroadband.co +bendbroadbend.com +bendlinux.net +bendonabendo.xyz +benedict90.org +benefitsquitsmoking.com +benefitturtle.com +benekori.icu +benemyth.com +benepix.com +benflix.biz +benfrey.com +bengkelseo.com +bengkoan.live +benink.site +benipaula.org +benj.com +benjamin-roesch.com +benlianfoundation.com +benphim.com +benphim.net +benshelf.com +bensinstantloans.co.uk +bensullivan.au +bentleypaving.com +bentleysmarket.com +bentoboxmusic.com +bentonschool.org +bentonshome.tk +bentonsnewhome.tk +bentonspms.tk +bentsgolf.com +bentsred.com +benv6.anonbox.net +benwes.xyz +benwola.pl +benzes.site +benznoi.com +beo.kr +beom5.anonbox.net +beooo.anonbox.net +bepdientugiare.net +bephoa.com +bepj.mailpwr.com +bepureme.com +berams.club +berandi.com +berawa-beach.com +beraxs.id +beraxs.nl +berdeen.com +beremkredit.info +beresleting.cf +beresleting.ga +beresleting.gq +beresleting.ml +beresleting.tk +berfield51.universallightkeys.com +bergenregional.com +bergservices.cf +beribase.ru +beribaza.ru +berirabotay.ru +beritahajidanumroh.com +beritaproperti.com +berjalansasuiquasd.codes +berkahfb.com +berkahjaran.xyz +berkatrb.com +berkeleyif.com +berlineats.com +berlusconi.cf +berlusconi.ga +berlusconi.gq +berlusconi.ml +bermainjudionline.com +bermondseypubco.com +bermr.org +bernardmail.xyz +berodomoko.be +berracom.ph +berryblitzreview.com +berrymail.men +berryslawn.com +berryslimming.com +bershka-terim.space +beruka.org +berwie.com +bes3m.anonbox.net +besaies.com +besenica.com +besibali.com +beskohub.site +beslq.shop +besnetor.com +besplatnie-conspecti.ru +besplatnoigraj.com +best-advert-for-your-site.info +best-airmaxusa.us +best-carpetcleanerreviews.com +best-cruiselines.com +best-day.pw +best-detroit-doctors.info +best-electric-cigarettes.co.uk +best-email.bid +best-fiverr-gigs.com +best-hosting.biz +best-john-boats.com +best-mail.net +best-market-search.com +best-new-casino.com +best-paydayloan24h7.com +best-store.me.uk +best-temp-mail.com +best-things.ru +best-ugg-canada.com +best-vpn.xyz +best.blatnet.com +best.marksypark.com +best.poisedtoshrike.com +best24hmagazine.xyz +bestadvertisingsolutions.info +bestats.top +bestattach.gq +bestbets123.net +bestbooksite.site +bestbot.pro +bestbuy-couponcodes.com +bestbuyswebs.com +bestbuyvips.com +bestcamporn.com +bestcarpetcleanerreview.org +bestcastlevillecheats.info +bestcatbook.site +bestcatbooks.site +bestcatfiles.site +bestcatstuff.site +bestchannelstv.info +bestcharger.shop +bestcharm.net +bestcheapdeals.org +bestcheapshoesformenwomen.com +bestchoiceofweb.club +bestchoiceusedcar.com +bestcigarettemarket.net +bestcityinformation.com +bestcoins.xyz +bestcpacompany.com +bestcraftsshop.com +bestcreditcart-v.com +bestcryptonews.one +bestcustomlogo.com +bestdarkspotcorrector.org +bestdating.lat +bestday.pw +bestdealsdiscounts.co.in +bestdefinitions.com +bestdickpills.info +bestdiningarea.com +bestdirbook.site +bestdirbooks.site +bestdirfiles.site +bestdirstuff.site +bestdownjackets.com +bestdrones.store +bestdvdblurayplayer.com +bestemail.bid +bestemail.stream +bestemail.top +bestemail.website +bestemail2014.info +bestemail24.info +bestenuhren.com +bestescort4u.com +bestexerciseequipmentguide.com +bestfakenews.xyz +bestfinancecenter.org +bestfreeliveporn.com +bestfreepornvideo.com +bestfreshbook.site +bestfreshbooks.site +bestfreshfiles.site +bestfreshstuff.site +bestfuture.pw +bestgames.ch +bestgames4fun.com +bestgear.com +bestglockner.com +bestguccibags.com +bestgunsafereviews.org +besthostever.xyz +bestideas.tech +bestiengine.com +bestinfonow.cf +bestinfonow.tk +bestjerseysforu.com +bestjoayo.com +bestkeylogger.org +bestkitchens.fun +bestkonto.pl +bestlawyerinhouston.com +bestlibbooks.site +bestlibfile.site +bestlibfiles.site +bestlibtext.site +bestlifep.com +bestlistbase.com +bestlistbook.site +bestliststuff.site +bestlisttext.site +bestlivecamporn.com +bestlivesexsites.com +bestloot.tk +bestlordsmobilehack.eu +bestlovesms.com +bestlucky.pw +bestmail-host.info +bestmail.club +bestmail.site +bestmail.top +bestmail2016.club +bestmail24.cf +bestmail24.ga +bestmail24.tk +bestmail365.eu +bestmailer.gq +bestmailer.tk +bestmailgen.com +bestmails.tk +bestmailservices.xyz +bestmailtoday.com +bestmarksites.info +bestmedicinedaily.net +bestmedicinehat.net +bestmemory.net +bestmitel.tk +bestmlmleadsmarketing.com +bestmms.cloud +bestmogensen.com +bestmonopoly.ru +bestn4box.ru +bestnecklacessale.info +bestnerfblaster.com +bestnewbook.site +bestnewbooks.site +bestnews365.info +bestnewtext.site +bestnewtexts.site +bestofbest.biz +bestofironcounty.com +bestofprice.co +bestofyou.blog +bestoilchangeinmichigan.com +bestonlinecasinosworld.com +bestonlineusapharmacy.ru +bestoption25.club +bestparadize.com +bestpdfmanuales.xyz +bestphonecasesshop.com +bestphonefarm.com +bestpieter.com +bestpochtampt.ga +bestpokerlinks.net +bestpokerloyalty.com +bestposta.cf +bestpozitiv.ru +bestpressgazette.info +bestprice.exchange +bestregardsmate.com +bestrestaurantguides.com +bestreverbpedal.com +bestreviewsonproducts.com +bestring.org +bestseojobs.com +bestseomail.cn +bestservice.me +bestserviceforwebtraffic.info +bestservicemail.eu +bestsexcamlive.com +bestshopcoupon.net +bestshoppingmallonline.info +bestshopsoffer.com +bestsmesolutions.com +bestsnowgear.com +bestsoundeffects.com +bestspeakingcourses.com +bestspmall.com +bestspotbooks.site +bestspotfile.site +bestspotstuff.site +bestspottexts.site +bestsunshine.org +besttandberg.com +bestteethwhiteningstripss.com +besttempmail.com +besttimenews.xyz +besttoggery.com +besttopbeat.com +besttopbeatssale.com +besttopdeals.net +besttrialpacksmik.com +besttrommler.com +besttwoo1.info +bestuggbootsoutletshop.com +bestvalentinedayideas.com +bestvaluehomeappliances.com +bestvashikaran.com +bestvideogamesevermade.com +bestvirtualrealitysystems.com +bestvpn.top +bestvpncanada.pro +bestvps.info +bestvpsfor.xyz +bestvpshostings.com +bestw.space +bestwatches.com +bestways.ga +bestweightlossfitness.com +bestwesternpromotioncode.org +bestwheelspinner.com +bestwindows7key.net +bestwish.biz +bestwishes.pw +bestworldcasino.com +bestwrinklecreamnow.com +bestyoumail.co.cc +besun.cf +bet-fi.info +beta.edu.pl +beta.inter.ac +beta.tyrex.cf +betaalverzoek.cyou +betabhp.pl +betaforcemusclereview.com +betanywhere.com +betaprice.co +betbing.com +betcooks.com +beteajah.ga +beteajah.gq +beteajah.ml +beteajah.tk +betemail.cf +betermalvps.com +betestream31.com +betestream42.com +betfafa.com +bethere4mj4ever.com +bethguimitchie.xyz +bethlehemcenter.org +betkava.com +betliketv9.com +betmelli20.com +betmoon.org +betmove888.com +betnaste.tk +betofis.net +betofis2.com +betonchehov.ru +betonoweszambo.com.pl +betontv.com +betpapel.info +betr.co +betration.site +betriebsdirektor.de +bets-spor.com +bets-ten.com +betsitem404.com +bettafishbubble.com +better-place.pl +betterbeemktg.com +bettereve.com +bettereyesight.store +betterlink.info +bettermail24.eu +bettermail384.biz +betteropz.com +bettershop.biz +bettersucks.exposed +bettersunbath.co.uk +betterwisconsin.com +betteryoutime.com +bettilt70.com +betto888.com +betusbank.com +betweentury.site +betzenn.com +beumont.org +beupmore.win +beutyfz.com +beveragedictionary.com +beverlytx.com +bevhattaway.com +bevsemail.com +bewealthynation.com +bewedfv.com +bewvk.anonbox.net +beydent.com +beymail.com +beyoncenetworth.com +beyond-web.com +beyondsightfoundation.org +beypj.anonbox.net +beyzanurtaslan.sbs +bez-odsetek.pl +bezblednik.pl +bezique.info +bezlimitu.waw.pl +bezpiecznyfinansowo.pl +bf3hacker.com +bf4ff.anonbox.net +bf8878.com +bfagv.anonbox.net +bfat7fiilie.ru +bfccr.anonbox.net +bfcie.anonbox.net +bff.spymail.one +bfffk.anonbox.net +bffzg.anonbox.net +bfhbrisbane.com +bfhgh.com +bfile.site +bfiq5.anonbox.net +bfitcpupt.pl +bfk7x.anonbox.net +bflcafe.com +bfltv.shop +bfmwp.anonbox.net +bfncaring.com +bfnkv.anonbox.net +bfo.kr +bfpos.anonbox.net +bfqn2.anonbox.net +bfre675456mails.com +bfremails.com +bftoyforpiti.com +bfuz8.pl +bfz.emlpro.com +bg2it.anonbox.net +bg2q2.anonbox.net +bg4llrhznrom.cf +bg4llrhznrom.ga +bg4llrhznrom.gq +bg4llrhznrom.ml +bg4llrhznrom.tk +bg632.anonbox.net +bg6i6.anonbox.net +bgboad.ga +bgboad.ml +bgchan.net +bgctw.anonbox.net +bget0loaadz.ru +bget3sagruz.ru +bgfb2.anonbox.net +bggd.dropmail.me +bgget2zagruska.ru +bgget4fajli.ru +bgget8sagruz.ru +bgi-sfr-i.pw +bgifo.anonbox.net +bgisfri.pw +bgmj.com +bgnnd.anonbox.net +bgns6.anonbox.net +bgob.com +bgoy24.pl +bgr.laste.ml +bgrny.com +bgrwc.spymail.one +bgsaddrmwn.me +bgtedbcd.com +bgtmail.com +bgult.anonbox.net +bgwjb.anonbox.net +bgx.ro +bgyfr.anonbox.net +bgz2kl.com +bgzbbs.com +bh.yomail.info +bh3zw.anonbox.net +bh57d.anonbox.net +bh5zr.anonbox.net +bha.spymail.one +bha6v.anonbox.net +bhaappy0faiili.ru +bhaappy1loadzzz.ru +bhadoomail.com +bhag.us +bhakti-tree.com +bhap.me +bhappy0sagruz.ru +bhappy1fajli.ru +bhappy2loaadz.ru +bhappy3zagruz.ru +bhapy1fffile.ru +bhapy2fiilie.ru +bhapy3fajli.ru +bharatasuperherbal.com +bharatpatel.org +bhcxc.com +bhddmwuabqtd.cf +bhddmwuabqtd.ga +bhddmwuabqtd.gq +bhddmwuabqtd.ml +bhddmwuabqtd.tk +bhebhemuiegigi.com +bhecs.anonbox.net +bhelpsnr.co.in +bheps.com +bhfhueyy231126t1162216621.unaux.com +bhgbe.anonbox.net +bhgm7.club +bhgrftg.online +bhhpl.anonbox.net +bhicw.anonbox.net +bhl6t.anonbox.net +bhmhtaecer.pl +bhms.mailpwr.com +bhmvy.anonbox.net +bhmwriter.com +bho.hu +bho.kr +bhollander.com +bhonl.anonbox.net +bhp.yomail.info +bhpdariuszpanczak.pl +bhrii.anonbox.net +bhringraj.net +bhrpsck8oraayj.cf +bhrpsck8oraayj.ga +bhrpsck8oraayj.gq +bhrpsck8oraayj.ml +bhrpsck8oraayj.tk +bhs.laste.ml +bhs70s.com +bhsf.net +bhslaughter.com +bhss.de +bhtbr.anonbox.net +bhuxp.org +bhuyarey.ga +bhuyarey.ml +bhw.emlpro.com +bhwjl.anonbox.net +bhyjc.anonbox.net +bhz2v.anonbox.net +bi.laste.ml +bi.name.tr +bi.spymail.one +bi2hr.anonbox.net +bi5h6.anonbox.net +bi5zg.anonbox.net +bi6st.anonbox.net +bia29564886.xyz +bialode.com +bialy.agencja-csk.pl +bialystokkabury.pl +bian.capital +bianco.ml +biasalah.me +biasdocore.com +bibars.cloud +bibbiasary.info +bibi.biz.st +bibicaba.cf +bibicaba.ga +bibicaba.gq +bibicaba.ml +biblia.chat +bibliotekadomov.com +bibooo.cf +bibpond.com +bibucabi.cf +bibucabi.ga +bibucabi.gq +bibucabi.ml +bicrun.info +bictise.com +bidcoin.cash +biden.com +bidly.pw +bidoggie.net +bidoubidou.com +bidourlnks.com +bidprint.com +bidu.cf +bidu.gq +bidvmail.cf +bieberclub.net +biedra.pl +biegamsobie.pl +bielaozai.cc +bielizna.com +bieliznasklep.net +bienhoamarketing.com +bieszczadyija.info.pl +bifayl.com +bifl.app +big-max24.info +big-post.com +big-sales.ru +big.blatnet.com +big.marksypark.com +big00010mine.cf +big0001mine.cf +big0002mine.cf +big0004mine.cf +big0005mine.cf +big0006mine.cf +big0007mine.cf +big0009mine.cf +big1.us +bigatel.info +bigbang-1.com +bigbangfairy.com +bigbash.ru +bigbobs.com +bigbonus.com +bigboobz.tk +bigbowltexas.info +bigbreast-nl.eu +bigcloudmail.com +bigcoz.com +bigcrop.pro +bigdat.site +bigddns.com +bigddns.net +bigddns.org +bigdo.art +bigdogfrontseat.com +bigdresses.pw +bigfangroup.name +bigfastmail.com +bigfatmail.info +bigg.pw +biggerbuttsecretsreview.com +biggestdeception.com +biggestgay.com +biggestresourcelink.info +biggestresourceplanning.info +biggestresourcereview.info +biggestresourcetrek.info +biggestyellowpages.info +bighome.site +bighost.bid +bighost.download +bigideamastermindbyvick.com +bigimages.pl +biginfoarticles.info +bigjoes.co.cc +bigkv.anonbox.net +biglinks.me +biglive.asia +bigmail.info +bigmail.org +bigmine.ru +bigmir.net +bigmoist.me +bigmon.ru +bigmountain.peacled.xyz +bigonla.com +bigorbust.net +bigpicnic.ru +bigpicturetattoos.com +bigpons.com +bigppnd.com +bigprofessor.so +bigredmail.com +bigrocksolutions.com +bigseopro.co.za +bigsizetrend.com +bigsocalfestival.info +bigstart.us +bigstring.com +bigtetek.cf +bigtetek.ga +bigtetek.gq +bigtetek.ml +bigtetek.tk +bigtokenican2.hmail.us +bigtokenican3.hmail.us +bigtuyul.me +bigua.info +bigwhoop.co.za +bigwiki.xyz +bigyand.ru +bigzobs.com +bih6v.anonbox.net +bihoj.anonbox.net +bii6u.anonbox.net +biiba.com +biishops.tk +biivo.anonbox.net +bij.pl +bikebees.net +bikedid.com +bikerbrat.com +bikerleathers.com +bikey.site +bikinakun.com +bikingwithevidence.info +bikinibrazylijskie.com +bikramsinghesq.com +bilans-bydgoszcz.pl +bilans-zamkniecia-roku.pl +bilcnk.online +bilderbergmeetings.org +bildirio.com +biletsavia.ru +bilgisevenler.com +bilhantokatoglu.shop +biliberdovich.ru +bilibili.bar +bill-consolidation.info +bill.pikapiq.com +billiamendment.xyz +billieb.shop +billiges-notebook.de +billings.systems +billionvj.com +billisworth.shop +billkros.net.pl +billpoisonbite.website +billseo.com +billyjoellivetix.com +billythekids.com +bilo.com +biltmoremotorcorp.com +bimbingan.store +bimgir.net +bimj.emlpro.com +bimt.us +bin-ich.com +bin-wieder-da.de +binafex.com +binanceglobalpoolspro.cloud +binancepools.cloud +binans.su +binary-bonus.net +binaryoptions60sec.com +binarytrendprofits.com +binboss.ru +binbug.xyz +binbx.net +bindboundbound.emlhub.com +bindrup62954.co.pl +binech.com +binexx.com +binfest.info +bingakilo.ga +bingakilo.ml +binge.com +binghuodao.com +bingok.site +bingotonight.co.uk +bingzone.net +binhduong.bar +binhtichap.com.vn +binhvt.com +binich.com +binict.org +binka.me +binkmail.com +binnary.com +binnerbox.info +binoculars-rating.com +binoma.biz +binsh.kro.kr +binus.eu.org +bio-consultant.com +bio-muesli.info +bio-muesli.net +bio.clothing +bio.toys +bio.trade +bio123.net +bioauto.info +biobreak.net +biodieselrevealed.com +biofuelsmarketalert.info +biohazardeliquid.com +biohorta.com +biojuris.com +biomails.com +biometicsliquidvitamins.com +bioncore.com +bione.co +biorezonans-warszawa.com.pl +biorocketblasttry.com +biorosen1981.ru +biosciptrx.com +biosor.cf +biosoznanie.ru +biostatistique.com +biotasix.com +biotechind.com +bioturell.com +biowerk.com +biowey.com +biozul.com +bipane.com +bipasesores.info +bipko.info +bipochub.com +biqcy.anonbox.net +birbakmobilya.com +bird-gifts.net +bird.favbat.com +birdbabo.com +birdion.com +birdseed.shop +birdsfly.press +birecruit.com +birige.com +birkinbags.info +birkinhermese.com +birmail.at +birmandesign.com +birminghamfans.com +biro.gq +biro.ml +biro.tk +birota.com +birtattantuni.com +birthassistant.com +birthday-gifts.info +birthday-party.info +birthdaypw.com +birthelange.me +birthwar.site +birtmail.com +biruni.cc.marun.edu.tr +biruni.cc.mdfrun.edu.tr +biscoine.com +biscuitvn.xyz +biscuitvn15.xyz +biscutt.us +biser.woa.org.ua +bisevents.com +bishop.com +bishoptimon74.com +biskampus.ga +biskvitus.ru +bisongl.com +bissabiss.com +bistonplin.com +bisuteriazaiwill.com +bisxk.anonbox.net +bit-degree.com +bit-ion.net +bit-tehnika.in.ua +bit.laste.ml +bit2tube.com +bitbet.xyz +bitchmail.ga +bitcoin2014.pl +bitcoinadvocacy.com +bitcoinandmetals.com +bitcoinbet.us +bitcoinbonus.org +bitcoinexchange.cash +bitcoinsera.com +bitdownloader.su +bitems.com +bitemyass.com +bitesatlanta.com +bitfami.com +bitini.club +bitlly.xyz +bitlove.world +bitmens.com +bitmonkey.xyz +bitofee.com +bitoini.com +bitok.co.uk +bitonc.com +bitpost.site +bitrf.anonbox.net +bitrix-market.ru +bitrixmail.xyz +bitsslto.xyz +bitterpanther.info +bitterrootrestoration.com +bittiry.com +biturl.monster +biturl.one +bitvoo.com +bitwerke.com +bitwhites.top +bitx.nl +bityemedia.com +bitymails.us +bitzonasy.info +biumemail.com +biuranet.pl +biuro-naprawcze.pl +bivforbrooklyn.com +biwf5.anonbox.net +bixolabs.com +biyac.com +biycy.anonbox.net +biz-art.biz +biz.st +bizalem.com +bizalon.com +bizatop.com +bizax.org +bizbiz.tk +bizbre.com +bizcomail.com +bizfests.com +bizhacks.org +bizimails.com +bizimalem-support.de +bizisstance.com +bizmastery.com +bizml.ru +bizmud.com +bizplace.info +bizsa.anonbox.net +bizsearch.info +bizsportsnews.com +bizsportsonlinenews.com +bizuteriazklasa.pl +bizuteryjkidlawosp.pl +bizybin.com +bizybot.com +bizzinfos.info +bizzz.pl +bj.emlpro.com +bj4oq.anonbox.net +bj57z.anonbox.net +bj7dd.anonbox.net +bj7z7.anonbox.net +bjbekhmej.pl +bjcxw.anonbox.net +bjdhrtri09mxn.ml +bjgpond.com +bjimu.anonbox.net +bjjj.ru +bjjmu.anonbox.net +bjkh.yomail.info +bjmd.cf +bjorn-frantzen.art +bjorwi.click +bjp.emltmp.com +bjpkj.anonbox.net +bjr3i.anonbox.net +bjs-team.com +bjsiequykz.ga +bjt35.anonbox.net +bjtbv.anonbox.net +bjtj.emlpro.com +bjurdins.tech +bjuyg.anonbox.net +bjvya.anonbox.net +bjwnh.anonbox.net +bjwx.emltmp.com +bjx2m.anonbox.net +bjxej.anonbox.net +bjxr4.anonbox.net +bk.emlhub.com +bk4cl.anonbox.net +bk5er.anonbox.net +bk73d.anonbox.net +bk7vw.anonbox.net +bka7z.anonbox.net +bkahz.anonbox.net +bkbgzsrxt.pl +bkbxr.anonbox.net +bkbyj.anonbox.net +bkdmaral.pl +bkegfwkh.agro.pl +bkfarm.fun +bkhb.emlhub.com +bki7rt6yufyiguio.ze.am +bkijhtphb.pl +bkjew.anonbox.net +bkjmtp.fun +bkkmaps.com +bkkpkht.cf +bkkpkht.ga +bkkpkht.gq +bkkpkht.ml +bko.kr +bkoil.anonbox.net +bkp77.anonbox.net +bkrointernational.site +bkru.freeml.net +bkru.spymail.one +bktps.com +bktyo.anonbox.net +bky168.com +bkyam.anonbox.net +bl.ctu.edu.gr +bl.freeml.net +bl.opheliia.com +bl36v.anonbox.net +bl5ic2ywfn7bo.cf +bl5ic2ywfn7bo.ga +bl5ic2ywfn7bo.gq +bl5ic2ywfn7bo.ml +bl5ic2ywfn7bo.tk +blablabla24.com +blablaboiboi.com +blablaboyzs.com +blabladoizece.com +blablo2fosho.com +blablop.com +blaboyhahayo.com +blachstyl.pl +black-stones.ru +black.bianco.cf +blackbeshop.com +blackbird.ws +blackbookdate.info +blackcock-finance.com +blackdiamondcc.org +blackdragonfireworks.com +blackdrebeats.info +blacked-com.ru +blackfridayadvice2011.cc +blackgate.tk +blackgoldagency.ru +blackhat-seo-blog.com +blackhole.djurby.se +blackhole.targeter.nl +blackinbox.com +blackinbox.org +blacklist.city +blackmagicblog.com +blackmagicdesign.in +blackmagicspells.co.cc +blackmail.ml +blackmarket.to +blackpeople.xyz +blackrockasfaew.com +blacksarecooleryo.com +blackseo.top +blackshipping.com +blacksong.pw +blacktopindustries.net +blacktopscream.com +blackwood-online.com +bladeandsoul-gold.us +bladesmail.net +blah.com +blak.net +blakasuthaz52mom.tk +blakeconstruction.net +blakemail.men +blan.tech +blancheblatter.co +blanchhouse.co +blandcoconstruction.com +blangbling784yy.tk +blank.com +blarakfight67dhr.ga +blarneytones.com +blassed.site +blastdeals.com +blastmail.biz +blastol.com +blastxlreview.com +blatablished.xyz +blatopgunfox.com +blavixm.ie +blaxion.com +blazeent.com +blazeli.com +blazestreamz.xyz +blbecek.ml +blbkf.anonbox.net +blbt5.anonbox.net +bldemail.com +bleactordo.xyz +bleb.com +bleblebless.pl +bleedbledbled.emlpro.com +bleib-bei-mir.de +blenched.com +blendercompany.com +blendertv.com +blendlog.com +blenro.com +blerf.com +blerg.com +blespi.com +blesscup.cf +blessingvegetarian.com +bleubers.com +blexx.eu +blf4z.anonbox.net +blfjp.anonbox.net +bli.muvilo.net +blibrary.site +blic.pl +bliejeans.com +blightpro.org +blinkmatrix.com +blinkster.info +blinkweb.bid +blinkweb.top +blinkweb.trade +blinkweb.website +blinkweb.win +blip.ch +blip.ovh +blit.emlhub.com +blitzed.space +blitzprogripthatshizz.com +bljekdzhekkazino.org +blkf6.anonbox.net +bllibl.com +bllsouth.net +blm.emltmp.com +blm7.net +blm9.net +blndrco.com +blnkt.net +bloatbox.com +bloc.quebec +block.bdea.cc +block521.com +blockbusterhits.info +blockdigichain.com +blocked-drains-bushey.co.uk +blockfilter.com +blockgemini.org +blocktapes.com +blockthatmagefcjer.com +blockzer.com +bloconprescong.xyz +blocquebecois.quebec +blog-1.ru +blog-galaxy.com +blog.annayake.pl +blog.blatnet.com +blog.cowsnbullz.com +blog.metal-med.pl +blog.net.gr +blog.oldoutnewin.com +blog.poisedtoshrike.com +blog.quirkymeme.com +blog.sjinks.pro +blog.yourelection.net +blog4us.eu +blogav.ru +blogdiary.info +blogdiary.live +blogdigity.fun +blogdigity.info +blogerspace.com +blogerus.ru +blogforwinners.tk +bloggermailinfo.info +bloggermania.info +bloggerninja.com +bloggersxmi.com +bloggg.de +blogging.com +bloggingargentina.com.ar +bloggingnow.club +bloggingnow.info +bloggingnow.pw +bloggingnow.site +bloggingpro.fun +bloggingpro.host +bloggingpro.info +bloggingpro.pw +bloggorextorex.com +bloggybro.cc +bloghangbags.com +bloginator.tk +blogketer.com +blogmaster.me +blogmastercom.net +blogmyway.org +blogneproseo.ru +blognews.com +blogoagdrtv.pl +blogomaiaidefacut.com +blogomob.ru +blogonews2015.ru +blogos.com +blogos.net +blogox.net +blogpentruprostisicurve.com +blogroll.com +blogrtui.ru +blogs.com +blogschool.edu +blogshoponline.com +blogspam.ro +blogster.host +blogster.info +blogthis.com +blogwithbloggy.net +blogwithtech.com +blogxxx.biz +bloheyz.com +blohsh.xyz +blokom.com +blolohaibabydot.com +blolololbox.com +blomail.com +blomail.info +blondecams.xyz +blondemorkin.com +blondmail.com +blonnik1.az.pl +blood-pressure.tipsinformationandsolutions.com +bloodchain.org +bloodofmybrother.com +bloodonyouboy.com +bloodyanybook.site +bloodyanylibrary.site +bloodyawesomebooks.site +bloodyawesomefile.site +bloodyawesomefiles.site +bloodyawesomelib.site +bloodyawesomelibrary.site +bloodyfreebook.site +bloodyfreebooks.site +bloodyfreelib.site +bloodyfreetext.site +bloodyfreshbook.site +bloodyfreshfile.site +bloodygoodbook.site +bloodygoodbooks.site +bloodygoodfile.site +bloodygoodfiles.site +bloodygoodlib.site +bloodygoodtext.site +bloodynicebook.site +bloodynicetext.site +bloodyrarebook.site +bloodyrarebooks.site +bloodyrarelib.site +bloodyraretext.site +bloodysally.xyz +bloog-24.com +bloog.me +bloogs.space +bloomning.com +bloomning.net +bloomspark.com +blooops.com +blop.bid +bloq.ro +bloqstock.com +blosell.xyz +bloszone.com +blouseness.com +blow-job.nut.cc +blox.eu +bloxersmkt.shop +bloxter.cu.cc +blpzx.anonbox.net +blqthexqfmmcsjc6hy.cf +blqthexqfmmcsjc6hy.ga +blqthexqfmmcsjc6hy.gq +blqthexqfmmcsjc6hy.ml +blqthexqfmmcsjc6hy.tk +blssmly.com +blst.gov +bltiwd.com +blue-mail.org +blue-rain.org +bluebasketbooks.com.au +blueboard.careers +bluebonnetrvpark.com +bluebottle.com +bluechipinvestments.com +bluecitynews.com +blueco.top +bluedelivery.store +bluedumpling.info +blueeggbakery.com +bluefishpond.com +bluefriday.top +bluehotmail.com +bluejansportbackpacks.com +bluejaysjerseysmart.com +bluelawllp.com +bluemail.my +bluenet.ro +bluenetfiles.com +blueoceanrecruiting.com +blueonder.co.uk +bluepills.pp.ua +blueportalmail.com +blueright.net +bluesestodo.com +bluesitecare.com +blueskyangel.de +blueskydogsny.com +bluesmail.pw +bluetoothbuys.com +bluewerks.com +bluewin.cx +blueyander.co.uk +blueyi.com +blueynder.co.uk +blueyoder.co.uk +blueyomder.co.uk +blueyondet.co.uk +blueyoner.co.uk +blueyounder.co.uk +bluffersguidetoit.com +blulapka.pl +blumenkranz78.glasslightbulbs.com +bluni.anonbox.net +blurbulletbody.website +blurmail.net +blurme.net +blurp.tk +blurpemailgun.bid +blutig.me +bluwurmind234.cf +bluwurmind234.ga +bluwurmind234.tk +blvtq.anonbox.net +bm.emlpro.com +bm0371.com +bm2grihwz.pl +bm2md.anonbox.net +bm4e7.anonbox.net +bm4gm.anonbox.net +bm4jb.anonbox.net +bm4zj.anonbox.net +bm5.biz +bm5.live +bmaker.net +bmale.com +bmchsd.com +bmcoq.anonbox.net +bme.spymail.one +bmfeq.anonbox.net +bmgm.info +bmmh.com +bmo55.anonbox.net +bmobilerk.com +bmomento.com +bmonlinebanking.com +bmpk.org +bmqu6.anonbox.net +bmsh.dropmail.me +bmsojon4d.pl +bmsus.anonbox.net +bmtx47.com +bmuss.com +bmvak.anonbox.net +bmw-ag.cf +bmw-ag.ga +bmw-ag.gq +bmw-ag.ml +bmw-ag.tk +bmw-i8.gq +bmw-mini.cf +bmw-mini.ga +bmw-mini.gq +bmw-mini.ml +bmw-mini.tk +bmw-rollsroyce.cf +bmw-rollsroyce.ga +bmw-rollsroyce.gq +bmw-rollsroyce.ml +bmw-rollsroyce.tk +bmw-service-mazpol.pl +bmw-x5.cf +bmw-x5.ga +bmw-x5.gq +bmw-x5.ml +bmw-x5.tk +bmw-x6.ga +bmw-x6.gq +bmw-x6.ml +bmw-x6.tk +bmw-z4.cf +bmw-z4.ga +bmw-z4.gq +bmw-z4.ml +bmw-z4.tk +bmw4life.com +bmw4life.edu +bmwgroup.cf +bmwgroup.ga +bmwgroup.gq +bmwgroup.ml +bmwinformation.com +bmwmail.pw +bmx5s.anonbox.net +bmx7z.anonbox.net +bmyb5.anonbox.net +bmycm.anonbox.net +bn.laste.ml +bn254.anonbox.net +bn3mo.anonbox.net +bn5me.anonbox.net +bnbme.info +bnbme.tv +bnckms.cf +bnckms.ga +bnckms.gq +bnckms.ml +bncoastal.com +bnessa.com +bnf.emlpro.com +bnfgtyert.com +bnghdg545gdd.gq +bniwpwkke.site +bnj52.anonbox.net +bnm612.com +bnmlp.anonbox.net +bnny4.anonbox.net +bnoko.com +bnote.com +bnovel.com +bnq2k.anonbox.net +bnrlner.shop +bnrmn.anonbox.net +bnrsy.anonbox.net +bntjo.anonbox.net +bnuis.com +bnv0qx4df0quwiuletg.cf +bnv0qx4df0quwiuletg.ga +bnv0qx4df0quwiuletg.gq +bnv0qx4df0quwiuletg.ml +bnv0qx4df0quwiuletg.tk +bnx53.anonbox.net +bnxuh.anonbox.net +bnyhr.us +bnyzw.info +bnz.dropmail.me +bnzlu.anonbox.net +bo.freeml.net +bo6yw.anonbox.net +bo7gd.anonbox.net +bo7uolokjt7fm4rq.cf +bo7uolokjt7fm4rq.ga +bo7uolokjt7fm4rq.gq +bo7uolokjt7fm4rq.ml +bo7uolokjt7fm4rq.tk +boacoco.cf +boacreditcard.org +boagasudayo.com +boaine.com +boamei.com +boanrn.com +boardth.com +boastfullaces.top +boastfusion.com +boatcoersdirect.net +boater-x.com +boatmail.us +boatmonitoring.com +boatparty.today +bob.inkandtonercartridge.co.uk +bobablast.com +bobandvikki.club +bobbydcrook.com +bobbytc.cc +bobethomas.com +bobfilm.xyz +bobfilmclub.ru +bobgf.ru +bobgf.store +bobkhatt.cloud +bobmail.info +bobmarshallforcongress.com +bobmurchison.com +bobocooler.com +bobohieu.tk +boborobocobo.com +bobq.com +bobs.ca +bobs.dyndns.org +bocaneyobalac.com +bocapies.com +bocav.com +bocba.com +boccelmicsipitic.com +boceuncacanar.com +bocigesro.xyz +bocil.tk +bocilaws.club +bocilaws.online +bocilaws.shop +bocldp7le.pl +bocps.biz +bodachina.com +bodeem.com +bodelapen.ovh +bodgj.anonbox.net +bodhi.lawlita.com +bodlet.com +bodmod.ga +bodog-asia.net +bodog-poker.net +bodog180.net +bodog198.net +body55.info +bodyandfaceaestheticsclinic.com +bodybikinitips.com +bodybuildingdieta.co.uk +bodybuildings24.com +bodydiamond.com +bodylasergranada.com +bodyplanes.com +bodyscrubrecipes.com +bodystyle24.de +bodysuple.top +boemen.com +boerakemail.com +boerneisd.com +boero.info +boersy.com +boeutyeriterasa.cz.cc +bofrateyolele.com +bofthew.com +boftm.com +bofv.emlhub.com +bog.emlhub.com +bog3m9ars.edu.pl +bogemmail.com +bogger.com +bogneronline.ru +bogotadc.info +bogotaredetot.com +bogsmail.me +bogusflow.com +boh3n.anonbox.net +bohani.cf +bohani.ga +bohani.gq +bohani.ml +bohani.tk +bohemian-pictures.de +bohemiantoo.com +bohgenerate.com +bohotmail.com +boight.com +boigroup.ga +boiledment.com +boimail.com +boimail.tk +boinformado.com +boinkmas.top +boinnn.net +boiserockssocks.com +boixi.com +bojogalax.ga +bokcx.anonbox.net +bokgumail.kr +bokikstore.com +bokilaspolit.tk +bokllhbehgw9.cf +bokllhbehgw9.ga +bokllhbehgw9.gq +bokllhbehgw9.ml +bokllhbehgw9.tk +bokpg.anonbox.net +boks4u.gq +boksclubibelieve.online +bokstone.com +bola.mom +bola228run.com +bola389.bid +bola389.info +bola389.live +bola389.net +bola389.org +bola389.top +bolalogam.com +bolamas88.online +bolaymay.top +bold.ovh +boldhut.com +bolg-nedv.ru +bolinylzc.com +bolitart.site +boliviya-nedv.ru +bollouisvuittont.info +bolomycarsiscute.com +bolsosalpormayor.com +boltoffsite.com +bomaioortfolio.cloud +bombamail.icu +bombaya.com +bombsquad.com +bommails.ml +bomnet.net +bomoads.com +bomukic.com +bonacare.com +bonackers.com +bonbon.net +bondjol.com +bondlayer.org +bondmail.men +bondrewd.cf +bondsphere.lat +bone7.anonbox.net +bonfunrun15.site +bongcs.com +bongda.vin +bongo.gq +bongobank.net +bongobongo.cf +bongobongo.flu.cc +bongobongo.ga +bongobongo.gq +bongobongo.igg.biz +bongobongo.ml +bongobongo.nut.cc +bongobongo.tk +bongobongo.usa.cc +bongsoon.store +bonicious.xyz +boningly.com +bonnellproject.org +bonobo.email +bonrollen.shop +bonusess.me +bonuspharma.pl +bonwear.com +boobies.pro +boofx.com +boogiejunction.com +booglecn.com +book.bthow.com +book178.tk +book4money.com +booka.info +booka.press +bookaholic.site +bookb.site +bookc.site +bookd.press +bookd.site +bookea.site +bookec.site +bookee.site +bookef.site +bookeg.site +bookeh.site +bookej.site +bookek.site +bookel.site +bookep.site +bookeq.site +booket.site +bookeu.site +bookev.site +bookew.site +bookex.site +bookez.site +bookf.site +bookg.site +bookgame.org +bookh.site +booki.space +bookia.site +bookib.site +bookic.site +bookid.site +bookig.site +bookih.site +bookii.site +bookij.site +bookik.site +bookil.site +bookim.site +booking-event.de +bookings.onl +bookingzagreb.com +bookip.site +bookiq.site +bookir.site +bookiu.site +bookiv.site +bookiw.site +bookix.site +bookiy.site +bookj.site +bookkeepr.ca +bookking.club +bookl.site +booklacer.site +bookliop.xyz +bookmarklali.win +bookmarks.edu.pl +booko.site +bookofexperts.com +bookofhannah.com +bookoneem.ga +bookp.site +bookprogram.us +bookq.site +bookquoter.com +books-bestsellers.info +books-for-kindle.info +books.heartmantwo.com +books.lakemneadows.com +books.marksypark.com +books.oldoutnewin.com +books.popautomated.com +booksb.site +booksd.site +bookse.site +booksf.site +booksg.site +booksh.site +booksharedpdf.com +booksi.site +booksj.site +booksl.site +booksm.site +bookso.site +booksohu.com +booksp.site +bookspack.site +bookspre.com +booksq.site +booksr.site +bookst.site +booksv.site +booksw.site +booksx.site +booksz.site +bookt.site +bookthemmore.com +booktonlook.com +booktoplady.com +booku.press +booku.site +bookua.site +bookub.site +bookuc.site +bookud.site +bookue.site +bookuf.site +bookug.site +bookv.site +bookwork.us +bookwormsboutiques.com +bookx.site +bookyah.com +bookz.site +bookz.space +booleserver.mobi +boombeachgenerator.cf +boombeats.info +boomerinternet.com +boomsaer.com +boomykqhw.pl +boomzik.com +booooble.com +boopmail.com +boopmail.info +boosterclubs.store +boosterdomains.tk +boostme.es +boostoid.com +bootax.com +bootcampmania.co.uk +bootdeal.com +bootiebeer.com +bootkp8fnp6t7dh.cf +bootkp8fnp6t7dh.ga +bootkp8fnp6t7dh.gq +bootkp8fnp6t7dh.ml +bootkp8fnp6t7dh.tk +boots-eshopping.com +bootsaletojp.com +bootsance.com +bootscanadaonline.info +bootsformail.com +bootsgos.com +bootshoes-shop.info +bootshoeshop.info +bootson-sale.info +bootsosale.com +bootsoutletsale.com +bootssale-uk.info +bootssheepshin.com +bootssl.com +bootstringer.com +bootsukksaleofficial1.com +bootsvalue.com +booty.com +bootybay.de +boow.cf +boow.ga +boow.gq +boow.ml +boow.tk +booyabiachiyo.com +bopff.anonbox.net +bopra.xyz +boprasimes.com +bopunkten.se +boraa.xyz +borakvalley.com +boran.ga +boranora.com +borderflowerydivergentqueen.top +bordermail.com +bordiers.com +borealinbox.com +bored.lol +boredlion.com +borefestoman.com +boreorg.com +borexedetreaba.com +borged.com +borged.net +borged.org +borgish.com +borgopeople.it +borguccioutlet1.com +boriarynate.cyou +boringity.com +boris4x4.com +bornboring.com +boromirismyherobro.com +borsebbysolds.com +borseburberryoutletitt.com +borseburbery1.com +borseburberyoutlet.com +borsebvrberry.com +borsechan1.com +borsechane11.com +borsechaneloutletonline.com +borsechaneloutletufficialeit.com +borsechanemodaitaly.com +borsechanlit.com +borsechanlit1.com +borsechanlit2.com +borsechanuomomini1.com +borsechanuomomini2.com +borsechelzou.com +borseeguccioutlet.com +borseelouisvuittonsitoufficiale.com +borsegucc1outletitaly.com +borsegucciitalia3.com +borseguccimoda.com +borsegucciufficialeitt.com +borseitaliavendere.com +borseitalychane.com +borseitguccioutletsito4.com +borselouisvuitton-italy.com +borselouisvuitton5y.com +borselouisvuittonitalyoutlet.com +borselouvutonit9u.com +borselvittonit3.com +borselvoutletufficiale.com +borsemiumiuoutlet.com +borsesvuitton-it.com +borsevuittonborse.com +borsevuittonit1.com +bos-ger-nedv.ru +bos21.club +bosahek.com +bosakun.com +bosdal.com +bosgrit.finance +bosgrit.online +bosinaa.com +bosjin.com +boss.bthow.com +boss.cf +boss901.com +bossless.net +bossmail.de +bossman.chat +bossmanjack.lol +bossmanjack.shop +bossmanjack.store +bossmanjack.xyz +bosterpremium.com +bostonhydraulic.com +bostonplanet.com +bot.nu +bot3n.anonbox.net +botasuggm.com +botasuggsc.com +botayroi.com +botbilling.com +botfed.com +botgetlink.com +botgetlink.net +bothgames.com +bothris.pw +botmetro.com +botnet.my.id +botox-central.com +bots.com +botsoko.com +bottesuggds.com +bottomav.com +botz.online +bougenville.ga +boukshilf.com +boulderback.com +bouldercycles.com +boun.cr +bouncr.com +boundac.com +bountifulgrace.org +bourdeniss.gr +boursiha.com +bouss.net +boussagay.tk +boutiqueenlignefr.info +boutsary.site +bovinaisd.net +bovu7.anonbox.net +bowamaranth.website +bowba.me +bowlinglawn.com +bowtrolcolontreatment.com +box-email.ru +box-emaill.info +box-mail.ru +box-mail.store +box.comx.cf +box.ra.pe +box.yadavnaresh.com.np +box10.pw +box4mls.com +boxa.host +boxa.shop +boxe.life +boxem.ru +boxem.store +boxermail.info +boxervibe.us +boxfi.uk +boxformail.in +boximail.com +boxless.info +boxlet.ru +boxlet.store +boxlogas.com +boxloges.com +boxlogos.com +boxmach.com +boxmail.co +boxmail.lol +boxmail.store +boxmailbox.club +boxmailers.com +boxmailvn.com +boxmailvn.space +boxnavi.com +boxnexa.com +boxofficevideo.com +boxomail.live +boxphonefarm.net +boxppy.ru +boxs5.anonbox.net +boxsmoke.com +boxtemp.com.br +boxtwos.com +boy-scout-slayer.com +boyaga.com +boyah.xyz +boyalovemyniga.com +boycey.space +boycie.space +boyfargeorgica.com +boyfriendmail.tk +boyoboygirl.com +boyscoutsla.org +boysteams.site +boythatescaldqckly.com +boyw3.anonbox.net +boyztomenlove4eva.com +bozenarodzenia.pl +bp.dropmail.me +bp.yomail.info +bp2jn.anonbox.net +bp3xxqejba.cf +bp3xxqejba.ga +bp3xxqejba.gq +bp3xxqejba.ml +bp3xxqejba.tk +bp560.com +bpda.de +bpda1.com +bpdf.site +bpdfw.anonbox.net +bped6.anonbox.net +bper.cf +bper.ga +bper.gq +bper.tk +bpg.emlpro.com +bpghmag.com +bpgt.yomail.info +bph4i.anonbox.net +bpham.info +bphwk.anonbox.net +bpj.emlpro.com +bpl25.anonbox.net +bplinlhunfagmasiv.com +bpmsound.com +bpn2t.anonbox.net +bpnd.laste.ml +bpngr.anonbox.net +bpo67.anonbox.net +bpool.site +bpornd.com +bppls.anonbox.net +bpq.mailpwr.com +bpqagency.xyz +bpr.emlpro.com +bpr4g.anonbox.net +bprfu.anonbox.net +bpsl.emltmp.com +bpsv.com +bpsx.laste.ml +bptfp.com +bptfp.net +bpunb.anonbox.net +bpvi.cf +bpvi.ga +bpvi.gq +bpvi.ml +bpvi.tk +bq.emlhub.com +bq.yomail.info +bq45o.anonbox.net +bq75i.anonbox.net +bq7n6.anonbox.net +bq7nr.anonbox.net +bq7ry.anonbox.net +bqaxcaxzc.com +bqaz.xyz +bqb76.anonbox.net +bqc4tpsla73fn.cf +bqc4tpsla73fn.ga +bqc4tpsla73fn.gq +bqc4tpsla73fn.ml +bqc4tpsla73fn.tk +bqcascxc.com +bqd6u.anonbox.net +bqe.pl +bqhonda.com +bqhost.top +bqi7k.anonbox.net +bqkqt.anonbox.net +bqlgv.anonbox.net +bqm2dyl.com +bqmjotloa.pl +bqmn.dropmail.me +bqn.yomail.info +bqnlk.anonbox.net +bqph.freeml.net +bqsaptri.ovh +bqv36.anonbox.net +bqvn.laste.ml +bqxy4.anonbox.net +bqyus.anonbox.net +br.dropmail.me +br.emltmp.com +br.mintemail.com +br2ow.anonbox.net +br5l6.anonbox.net +br67o.anonbox.net +br6qtmllquoxwa.cf +br6qtmllquoxwa.ga +br6qtmllquoxwa.gq +br6qtmllquoxwa.ml +br6qtmllquoxwa.tk +br88.trade +br880.com +braaapcross.com +brack.in +bracyenterprises.com +bradan.space +bragv.anonbox.net +braha.anonbox.net +brain-shop.online +brainbang.com +brainboostingsupplements.org +brainfoodmagazine.info +brainhard.net +brainloaded.com +brainme.site +brainown.com +brainpowernootropics.xyz +brainsworld.com +brainworm.ru +brainysoftware.net +brajer.pl +brakhman.ru +bralbrol.com +braloon.com +branchom.com +brand-app.biz +brand-like.site +brand8usa.com +brandalan.sbs +brandallday.net +brandbeuro.com +brandbuzzpromotions.com +brandcruz.com +brandednumber.com +branden1121.club +brandi.eden.aolmail.top +branding.goodluckwith.us +brandjerseys.co +brandnamewallet.com +brandoncommunications.com +brandonek.web.id +brandonivey.info +brandoza.com +brandsdigitalmedia.com +brandshield-ip.com +brandshoeshunter.com +brandupl.com +brandway.com.tr +brank.io +brankasmu.com +branorus.ru +bras-bramy.pl +braseniors.com +brashbeat.online +brasher29.spicysallads.com +brasil-empresas.com +brasil-nedv.ru +brasillimousine.com +brasilybelleza.com +brassband2.com +brasx.org +bratsey.com +bratwurst.dnsabr.com +braun4email.com +bravecoward.com +braveworkforce.com +bravohotel.webmailious.top +braynight.club +braynight.online +braynight.xyz +brayy.com +brazilbites.com +brazucasms.com +brazuka.ga +brazza.ru +brbqx.com +brbrasiltransportes.com +brclip.com +brd6w.anonbox.net +brdy5.anonbox.net +breadboardpies.com +breadtimes.press +breaite.com +break.ruimz.com +breakloose.pl +breaksmedia.com +breaktheall.org.ua +breakthru.com +breakwooden.vn +brealynnvideos.com +breanna.alicia.kyoto-webmail.top +breanna.kennedi.livemailbox.top +breathestime.org.ua +breazeim.com +breedaboslos.xyz +breeze.eu.org +breezyflight.info +brefmail.com +bregerter.org +breglesa.website +breitbandanbindung.de +breitlingsale.org +breka.orge.pl +brekai.nl +brendonweston.info +brenlova.com +brennendesreich.de +brentnunez.website +bresnen.net +bresslertech.com +brevisionarch.xyz +brevn.net +brewbuddies.website +brewstudy.com +brflix.com +brflk.com +brfw.com +brgo.ru +brgrid.com +briandbry.us +briarhillmontville.com +bribw.anonbox.net +brickoll.tk +brickrodeosteam.org +bricomedical.info +bridgeslearningcenter.com +briee.anonbox.net +briefalpha.org +briefbest.com +briefcase4u.com +briefcaseoffice.info +briefemail.com +brieffirst.com +briefkasten2go.de +briggsmarcus.com +brightadult.com +brightenmail.com +brighterbroome.org +brightsitetrends.com +brigittacynthia.art +brilleka.ml +brillionhistoricalsociety.com +brillob.com +bring-luck.pw +bringluck.pw +bringmea.org.ua +bringnode.xyz +brinkc.com +brinkvideo.win +brisbanelivemusic.com +brisbanelogistics.com +britainst.com +britbarnmi.ga +britemail.info +british-leyland.cf +british-leyland.ga +british-leyland.gq +british-leyland.ml +british-leyland.tk +britishintelligence.co.uk +britishpreschool.net +britneybicz.pl +britted.com +brixmail.info +brizzolari.com +brjh2.anonbox.net +brksea.com +brll.emltmp.com +brmailing.com +brmq.mailpwr.com +bro.fund +broablogs.online +broadbandninja.com +broadnetalliance.org +broadway-west.com +broccoli.store +brocell.com +brodcom.com +brodilla.email +brodzikowsosnowiec.pl +brogrammers.com +broilone.com +brokenion.com +brokenvalve.com +brokenvalve.org +brokeragedxb.com +bromailservice.xyz +bromeil.com +bromtedlicyc.xyz +broncomower.xyz +bronews.ru +bronix.ru +bronxarea.com +bronxcountylawyerinfo.com +bronze.blatnet.com +bronze.marksypark.com +brooklynbookfestival.mobi +brookshiers.com +brooksideflies.com +broothi.com +brosj.net +brostream.net +broszreforhoes.com +broted.site +brothercs6000ireview.org +brothershit.me +brownal.net +browndecorationlights.com +brownell150.com +browniesgoreng.com +brownieslumer.com +brownsvillequote.com +browsechat.eu +browseforinfo.com +browselounge.pl +brptb.anonbox.net +brqma.anonbox.net +brqup.anonbox.net +brrb.spymail.one +brrmail.gdn +brrval.com +brrvpuitu8hr.cf +brrvpuitu8hr.ga +brrvpuitu8hr.gq +brrvpuitu8hr.ml +brrvpuitu8hr.tk +brtby.anonbox.net +brtonthebridge.org +brtop.shop +bru-himki.ru +bru-lobnya.ru +brubank.club +brueyinl.emlhub.com +brufef.emlpro.com +brul6.anonbox.net +brunhilde.ml +brunomarsconcert2014.com +brunosamericangrill.com +brunsonline.com +bruson.ru +brutaldate.com +brutuscontingencia.site +bruzdownice-v.pl +brxe.dropmail.me +brxkf.anonbox.net +bryanlgx.com +bryantspoint.com +bryq.site +bryzwebcahw.ga +brzi.freeml.net +brzns.anonbox.net +brzydmail.ml +bs-evt.at +bs30.fun +bs4gq.anonbox.net +bs6bjf8wwr6ry.cf +bs6bjf8wwr6ry.ga +bs6bjf8wwr6ry.gq +bs6bjf8wwr6ry.ml +bs6mr.anonbox.net +bsaloving.com +bsb5u.anonbox.net +bsbhz1zbbff6dccbia.cf +bsbhz1zbbff6dccbia.ga +bsbhz1zbbff6dccbia.ml +bsbhz1zbbff6dccbia.tk +bsbvans.com.br +bsc.anglik.org +bscglobal.net +bschost.com +bscu.emlpro.com +bsderqwe.com +bsece.anonbox.net +bselek.website +bseomail.com +bsesrajdhani.com +bsezjuhsloctjq.cf +bsezjuhsloctjq.ga +bsezjuhsloctjq.gq +bsezjuhsloctjq.ml +bsezjuhsloctjq.tk +bshew.online +bshew.site +bsidesmn.com +bskbb.com +bskvzhgskrn6a9f1b.cf +bskvzhgskrn6a9f1b.ga +bskvzhgskrn6a9f1b.gq +bskvzhgskrn6a9f1b.ml +bskvzhgskrn6a9f1b.tk +bskyb.cf +bskyb.ga +bskyb.gq +bskyb.ml +bskyx.fun +bslvp.anonbox.net +bsmitao.com +bsml.de +bsmne.website +bsne.website +bsnea.shop +bsnmed.com +bsnow.net +bso.emlpro.com +bsomek.com +bsomy.anonbox.net +bspamfree.org +bspe5.anonbox.net +bspin.club +bspooky.com +bsquochoai.ga +bsrdf.anonbox.net +bssjz.anonbox.net +bst-72.com +bst5m.anonbox.net +bsuakrqwbd.cf +bsuakrqwbd.ga +bsuakrqwbd.gq +bsuakrqwbd.ml +bsuakrqwbd.tk +bsw.laste.ml +bsylmqyrke.ga +bsz.us +bt.dropmail.me +bt0zvsvcqqid8.cf +bt0zvsvcqqid8.ga +bt0zvsvcqqid8.gq +bt0zvsvcqqid8.ml +bt0zvsvcqqid8.tk +bt3019k.com +bt6fn.anonbox.net +btar.spymail.one +btarikarlinda.art +btb-notes.com +btbe.dropmail.me +btc-mail.net +btc.email +btc0003mine.tk +btc0004mine.cf +btc0005mine.tk +btc0010mine.tk +btc0011mine.ml +btc0012mine.cf +btc0012mine.ml +btc24.org +btcgivers.com +btcmail.pw +btcmail.pwguerrillamail.net +btcmod.com +btcposters.com +btcprestige.net +btcproductkey.com +btd4p9gt21a.cf +btd4p9gt21a.ga +btd4p9gt21a.gq +btd4p9gt21a.ml +btd4p9gt21a.tk +btf3z.anonbox.net +btfabricsdubai.com +btfhn.anonbox.net +btgmka0hhwn1t6.cf +btgmka0hhwn1t6.ga +btgmka0hhwn1t6.ml +btgmka0hhwn1t6.tk +btgx6.anonbox.net +bth2d.anonbox.net +btiho.anonbox.net +btinernet.com +btinetnet.com +btinteernet.com +btintenet.com +btinterbet.com +btinterne.com +btinterney.com +btinternrt.com +btintnernet.com +btintrtnet.com +btinyernet.com +btiternet.com +btizet.pl +btj.pl +btj2uxrfv.pl +btjia.net +btjkv.anonbox.net +btjz6.anonbox.net +btkylj.com +btlatamcolombiasa.com +btn.spymail.one +bto.freeml.net +bto.laste.ml +bto5u.anonbox.net +btoc.emlhub.com +btopenworl.com +btpd3.anonbox.net +btpes.anonbox.net +btrvo.anonbox.net +btsblock.com +btsese.com +btsi.dropmail.me +btsroom.com +btukskkzw8z.cf +btukskkzw8z.ga +btukskkzw8z.gq +btukskkzw8z.ml +btukskkzw8z.tk +btwn4.anonbox.net +btwyh.anonbox.net +btxfovhnqh.pl +btxyv.anonbox.net +btz3kqeo4bfpqrt.cf +btz3kqeo4bfpqrt.ga +btz3kqeo4bfpqrt.ml +btz3kqeo4bfpqrt.tk +bu.emlhub.com +bu.mintemail.com +bu.name.tr +bu2qebik.xorg.pl +bu43t.anonbox.net +buatwini.tk +buayapoker.online +buayapoker.xyz +bubblybank.com +bubkc.anonbox.net +bubmone.top +bucbdlbniz.cf +bucbdlbniz.ga +bucbdlbniz.gq +bucbdlbniz.ml +bucbdlbniz.tk +buccalmassage.ru +buccape.com +buchach.info +buchananinbox.com +buchhandlung24.com +buckeyeag.com +buckrubs.us +bucol.net +bucols.org +bucqr.anonbox.net +budakcinta.online +buday.htsail.pl +budaya-tionghoa.com +budayationghoa.com +budded.site +buddyfly.top +budemeadows.com +budgermile.rest +budgetblankets.com +budgetocean.com +budgetsuites.co +budgetted.com +budgjhdh73ctr.gq +budin.men +budistore.me +budk.spymail.one +budmen.pl +budokainc.com +budon.com +budowa-domu-rodzinnego.pl +budowadomuwpolsce.info +budowlaneusrem.com +budrem.com +buefkp11.edu.pl +buenosaires-argentina.com +buenosaireslottery.com +buerotiger.de +buffalo-poland.pl +buffalocolor.com +buffaloquote.com +buffemail.com +buffmxh.net +buffysmut.com +buford.us.to +bug.cl +bugfoo.com +bugmenever.com +bugmenot.com +bugmenot.ml +buhia.anonbox.net +buides.com +buildabsnow.com +building-bridges.com +buildingandtech.com +buildingfastmuscles.com +buildingstogo.com +buildrapport.co +buildsrepair.ru +buildwithbubble.com +buildyourbizataafcu.com +builtindishwasher.org +buissness.com +bujatv8.fun +bujd7.anonbox.net +buk24.anonbox.net +bukaaja.site +bukan.es +bukanimers.com +bukatv8.com +bukfq.anonbox.net +bukhariansiddur.com +bukkin.com +bukq.in.ua +bukutututul.xyz +bukv.site +bukwos7fp2glo4i30.cf +bukwos7fp2glo4i30.ga +bukwos7fp2glo4i30.gq +bukwos7fp2glo4i30.ml +bukwos7fp2glo4i30.tk +bulahxnix.pl +bulantoto.com +bulantoto.net +bulb.emlhub.com +bulemasukkarung.bar +bulkbacklinks.in +bulkbye.com +bulkcleancheap.com +bulkemailregistry.com +bulkers.com +bulksmsad.net +bullbeer.net +bullbeer.org +bullet1960.info +bullosafe.com +bullseyewebsitedesigns.com +bullstore.net +bulmp3.com +buloo.com +bulrushpress.com +bultoc.com +bulutdns.com +bum.net +buma.emltmp.com +bumail.site +bumblomti.gq +bumbuireng.xyz +bumppack.com +bumpymail.com +bunchofidiots.com +bund.us +bundes-li.ga +bundlesjd.com +bung.holio.day +bunga.net +bungabunga.cf +bungajelitha.art +bunirvjrkkke.site +bunkbedsforsale.info +bunkstoremad.info +bunlets.com +bunnyboo.it +bunsenhoneydew.com +buntatukapro.com +buntuty.cf +buntuty.ga +buntuty.ml +buomeng.com +buon.club +bupzv.anonbox.net +burakarda.xyz +burangir.com +burberry-australia.info +burberry-blog.com +burberry4u.net +burberrybagsjapan.com +burberryoutlet-uk.info +burberryoutletmodauomoit.info +burberryoutletsalezt.co.uk +burberryoutletsscarf.net +burberryoutletsshop.net +burberryoutletstore-online.com +burberryoutletukzt.co.uk +burberryoutletzt.co.uk +burberryukzt.co.uk +burberrywebsite.com +burcopsg.org +burdet.xyz +burem.studio +buremail.com +burgas.vip +burger56.ru +burgercentral.us +burgoscatchphrase.com +burjanet.ru +burjkhalifarent.com +burjnet.ru +burnacidgerd.com +burner-email.com +burnermail.io +burnfats.net +burniawa.pl +burnmail.ca +burnthespam.info +burobedarfrezensionen.com +burritos.ninja +bursa303.wang +bursa303.win +bursaservis.site +burstmail.info +burundipools.com +burung.store +bus-motors.com +bus9alizaxuzupeq3rs.cf +bus9alizaxuzupeq3rs.ga +bus9alizaxuzupeq3rs.gq +bus9alizaxuzupeq3rs.ml +bus9alizaxuzupeq3rs.tk +busantei.com +buscarlibros.info +buscarltd.com +bushdown.com +businclude.site +businesideas.ru +business-agent.info +business-degree.live +business-intelligence-vendor.com +business-sfsds-advice.com +business1300numbers.com +businessagent.email +businessbackend.com +businessbayproperty.com +businesscardcases.info +businesscell.network +businesscredit.xyz +businessfinancetutorial.com +businesshowtobooks.com +businesshowtomakemoney.com +businessideasformoms.com +businessinfo.com +businessinfoservicess.com +businessinfoservicess.info +businessmail.com +businessmoney.us +businessneo.com +businesspier.com +businessrex.info +businesssource.net +businessstate.us +businesssuccessislifesuccess.com +businessthankyougift.info +businesstutorialsonline.org +busniss.com +buspad.org +buspilots.com +bussinesa.app +bussinessemails.website +bussinessmail.info +bussitussi.com +bussitussi.net +bustamove.tv +bustayes.com +busume.com +busy-do-holandii24.pl +busydizzys.com +busykitchen.com +busyresourcebroker.info +but.bthow.com +but.lakemneadows.com +but.ploooop.com +but.poisedtoshrike.com +but.powds.com +butbetterthanham.com +butlercc.com +butter.cf +butter9x.com +buttliza.info +buttmonkey.com +buttonfans.com +buttonrulesall.com +buuu.com +buwt2.anonbox.net +buxap.com +buxiy.anonbox.net +buxod.com +buy-24h.net.ru +buy-acyclovir-4sex.com +buy-bags-online.com +buy-blog.com +buy-caliplus.com +buy-canadagoose-outlet.com +buy-car.net +buy-clarisonicmia.com +buy-clarisonicmia2.com +buy-furosemide-online-40mg20mg.com +buy-mail.eu +buy-nikefreerunonline.com +buy-steroids-canada.net +buy-steroids-europe.net +buy-steroids-paypal.net +buy-viagracheap.info +buy.blatnet.com +buy.lakemneadows.com +buy.marksypark.com +buy.poisedtoshrike.com +buy.tj +buy003.com +buy6more2.info +buyad.ru +buyairjordan.com +buyamf.com +buyamoxilonline24h.com +buyandsmoke.net +buyanessaycheape.top +buyapp.foo +buyapp.web.id +buyatarax-norx.com +buybacklinkshq.com +buybm.one +buycanadagoose-ca.com +buycannabisonlineuk.co.uk +buycaverta12pills.com +buycheapbeatsbydre-outlet.com +buycheapcipro.com +buycheapfacebooklikes.net +buycheapfireworks.com +buycialis-usa.com +buycialisusa.com +buycialisusa.org +buycialisz.xyz +buyclarisonicmiaoutlet.com +buyclarisonicmiasale.com +buycow.org +buycultureboxes.com +buycustompaper.review +buydefender.com +buydeltasoneonlinenow.com +buydfcat9893lk.cf +buydiabloaccounts.com +buydiablogear.com +buydiabloitem.com +buydubaimarinaproperty.com +buyedoewllc.com +buyer-club.top +buyeriacta10pills.com +buyessays-nice.org +buyfacebooklikeslive.com +buyfcbkfans.com +buyfiverrseo.com +buyfollowers247.com +buyfollowers365.co.uk +buygapfashion.com +buygenericswithoutprescription.com +buygolfclubscanada.com +buygolfmall.com +buygoods.com +buygoodshoe.com +buygooes.com +buygsalist.com +buyhairstraighteners.org +buyhardwares.com +buyhegotgame13.net +buyhegotgame13.org +buyhegotgame13s.net +buyhenryhoover.co.uk +buyhermeshere.com +buyingafter.com +buyintagra100mg.com +buyitforlife.app +buyjoker.com +buykarenmillendress-uk.com +buykdsc.info +buylaptopsunder300.com +buylevitra-us.com +buylevitra.website +buylikes247.com +buyliquidatedstock.com +buylouisvuittonbagsjp.com +buymichaelkorsoutletca.ca +buymileycyrustickets.com +buymoreplays.com +buymotors.online +buynewmakeshub.info +buynexiumpills.com +buynolvadexonlineone.com +buynow.host +buynowandgo.info +buyonlinestratterapills.com +buyordie.info +buypill-rx.info +buypq.anonbox.net +buypresentation.com +buyprice.co +buyprosemedicine.com +buyprotopic.name +buyproxies.info +buyraybansuk.com +buyreliablezithromaxonline.com +buyrenovaonlinemeds.com +buyreplicastore.com +buyresourcelink.info +buyrocaltrol.name +buyrx-pill.info +buyrxclomid.com +buysellonline.in +buysellsignaturelinks.com +buysomething.me +buysspecialsocks.info +buysteroids365.com +buyteen.com +buytramadolonline.ws +buytwitterfollowersreviews.org +buyu308.com +buyu491.com +buyu583.com +buyu826.com +buyusabooks.com +buyusdomain.com +buyusedlibrarybooks.org +buyviagracheapmailorder.us +buyviagraonline-us.com +buyviagru.com +buywinstrol.xyz +buywithoutrxpills.com +buyxanaxonlinemedz.com +buyyoutubviews.com +buzblox.com +buzersocia.tk +buziosbreeze.online +buzlin.club +buzzcluby.com +buzzcol.com +buzzcompact.com +buzzcut.ws +buzzdating.info +buzzedibles.org +buzznor.ga +buzzsocial.tk +buzztrucking.com +buzzuoso.com +buzzvirale.xyz +buzzzyaskz.site +bv.emlhub.com +bv3kl.anonbox.net +bv3su.anonbox.net +bvaak.anonbox.net +bvbeh.anonbox.net +bvbrw.anonbox.net +bvbwi.anonbox.net +bvc6g.anonbox.net +bvdt.com +bvhrk.com +bvhrs.com +bviab.anonbox.net +bvigo.com +bvio5.anonbox.net +bvlma.anonbox.net +bvlvd.anonbox.net +bvmjj.anonbox.net +bvmvbmg.co +bvngf.com +bvoxsleeps.com +bvp.yomail.info +bvqc5.anonbox.net +bvqjwzeugmk.pl +bvrs5.anonbox.net +bvstj.anonbox.net +bvttq.anonbox.net +bvvqctbp.xyz +bvwtu.anonbox.net +bvx3l.anonbox.net +bvxay.anonbox.net +bvya.mimimail.me +bvzoonm.com +bvzqt.anonbox.net +bw.freeml.net +bw2cn.anonbox.net +bw56t.anonbox.net +bwa33.net +bwaua.anonbox.net +bwbiw.anonbox.net +bweqvxc.com +bwfpg.anonbox.net +bwfvc.anonbox.net +bwfy5.anonbox.net +bwhdk.anonbox.net +bwhey.com +bwiv.emlpro.com +bwj4j.anonbox.net +bwm7n.anonbox.net +bwmail.us +bwmyga.com +bwn6x.anonbox.net +bwndl.anonbox.net +bwrqi.anonbox.net +bwtdmail.com +bwwbt.anonbox.net +bwwqr.anonbox.net +bwwsb.com +bwwsrvvff3wrmctx.cf +bwwsrvvff3wrmctx.ga +bwwsrvvff3wrmctx.gq +bwwsrvvff3wrmctx.ml +bwwsrvvff3wrmctx.tk +bwycn.anonbox.net +bwyv.com +bwzemail.eu +bwzemail.in +bwzemail.top +bwzemail.xyz +bx.laste.ml +bx43d.anonbox.net +bx6r9q41bciv.cf +bx6r9q41bciv.ga +bx6r9q41bciv.gq +bx6r9q41bciv.ml +bx6r9q41bciv.tk +bx6sm.anonbox.net +bx7rr.anonbox.net +bx8.pl +bx9puvmxfp5vdjzmk.cf +bx9puvmxfp5vdjzmk.ga +bx9puvmxfp5vdjzmk.gq +bx9puvmxfp5vdjzmk.ml +bx9puvmxfp5vdjzmk.tk +bxazp.anonbox.net +bxbofvufe.pl +bxbqrbku.xyz +bxcsr.anonbox.net +bxdvb.anonbox.net +bxerq.anonbox.net +bxfmtktkpxfkobzssqw.cf +bxfmtktkpxfkobzssqw.ga +bxfmtktkpxfkobzssqw.gq +bxfmtktkpxfkobzssqw.ml +bxfmtktkpxfkobzssqw.tk +bxg.spymail.one +bxhd.emltmp.com +bxm2bg2zgtvw5e2eztl.cf +bxm2bg2zgtvw5e2eztl.ga +bxm2bg2zgtvw5e2eztl.gq +bxm2bg2zgtvw5e2eztl.ml +bxm2bg2zgtvw5e2eztl.tk +bxneh.anonbox.net +bxouuu.mimimail.me +bxpid.anonbox.net +bxpvq.anonbox.net +bxrzq.anonbox.net +bxs.emltmp.com +bxs1yqk9tggwokzfd.cf +bxs1yqk9tggwokzfd.ga +bxs1yqk9tggwokzfd.ml +bxs1yqk9tggwokzfd.tk +bxvha.anonbox.net +by-nad.online +by-simply7.tk +by.cowsnbullz.com +by.heartmantwo.com +by.lakemneadows.com +by.laste.ml +by.poisedtoshrike.com +by665.anonbox.net +by6tz.anonbox.net +by8006l.com +by9.lol +byagu.com +byakuya.com +bybklfn.info +bycy.xyz +byd686.com +byebyemail.com +byespm.com +byespn.com +byf3h.anonbox.net +byfoculous.club +byggcheapabootscouk1.com +byj53bbd4.pl +byjfc.anonbox.net +byknv.anonbox.net +bylup.com +bymail.info +bymcn.anonbox.net +bymercy.com +byng.de +byom.de +byorby.com +bypass-captcha.com +bypfk.anonbox.net +bypwz.anonbox.net +bypyn.es +byqv.ru +byrnewear.com +bysky.ru +bystarlex.us +bytedigi.com +bytegift.com +bytesundbeats.de +bytetutorials.net +bytom-antyraddary.pl +bytonf.com +byui.me +bywc.emlpro.com +bywuicsfn.pl +byyondob.xyz +byzoometri.com +bz-cons.ru +bz-mytyshi.ru +bz.emlhub.com +bz4jk.anonbox.net +bzajx.anonbox.net +bzbu9u7w.xorg.pl +bzctv.online +bzcvc.anonbox.net +bzemail.com +bzfads.space +bzfr7.anonbox.net +bzg.emlpro.com +bzgiv.anonbox.net +bzhpc.anonbox.net +bzhyd.anonbox.net +bzhzd.anonbox.net +bzidohaoc3k.cf +bzidohaoc3k.ga +bzidohaoc3k.gq +bzidohaoc3k.ml +bzidohaoc3k.tk +bzip.site +bzmt6ujofxe3.cf +bzmt6ujofxe3.ga +bzmt6ujofxe3.gq +bzmt6ujofxe3.ml +bzmt6ujofxe3.tk +bzocey.xyz +bzq6n.anonbox.net +bzr.com +bzrff.anonbox.net +bztf1kqptryfudz.cf +bztf1kqptryfudz.ga +bztf1kqptryfudz.gq +bztf1kqptryfudz.ml +bztf1kqptryfudz.tk +bzvql.anonbox.net +bzw43.anonbox.net +bzwhe.anonbox.net +bzwv.emltmp.com +bzymail.top +bzzpm.anonbox.net +c-14.cf +c-14.ga +c-14.gq +c-14.ml +c-c-p.de +c-cadeaux.com +c-dreams.com +c-eric.fr.nf +c-mail.cf +c-mail.gq +c-n-shop.com +c-newstv.ru +c-pkk.icu +c-tta.top +c.andreihusanu.ro +c.asiamail.website +c.beardtrimmer.club +c.bestwrinklecreamnow.com +c.bettermail.website +c.captchaeu.info +c.coloncleanse.club +c.crazymail.website +c.dogclothing.store +c.emlhub.com +c.fastmail.website +c.garciniacambogia.directory +c.gsasearchengineranker.pw +c.gsasearchengineranker.site +c.gsasearchengineranker.space +c.gsasearchengineranker.top +c.gsasearchengineranker.xyz +c.hcac.net +c.kadag.ir +c.kerl.gq +c.mashed.site +c.mediaplayer.website +c.mylittlepony.website +c.nut.emailfake.nut.cc +c.ouijaboard.club +c.polosburberry.com +c.searchengineranker.email +c.theplug.org +c.uhdtv.website +c.waterpurifier.club +c.wlist.ro +c.yourmail.website +c0ach-outlet.com +c0ach-outlet1.com +c0achoutletonlinesaleus.com +c0achoutletusa.com +c0achoutletusa2.com +c0ndetzleaked.com +c0rtana.cf +c0rtana.ga +c0rtana.gq +c0rtana.ml +c0rtana.tk +c0sau0gpflgqv0uw2sg.cf +c0sau0gpflgqv0uw2sg.ga +c0sau0gpflgqv0uw2sg.gq +c0sau0gpflgqv0uw2sg.ml +c0sau0gpflgqv0uw2sg.tk +c1oramn.com +c1ph3r.xyz +c20vussj1j4glaxcat.cf +c20vussj1j4glaxcat.ga +c20vussj1j4glaxcat.gq +c20vussj1j4glaxcat.ml +c20vussj1j4glaxcat.tk +c21rd.site +c21service.com +c23vt.anonbox.net +c2ayq83dk.pl +c2clover.info +c2csoft.com +c2rnm.anonbox.net +c306u.com +c3e3r7qeuu.cf +c3e3r7qeuu.ga +c3e3r7qeuu.gq +c3e3r7qeuu.ml +c3e3r7qeuu.tk +c3email.win +c4.fr +c4anec0wemilckzp42.ga +c4anec0wemilckzp42.ml +c4anec0wemilckzp42.tk +c4pro.uk +c4ster.gq +c4utar.cf +c4utar.ga +c4utar.gq +c4utar.ml +c4utar.tk +c51vsgq.com +c58n67481.pl +c5ccwcteb76fac.cf +c5ccwcteb76fac.ga +c5ccwcteb76fac.gq +c5ccwcteb76fac.ml +c5ccwcteb76fac.tk +c5inz.anonbox.net +c5qawa6iqcjs5czqw.cf +c5qawa6iqcjs5czqw.ga +c5qawa6iqcjs5czqw.gq +c5qawa6iqcjs5czqw.ml +c5qawa6iqcjs5czqw.tk +c63q.com +c686q2fx.pl +c6cd3.anonbox.net +c6h12o6.cf +c6h12o6.ga +c6h12o6.gq +c6h12o6.ml +c6h12o6.tk +c6loaadz.ru +c73cz.anonbox.net +c7fk799.com +c7rle.anonbox.net +c81hofab1ay9ka.cf +c81hofab1ay9ka.ga +c81hofab1ay9ka.gq +c81hofab1ay9ka.ml +c81hofab1ay9ka.tk +c99.me +c9gbrnsxc.pl +ca-canadagoose-jacets.com +ca-canadagoose-outlet.com +ca.verisign.cf +ca.verisign.ga +ca.verisign.gq +caainpt.com +cab22.com +cabaininin.io +cabal72750.co.pl +caballerooo.tk +cabangnursalina.net +cabekeriting99.com +cabezonoro.cl +cabinets-chicago.com +cabinmail.com +cabioinline.com +cabiste.fr.nf +cablegateast.com +cabonmania.ga +cabonmania.tk +cabose.com +cacanhbaoloc.com +cachedot.net +cachlamdep247.com +cad.edu.gr +caddegroup.co.uk +caddelll12819.info +cade.org.uk +cadillac-ats.tk +cadolls.com +cadomoingay.info +cadoudecraciun.tk +cadsaf.us +caeboyleg.ga +caerwyn.com +cafe-morso.com +cafebacke.com +cafebacke.net +cafecar.xyz +cafecoquin.com +cafeqrmenu.xyz +cafesui.com +cafrem3456ails.com +caftee.com +cageymail.info +cagi.ru +caglikalp.de +cahayasenja.online +cahkerjo.tk +cahsintru.cf +cai-nationalmuseum.org +caidadepeloyal26.eu +caidatssl.com +caipiratech.store +caitlinhalderman.art +caiwenhao.cn +cajacket.com +cajyre.xyz +cakdays.com +cake99.ml +cakeitzwo.com +cakemayor.com +cakeonline.ru +cakesrecipesbook.com +cakk.us +cakottery.com +cakybo.com +calabreseassociates.com +calav.site +calcm8m9b.pl +calculatord.com +calcy.org +caldwellbanker.in +caledominoskic.co.uk +calendro.fr.nf +calgarymortgagebroker.info +calibex.com +calibra-travel.com +califohdsj.space +california-nedv.ru +californiabloglog.com +californiaburgers.com +californiacolleges.edu +californiafitnessdeals.com +californiatacostogo.com +caligulux.co +calima.asso.st +calintec.com +caliperinc.com +caliskanofis.store +call.favbat.com +callberry.com +callcentreit.com +callejondelosmilagros.com +callemarevasolaretolew.online +callistu.com +callmemaximillian.kylos.pl +calloneessential.com +callpage.work +callthegymguy.top +callwer.com +callzones.com +calmgatot.net +calmpros.com +calnam.com +caloriecloaks.com +caloriesandwghtlift.co.uk +calorpg.com +calunia.com +calvarystreetisa.org +calvinkleinbragas.com +calypsoservice.com +calyx.site +cam4you.cc +camachohome.com +cambeng.com +cambodiaheritage.net +cambridge-satchel.com +cambridge.ga +cambridgechina.org +cambridgetowel.com +camcaribbean.com +camcei.dynamic-dns.net +camconstr.com +camdenchc.org +camefrog.com +camellieso.com +cameltok.com +camentifical.site +camera47.net +camerabuy.info +camerabuy.ru +camerachoicetips.info +camerahanhtrinhoto.info +cameraity.com +cameratouch-849.online +cameroon365.com +camgirls.de +camilion.com +camillosway.com +caminoaholanda.com +caminvest.com +camionesrd.com +camisetashollisterbrasil.com +camjoint.com +cammk.com +camnangdoisong.com +camoney.xyz +campano.cl +campatar.com +campbellap.com +campcuts.com +camphor.cf +camping-grill.info +campingandoutdoorsgear.com +camplvad.com +campredbacem.site +campus.agp.edu.pl +campusman.com +camrew.com +camsexyfree.com +camshowsex.com +camsonlinesex.com +camthaigirls.com +camtocamnude.com +can.blatnet.com +can.warboardplace.com +canadabit.com +canadacoachhandbags.ca +canadafamilypharm.com +canadafreedatingsite.info +canadagoosecashop.com +canadagoosedoudounepascher.com +canadagoosejakkerrno.com +canadagoosets.info +canadan-pharmacy.info +canadaonline.biz +canadaonline.pw +canadapharm.email +canadapharmaciesonlinebsl.bid +canadapharmacybsl.bid +canadapharmacyonlinebestcheap.com +canadawebmail.ca.vu +canadian-onlinep-harmacy.com +canadian-pharmacy.xyz +canadian-pharmacys.com +canadian-pharmacyu.com +canadian-pharmacyw.com +canadiancourts.com +canadianhackers.com +canadianmsnpharmacy.com +canadianonline.email +canadianonlinepharmacybase.com +canadianonlinepharmacyhere.com +canadianpharmaceuticalsrx.com +canadianpharmaciesbnt.com +canadianpharmaciesmsn.com +canadianpharmaciesrxstore.com +canadianpharmacy-us.com +canadianpharmacyed.com +canadianpharmacyfirst.com +canadianpharmacymim.com +canadianpharmacyntv.com +canadianpharmacyrxp.bid +canadianpharmacyseo.us +canadianrxpillusa.com +canadians.biz +canadiantoprxstore.com +canadianvaping.com +canadlan-pharmacy.info +canadph.com +canaimax.xyz +canallow.com +canamhome.com +canamimports.com +canborrowhot.com +cancer-treatment.xyz +cancer.waw.pl +cancerbuddyapp.com +cancut.biz.id +candapizza.net +candassociates.com +candcentertainment.com +candcluton.com +candida-remedy24.com +candidteenagers.com +candlesjr.com +candlesticks.org +candoit624.com +candokyear.com +candy-blog-adult.ru +candy-private-blog.ru +candyjapane.ml +candylee.com +candyloans.com +candymail.de +candywrapperbag.com +candywrapperbag.info +candyyxc45.biz +cane.pw +canfga.org +cangcuters.my.id +canggih.net +cangutsiapa.sbs +cangxcut.com +canhac.vn +canhacaz.com +canhacvn.net +canhardco.ga +canhcvn.net +canhoehome4.info +canie.assassins-creed.org +canilvonhauseferrer.com +canitta.icu +canmath.com +canmorenews.com +cannabisresoulution.net +cannedsoft.com +cannn.com +cannoncrew.com +canonlensmanual.com +canonwirelessprinters.com +canpha.com +canpilbuy.online +canrelnud.com +cantate-gospel.de +cantikbet88.com +cantikmanja.online +cantouri.com +cantozil.com +cantsleep.gq +canuster.xyz +canvaedu.id +canvagiare.me +canvasarttalk.com +canvasshoeswholesalestoress.info +canvect.com +canyona.com +canyouhearmenow.cf +canytimes.com +cao6sd.xyz +caonima.gq +caosusaoviet.vn +caowar.com +cap-or.com +capatal.com +capcut.digital +capcut.sbs +capcut.team +capcuter.com +capcutgw.cfd +capcutisme.app +capcutisme.com +capcutku.app +capcutku.com +capcutku.io +capcutmeflo.shop +capcutnihbos.site +capcutpro.click +capebretonpost.com +capgenini.com +capiena.com +capisci.org +capital.tk +capitalfloors.net +capitalistdilemma.com +capitalizable.one +capitalswarm.com +capkakitiga.pw +cappadociadaytours.com +cappriccio.ru +capricornh.my.id +capsawinspkr.com +captainamericagifts.com +captainmaid.top +captbig.com +captchaboss.com +captchacoder.com +captchaeu.info +captnsvo23t.website +capturehisheartreviews.info +captus.cyou +capzone.io +caqpacks.com +car-and-girls.co.cc +car-wik.com +car-wik.tk +car101.pro +caraalami.xyz +caraff.com +caramail.pro +carambla.com +caramenangmainslot.net +caramil.com +caraparcal.com +caratsjewelry.com +caraudiomarket.ru +carbbackloadingreviews.org +carbo-boks.pl +carbonbrushes.us +carbonia.de +carbonnotr.com +carbtc.net +carcanner.site +carcerieri.ml +carch.site +card.zp.ua +card4kurd.xyz +cardetailerchicago.com +cardiae.info +cardjester.store +cardkurd.com +cardosoadvogadoassociados.com +cardsexpert.ru +care-breath.com +careandvital.com +careerladder.org +careermans.ru +careersschool.com +careerupper.ru +careerwill.com +carefreefloor.com +carehabcenter.com +carehp.com +careless-whisper.com +carewares.club +carewares.live +carewares.solutions +carfola.site +cargids.site +cargobalikpapan.com +cargoships.net +cargruus.com +carinamiranda.org +carins.io +carinsurance2018.top +carinsurancebymonth.co.uk +carinsurancegab.info +carioca.biz.st +caritashouse.org +carlasampaio.com +carlbro.com +carleasingdeals.info +carloansbadcredit.ca +carlosandrade.co +carloseletro.site +carlossanchez.ga +carlossanchez.tk +carloszbs.ru +carlsonco.com +carmail.com +carmanainsworth.com +carmit.info +carnalo.info +carnesa.biz.st +carney.website +carny.website +carolinabank.com +carolinarecords.net +carolserpa.com +carolus.website +carpaltunnelguide.info +carpet-cleaner-northampton.co.uk +carpet-oriental.org +carpetcleaningventura.net +carpetd.com +carpetra.com +carpetremoval.ca +carpin.org +carpoo.com +carraps.com +carras.ga +carriwell.us +carrnelpartners.com +carrosusadoscostarica.com +carrys.site +carrystore.online +cars2.club +carsencyclopedia.com +carsflash.com +carsik.com +carslon.info +carsonarts.com +carspack.com +carspure.com +cartasnet.com +carte3ds.org +cartelera.org +cartelrevolution.co.uk +cartelrevolution.com +cartelrevolution.de +cartelrevolution.net +cartelrevolution.org +cartelrevolutions.com +cartep.com +cartermanufacturing.com +cartflare.com +carthagen.edu +cartieruk.com +cartmails.com +cartone.fun +cartone.life +cartoonarabia.com +cartoutz.com +cartproz.com +cartsoonalbumsales.info +cartuningshop.co.uk +carubull.com +carver.com +carver.website +carvives.site +carwoor.club +carwoor.online +carwoor.store +cary.website +caryl.website +casa-versicherung.de +casa.myz.info +casadecampo.online +casaderta.com +casanovalar.com +casaobregonbanquetes.com +casar.website +casarosita.info +casavincentia.org +case4pads.com +caseedu.tk +casehome.us +caseincancer.com +casemails.com +casemails.online +casequestion.us +casetnibo.xyz +cash.camera +cash.org +cash128.com +cash4.xyz +cash4nothing.de +cash8.xyz +cashadvance.com +cashadvance.us +cashadvanceqmvt.com +cashadvancer.net +cashadvances.us +cashbackr.com +cashbn.com +cashette.com +cashflow35.co +cashflow35.com +cashhloanss.com +cashint.com +cashlinesreview.info +cashloan.org +cashloan.us +cashloannetwork.org +cashloannetwork.us +cashloans.com +cashloans.org +cashloans.us +cashloansnetwork.com +cashmons.net +cashstroked.com +cashwm.com +cashxl.com +casino-bingo.nl +casino-bonus-kod.com +casino-x.co.uk +casino892.com +casinoaustralia-best.com +casinofun.com +casinogreat.club +casinojack.xyz +casinolotte.com +casinomegapot.com +casinoohnedeutschelizenz.net +casinopokergambleing.com +casinoremix.com +casinos.ninja +casinos4winners.com +casinovaz.com +casinovip.ru +casinoxigrovie.ru +casio-edu.cf +casio-edu.ga +casio-edu.gq +casio-edu.ml +casio-edu.tk +casitsupartners.com +casiwo.info +casoron.info +caspianfan.ir +caspianshop.com +casquebeatsdrefrance.com +casrod.com +cassiawilliamsrealestateagentallentx.com +cassiawilliamsrealestateagentaubreytx.com +cassidony.info +cassiomurilo.com +cassius.website +castillodepavones.com +castlebranchlogin.com +castlelawoffice.com +castromail.bid +casualdx.com +cat.pp.ua +catalinaloves.com +catalystwms.com +catamma.com +catanybook.site +catanybooks.site +catanyfiles.site +catanytext.site +catawesomebooks.site +catawesomefiles.site +catawesomelib.site +catawesometext.site +catbirdmedia.com +catch.everton.com +catch12345.tk +catchall.fr +catchemail1.xyz +catchemail5.xyz +catchletter.com +catchmeifyoucan.xyz +catchonline.ooo +catdogmail.live +catdrout.xyz +catering.com +cateringegn.com +catfishsupplyco.com +catfreebooks.site +catfreefiles.site +catfreetext.site +catfreshbook.site +catfreshbooks.site +catfreshfiles.site +catfreshlib.site +catfreshlibrary.site +catgoodbooks.site +catgoodfiles.site +catgoodlib.site +catgoodtext.site +catgroup.uk +cath17.com +cathedraloffaith.com +catherinewilson.art +cathouseninja.com +cathysharon.art +catindiamonds.com +catkat24.pl +catnicebook.site +catnicetext.site +catnipcat.net +catrarebooks.site +catreena.ga +catsoft.store +catson.us +catty.wtf +catypo.site +caugiay.tech +causesofheadaches.net +causeylaw.com +cavi.mx +caviaruruguay.com +cavisto.ru +cavo.tk +cavoyar.com +cawfeetawkmoms.com +cawxrsgbo.pl +caxa.site +caychay.online +caychayyy.shop +caye.org.uk +cayrdzhfo.pl +cayxupro5.com +cazino777.pro +cazinoid.ru +cazinoz.biz +cazis.fr +cazlg.com +cazlp.com +cazlq.com +cazlv.com +cazzie.website +cazzo.cf +cazzo.ga +cazzo.gq +cb-100.me +cb367.space +cb6ed.xyz +cba-1.top +cbair.com +cbarata.pro +cbarato.plus +cbarato.pro +cbarato.vip +cbaweqz.com +cbcglobal.net +cbchoboian.com +cbcmm.com +cbd-7.com +cbd-treats.com +cbd.clothing +cbdcrowdfunder.com +cbdious.com +cbdlandia.pl +cbdnut.net +cbdoilwow.com +cbdpicks.com +cbdpowerflower.com +cbdw.pl +cbe.yomail.info +cbes.net +cbgh.ddns.me +cbghot.com +cbhb.com +cbjst.myddns.me +cbjunkie.com +cbnd.online +cbot1fajli.ru +cbr.emlhub.com +cbreviewproduct.com +cbrl.emltmp.com +cbrolleru.com +cbsbada.com +cbsglobal.net +cbty.ru +cbty.store +cbv.com +cbyourself.com +cbzmail.tk +cc-cc.usa.cc +cc-s3x.cf +cc-s3x.ga +cc-s3x.gq +cc-s3x.ml +cc-s3x.tk +cc.emltmp.com +cc.mailboxxx.net +cc.spymail.one +cc.these.cc +cc10.de +cc2ilplyg77e.cf +cc2ilplyg77e.ga +cc2ilplyg77e.gq +cc2ilplyg77e.ml +cc2ilplyg77e.tk +ccad.emlpro.com +ccat.cf +ccat.ga +ccat.gq +ccategoryk.com +ccbd.com +ccbilled.com +cccc.com +cccod.com +cccold.com +ccdepot.xyz +ccdxc.com +ccfirmalegal.com +ccgtoxu3wtyhgmgg6.cf +ccgtoxu3wtyhgmgg6.ga +ccgtoxu3wtyhgmgg6.gq +ccgtoxu3wtyhgmgg6.ml +ccgtoxu3wtyhgmgg6.tk +cchaddie.website +cchancesg.com +cchatz.ga +cciatori.com +ccid.de +cckuy.com +cckuy.site +cckuy.space +ccmail.men +ccmail.uk +ccn35.com +cconsistwe.com +ccpt.lol +ccqu.top +ccre1.club +ccren9.club +ccrenew.club +ccs.emlhub.com +cctoolz.com +cctyoo.com +ccvisal.xyz +ccxpnthu2.pw +cd.emlpro.com +cd.freeml.net +cd.mintemail.com +cd.usto.in +cd2in.com +cdactvm.in +cdaixin.com +cdash.space +cdbk.spymail.one +cdc.com +cdc.dropmail.me +cdcmail.date +cdcovers.icu +cderota.com +cdeter.com +cdfaq.com +cdfbhyu.site +cdin.emltmp.com +cdjiazhuang.com +cdkey.com +cdkwjdm523.com +cdm.laste.ml +cdmstudio.com +cdn.rent +cdn28.emvps.xyz +cdn92.soloadvanced.com +cdnaas.com +cdnlagu.com +cdnmia.com +cdnqa.com +cdnripple.com +cdofutlook.com +cdp6.com +cdpa.cc +cdpc.com +cdq.spymail.one +cdr.yomail.info +cdressesea.com +cdrhealthcare.com +cdrmovies.com +cdsshv.info +cdt.laste.ml +cdtj.dropmail.me +cdvaldagno.it +cdvig.com +cdvo.dropmail.me +cdyhea.xyz +ce.emlpro.com +ce.mimimail.me +ce.mintemail.com +ceb.emlpro.com +cebaike.com +ceberium.com +cebolsarep.ga +cebong.cf +cebong.ga +cebong.gq +cebong.ml +cebong.tk +cec.yomail.info +cech-liptov.eu +ceco3kvloj5s3.cf +ceco3kvloj5s3.ga +ceco3kvloj5s3.gq +ceco3kvloj5s3.ml +ceco3kvloj5s3.tk +cederajenab.biz +ceed.se +ceefax.co +ceftvhxs7nln9.cf +ceftvhxs7nln9.ga +ceftvhxs7nln9.gq +ceftvhxs7nln9.ml +ceftvhxs7nln9.tk +cegil.site +ceh.spymail.one +cehm.dropmail.me +cek.pm +cekajahhs.tk +ceklaww.ml +cekut.space +cel-tech.com +celc.com +cele.ro +celebans.ru +celebfap.net +celebleak.co +celebrinudes.com +celebriporn.net +celebritron.app +celebrityadz.com +celebritydetailed.com +celebrything.com +celebslive.net +celebwank.com +celerto.tk +celinea.info +celinebags2012.sg +celinecityitalia.com +celinehandbagjp.com +celinehandbagsjp.com +celinejp.com +celinesoldes.com +celinestores.com +celinevaska.com +cell1net.net +cellphonegpstracking.info +cellphoneparts.tk +cellphonespysoftware2012.info +cellstar.com +cellularispia.info +cellularispiaeconomici.info +celluliteremovalmethods.com +cellurl.com +cem.net +cemailes.com +cemalettinv1.ml +cemdevelopers.com +cemdevelopers.info +cemdevelopers.org +cemouton.com +cenanatovar.ru +ceneio.pl +cenglandb.com +cengrop.com +cenkdogu.cf +cent23.com +centa93.icu +center-kredit.de +center-mail.de +center-zemli.ru +center4excellence.com +centerf.com +centerforresponsiveschools.com +centerforresponsiveschools.info +centerforresponsiveschools.org +centerhash.com +centerlasi.ml +centermail.at +centermail.ch +centermail.com +centermail.de +centermail.info +centermail.net +centerpiecis.space +centerpointecontractors.info +centerpointecontractors.net +centerpointecontractors.org +centervilleapartments.com +centerway.site +centerway.xyz +centexpathlab.com +centima.ml +centimeter.online +centirytel.net +centleadetai.eu +centnetploggbu.eu +centol.us +centou45.icu +centoviki.cf +centoviki.gq +centoviki.ml +centr-fejerverkov28.ru +centr-luch.ru +centr-p-i.ru +central-asia.travel +central-cargo.co.uk +central-grill-takeaway.com +central-realestate.com +central-series.com +central-servers.xyz +centralatomics.com +centralblogai.com +centralcomprasanitaria.com +centrale.wav.pl +centrale.waw.pl +centralgcc.biz +centralgrillpizzaandpasta.com +centralheatingproblems.net +centraljoinerygroup.com +centrallosana.ga +centralmicro.net +centralplatforms.com +centralstaircases.com +centralstairisers.com +centralteam.org +centraltoto.biz +centralux.org +centralwisconsinfasteners.com +centresanteglobaleles4chemins.com +centreszv.com +centrodeolhoscampos.com +centrodesaude.website +centroone.com +centrumchwilowek.com +centrumfinansow24.pl +centrurytel.net +centurtel.net +centurtytel.net +centurytrl.net +centvps.com +centy.ga +cenurytel.net +ceoll.com +ceoshub.com +cepatbet.com +cepheusgraphics.tech +cepllc.com +ceramicsouvenirs.com +ceramictile-outlet.com +cerapht.site +cerdikiawan.me +ceremonydress.net +ceremonydress.org +ceremonydresses.com +ceremonydresses.net +ceremonyparty.com +ceresko.com +cergon.com +ceria.cloud +cerisun.com +cerkwa.net +cerry643.eu.org +certansia.net +certbest.com +certexx.fr.nf +certificenter.com +certifiedtgp.com +certiflix.com +certphysicaltherapist.com +certve.com +cervejeiromestre.com.br +cesitayedrive.live +cesknurs69.de +cestdudigital.info +cestorestore.com +cesuoter.com +cesur.pp.ua +cetamision.site +cetgpt.com +cetmen.cyou +cetmen.store +cetpass.com +cetta.com +cevipsa.com +ceweknakal.cf +ceweknakal.ga +ceweknakal.ml +cewekonline.buzz +cewtrte555.cz.cc +cex1z9qo.cf +cexch.com +cexkg50j6e.cf +cexkg50j6e.ga +cexkg50j6e.gq +cexkg50j6e.ml +cexkg50j6e.tk +ceylonleaf.com +cf.yomail.info +cfa.emlpro.com +cfainstitute.com +cfat9fajli.ru +cfat9loadzzz.ru +cfatt6loadzzz.ru +cfazal.cfd +cfb.emltmp.com +cfbu.laste.ml +cfcae.org +cfcjy.com +cfdlstackf.com +cfe21.com +cfifa.net +cfllx7ix9.pl +cflv.com +cfo2go.ro +cfoto24.pl +cfqq.yomail.info +cfremails.com +cfskrxfnsuqck.cf +cfskrxfnsuqck.ga +cfskrxfnsuqck.gq +cfskrxfnsuqck.ml +cfskrxfnsuqck.tk +cftcmaf.com +cftrextriey-manage1.com +cfu.spymail.one +cfvgftv.in +cfx.emltmp.com +cfy.emlhub.com +cfyawstoqo.pl +cfz.emlhub.com +cfz.emltmp.com +cg.emltmp.com +cg.spymail.one +cgbird.com +cgcj.dropmail.me +cge.freeml.net +cgek.yomail.info +cget0faiili.ru +cget3zaggruz.ru +cget4fiilie.ru +cget6zagruska.ru +cgfrinfo.info +cgfrredi.info +cgget5zaggruz.ru +cgget5zagruz.ru +cggup.com +cghdgh4e56fg.ga +cghost.s-a-d.de +cgilogistics.com +cgnn.freeml.net +cgnz7xtjzllot9oc.cf +cgnz7xtjzllot9oc.ga +cgnz7xtjzllot9oc.gq +cgnz7xtjzllot9oc.ml +cgnz7xtjzllot9oc.tk +cgpq.dropmail.me +cgredi.info +cgrtstm0x4px.cf +cgrtstm0x4px.ga +cgrtstm0x4px.gq +cgrtstm0x4px.ml +cgrtstm0x4px.tk +cgtq.tk +cgu.yomail.info +cguf.site +cgx.dropmail.me +cgyvgtx.xorg.pl +cgz.emltmp.com +ch.laste.ml +ch.ma +ch.mintemail.com +ch.spymail.one +ch.tc +cha-cha.org.pl +chaamtravel.org +chaappy9zagruska.ru +chaatalop.club +chaatalop.online +chaatalop.site +chaatalop.store +chaatalop.website +chaatalop.xyz +chachia.net +chachupa.com +chachyn.site +chacuo.net +chahcyrans.com +chaichuang.com +chainc.com +chaincurve.com +chainds.com +chaineor.com +chainlinkthemovie.com +chajnik-bokal.info +chaladas.com +chalemarket.online +chalupaurybnicku.cz +cham.co +chamberlinre.com +chambile.com +chamconnho.com +chammakchallo.com +chammy.info +champmails.com +chamsocdavn.com +chamsocvungkin.vn +chancekey.com +chancemorris.co.uk +chaneborseoutletmodaitaly.com +chanel-bag.co +chanel-outletbags.com +chanelbagguzu.com +chanelcheapbagsoutlett.com +chanelforsalejp.org +chanelhandbagjp.com +chaneloutlettbagsuus.com +chanelstore-online.com +chaneoutletcheapbags.com +chaneoutletuomoitmini1.com +chaneoutletuomoitmini2.com +changaji.com +changemail.cf +changenypd.org +changeofname.net +changesmile.org.ua +changetheway.org.ua +changethewayyoubank.org +changing.info +changingemail.com +changinger.com +changuaya.site +chanluuuk.com +chanmelon.com +channable.us +channel9.cf +channel9.ga +channel9.gq +channel9.ml +chansd.com +chantellegribbon.com +chaocosen.com +chaoji.icu +chaonamdinh.com +chaonhe.club +chaos.ml +chaosfen.com +chaosi0t.com +chaoyouliao.sbs +chapar.cf +chaparmail.tk +chapedia.net +chapedia.org +chapmanfuel.com +chappy1faiili.ru +chappy9sagruz.ru +chapsmail.com +charav.com +chardrestaurant.com +charenthoth.emailind.com +charfoce.cf +charfoce.ga +charfoce.gq +charfoce.ml +chargerin.com +charitesworld.club +charitiesonly.online +charitiesonly.world +charityfloor.com +charityforpoorregions.com +charitysmith.us +charjmostaghim.com +charl.us +charlescottrell.com +charlesjordan.com +charlesmoesch.com +charlie.mike.spithamail.top +charlie.omega.webmailious.top +charlielainevideo.com +charliesplace.com +charlotteaddictiontreatment.com +charlotteheroinrehab.com +charltons.biz +charm-sexylingerie.com +charminggirl.net +charmlessons.com +charmrealestate.com +chartef.net +charter.bet +chasefreedomactivate.com +chat-wa.click +chat080.net +chatbelgique.com +chatdays.com +chatfap.info +chatfrenchguiana.com +chatgpt-ar.com +chatgpt.bounceme.net +chatgptku.cloud +chatgptku.com +chatgptku.pro +chatgptuk.pp.ua +chatich.com +chatily.com +chatjunky.com +chatkamu.com +chatlines.club +chatlines.wiki +chatlivesexy.com +chatmailboxy.com +chatpolynesie.com +chatwesi.com +chatworkstation.com +chatxat.com +chaublog.com +chaukkas.com +chausport.store +chaussure-air-max.com +chaussure-air-maxs.com +chaussure-airmaxfr.com +chaussure-airmaxs.com +chaussureairmaxshop.com +chaussuresadaptees.com +chaussuresairjordansoldes.com +chaussuresllouboutinpascherfr.com +chaussureslouboutinmagasinffr.com +chaussureslouboutinpascherfrance.com +chaussureslouboutinpascherparis.com +chaussuresslouboutinpascherfrance.com +chaussuresslouboutinppascher.com +chaussurs1ouboutinffrance.com +chavezschool.org +chbkstore.cloud +chcial.com +chclzq.com +cheadae.com +chealsea.com +cheap-beatsbydre-online.com +cheap-carinsurancecanada.info +cheap-carinsuranceuk.info +cheap-carinsuranceusa.info +cheap-coachpurses.us +cheap-ghdaustraliastraightener.com +cheap-inflatables.com +cheap-monsterbeatsdre-headphones.com +cheap-nikefreerunonline.com +cheap-tadacip.info +cheap2trip.com +cheap3ddigitalcameras.com +cheap5831bootsukonsale.co.uk +cheapabeatsheadphones.com +cheapabercrombieuk.com +cheapadidasashoes.com +cheapairjordan.org +cheapairmaxukv.com +cheapantivirussoftwaress.info +cheapbacklink.net +cheapbagsblog.org +cheapbagsmlberryuksale.co.uk +cheapbarbourok.com +cheapbeatsbuynow.com +cheapbedroomsets.info +cheapbootsonuksale1.co.uk +cheapcar.com +cheapcarinsurancerus.co.uk +cheapcarrentalparis.info +cheapchaneljp.com +cheapcheapppes.org +cheapchristianllouboutinshoes.info +cheapchristianlouboutindiscount.com +cheapchristinlouboutinshoesusa.com +cheapcoacbagsoutletusa.com +cheapcoachbagsonlineoutletusa.com +cheapcoachfactoryyonlineus.com +cheapcoachotletstore.com +cheapcoachoutletonlinestoreusa.com +cheapcoachstoreonlinesale.com +cheapcoahoutletstoreonline.com +cheapcoahusa.com +cheapdsgames.org +cheapedu.me +cheapeffexoronline.net +cheapelectronicreviews.info +cheaperredbottoms.com +cheapers.me +cheapessaywriting.top +cheapestnewdriverinsurance.co.uk +cheapestnikeairmaxtz.co.uk +cheapestnikeairmaxzt.co.uk +cheapfacebooklikes.net +cheapfashionbootsa.com +cheapfashionshoesbc.com +cheapfashionshoesbd.com +cheapfashionshoesbg.com +cheapfashionshoesbu.com +cheapfootwear-sale.info +cheapforexrobot.com +cheapgenericciprosure.com +cheapgenericdiflucansure.com +cheapgenericdostinexsure.com +cheapgenericlexaprosure.com +cheapgenericlipitorsure.com +cheapgenericnexiumsure.com +cheapgenericnorvascsure.com +cheapgenericpropeciasure.com +cheapgenericvaltrexsure.com +cheapgenericxenicalsure.com +cheapgenericzoviraxsure.com +cheapggbootsuksale1.com +cheapghdahairstraighteneraghduksale.co.uk +cheapghddssaleukonlinestraighteners.co.uk +cheapghdsaleaustralia.co.uk +cheapghdstraightenerghdsale.co.uk +cheapghdstraighteneruk.co.uk +cheapghduksalee.co.uk +cheapgraphicscards.info +cheapgreenteabags.com +cheapgucchandbags.com +cheapgucchandbas.com +cheapgucchandsbags.com +cheapguccoutlet.com +cheaph.com +cheaphandbagssite.net +cheaphatswholesaleus.com +cheaphie.com +cheaphorde.com +cheaphub.net +cheapisabelmarantsneakerss.info +cheapjerseys1.co +cheapjerseysforsaleonline.com +cheapjerseysprostore.com +cheapjerseysstoreusa.com +cheapkidstoystore.com +cheapkitchens-direct.co.uk +cheaplinksoflondoncharms.net +cheapllvoutlet.com +cheaplouboutinshoesuksale.co.uk +cheaplouisvuitton-handbags.info +cheaplouisvuittonaubags.com +cheaplouisvuittonukzt.co.uk +cheaplouisvuittoonusoutletusa.com +cheaplvbags.net +cheaplvbagss.com +cheapmailhosting.live +cheapmenssuitsus.com +cheapmichaelkorsonsaleuus.com +cheapminibootssonsaleuk.co.uk +cheapminibootssonsaleuk1.co.uk +cheapminibootssonsaleuk2.co.uk +cheapmlberryuksalebags.co.uk +cheapmonster098.com +cheapmulberrysalebagsuk.co.uk +cheapn1keshoes.com +cheapnamedeals.info +cheapnetbooksunder200.net +cheapnfjacketsusvip.com +cheapnicedress.net +cheapnikeairmax1shoes.co.uk +cheapnikeairmax1ukvip.co.uk +cheapnikeairmax1vip.co.uk +cheapnikeairmax90shoes.co.uk +cheapnikeairmax90zu.co.uk +cheapnikeairmax95uk.co.uk +cheapnikeairmax95zt.co.uk +cheapnikeairmaxmvp.co.uk +cheapnikeairmaxshoesus.com +cheapnikeairmaxuktz.co.uk +cheapniketrainersuksale.co.uk +cheapnitros.com +cheapnorthfacejacketsoutlet.net +cheapoakley-storeus.com +cheapoakleyoutletvip.com +cheapoakleystoreus.com +cheapoakleysunglasseshotsale.com +cheapoakleysunglassesoutlet.org +cheapoakleysunglasseszt.co.uk +cheapoakleyvipa.com +cheapoakleyzt.co.uk +cheapoir.com +cheapoksunglassesstore.com +cheapooakleysunglassesussale.com +cheapoutlet10.com +cheapoutlet11.com +cheapoutlet12.com +cheapoutlet3.com +cheapoutlet6.com +cheapoutlet9.com +cheapoutletonlinecoachstore.com +cheappbootsuksale.com +cheappghdstraightenersoutlet1.co.uk +cheappradabagau.com +cheappradaoutlet.us +cheapprescriptionspectacles.in +cheappropeciaonlinepills.com +cheapproxy.app +cheapraybanswayfarersunglassesoutlet.com +cheapraybanukoutlett.com +cheaps5.com +cheapscript.net +cheapseller.cf +cheapshoeslouboutinsale.co.uk +cheapsnowbootsus.com +cheapstomshoesoutlet.com +cheapstore.club +cheapthelouboutinshoesusa1.com +cheapthenorthfacesalee.com +cheapthermalpaper.com +cheaptheuksaleface.com +cheaptiffanyandcoclub.co.uk +cheaptomshoesoutlet.com +cheaptomshoesoutlet.net +cheaptoothpicks.com +cheaptraineruk.com +cheaptravelguide.net +cheapuggbootonsaleus.com +cheapuggbootsslippers.com +cheapuggbootsuk-store.info +cheapuggoutletmall.com +cheapuggoutletonsale.com +cheapukbootsbuy.com +cheapuknikeairmaxsale.co.uk +cheapukniketrainers.co.uk +cheapukniketrainerssale.co.uk +cheapuksalehandbagsoutletlv.co.uk +cheapukstraightenerssale.info +cheapusbspeakers.info +cheapvps.space +cheapweekendgetawaysforcouples.com +cheatautomation.com +cheaterboy.com +cheatis.fun +cheatmail.de +cheatsgenerator.online +cheatsorigin.com +cheattuts.com +chechnya.conf.work +checkadmin.me +checkbesthosting.com +checkbox.biz +checkemail.biz +checklok.shop +checkmatemail.info +checkmyip.cc +checknew.pw +checknow.online +checknowmail.com +checkout.lakemneadows.com +checkwilez.com +cheekyart.net +cheerclass.com +cheesepin.info +cheesethecakerecipes.com +cheetabet12.com +cheeze25421.com +cheezy.cf +chef.asana.biz +chefalicious.com +chefandrew.com +chefmail.com +chefscrest.com +chefsipa.tk +chehov-beton-zavod.ru +cheine.online +chekist.info +cheliped.info +chellup.info +chelsea.com.pl +chelseaartsgroup.com +chelton.dynamailbox.com +chelyab-nedv.ru +chemeng-masdar.com +chemiaakwariowabytom.pl +chemiahurt.eu +cheminsdevie.ink +chemo.space +chemodanymos.com +chemolysis.info +chemonite.info +chemosorb.info +chenbot.email +chengshinv.com +chengshiso.com +chennuo.xyz +chenteraz.flu.cc +cherbeli.ml +cherchesalope.eu +chernogory-nedv.ru +chernyshow.ru +cherrcreekschools.org +cherrysfineart.com +chery-clubs.ru +cheska-nedv.ru +chesles.com +chessgameland.com +chessgamingworld.com +chesterfieldcountyschools.com +chetroi.site +chevachi.com +cheverlyamalia.art +chewcow.com +chewiemail.com +chewmumma.com.au +chewydonut.com +chexsystemsaccount.com +chezdepaor.com +chfp.de +chfx.com +chg.yomail.info +chgchgm.com +chgio.store +chi-news.ru +chiamn.com +chiangmaiair.org +chiaplotbuy.club +chiara.it +chiasehoctap.net +chibakenma.ml +chicagobears-jersey.us +chicagochurch.info +chicagoquote.com +chicasdesnudas69.com +chicasticas.info +chicco.com.es +chicco.org.es +chicdressing.com +chicha.net +chichichichi.com +chicken-girl.com +chickenadobo.org +chickenbreeds.net +chickenkiller.com +chickerwau.fun +chickerwau.online +chickerwau.site +chickerwau.website +chicksnd52.com +chicomaps.com +chidelivery.com +chider.com +chief-electrical.com +chiefcoder.com +chiefyagan.com +chielo.com +chieninsta.shop +chiet.ru +chiguires.com +chihairstraightenerv.com +chikd73.com +childrenofthesyrianwar.com +childrenth.com +childrentoys.site +childsavetrust.org +childwork.biz +chilecokk.com +chilelinks.cl +chilepro.cc +chili-nedv.ru +chilkat.com +chilli.biz +chillmailing.win +chillphet.com +chimerahealth.com +chimesearch.com +chimneycats.com +chimpad.com +china-mattress.org +china-nedv.ru +china183.com +china1mail.com +chinaecapital.com +chinaflights.store +chinagold.com +chinalww.com +chinamkm.com +chinanew.com +chinaqoe.com +chinatabletspcs.com +chinatongyi.com +chinatov.com +chinauxm.com +chinax.tech +chinchillaspam.com +chindyanggrina.art +chineafrique.com +chinese-opportunity.com +chineseclothes12345678.net +chingchongme.site +chinjow.xyz +chintamiatmanegara.art +chipbankasi.com +chipekii.cf +chipekii.ga +chipeling.xyz +chipkolik.com +chipmunkbox.com +chiptuningworldbenelux.com +chiragra.pl +chirio.co +chironglobaltechnologies.com +chise.com +chisers.xyz +chistopole.ru +chithi.xyz +chithinh.com +chito-18.info +chitthi.in +chivasso.cf +chivasso.ga +chivasso.gq +chivasso.ml +chivasso.tk +chmail.cf +chnaxa.com +chnlog.com +cho.com +choang.asia +chobam15.net +chobler.com +chocklet.us +choco.la +chocolategiftschoice.info +chocolato39mail.biz +chodas.com +chodyi.com +choeunart.com +chogmail.com +choicecomputertechnologies.com +choicefoods.ru +choicemail1.com +choiceoneem.ga +choichay.com +choigi.com +chokiwnl.men +chokodog.xyz +chokxus.com +cholaban.ml +choladhisdoctor.com +chomagor.com +chong-mail.com +chong-mail.net +chong-mail.org +chong-soft.net +chongblog.com +chongqilai.cc +chongseo.cn +chongsoft.cn +chongsoft.com +chongsoft.org +chonxi.com +chookie.com +chooky.site +choosietv.com +choozcs.com +chooze254.com +chophim.com +choqr6r4.com +chordguitar.us +chordmi.com +chort.eu +chosenx.com +chotgo.com +chothuevinhomesquan9.com +chotunai.com +chovy12.com +chowet.site +chratechbeest.club +chrfeeul.com +chris.burgercentral.us +chrisanhill.com +chriscd.best +chrisgomabouna.eu +chrisitina.com +chrissellskelowna.com +christ.show +christian-louboutin.com +christian-louboutin4u.com +christian-louboutinsaleclearance.com +christianlouboutin-uk.info +christianlouboutinaustralia.info +christianlouboutincanada.info +christianlouboutinccmagasin.com +christianlouboutinmagasinffr.com +christianlouboutinmagasinffrance1.com +christianlouboutinmagasinfra.com +christianlouboutinnoutlet.com +christianlouboutinnreplica.com +christianlouboutinopascherfr.com +christianlouboutinoutletstores.info +christianlouboutinpascherenligne.com +christianlouboutinpascherffr.com +christianlouboutinpascherr.com +christianlouboutinportugal.com +christianlouboutinppascher.com +christianlouboutinppaschers.com +christianlouboutinrfrance.com +christianlouboutinsale-shoes.info +christianlouboutinsaleshoes.info +christianlouboutinshoe4sale.com +christianlouboutinsuk.net +christianlouboutinukshoes.info +christianlouboutsshoes.com +christiansongshnagu.com +christinacare.org +christmass.org +christopherfretz.com +chroeppel.com +chromail.info +chronicle.digital +chronocrusade.com +chronosport.ru +chrspkk.ru +chsl.tk +chsp.com +chteam.net +chuacotsong.online +chuan.info +chubbyteenmodels.com +chuckbennettcontracting.com +chuckbrockman.com +chuckstrucks.com +chudosbor-yagodnica.ru +chuhstudent.org +chuj.de +chukenpro.tk +chumpstakingdumps.com +chundage.help +chungnhanisocert.com +chuongtrinhcanhac.com +chupanhcuoidep.com +chupanhcuoidep.vn +churning.app +chvtqkb.pl +chvz.com +chwilowkiibezbik.pl +chwilowkiionlinebezbik.pl +chwytyczestochowa.pl +chxxfm.com +chyju.com +chysir.com +ci.mintemail.com +cia-spa.com +cia.hytech.biz.st +ciagorilla.com +cialis-20.com +cialis20mgrxp.us +cialiscouponss.com +cialisgeneric-us.com +cialisgeneric-usa.com +cialisgenericx.us +cialisietwdffjj.com +cialiskjsh.us +cialismim.com +cialisonline-20mg.com +cialisonlinenopresx.us +cialisonlinerxp.us +cialisopharmacy.com +cialispills-usa.com +cialisrxmsn.com +cialissuperactivesure.com +cialiswithoutadoctorprescriptions.com +cialisy.info +ciaoitaliano.info +ciapharmshark.com +ciaresmi-orjinalsrhbue.ga +ciaterides.quest +ciatico.site +cibermedia.com +cibernews.ru +cibrian.com +cicek12.xyz +cicie.club +cid.kr +cidoad.com +cidolo.fun +cidorigas.one +cidria.com +ciekawa-strona-internetowa.pl +ciekawastronainternetowa.pl +ciekawostkii.eu +ciekawostkilol.eu +ciensun.co.pl +cientifica.org +ciesz-sie-moda.pw +cif.emlhub.com +cigar-auctions.com +cigarshark.com +cigidea.com +cigs.com +cikantor.fun +cikuh.com +cilemail.ga +cilian.mom +cilo.us +cilundir.com +cimagupy.online +cimario.com +cimas.info +cindalle.com +cinderblast.top +cindyfatikasari.art +cindygarcie.com +cinemacollection.ru +cinemaestelar.com +cinemalive.info +cingcawow.guru +cingularpvn.com +cinnamonproductions.com +cioin.pl +ciosopka.ml +ciproonlinesure.com +ciprorxpharma.com +ciptasphere.tech +ciqv53tgu.pl +circinae.com +circlechat.org +cirengisibom.guru +ciromarina.net +cironex.com +cirrushdsite.com +cisadane.tech +cishanghaimassage.com +ciskovibration.com +citationslist.com +citdaca.com +cite.name +citi.articles.vip +cities-countries.ru +citiinter.com.sg +citippgad.ga +citizen6y6.com +citizencheck.com +citizenkane.us +citizenlaw.ru +citizensonline.com +citizenssouth.com +citmo.net +citron-client.ru +citrusvideo.com +city-girls.org +city.blatnet.com +city.droidpic.com +city6469.ga +cityanswer.ru +citykurier.pl +citylightsart.com +citymail.online +citymax.vn +cityoflakeway.com +cityofsomerton.com +cityroyal.org +citywideacandheating.com +citywinetour.com +ciud.emlhub.com +ciudad-activa.com +civbc.com +cividuato.site +civikli.com +civilengineertop.com +civilium.com +civilius.xyz +civilizationdesign.xyz +civilokant903.ga +civilokant903.gq +civilroom.com +civinbort.site +civisp.site +civitellaroveto.eu +civoo.com +civvic.ro +civx.org +ciweltrust33deep.tk +cj.mintemail.com +cj2v45a.pl +cjal.emlhub.com +cjck.eu +cjet.net +cjhc.yomail.info +cjj.com +cjpeg.com +cjrnskdu.com +cjuprf2tcgnhslvpe.cf +cjuprf2tcgnhslvpe.ga +cjuprf2tcgnhslvpe.gq +cjuprf2tcgnhslvpe.ml +cjuprf2tcgnhslvpe.tk +cjvc.emlpro.com +cjxn.dropmail.me +ck12.cf +ck12.ga +ck12.gq +ck12.ml +ck12.tk +ckaazaza.tk +ckatalog.pl +ckaywo.emltmp.com +ckcltd.ru +ckdvjizln.pl +ckentuckyq.com +cketrust.org +ckfibyvz1nzwqrmp.cf +ckfibyvz1nzwqrmp.ga +ckfibyvz1nzwqrmp.gq +ckfibyvz1nzwqrmp.ml +ckfibyvz1nzwqrmp.tk +ckfirmy.pl +ckfmqf.fun +ckfsunwwtlhwkclxjah.cf +ckfsunwwtlhwkclxjah.ga +ckfsunwwtlhwkclxjah.gq +ckfsunwwtlhwkclxjah.ml +ckfsunwwtlhwkclxjah.tk +ckhouse.hk +ckiaspal.ovh +ckiso.com +ckkdetails.com +ckme1c0id1.cf +ckme1c0id1.ga +ckme1c0id1.gq +ckme1c0id1.ml +ckme1c0id1.tk +cko.kr +ckoie.com +ckptr.com +ckr5o.anonbox.net +ckv.dropmail.me +ckvn.edu.vn +ckw.emlhub.com +ckyxtcva19vejq.cf +ckyxtcva19vejq.ga +ckyxtcva19vejq.gq +ckyxtcva19vejq.ml +ckyxtcva19vejq.tk +cl-cl.org +cl-outletonline.info +cl-pumps.info +cl-pumpsonsale.info +cl.gl +cl0ne.net +cl2004.com +claarcellars.com +claimab.com +claimtaxrebate.com +clairineclay.art +clamiver.ga +clamiver.ml +clan.emailies.com +clan.marksypark.com +clan.oldoutnewin.com +clan.poisedtoshrike.com +clandest.in +clanranks.com +clanstorm.com +claratrend.shop +clare-smyth.art +claresmyth.art +clargest.site +clarionsj.com +clark-college.cf +clarkgriswald.net +clarkown.com +clarksco.com +clarkwardlaw.com +claromail.co +clashatclintonemail.com +clashgems2016.tk +clashlive.com +clashofclanshackdeutsch.xyz +clasicvacations.store +claspira.com +class.droidpic.com +class.emailies.com +classesmail.com +classgess.com +classibooster.com +classicalconvert.com +classicaltantra.com +classicdvdtv.com +classicebook.com +classichandbagsforsale.info +classiclouisvuittonsale.com +classicnfljersey.com +classictiffany.com +classicweightloss.org +classiestefanatosmail.net +classificadosdourados.com +classificadosdourados.org +classified.zone +classitheme.com +classydeveloper.com +classywebsite.co +claud.it +claudd.com +claudebosi.art +claudiaamaya.com +claudiabest.com +claudiahidayat.art +claudyputri.art +claus.tk +clay.xyz +clayandplay.ru +clayeastx.com +clcraftworks.com +cld.emlpro.com +cldxonline.com +clean-calc.de +clean-living-ventures.com +clean.adriaticmail.com +clean.cowsnbullz.com +clean.oldoutnewin.com +clean.pro +cleaning-co.ru +cleaningcompanybristol.com +cleaningtalk.com +cleanmail.fun +cleansafemail.com +cleantalkorg.ru +cleantalkorg1.ru +cleantalkorg2.ru +cleantalkorg4.ru +cleantalkorg5.ru +cleanzieofficial.online +clear-code.ru +clearancebooth.com +clearcutcreative.com +clearmail.online +clearwaterarizona.com +clearwatercpa.com +clearwatermail.info +clearworry.com +clendere.asia +clene.xyz +clevelandcoupondiva.com +clevelandquote.com +cleverr.site +cleverwearing.us +clhtv.online +click-email.com +click-mail.net +click-mail.top +click-wa.me +click24.site +click2btc.com +click2mail.net +clickanerd.net +clickdeal.co +clickernews.com +clickmagnit.ru +clickmail.info +clickmail.tech +clickmarte.xyz +clickmenetwork.com +clickonce.org +clickr.pro +clicks2you.com +clicksecurity.com +clicktrack.xyz +client.makingdomes.com +client.marksypark.com +client.ploooop.com +client.popautomated.com +clientesftp55.info +clientologist.net +clientric.com +clients.blatnet.com +clients.cowsnbullz.com +clients.poisedtoshrike.com +clifors.xyz +clikhere.net +climaconda.ru +climate-changing.info +climatefoolsday.com +climbing-dancing.info +climchabjale.tk +climitory.site +clindamycin.website +clinical-studies.com +clinicalcheck.com +clinicatbf.com +clinicsworlds.live +cliniquedarkspotcorrector.com +clintonemailhearing.com +clintonsparks.com +cliol.com +clip.lat +clipmail.cf +clipmail.eu +clipmail.ga +clipmail.gq +clipmail.ml +clipmail.tk +clipmails.com +cliptik.net +cliqueone.com +clit.games +clitbate.com +clitor-tube.com +clixser.com +clk-safe.com +clk2020.co +clk2020.com +clk2020.info +clk2020.net +clk2020.org +clm-blog.pl +clock.com +clock64.ru +clockance.com +clockemail.com +clockth.com +clockus.ru +clomid.info +clomidonlinesure.com +clonchectu.ga +clone79.com +cloneads.top +clonechatluong.net +clonechoitut.vip +cloneemail.com +clonefb247-net.cf +clonefb247-net.ga +clonefb247-net.gq +clonefb247-net.ml +clonefb247-net.tk +clonefbtmc1.club +clonegiare.shop +cloneig.shop +cloneigngon.click +cloneiostrau.org +clonekhoe.com +clonemailgiare.com +clonemailsieure.click +clonemailsieure.com +clonemoi.tk +clonenbr.site +clonenpa.com +clonere.net +clonetop1.shop +clonetrust.com +clonevietmail.click +cloneviptmc1.club +clonevnmail.com +clonezu.fun +clonvn2.com +close-room.ru +closedbyme.com +closente.com +closetab.email +closetguys.com +closeticv.space +closetonyc.info +closurist.com +closurize.com +clothance.com +clothingbrands2012.info +cloud-mail.id +cloud-mail.net +cloud-mail.top +cloud-server.id +cloud-temp.com +cloud.blatnet.com +cloud.cowsnbullz.com +cloud.oldoutnewin.com +cloud43music.xyz +cloud99.pro +cloud99.top +cloudbst.com +cloudcua.art +cloudcua.cloud +cloudcua.one +clouddisruptor.com +cloudeflare.com +cloudemail.xyz +cloudflare.gay +cloudfoundry.store +cloudgen.world +cloudhosting.info +cloudido.com +cloudkuimages.com +cloudlfront.com +cloudmail.gq +cloudmail.tk +cloudmails.tech +cloudmarriage.com +cloudns.asia +cloudns.cc +cloudns.cf +cloudns.cx +cloudns.gq +cloudonf.com +cloudscredit.com +cloudservicesproviders.net +cloudsigmatrial.cf +cloudsign.in +cloudssima.myvnc.com +cloudstat.top +cloudstreaming.info +cloudsyou.com +cloudt12server01.com +cloudtempmail.net +cloudts.shop +cloudxanh.vn +cloudy-inbox.com +cloudysmart.ga +clout.wiki +cloutlet-vips.com +cloverdelights.com +clovergy.co +clovet.com +clovet.ga +clovisattorneys.com +clowmail.com +clozec.online +clpuqprtxtxanx.cf +clpuqprtxtxanx.ga +clpuqprtxtxanx.gq +clpuqprtxtxanx.ml +clpuqprtxtxanx.tk +clr.dropmail.me +clrmail.com +cls-audio.club +clsn.top +clsn1.com +club.co +club106.org.uk +club55vs.host +clubbaboon.com +clubcaterham.co.uk +clubdetirlefaucon.com +clubemp.com +clubexnis.gq +clubfanshd.com +clubfier.com +clublife.ga +clubmercedes.net +clubnew.uni.me +clubnews.ru +clubsanswers.ru +clubstt.com +clubtonurse.com +clubuggboots.com +clubwarp.top +clubzmail.club +clue-1.com +clue.bthow.com +clunker.org +cluom.com +clup.work +clutchbagsguide.info +clutthob.com +clutunpodli.ddns.info +cluu.de +clwellsale.com +clzo.com +clzoptics.com +cmael.com +cmail.club +cmail.com +cmail.host +cmail.net +cmail.org +cmailing.com +cmawfxtdbt89snz9w.cf +cmawfxtdbt89snz9w.ga +cmawfxtdbt89snz9w.gq +cmawfxtdbt89snz9w.ml +cmawfxtdbt89snz9w.tk +cmc88.tk +cmcast.com +cmcoen.com +cmcproduce.com +cmdg.laste.ml +cmdkl.com +cmeinbox.com +cmheia.com +cmhr.com +cmhvqhs.ml +cmhvzylmfc.com +cmial.com +cmjinc.com +cmmail.ru +cmmgtuicmbff.ga +cmmgtuicmbff.ml +cmmgtuicmbff.tk +cmna.emlhub.com +cmoki.pl +cmpschools.org +cmr.yomail.info +cms-rt.com.com +cmsf.com +cmstatic.com +cmtcenter.org +cmu.yomail.info +cmusicsxil.com +cn-chivalry.com +cn.dropmail.me +cn7c.com +cn9n22nyt.pl +cnamed.com +cnanb.com +cnazure.com +cnbet8.com +cncb.de +cncsystems.de +cnctexas.com +cncu.freeml.net +cndps.com +cne.emltmp.com +cneemail.com +cnetmail.net +cnew.ir +cnewsgroup.com +cngf.emltmp.com +cnguopin.com +cnh.industrial.ga +cnh.industrial.gq +cnhindustrial.cf +cnhindustrial.ga +cnhindustrial.gq +cnhindustrial.ml +cnhindustrial.tk +cnieux.com +cniirv.com +cnj.agency +cnm.emlpro.com +cnmsg.net +cnn.coms.hk +cnnglory.com +cnogs.com +cnolder.net +cnovelhu.com +cnsa.biz +cnsds.de +cnshosti.in +cnurbano.com +cnxcoin.com +cnxingye.com +co.cc +co.mailboxxx.net +co.uk.com +co1vgedispvpjbpugf.cf +co1vgedispvpjbpugf.ga +co1vgedispvpjbpugf.gq +co1vgedispvpjbpugf.ml +co1vgedispvpjbpugf.tk +co2uk.shop +coach-outletonlinestores.info +coach-purses.info +coachartbagoutlet.com +coachbagoutletjp.org +coachbagsforsalejp.com +coachbagsonlinesale.com +coachbagsonsalesjp.com +coachbagssalesjp.com +coachbagsshopjp.com +coachcheapjp.com +coachchoooutlet.com +coachfactorybagsjp.com +coachfactorystore-online.us +coachfactorystoreonline.us +coachhandbags-trends.us +coachhandbagsjp.net +coaching-supervision.at +coachnetworkmarketing.com +coachnewoutlets.com +coachnutrio.com +coachonlinejp.com +coachonlinepurse.com +coachoutletbagscaoutlet.ca +coachoutletlocations.com +coachoutletonline-stores.us +coachoutletonlinestores.info +coachoutletpop.org +coachoutletstore.biz +coachoutletstore9.com +coachoutletvv.net +coachsalejp.com +coachsalestore.net +coachseriesoutlet.com +coachstorejp.net +coachstoresjp.com +coachtransformationacademy.com +coachupoutlet.com +coaeao.com +coagro.net +coainu.com +coalamails.com +coalhollow.org +coalitionfightmusic.com +coania.com +coapp.net +coasah.com +coastalbanc.com +coastalorthopaedics.com +coastmagician.com +coatsnicejp.com +cobal.infos.st +cobaltcrowproductions.xyz +cobarekyo1.ml +cobete.cf +cobin2hood.com +cobin2hood.company +coboe.com +coc.freeml.net +cocabooka.site +cocac.uk +cocast.net +coccx1ajbpsz.cf +coccx1ajbpsz.ga +coccx1ajbpsz.gq +coccx1ajbpsz.ml +coccx1ajbpsz.tk +cochatz.ga +cochranmail.men +cockpitdigital.com +coclaims.com +cocledge.com +coco-dive.com +coco.be +coco00.com +cocochaneljapan.com +cocodani.cf +cocoidprzodu.be +cocolesha.space +cocooan.xyz +cocoro.uk +cocosrevenge.com +cocoting.space +cocovpn.com +cocreatorsventures.com +cocyo.com +codb.site +codc.site +codcodfns.com +code-gmail.com +code-mail.com +code.blatnet.com +code.cowsnbullz.com +code.marksypark.com +codea.site +codeandscotch.com +codeangel.xyz +codeb.site +codeconnoisseurs.ml +codee.site +codefarm.dev +codeg.site +codeguard.net +codeh.site +codei.site +codej.site +codel.site +codem.site +codemail1.com +codeo.site +codeq.site +coderdir.com +coderoutemaroc.com +codestar.site +codeu.site +codeuoso.com +codew.site +codexs.sbs +codeyou.site +codg.site +codgal.com +codh.site +codiagency.us +codib.site +codic.site +codid.site +codie.site +codif.site +codig.site +codih.site +codii.site +codij.site +codik.site +codil.site +codim.site +codingliteracy.com +codip.site +codiq.site +codir.site +codit.site +codiu.site +codiv.site +codivide.com +codiw.site +codix.site +codiz.site +codj.site +codjfiewhj21.com +codk.site +codm.community +codm.site +codmobilehack.club +codn.site +codp.site +codq.site +cods.space +codt.site +codu.site +codua.site +codub.site +coduc.site +codud.site +codue.site +coduf.site +codug.site +coduh.site +codui.site +coduj.site +coduk.site +codul.site +codum.site +codun.site +coduo.site +codup.site +codupmyspace.com +coduq.site +codur.site +codw.site +codx.site +codyfosterandco.com +codyting.com +codz.site +coegco.ca +coepoe.cf +coepoe.ga +coepoe.tk +coepoebete.ga +coepoekorea.ml +coffeeazzan.com +coffeejadore.com +coffeelovers.life +coffeepancakewafflebacon.com +coffeeshipping.com +coffeetimer24.com +coffeetunner.com +cofferoom.art +coffygroup.com +cognalsearch.com +cognitiveways.xyz +cogpal.com +cohdi.com +cohodl.com +cohwabrush.com +coieo.com +coin-host.net +coin-hub.net +coin-link.com +coin-mail.com +coin-one.com +coin.wf +coinalgotrader.com +coinbroker.club +coincal.org +coincheckup.net +coindie.com +coinecon.com +coinero.com +coinhelp123.com +coinific.com +coinlink.club +coinnews.ru +coino.eu +coinsteemit.com +coinvers.com +coinxt.net +coiosidkry57hg.gq +cojita.com +cok.org.uk +cokbilmis.site +cokeandket.tk +cokeley84406.co.pl +cokhiotosongiang.com +cokils.com +coklat-qq.info +coklow88.aquadivingaccessories.com +colabeta.com +colacolaaa.com +colacompany.com +colacube.com +colafanta.cf +colaik.com +colaname.com +colddots.com +colde-mail.com +coldemail.info +coldmail.ga +coldmail.gq +coldmail.ml +coldmail.tk +coldsauce.com +coldzera.fun +coleure.com +colevillecapital.com +colimarl.com +colinrofe.co.uk +colinzaug.net +colivingbansko.com +collapse3b.com +collectionmvp.com +collectors.global +collectors.international +collectors.solutions +collegee.net +collegefornurse.com +collegeofpublicspeaking.com +collegewh.edu.pl +colletteparks.com +colloidalsilversolutions.com +colloware.com +coloc.venez.fr +colombiaword.ml +coloncleanse.club +coloncleansereview1.org +coloncleansingplan.com +coloniallifee.com +coloninsta.tk +coloplus.ru +colorado-nedv.ru +coloradoapplianceservice.com +coloradoes.com +colorcastmail.com +colorweb.cf +colosophich.site +colourmedigital.com +coltprint.com +columbianagency.com +columbuscheckcashers.com +columbusquote.com +colurmish.com +com-14147678891143.top +com-item.today +com-ma.net +com-posted.org +com-ty.biz +com.dropmail.me +comagrilsa.com +comam.ru +comantra.net +comassage.online +comatoze.com +combcub.com +combine.bar +combrotech77rel.gq +combustore.co +combyo.com +come-on-day.pw +come-to-win.com +come.heartmantwo.com +come.lakemneadows.com +come.marksypark.com +come.qwertylock.com +comececerto.com +comedimagrire24.it +comella54173.co.pl +comenow.info +comeonday.pw +comeonfind.me +comeporon.ga +comercialsindexa.com +comespiaresms.info +comespiareuncellulare.info +comespiareuncellularedalpc.info +comethi.xyz +cometoclmall.com +comexa.uk +comfortableshoejp.com +comfythings.com +comfytrait.xyz +comilzilla.org +comisbnd.com +comitatofesteteolo.com +comitatofesteteolo.xyz +comk2.peacled.xyz +comlive.tk +comm.craigslist.org +comments2g.com +commercialpropertiesphilippines.com +commercialwindowcoverings.org +commercialworks.com +commissionship.xyz +communitas.site +communitize.net +community-college.university +communityans.ru +communitybuildingworks.xyz +communityforumcourse.com +communityhealthplan.org +comodormail.com +comoestudarsozinho.com.br +comohacer.club +comohacerunmillon.com +comolohacenpr.com +compali.com +compandlap.xyz +companiesdates.live +companieslife.life +company-mails.com +companycontacts.net +companycontactslist.com +companydsmeun.cloud +companyhub.cloud +companyhubs.live +companyid.shop +companynotifier.com +companyprogram.biz +companytitles.com +companytour.online +companytour.shop +companywa.live +companyworld.us +compaq.com +compare-carinsurancecanada.info +compare-carinsuranceusa.info +comparedigitalcamerassidebyside.org +comparegoodshoes.com +comparekro.com +comparepetinsurance.biz +compareshippingrates.org +comparisherman.xyz +comparisions.net +compartedata.com.ar +comparteinformacion.com.ar +comparthe.site +compasschat.ru +competirer.com +complete-hometheater.com +completegolfswing.com +completemad.com +completemedicalmgnt.com +completeoilrelief.com +complextender.ru +componentartscstamp.store +compoundtown.com +comprabula.pt +comprar-com-desconto.com +comprarcapcut.shop +compraresteroides.xyz +comprarfarmacia.site +comprehensivesearchinitiatives.com +comprensivosattacarbonia.it +compressionrelief.com +compressjpg.io +compscorerric.eu +compservmail.com +compservsol.com +comptophone.net +comptravel.ru +compuhelper.org +compung.com +computations.me +computatrum.online +computer-service-in-heidelberg.de +computer-service-in-heilbronn.de +computer-service-sinsheim.de +computercrown.com +computerdrucke.de +computerengineering4u.com +computerhardware2012.info +computerinformation4u.com +computerlookup.com +computerrepairinfosite.com +computerrepairredlands.com +computersarehard.com +computerserviceandsupport.com +computersoftware2012.info +computerspeakers22.com +computtee.com +coms.hk +comsafe-mail.net +comsb.com +comspotsforsale.info +comunidadtalk.com +comwest.de +comyze.org +con.com +con.net +conadep.cd +concavodka.com +concealed.company +conceptdesigninc.com +conceptspringstudio.com +concetomou.eu +conciergenb.pl +concordhospitality.com +concoursup.com +concretegrinding.melbourne +concretepolishinghq.com +concreteremoval.ca +concu.net +condating.info +condecco.com +condorviajes.com +condovallarta.info +conduongmua.site +conf.work +conferencecallfree.net +conferencelife.site +confessionsofatexassugarbaby.com +confessium.com +confidential.life +confidential.tips +confidentialmakeup.com +config.work +confighub.eu +confirm.live +confirmed.in +confmin.com +congatelephone.com +congetrinf.site +congle.us +congnghemoi.top +congthongtin247.net +congtythangmay.top +conisocial.it +conjurius.pw +connati.com +connectacc.com +connectcrossword.com +connectdeshi.com +connected-project.online +connecticut-nedv.ru +connecticutquote.com +connectiontheory.org +connectmail.online +connho.com +connho.net +connr.com +connriver.net +conone.ru +conquer-horizons.online +conquer-matrix.com +consentientgroup.com +conservativesagainstbush.com +consfant.com +considerinsurance.com +consimail.com +consolidate.net +conspicuousmichaelkors.com +conspiracyfreak.com +conspiracyliquids.com +constantinsbakery.com +constellational.com +constineed.site +constright.ru +constructionandesign.xyz +consulhosting.site +consultancies.cloud +consultancy.buzz +consultingcorp.org +consultservices.site +consumerriot.com +contabilidadebrasil.org +contabilitate.ws +contaco.org +contact.academic.edu.rs +contact.biz.st +contact.fifieldconsulting.com +contact.infos.st +contacterpro.com +contactmanagersuccess.com +contactout1000.ga +containergroup.com.au +containzof.com +contbay.com +contenand.xyz +contentwanted.com +contextconversation.com +continental-europe.ru +continumail.com +contmy.info +contopo.com +contple.com +contracommunications.com +contractorsupport.org +contrasto.cu.cc +controlinbox.com +controllerblog.com +contuild.com +contumail.com +conventionpreview.com +conventionstrategy.win +conventnyc.com +convergenceservice.com +conversejapan.com +conversister.xyz +convert-five.ru +convert.africa +converys.com +convexmirrortop.com +convoith.com +convoitu.com +convoitu.org +convoitucpa.com +convowall.com +coo.laste.ml +coobz0gobeptmb7vewo.cf +coobz0gobeptmb7vewo.ga +coobz0gobeptmb7vewo.gq +coobz0gobeptmb7vewo.ml +coobz0gobeptmb7vewo.tk +cooc.xyz +coochi.nl +cood.food +coofy.net +cooh-2.site +cookassociates.com +cookie007.fr.nf +cookiealwayscrumbles.co.uk +cookiecooker.de +cookiepuss.info +cookiers.tech +cookinglove.club +cookinglove.website +cookjapan.com +cookmasterok.ru +cookmeal.store +cool-your.pw +cool.com +cool.fr.nf +coolandwacky.us +coolbikejp.com +coolbluenet.com +coolcarsnews.net +coole-files.de +coolemailer.info +coolemails.info +coolex.site +coolimpool.org +cooljordanshoesale.com +cooljump.org +coolmail.com +coolmail.fun +coolmail.ooo +coolmailcool.com +coolmailer.info +coolmanuals.com +coolprototyping.com +coolstyleusa.com +coolvesti.ru +coolyarddecorations.com +coolyour.pw +cooo23.com +coooooool.com +coop1001facons.ca +coopals.com +cooperativalatina.org +cooperativeplus.com +cooperdoe.tk +copastore.co +copd.edu +copecbd.com +copi.site +copjlix.de.vc +copley.entadsl.com +copot.info +copperemail.com +copperstream.club +copyandart.de +copycashvalve.com +copyhome.win +copymanprintshop.com +copyright-gratuit.net +coqh.com +coqmail.com +cora.marketdoors.info +coraglobalista.com +coralgablesguide.com +coraljoylondon.com +coramail.live +coramaster.com +coranorth.com +corantct.com +cordialco.com +cordlessduoclean.com +cordtokens.com +core-rehab.org +corebitrun.com +corebux.com +coreclip.com +corecross.com +coreef.co.uk +coreef.uk +coreff.uk +corefitrun.com +corehabcenters.com +corejetgrid.com +corf.com +corhash.net +corkcoco.com +corkenpart.com +corksaway.com +corn.holio.day +cornwallschool.org +corona.is.bullsht.dedyn.io +corona99.net +coronachurch.org +coronacoffee.com +coronafleet.com +coronaforum.ru +coronagg.com +coronaschools.com +coronavirusguide.online +corp.ereality.org +corpkind.com +corpohosting.com +corporatet.com +correo.blogos.net +correofa.ga +correofa.tk +correoparacarlos.ga +correoparacarlos.ml +correoparacarlos.tk +correotemporal.org +correotodo.com +corrientelatina.net +corseesconnect1to1.com +corsenata.xyz +corsj.net +corsovenezia.com +cortex.kicks-ass.net +coruco.com +corunda.com +corylan.com +cosaxu.com +cosbn.com +coslots.gdn +cosmax25.com +cosmeticsurgery.com +cosmicart.ru +cosmogame.site +cosmolot-slot.site +cosmopokers.net +cosmopoli.co.uk +cosmopoli.org.uk +cosmorph.com +cosmos.com +cosoinan.com +cosrobo.com +costatop.xyz +costinluis.com +coswz.com +cosxo.com +cosynookoftheworld.com +cotasen.com +cotdvire543.com +coteconline.com +cotigz.com +cotocheetothecat12.com +cottage-delight.com +cottagefarmsoap.com +cottagein.ru +cottonandallen.com +cottononloverz.com +cottonsleepingbags.com +cotynet.pl +couchtv.biz +coughone.com +coukfree.co.uk +coukfree.uk +could.cowsnbullz.com +could.marksypark.com +could.oldoutnewin.com +could.poisedtoshrike.com +counselling-psychology.eu +countainings.xyz +counterdusters.us +countmoney.ru +countrusts.xyz +countrycommon.com +countryfinaancial.com +countryhotel.org +countrymade.com +countrypub.com +countrystudent.us +countytables.com +coupleedu.com +couplesandtantra.com +coupon-reviewz.com +couponcodey.com +couponhouse.info +couponm.net +couponmoz.org +couponoff.com +couponsdisco.com +couponsgod.in +couponslauncher.info +couponsmountain.com +courriel.fr.nf +courrieltemporaire.com +course-fitness.com +course.nl +courseair.com +coursesall.ru +coursora.com +courtney.maggie.istanbul-imap.top +courtrf.com +courtsugkq.com +cousinit.mooo.com +cousinment.com +covbase.com +covell37.plasticvouchercards.com +covelocoop.com +coveninfluence.ml +coverification.org +covermygodfromsummer.com +coveryourpills.org +covfefe-mail.gq +covfefe-mail.tk +covidnews24.xyz +covorin.com +covteh37.ru +cowabungamail.com +cowaway.com +cowboywmk.com +cowcell.com +cowck.com +cowgirljules.com +cown.com +cowokbete.ga +cowokbete.ml +cowstore.net +cowstore.org +cox.bet +coxbete.cf +coxbete99.cf +coxinternet.com +coxnet.cf +coxnet.ga +coxnet.gq +coxnet.ml +coza.ro +cozmingusa.info +cozybop.com +cozydrop.xyz +cp.yomail.info +cpamail.net +cpaoz.com +cpaurl.com +cpav3.com +cpc.cx +cpcprint.com +cpdr.emlhub.com +cpeo3.anonbox.net +cpf-info.com +cpffinanceiro.club +cph.su +cpmail.life +cpmcast.net +cpmm.ru +cpmr.com +cpo.spymail.one +cpolp.com +cpqx.emltmp.com +cproxy.store +cps.freeml.net +cps.org +cpsystems.ru +cpt-emilie.org +cpuk3zsorllc.cf +cpuk3zsorllc.ga +cpuk3zsorllc.gq +cpuk3zsorllc.ml +cpuk3zsorllc.tk +cqczth.com +cqm.spymail.one +cqminan.com +cqpcut.id +cqtest.ru +cqutssntx9356oug.cf +cqutssntx9356oug.ga +cqutssntx9356oug.gq +cqutssntx9356oug.ml +cqutssntx9356oug.tk +cqwrxozmcl.ga +cr.cloudns.asia +cr.emlhub.com +cr.laste.ml +cr219.com +cr3wmail.sytes.net +cr3wxmail.servequake.com +cr97mt49.com +crab.dance +crablove.in +crackerbarrelcstores.com +crackingaccounts.ga +crackpot.ga +craet.top +craft.bthow.com +craftapk.com +craftinc.com +craftlures.com +crafttheweb.com +craftyclone.xyz +crankengine.net +crankhole.com +crankmails.com +crap.kakadua.net +crapmail.org +crappykickstarters.com +crapsforward.com +crashkiller.ovh +crashlandstudio.com +crass.com +crastination.de +crator.com +crayonseo.com +crazespaces.pw +crazy-xxx.ru +crazy18.xyz +crazybeta.com +crazycam.org +crazyclothes.ru +crazydoll.us +crazydomains.com +crazyijustcantseelol.com +crazykids.info +crazymail.info +crazymail.online +crazymailing.com +crazyshitxszxsa.com +crazyt.tk +crazzzyballs.ru +crboger.com +crcrc.com +cre8to6blf2gtluuf.cf +cre8to6blf2gtluuf.ga +cre8to6blf2gtluuf.gq +cre8to6blf2gtluuf.ml +cre8to6blf2gtluuf.tk +creahobby.it +crealat.com +creality3dturkiye.com +cream.pink +creamail.info +creamcheesefruitdipps.com +creamstrn.fun +creamstrn.live +creamstrn.online +creamstrn.shop +creamstrn.store +creamstrn.xyz +creamway.club +creamway.online +creamway.xyz +creaphototive.com +creatingxs.com +creationuq.com +creativainc.com +creativas.de +creative-journeys.com +creative-lab.com +creative-vein.co.uk +creative365.ru +creativecommonsza.org +creativeenergyworks.com +creativeindia.com +creativethemeday.com +creazionisa.com +credit-alaconsommation.com +credit-finder.info +credit-line.pl +credit-loans.xyz +credit-online.mcdir.ru +credit1.com +creditcardconsolidation.cc +creditcarddumpsites.ru +creditcardg.com +credithoperepair.com +creditorexchange.com +creditreportreviewblog.com +creditscorests.com +creditscoreusd.com +creditspread.biz +credo-s.ru +credtaters.ml +creek.marksypark.com +creek.poisedtoshrike.com +creekbottomfarm.com +creepfeed.com +creo.cad.edu.gr +creo.cloudns.cc +creo.ctu.edu.gr +creo.nctu.me +creou.dev +crepeau12.com +crescendu.com +crescentadvisory.com +cresek.cloud +cressa.com +crest-premedia.in +cretalscowad.xyz +creteanu.com +cretinblog.com +crezjumevakansii20121.cz.cc +crgevents.com +cribafmasu.co.tv +cricketworldcup2015news.com +crimenets.com +crimesont.com +criminal-lawyer-attorney.biz +criminal-lawyer-texas.net +criminalattorneyhouston.info +criminalattorneyinhouston.info +criminalattorneyinhouston.org +criminalisticsdegree.com +criminalizes233iy.online +criminallawyersinhoustontexas.com +criminalsearch1a.com +crimright.ru +cringemonster.com +criptacy.com +crisiscrisis.co.uk +crislosangeles.com +cristalin.ru +cristobalsalon.com +cristout.com +criteriourbano.es +crk.dropmail.me +crm-mebel.ru +crmail.top +crmlands.net +crmrc.us +croatia-nedv.ru +crobinkson.hu +crocoyes.fun +crodity.com +cron1s.vn +cronack.com +cronicasdepicnic.com +cronostv.site +cronot.xyz +cronx.com +cropur.com +cropuv.info +cropyloc.com +crosmereta.eu +cross-group.ru +cross-law.ga +cross-law.gq +cross.edu.pl +cross5161.site +crossed.de +crossfirecheats.org +crossfitcoastal.com +crossmail.bid +crossmailjet.com +crossroads-spokane.com +crossroadsmail.com +crosstelecom.com +crosswaytransport.net +crosswordchecker.com +crosswordtracker.net +crossyroadhacks.com +crotslep.ml +crotslep.tk +croudmails.info +croudmails.space +crow.gq +crow.ml +crowd-mail.com +crowd-mobile.com +crowdaffiliates.com +crowdanimoji.com +crowdcoin.biz +crowdeos.com +crowdlycoin.com +crowdpiggybank.com +croweteam.com +crowfiles.shop +crowity.com +crowncasinomacau.com +crpotu.com +crrec.anonbox.net +crsay.com +crtapev.com +crtfy.xyz +crtpy.xyz +crtsec.com +crturner.com +crub.cf +crub.ga +crub.gq +crub.ml +crub.tk +crublowjob20127.co.tv +crublowjob20127.com +crublowjob20129.co.tv +crufreevideo20123.cz.cc +crunchcompass.com +crunchyremark.site +cruncoau.asia +crur.com +crushdv.com +crushes.com +crusthost.com +crutenssi20125.co.tv +cruxmail.info +crw.emltmp.com +crydeck.com +cryingcon.com +crymail2.com +cryp.email +crypemail.info +crypgo.io +crypstats.top +crypt-world-pt.site +crypticinvestments.com +crypto-faucet.cf +crypto-net.club +crypto-nox.com +crypto.tyrex.cf +cryptoavalonsolhub.cloud +cryptoblad.nl +cryptoblad.online +cryptocitycenter.com +cryptocron.com +cryptocrowd.mobi +cryptofree.cf +cryptogameshub.com +cryptogmail.com +cryptogpt.live +cryptogpt.me +cryptohistoryprice.com +cryptolist.cf +cryptonet.top +cryptonews24h.xyz +cryptontrade.ga +cryptosmileys.com +cryptoszone.ga +cryptoupdates.live +cryptovilla.info +cryptowned.com +crystalhack.com +crystalrp.ru +crystaltapes.com +crystempens.site +crystle.club +cs-murzyn.pl +cs.email +cs4h4nbou3xtbsn.cf +cs4h4nbou3xtbsn.ga +cs4h4nbou3xtbsn.gq +cs4h4nbou3xtbsn.ml +cs4h4nbou3xtbsn.tk +cs5xugkcirf07jk.cf +cs5xugkcirf07jk.ga +cs5xugkcirf07jk.gq +cs5xugkcirf07jk.ml +cs5xugkcirf07jk.tk +cs6688.com +cs715a3o1vfb73sdekp.cf +cs715a3o1vfb73sdekp.ga +cs715a3o1vfb73sdekp.gq +cs715a3o1vfb73sdekp.ml +cs715a3o1vfb73sdekp.tk +csapparel.com +csc.spymail.one +csccsports.com +cscs.spymail.one +cscscs.spymail.one +csderf.xyz +csdfth.store +csdinterpretingonline.com +csdsl.net +csek.net +csf24.de +csfav4mmkizt3n.cf +csfav4mmkizt3n.ga +csfav4mmkizt3n.gq +csfav4mmkizt3n.ml +csfav4mmkizt3n.tk +csga.mimimail.me +csgo-market.ru +csgodemos.win +csgodose.com +csgofan.club +csgofreeze.com +csh.ro +csht.team +csi-miami.cf +csi-miami.ga +csi-miami.gq +csi-miami.ml +csi-miami.tk +csi-newyork.cf +csi-newyork.ga +csi-newyork.gq +csi-newyork.ml +csi-newyork.tk +csigma.myvnc.com +csiplanet.com +cslua.com +csmq.com +csmservicios.com +csmx.spymail.one +csoftmail.cn +cspaus.com +cspeakingbr.com +cspointblank.com +csr.hsgusa.com +csrbot.com +csrsoft.com +csslate.com +csso.laste.ml +cssu.edu +csupes.com +csuzetas.com +csvcialis.com +csvpubblicita.com +csx-1.store +csyriam.com +cszbl.com +ct.emlpro.com +ct.laste.ml +ct.spymail.one +ct345fgvaw.cf +ct345fgvaw.ga +ct345fgvaw.gq +ct345fgvaw.ml +ct345fgvaw.tk +ctair.com +ctasprem.pro +ctaylor.com +ctb.emlpro.com +ctechdidik.me +ctimendj.com +ctj.dropmail.me +ctmailing.us +ctopicsbh.com +ctos.ch +ctrobo.com +cts-lk-i.cf +cts-lk-i.ga +cts-lk-i.gq +cts-lk-i.ml +cts-lk-i.tk +ctshp.org +cttake1fiilie.ru +ctv.spymail.one +ctxh.site +cty.dropmail.me +ctycter.com +ctyctr.com +ctypark.com +ctzcyahxzt.ga +ctznqsowm18ke50.cf +ctznqsowm18ke50.ga +ctznqsowm18ke50.gq +ctznqsowm18ke50.ml +ctznqsowm18ke50.tk +cu.cc +cu.emlpro.com +cu8wzkanv7.cf +cu8wzkanv7.gq +cu8wzkanv7.ml +cu8wzkanv7.tk +cua77-official.gq +cua77.club +cua77.xyz +cuabebong.cyou +cuacua.foundation +cuadongplaza.com +cuaicloud.space +cuaina.com +cuan.email +cuanbrothers.com +cuanbrowncs.mom +cuanka.id +cuanka.online +cuanmarket.xyz +cuarl.com +cuasotrithuc.com +cuatrocabezas.com +cubavision.info +cubb6mmwtzbosij.cf +cubb6mmwtzbosij.ga +cubb6mmwtzbosij.gq +cubb6mmwtzbosij.ml +cubb6mmwtzbosij.tk +cubehost.us +cubeisland.com +cubene.com +cubfemales.com +cubicleremoval.ca +cubiclink.com +cubicview.site +cubox.biz.st +cucadas.com +cuckmere.org.uk +cucku.cf +cucku.ml +cucummail.com +cuddleflirt.com +cudimex.com +cuedigy.com +cuedingsi.cf +cuelmail.info +cuendita.com +cuenmex.com +cuentaspelis.top +cuentaspremium-es.xyz +cuerohosp.org +cufy.yomail.info +cuirugu.com +cuirushi.org +cuisine-recette.biz +cul0.cf +cul0.ga +cul0.gq +cul0.ml +cul0.tk +culasatu.site +culated.site +culbdom.com +culdemamie.com +culinaryservices.com +cullmanpd.com +culondir.com +cult-reno.ru +cultmovie.com +culturallyconnectedcook.org +cum.sborra.tk +cumallover.me +cumangeblog.net +cumanuallyo.com +cumbeeclan.com +cumfoto.com +cumonfeet.org +cumzle.com +cungchia.com +cungmua.vn +cungmuachung.net +cungmuachungnhom.com +cungsuyngam.com +cungtam.com +cunnilingus.party +cuoiz.com +cuoly.com +cuong.bid +cuongaquarium.com +cuongkigu.xyz +cuongrmfwbnpl43.online +cuongtaote.com +cuongvumarketingseo.com +cupbest.com +cupbret.com +cupf6mdhtujxytdcoxh.cf +cupf6mdhtujxytdcoxh.ga +cupf6mdhtujxytdcoxh.gq +cupf6mdhtujxytdcoxh.ml +cupf6mdhtujxytdcoxh.tk +cuponhostgator.org +cuppatweet.com +cupremplus.com +cuptober.com +cur.freeml.net +curcuplas.me +cure2children.com +curimbacreatives.online +curinglymedisease.com +curiousitivity.com +curletter.com +curlhph.tk +currencymeter.com +currentmail.com +currentmortgageratescentral.com +currymail.bid +currymail.men +curryworld.de +curso.tech +cursoconsertodecelular.top +cursodemicropigmentacao.us +cursodeoratoriasp.com +cursorvutr.com +cursospara.net +curtinicheme-sc.com +curtwphillips.com +curvehq.com +curvymail.top +cuscuscuspen.life +cushingsdisease.in +cushions.ru +cust.in +custom-wp.com +custom12.tk +customdevices.ru +customequipmentstore.com +customersupportdepartment.ga +customeyeslasik.com +customiseyourpc.xyz +customizedfatlossreviews.info +customjemds.com +customlogogolf-balls.com +custompatioshop.com +customrifles.info +customs.red +customs2g3.com +customsnapbackcap.com +customss.com +custonish.xyz +cutbebytsabina.art +cutcap.me +cuteblanketdolls.com +cuteboyo.com +cutedoll.shop +cutefier.com +cutefrogs.xyz +cutekinks.com +cutemailbox.com +cutie.com +cutout.club +cutradition.com +cutsup.com +cuttheory.com +cuttlink.me +cutxsew.com +cuvox.de +cuwanin.xyz +cuxade.xyz +cuzg.emlhub.com +cvagoo.buzz +cvd8idprbewh1zr.cf +cvd8idprbewh1zr.ga +cvd8idprbewh1zr.gq +cvd8idprbewh1zr.ml +cvd8idprbewh1zr.tk +cveiguulymquns4m.cf +cveiguulymquns4m.ga +cveiguulymquns4m.gq +cveiguulymquns4m.ml +cveiguulymquns4m.tk +cvelbar.com +cverizon.net +cvetomuzyk-achinsk.ru +cvijqth6if8txrdt.cf +cvijqth6if8txrdt.ga +cvijqth6if8txrdt.gq +cvijqth6if8txrdt.ml +cvijqth6if8txrdt.tk +cvkmonaco.com +cvmq.com +cvndr.com +cvoh.com +cvolui.xyz +cvs-couponcodes.com +cvscout.com +cvsout.com +cvurb5g2t8.cf +cvurb5g2t8.ga +cvurb5g2t8.gq +cvurb5g2t8.ml +cvurb5g2t8.tk +cvwq.emlpro.com +cvwvxewkyw.pl +cvx.spymail.one +cw8xkyw4wepqd3.cf +cw8xkyw4wepqd3.ga +cw8xkyw4wepqd3.gq +cw8xkyw4wepqd3.ml +cw8xkyw4wepqd3.tk +cw9bwf5wgh4hp.cf +cw9bwf5wgh4hp.ga +cw9bwf5wgh4hp.gq +cw9bwf5wgh4hp.ml +cw9bwf5wgh4hp.tk +cwcj.freeml.net +cwd.laste.ml +cwdt5owssi.cf +cwdt5owssi.ga +cwdt5owssi.gq +cwdt5owssi.ml +cwdt5owssi.tk +cwerwer.net +cwetg.co.uk +cwkdx3gi90zut3vkxg5.cf +cwkdx3gi90zut3vkxg5.ga +cwkdx3gi90zut3vkxg5.gq +cwkdx3gi90zut3vkxg5.ml +cwkdx3gi90zut3vkxg5.tk +cwmco.com +cwmxc.com +cwqksnx.com +cwr.emlpro.com +cwr.emltmp.com +cwrotzxks.com +cwroutinesp.com +cwtaa.com +cwzll.top +cx.de-a.org +cx.emlhub.com +cx.emltmp.com +cx4div2.pl +cxbhxb.site +cxboxcompone20121.cx.cc +cxcc.cf +cxcc.gq +cxcc.ml +cxcc.tk +cxetye.buzz +cxh.laste.ml +cxmyal.com +cxnlab.com +cxoc.us +cxpcgwodagut.cf +cxpcgwodagut.ga +cxpcgwodagut.gq +cxpcgwodagut.ml +cxpcgwodagut.tk +cxvixs.com +cxvxcv8098dv90si.ru +cxvxcvxcv.site +cxvxecobi.pl +cxwet.com +cyadp.com +cyanlv.com +cyantools.com +cyber-host.net +cyber-innovation.club +cyber-matrix.com +cyber-phone.eu +cyber-team.us +cyberbulk.me +cyberdada.live +cybergamerit.ga +cybergfl.com +cyberhohol.tk +cyberian.net +cyberknx.com +cyberlinkhub.com +cybermail.ga +cybermax.systems +cyberon.store +cyberper.net +cyberposthub.com +cybersecurityforentrepreneurs.com +cybersex.com +cybertipcore.com +cybrew.com +cycinst.com +cyclelove.cc +cyclesat.com +cyclisme-roltiss-over.com +cydco.org +cyelee.com +cygenics.com +cyhh.emlpro.com +cyhui.com +cyjd.top +cylab.org +cyluna.com +cyng.com +cynthialamusu.art +cyotto.ml +cypi.fr +cypresshop.com +cypriummining.com +cypruswm.com +cytsl.com +cyvuctsief.ga +cyw.laste.ml +cyz.com +cyza.mailpwr.com +cz.emlpro.com +cz.laste.ml +czanga.com +czarny.agencja-csk.pl +czbird.com +czblog.info +czeescibialystok.pl +czerwonaskarbonka.eu +czeta.wegrow.pl +czg.laste.ml +czilou.com +czm.emltmp.com +czpanda.cn +czqjii8.com +czqweasdefas.com +czsdzwqaasd.com +czub.xyz +czuj-czuj.pl +czuz.com +czyjtonumer.com +czystydywan.elk.pl +czystyzysk.net +czytnik-rss.pl +czzc.laste.ml +d-ax.xyz +d-link.cf +d-link.ga +d-link.gq +d-link.ml +d-link.tk +d-skin.com +d-v-w.de +d.asiamail.website +d.barbiedreamhouse.club +d.bestwrinklecreamnow.com +d.coloncleanse.club +d.crazymail.website +d.dogclothing.store +d.emltmp.com +d.extrawideshoes.store +d.gsamail.website +d.gsasearchengineranker.pw +d.gsasearchengineranker.site +d.gsasearchengineranker.space +d.gsasearchengineranker.top +d.gsasearchengineranker.xyz +d.mediaplayer.website +d.megafon.org.ua +d.mylittlepony.website +d.ouijaboard.club +d.polosburberry.com +d.searchengineranker.email +d.seoestore.us +d.uhdtv.website +d.virtualmail.website +d.waterpurifier.club +d.yourmail.website +d0gone.com +d10.michaelkorssaleoutlet.com +d123.com +d154cehtp3po.cf +d154cehtp3po.ga +d154cehtp3po.gq +d154cehtp3po.ml +d154cehtp3po.tk +d1rt.net +d1xrdshahome.xyz +d1yun.com +d2c6c.anonbox.net +d2pwqdcon5x5k.cf +d2pwqdcon5x5k.ga +d2pwqdcon5x5k.gq +d2pwqdcon5x5k.ml +d2pwqdcon5x5k.tk +d2v3yznophac3e2tta.cf +d2v3yznophac3e2tta.ga +d2v3yznophac3e2tta.gq +d2v3yznophac3e2tta.ml +d2v3yznophac3e2tta.tk +d32ba9ffff4d.servebeer.com +d3account.com +d3bb.com +d3ff.com +d3gears.com +d3p.dk +d3rwm.anonbox.net +d4eclvewyzylpg7ig.cf +d4eclvewyzylpg7ig.ga +d4eclvewyzylpg7ig.gq +d4eclvewyzylpg7ig.ml +d4eclvewyzylpg7ig.tk +d4networks.org +d4tco.anonbox.net +d4wan.com +d58pb91.com +d5fffile.ru +d5ipveksro9oqo.cf +d5ipveksro9oqo.ga +d5ipveksro9oqo.gq +d5ipveksro9oqo.ml +d5ipveksro9oqo.tk +d5ljl.anonbox.net +d5wwjwry.com.pl +d5yu3i.emlhub.com +d75d8ntsa0crxshlih.cf +d75d8ntsa0crxshlih.ga +d75d8ntsa0crxshlih.gq +d75d8ntsa0crxshlih.ml +d75d8ntsa0crxshlih.tk +d7bpgql2irobgx.cf +d7bpgql2irobgx.ga +d7bpgql2irobgx.gq +d7bpgql2irobgx.ml +d7lw7.anonbox.net +d8u.us +d8wjpw3kd.pl +d8zzxvrpj4qqp.cf +d8zzxvrpj4qqp.ga +d8zzxvrpj4qqp.gq +d8zzxvrpj4qqp.ml +d8zzxvrpj4qqp.tk +d9faiili.ru +d9jdnvyk1m6audwkgm.cf +d9jdnvyk1m6audwkgm.ga +d9jdnvyk1m6audwkgm.gq +d9jdnvyk1m6audwkgm.ml +d9jdnvyk1m6audwkgm.tk +d9tl8drfwnffa.cf +d9tl8drfwnffa.ga +d9tl8drfwnffa.gq +d9tl8drfwnffa.ml +d9tl8drfwnffa.tk +d9wow.com +da-bro.ru +da-da-da.cf +da-da-da.ga +da-da-da.gq +da-da-da.ml +da-da-da.tk +da.emltmp.com +da.laste.ml +da.spymail.one +daabox.com +daaiyurongfu.com +daawah.info +dab.ro +dabeixin.com +dabest.ru +dabestizshirls.com +dabjam.com +dabmail.xyz +dabrapids.com +dabrigs.review +dacarirato.com.my +dacgu.com +dacha-24.ru +dachinese.site +daciasandero.cf +daciasandero.ga +daciasandero.gq +daciasandero.ml +daciasandero.tk +dacoolest.com +dacre.us +dad.biprep.com +dadamango.life +dadaproductions.net +dadbgspxd.pl +dadd.kikwet.com +daddybegood.com +dadeschool.net +daditrade.com +dadosa.xyz +dadsa.com +dadschools.net +dae-bam.net +daeac.com +daegon.tk +daemoniac.info +daemonixgames.com +daemsteam.com +daerdy.com +daeschools.net +daewoo.gq +daewoo.ml +dafardoi1.com +daff.pw +dafgtddf.com +dafinally.com +dafrem3456ails.com +daftarjudimixparlay.com +dagagd.pl +dagatour.com +dagavip1.top +dagj.emlpro.com +dahaka696.com +dahelvets.gq +dahlenerend.de +dahongying.net +daibond.info +daiettodorinku.com +daiigroup.com +daiklinh.com +daikoa.com +daileyads.com +daily-cash.info +daily-dirt.com +daily-email.com +dailyautoapprovedlist.blogmyspot.com +dailyawesomedeal.com +dailycryptomedia.com +dailygaja.com +dailygoodtips.com +dailyhealthclinic.com +dailyjoa.com +dailyladylog.com +dailyloon.com +dailypowercleanse.com +dailypublish.com +dailyquinoa.com +dailysocialpro.com +dailywebnews.info +daimlerag.cf +daimlerag.ga +daimlerag.gq +daimlerag.ml +daimlerag.tk +daimlerchrysler.cf +daimlerchrysler.gq +daimlerchrysler.ml +dainaothiencung.vn +daintly.com +dairyfarm-residences-sg.com +daisapodatafrate.com +daisycouture.shop +daisyura.tk +dait.cf +dait.ga +dait.gq +dait.ml +dait.tk +daiuiae.com +dajotayo.com +dajy.dropmail.me +dakaka.org +dakcans.com +dakgunaqsn.pl +dakimakura.com +dakkapellenbreda.com +dakshub.org +dakuchiice.live +dal-net.ml +dalailamahindi.org +dalamanporttransfer.xyz +dalatvirginia.com +daleadershipinstitute.org +dalebig.com +dalebrooks.life +dalecagie.online +daleloan.com +dalevillevfw.com +dalexport.ru +daliamodels.pl +daliamoh.shop +dalianseasun.com +dalianshunan.com +daliborstankovic.com +dalins.com +dallaisd.org +dallas-ix.org +dallas.gov +dallas4d.com +dallasbuzz.org +dallascolo.biz +dallascowboysjersey.us +dallascriminaljustice.com +dallasdaybook.com +dallasdebtsettlement.com +dallasftworthdaybook.com +dallaslandscapearchitecture.com +dallaslotto.com +dallaspentecostal.org +dallaspooltableinstallers.com +dallassalons.com +dalremi.cf +dalremi.ga +daltongullo.com +daltonmillican.com +daltv14.com +daltv15.com +daluzhi.com +daly.malbork.pl +dalyoko.com +dalyoko.ru +damacosmetickh.com +damaginghail.com +damai.webcam +damail.ga +damanik.ga +damanik.tk +damaso-nguyen-tien-loi.xyz +damastand.site +damde.space +daminhptvn.com +damirtursunovic.com +damistso.cf +damistso.ga +damistso.ml +damlatas.com +damliners.biz +dammexe.net +damncity.com +damnser.co.pl +damnthespam.com +damonmorey.com +damptus.co.pl +dams.pl +damvl.site +dan-it.site +dan.lol +dan10.com +dan72.com +danamail.com +danangsale.net +danavibeauty.com +danburyjersey.com +dance-king-man.com +dance-school.me +danceinwords.com +dancejwh.com +danceliketiffanyswatching.org +dancemanual.com +danceml.win +dancethis.org.ua +dandang.email +dandanmail.com +dandantwo.com +dandcbuilders.com +dandenmark.com +dandikmail.com +dandinoo.com +dandrewsify.com +danelliott.live +daneral.com +danet.in +dangbatdongsan.com +dangemp.com +dangerbox.org +dangerouscriminal.com +dangerousdickdan.com +dangerousmailer.com +dangersdesmartphone.site +danggiacompany.com +dangirt.xyz +dangkibum.xyz +dangkydinhdanh.online +dangkygame.win +danhchinh.link +danhenry.watch +danhpro.top +danica1121.club +danielabrousse.com +danielcisar.com +danielfinnigan.com +danielgemp.info +danielgemp.net +danieljweb.net +danielkennedyacademy.com +danielkrout.info +danielmunoz.es +danielsagi.xyz +danielurena.com +daniilhram.info +danilkinanton.ru +danirafsanjani.com +danish4kids.com +daniya-nedv.ru +danjohnson.biz +dankmedical.com +dankmeme.zone +dankq.com +dankrangan77jui.ga +danlathel.cf +danlathel.ga +danlathel.gq +danlathel.ml +danlingjewelry.com +danmoulson.com +dann.mywire.org +danns.cf +danns.spicysallads.com +dannyhosting.com +danoshass.cloud +dantevirgil.com +dantri.com +danygioielli.it +danzeralla.com +dao.pp.ua +daoduytu.net +daolemi.com +daotaolamseo.com +daouse.com +dapelectric.com +daphnee1818.site +daponds.com +dapplica.com +dapurx.me +daqianbet.com +darazdigital.com +darbysdeals.com +dareblog.com +daricadishastanesi.com +daridarkom.com +dariolo.com +daritute.site +dark-tempmail.zapto.org +dark.lc +darkabyss.studio +darkestday.tk +darkfort.design +darkharvestfilms.com +darkmarket.live +darknode.org +darkse.com +darkstone.com +darkwulu79jkl.ga +darlibirneli.space +darlingaga.com +darlinggoodsjp.com +darlingtonradio.net +darloneaphyl.cf +darmowedzwonki.waw.pl +darnellmooremusic.com +darpun.xyz +darrowsponds.com +darrylcharrison.com +darsbyscman.ga +darsonline.com +dartmouthhearingaids.com +dartv2.ga +daryun.ru +daryxfox.net +das.market +dasarip.ru +dasayo.xyz +dasbeers.com +dasda321.fun +dasdada.com +dasdasdas.com +dasdasdascyka.tk +dash-pads.com +dash8pma.com +dashabase.com +dashangyi.com +dashaustralia.com +dashboardnew.com +dashbpo.net +dashengpk.com +dashinghackerz.tk +dashoffer.com +dashseat.com +dashskin.net +dasoft-hosting.com +dasttanday.ga +dasttanday.gq +dasttanday.ml +dasty-pe.fun +dasunpamo.cf +dasxe.online +dasymeter.info +daszyfrkfup.targi.pl +data-003.com +data-protect-job.com +data-protect-law.com +data-protection-solutions.com +data.dropmail.me +data.email +data1.nu +dataaas.com +dataarca.com +datab.info +databasel.xyz +databnk.com +databootcamp.org +datacenteritalia.cloud +datacion.icu +datacion.pw +datacion.xyz +datacoeur.com +datacogin.com +datadudi.com +datafordinner.com +datafres.ru +datagic.xyz +datakop.com +dataleak01.site +datalinc.com +datalist.biz +datalysator.com +datamanonline.com +datamarque.com +datamzone.com +datapinacle.com +datarca.com +dataretrievalharddrive.net +datasoma.com +datastrip.com +datauoso.com +datawurld.com +datazo.ca +datcaexpres.xyz +datcamermaid.com +datchka.ru +dategalaxy.lat +dateharbor.lat +datehype.com +datemail.online +datenschutz.ru +datespot.lat +dateverse.lat +dating4best.net +datinganalysis.com +datingbio.info +datingbit.info +datingcloud.info +datingcomputer.info +datingcon.info +datingeco.info +datingfails.com +datingfood.info +datinggeo.info +datinggreen.info +datinghacks.org +datinghyper.info +datinginternet.info +datingphotos.info +datingpix.info +datingplaces.ru +datingreal.info +datingshare.info +datingso.com +datingstores.info +datingsun.info +datingtruck.info +datingwebs.info +datingworld.com +datingx.co +dationish.site +datlk.ga +datoinf.com +datokyo.com +datosat.com +datphuyen.net +datquynhon.net +datrr.gq +datscans.com +datum2.com +datuxtox.host +daughertymail.bid +daum.com +daun.net +daupload.com +davecooke.eu +davehicksputting.com +davenstore.com +davesbillboard.com +davesdadismyhero.com +david-media.buzz +daviddjroy.com +davidjwinsor.com +davidkoh.net +davidlcreative.com +davidmiller.org +davidmorgenstein.org +davidodere.com +davidorlic.com +davidsonschiller.com +davidsouthwood.co.uk +davidtbernal.com +davidvogellandscaping.com +davies-club.com +davieselectrical.com +davievetclinic.com +daviiart.com +davinaveronica.art +davinci-dent.ru +davinci-institute.org +davinci.com +davincidiamonds.com +davis.exchange +davis1.xyz +davistechnologiesllc.com +davomo.com +davuboutique.site +davutkavranoglu.com +dawetgress72njx.cf +dawhe.com +dawidex.pl +dawin.com +dawk.com +dawn-smit.com +dawoosoft.com +dawsi.com +dawsonmarineservice.com +daxiake.com +daxrlervip.shop +daxur.mx +daxur.pro +daxur.xyz +daxurymer.net +day-one.pw +day.lakemneadows.com +day.marksypark.com +dayasolutions.com +dayemall.site +dayibiao.com +daylive.ru +dayloo.com +daymail.cf +daymail.ga +daymail.gq +daymail.life +daymail.men +daymail.ml +daymail.tk +daymailonline.com +daynews.site +daynightstory.com +dayone.pw +dayorgan.com +daypart.us +daypey.com +dayrep.com +dayrosre.cf +dayrosre.ga +dayrosre.gq +daysgox.ru +daysofourlivesrecap.com +daytau.com +daytondonations.com +daytraderbox.com +daytrippers.org +dazate.gq +dazplay.com +dazzlingcountertops.com +dazzvijump.cf +dazzvijump.ga +db-vets.com +db214.com +db2sports.com +db2zudcqgacqt.cf +db2zudcqgacqt.ga +db2zudcqgacqt.gq +db2zudcqgacqt.ml +db4t534.cf +db4t534.ga +db4t534.gq +db4t534.ml +db4t534.tk +db4t5e4b.cf +db4t5e4b.ga +db4t5e4b.gq +db4t5e4b.ml +db4t5e4b.tk +db4t5tes4.cf +db4t5tes4.ga +db4t5tes4.gq +db4t5tes4.ml +db4t5tes4.tk +dbadhe.icu +dbanote.net +dbatalk.com +dbataturkioo.com +dbawgrvxewgn3.cf +dbawgrvxewgn3.ga +dbawgrvxewgn3.gq +dbawgrvxewgn3.ml +dbawgrvxewgn3.tk +dbdrainagenottingham.co.uk +dbenoitcosmetics.com +dbg.yomail.info +dbits.biz +dbj.dropmail.me +dbmail.one +dbmw.mimimail.me +dbo.kr +dbook.pl +dboso.com +dboss3r.info +dbot2zaggruz.ru +dbprinting.com +dbrflk.com +dbunker.com +dbxjfnrbxhdb.freeml.net +dbz.com +dbz5mchild.com +dc-business.com +dc.dropmail.me +dc.laste.ml +dcah.freeml.net +dcap.emltmp.com +dcbarr.com +dcbin.com +dccsvbtvs32vqytbpun.ga +dccsvbtvs32vqytbpun.ml +dccsvbtvs32vqytbpun.tk +dcctb.com +dcemail.com +dcemail.men +dcepmix.com +dcgsystems.com +dcharter.net +dci.freeml.net +dcibb.xyz +dcj.pl +dcj.spymail.one +dckz.com +dcluxuryrental.com +dcndiox5sxtegbevz.cf +dcndiox5sxtegbevz.ga +dcndiox5sxtegbevz.gq +dcndiox5sxtegbevz.ml +dcndiox5sxtegbevz.tk +dcom.space +dcpa.net +dcsupplyinc.com +dctm.de +dcumi6.cloud +dcxg.spymail.one +dcyz.spymail.one +dd.emlhub.com +dd61234.com +ddaengggang.com +ddboxdexter.com +ddcrew.com +dddddd.com +dddfsdvvfsd.com +dddjiekdf.com +dddk.de +dddoudounee.com +dddu.laste.ml +ddffg.com +ddi-solutions.com +ddinternational.net +ddio.com +ddividegs.com +ddlg.info +ddlre.com +ddlskkdx.com +ddmail.win +ddmv.com +ddn.kz +ddns.ml +ddns.net +ddnsfree.com +ddoddogiyo.com +ddosed.us +ddoudounemonclerboutiquefr.com +ddr.laste.ml +ddressingc.com +ddsldldkdkdx.com +ddsongyy.com +ddukbam03.com +ddwfzp.com +ddxsoftware.com +ddyy599.com +ddz79.com +de-a.org +de-fake.instafly.cf +de-farmacia.com +de-femei.com +de.introverted.ninja +de.lakemneadows.com +de.newhorizons.gq +de.oldoutnewin.com +de.sytes.net +de.vipqq.eu.org +de4ce.gq +de5.pl +de5m7y56n5.cf +de5m7y56n5.ga +de5m7y56n5.gq +de5m7y56n5.ml +de5m7y56n5.tk +de8.xyz +de99.xyz +dea-21olympic.com +dea-love.net +dea.laste.ml +dea.soon.it +deadaddress.com +deadangarsk.ru +deadboypro.com +deadchildren.org +deadfake.cf +deadfake.ga +deadfake.ml +deadfake.tk +deadliftexercises.com +deadlyspace.com +deadmobsters.com +deadproject.ru +deadsmooth.info +deadspam.com +deadstocks.info +deagot.com +deahs.com +deaikon.com +deaimagazine.xyz +deajeng.store +deal-maker.com +dealble.com +dealbonkers.com +dealcost.com +dealcungmua.info +dealer.name +dealeredit.adult +dealerlms.com +dealersautoweb.com +dealgiare.info +dealgongmail.com +dealio.app +dealja.com +deallabs.org +dealligg.com +dealmuachung.info +dealnlash.com +dealoftheyear.top +dealpop.us +dealrek.com +dealremod.com +deals4pet.com +dealsbath.com +dealsoc.info +dealsontheweb.org +dealsopedia.com +dealsplace.info +dealsshack.com +dealsway.org +dealsyoga.com +dealtern.site +dealthrifty.com +dealtim.shop +dealvn.info +dealxin.com +dealyoyo.com +dealzbrow.com +dealzing.info +deamless.com +deamuseum.online +deapanendra.art +deapy.com +deathfilm.com +deathward.info +deathwishcoffee.us +debassi.com +debatehistory.com +debateplace.com +debatetayo.com +debb.me +debbiecynthiadewi.art +debbykristy.art +deboa.tk +debonnehumeur.com +deborahosullivan.com +debruler.dynamailbox.com +debsbluemoon.com +debsmail.com +debtdestroyers.com +debthelp.biz +debtloans.org +debtor-tax.com +debtrelief.us +debutqx.com +debutter.com +debza.com +decabg.eu +decacerata.info +decd.site +decentraland.website +decep.com +decginfo.info +decibelworship.org +decidaenriquecer.com +decisionao.com +deckerniles.com +deckfasli.cf +deckfasli.gq +decline.live +deco-rator.edu +decobar.ru +decode.ist +decodefuar.com +decodewp.com +decoplagerent.com +decor-idea.ru +decorandhouse.com +decoratefor.com +decoratingfromtheheart.com +decoratinglfe.info +decorationdiy.site +decorative-m.com +decorativedecks.com +decorbuz.com +decorigin.com +decoymail.com +decoymail.mx +decoymail.net +decuypere.com +ded-moroz-vesti.ru +dedatre.com +dede.infos.st +dede10hrs.site +dedesignessentials.com +dedetox.center +dedi.blatnet.com +dedi.cowsnbullz.com +dedi.ploooop.com +dedi.poisedtoshrike.com +dedi.qwertylock.com +dedicateddivorcelawyer.com +dedimkisanalcp.cfd +dedisutardi.eu.org +dedmail.com +dedmoroz-vesti.ru +deedinvesting.info +deegitalist.bond +deekayen.us +deemfit.com +deemzjewels.com +deenahouse.co +deenur.com +deepadnetwork.net +deepankar.info +deepavenue.com +deepbreedr.com +deepcleanac.com +deepconverts.com +deepdicker.com +deepexam.com +deepfsisob.cf +deepfsisob.ga +deepfsisob.ml +deepgameslab.org +deeplysimple.org +deepmails.org +deepmassage.club +deepmassage.online +deepmassage.store +deepmassage.xyz +deepmaster.fun +deepsdigitals.xyz +deepsea.ml +deepshop.xyz +deepsongshnagu.com +deepsouthclothingcompany.com +deepstaysm.org.ua +deepstore.online +deepstore.site +deepstore.space +deepthroat.monster +deepttoiy.cf +deepvpn.site +deepyinc.com +deercreeks.org +deerecord.org.ua +deerest.co +deermokosmetyki-a.pl +deesje.nl +defandit.com +default.tmail.thehp.in +defaultdomain.ml +defdb.com +defeatmyticket.com +defebox.com +defenceds.com +defencetalks.site +defindust.site +definedssh.com +definetheshift.com +definingjtl.com +definitern.site +defomail.com +defqon.ru +defvit.com +degap.fr.nf +degar.xyz +degradedfun.net +degreegame.com +degunk.com +dehler.spicysallads.com +deinbox.com +deinous.xyz +deisanvu.gov +deishmann.pl +deitada.com +deitermalian.site +dej.emlpro.com +dejamedia.com +dejavafurniture.com +dejavu.moe +dejtinggranska.com +dekaps.com +dekatri.cf +dekatri.ga +dekatri.gq +dekatri.ml +dekaufen.com +dekdkdksc.com +dekoracjeholajda.pl +dekpal.com +dekuwepas.media +del58.com +delaeb.com +delaemsami18.ru +delaware-nedv.ru +delawareo.com +delawaresecure.com +delawareshowerglass.com +delaxoft.ga +delaxoft.gq +delay.favbat.com +delayedflights.com +delayload.com +delayload.net +delayover.com +delays.site +delays.space +delays.website +delaysrnxf.com +delaysvoe.ru +delcan.me +delcomweb.com +deldoor.ru +deleomotosho.com +delexa.com +delhipalacemallow.com +delhispicetakeaway.com +delicacybags.com +delicategames.com +delichev.su +delicious-couture.com +deliciousnutritious.com +deliciousthings.net +delightbox.com +delightfulpayroll.com +delignate.xyz +deligy.com +delikkt.de +deliomart.com +deliriousrudiyat.biz +deliriumshop.de +deliverfreak.com +deliverme.top +delivery136.monster +delivery377.monster +deliveryconcierge.com +deliverydaily.org +delivrmail.com +delivvr.com +dell-couponcodes.com +dellarobbiathailand.com +dellfroi.ga +dellingr.com +dellrar.website +delmang.com +delorehouse.co +delorex.com +delorieas.cf +delorieas.ml +delotti.com +delowd.com +delphinine.xyz +delrikid.cf +delrikid.gq +delrikid.ml +delta.xray.thefreemail.top +delta8cartridge.com +deltabeta.livefreemail.top +deltacplus.info +deltakilo.ezbunko.top +deltaoscar.livefreemail.top +deltapearl.partners +deltashop-4g.ru +deltasoft.software +deluxedesy.info +deluxerecords.com +deluxetakeaway-sandyford.com +deluxewrappingmail.com +deluxmail.com +dely.com +demail.tk +demail3.com +demanddawgs.info +demandfull.date +demandmagic.com +demandsxz.com +demantly.xyz +demen.ml +demesmaeker.fr +deminyx.eu +demiou.com +demirprenses.com +demlik.org +demmail.com +demo.neetrix.com +demolition-hammers.com +demonclerredi.info +demotivatorru.info +demotywator.com +demowebsite02.click +dena.ga +dena.ml +denarcteel.com +denbaker.com +dencxvo.com +dendride.ru +denememory.co.uk +dengekibunko.cf +dengekibunko.ga +dengekibunko.gq +dengekibunko.ml +dengizaotvet.ru +dengmail.com +denipl.com +denipl.net +denirawiraguna.art +denispushkin.ru +denizenation.info +denizlipostasi.com +denizlisayfasi.com +denl.laste.ml +denlrhdltejf.com +denniscoltpackaging.com +dennisss.top +dennmail.win +dennymail.host +denomla.com +densahar.store +densebpoqq.com +density2v.com +densss.com +denstudio.pl +dental-and-spa.pl +dentalassociationgloves.com +dentaldiscover.com +dentaljazz.info +dentalmdnearme.com +dentaltz.com +dentistrybuzz.com +dentonhospital.com +denverareadirectory.com +denverbroncosproshoponline.com +denverbroncosproteamjerseys.com +denyfromall.org +deo.edu +deo.emlhub.com +deoanthitcho.site +deornaumail.com +dep.dropmail.me +depadua.eu +depaduahootspad.eu +depanjaloe.nl +departamentoparanaonline.com +department.com +dependity.com +depinpools.com +deplature.site +deposin.com +depressurizes908qo.online +der-kombi.de +der.emlhub.com +der.madhuratri.com +derbydales.co.uk +derder.net +derek.com +derevenka.biz +derisuherlan.info +derivative.studio +derkila.ml +derkombi.de +derliforniast.com +derluxuswagen.de +dermacareguide.com +dermacoat.com +dermalmedsblog.com +dermatendreview.net +dermatitistreatmentx.com +dermatologistcliniclondon.com +dermpurereview.com +deromise.tk +derphouse.com +dertul.xyz +des-law.com +desaptoh07yey.gq +descargalo.org +descher.ml +descretdelivery.com +descrimilia.site +descrive.info +desea.com +deselling.com +desertdigest.com +desertglen.com +desertlady.com +desertphysicist.site +desertseo.com +desertsundesigns.com +desfrenes.fr.nf +desheli.com +deshivideos.com +deshiz.net +deshnetarchadacalculator.one +deshyas.site +desi-tashan.su +design-first.com +design-seo.com +design199.com +designbydelacruz.com +designcreativegroup.com +designdemo.website +designerbagsoutletstores.info +designercl.com +designergeneral.com +designerhandbagstrends.info +designersadda.com +designerwatches-tips.info +designerwatchestips.info +designingenium.com +designingsolutions.net +designland.info +designmybrick.com +designobserverconference.com +designsource.info +designstudien.de +designthinkingcenter.com +designwigs.info +desimess.xyz +desireemadelyn.kyoto-webmail.top +desitashan.su +desiys.com +desk.cowsnbullz.com +desk.oldoutnewin.com +desknewsop.xyz +deskova.com +deskport.net +desksonline.com.au +desktop.blatnet.com +desktop.emailies.com +desktop.lakemneadows.com +desktop.martinandgang.com +desktop.ploooop.com +desktop.poisedtoshrike.com +deskz.bar +desmo.cf +desmo.ga +desmo.gq +desmontres.fr +desocupa.org +desoz.com +despairsquid.xyz +despam.it +despammed.com +destinationsmoke.com +destructiveblog.com +destweb.com +deszn1d5wl8iv0q.cf +deszn1d5wl8iv0q.ga +deszn1d5wl8iv0q.gq +deszn1d5wl8iv0q.ml +deszn1d5wl8iv0q.tk +detabur.com +detailtop.com +detalushka.ru +detectu.com +detektywenigma.pl +deterally.xyz +deterspecies.xyz +detetive.online +detexx.com +detoxstartsnow.org +detroitelectric.biz +detroitlionsjerseysstore.us +detrude.info +detsky-pokoj.net +dettol.cf +dettol.ga +dettol.gq +dettol.ml +dettol.tk +deucemail.com +deupa.anonbox.net +deusa7.com +deutsch-nedv.ru +deutsch-sprachschule.de +dev-null.cf +dev-null.ga +dev-null.gq +dev-null.ml +dev-tips.com +dev.bcm.edu.pl +dev.emailies.com +dev.marksypark.com +dev.ploooop.com +dev.poisedtoshrike.com +dev.qwertylock.com +dev.semar.edu.pl +devax.pl +devb.site +devbike.com +devcard.com +devdating.info +devdigs.com +devea.site +deveb.site +devec.site +deved.site +devef.site +deveg.site +deveh.site +devei.site +developan.ru +developer.cowsnbullz.com +developer.lakemneadows.com +developer.martinandgang.com +developermail.com +developfuel.com +developmentwebsite.co.uk +developtool.app +develoverpack.systems +develow.site +develows.site +devem.site +devep.site +deveq.site +devere-malta.com +deveu.site +devev.site +devew.site +devez.site +devfiltr.com +devge.com +devh.site +devhoster.tech +devhstore.online +devib.site +devicefoods.ru +devif.site +devig.site +devih.site +devii.site +devij.site +devinaaureel.art +devinmariam.coayako.top +deviouswiraswati.biz +devla.site +devlb.site +devlc.site +devld.site +devle.site +devlf.site +devlh.site +devli.site +devlj.site +devll.site +devlm.site +devln.site +devlo.site +devlr.site +devls.site +devlt.site +devlu.site +devlug.com +devlv.site +devlw.site +devlx.site +devly.site +devlz.site +devmeyou.tech +devncie.com +devnullmail.com +devo.ventures +devoa.site +devob.site +devoc.site +devod.site +devof.site +devog.site +devoi.site +devoidd.media +devoj.site +devok.site +devom.site +devoo.site +devops.cheap +devopstech.org +devostock.com +devot.site +devotedmarketing.com +devou.site +devov.site +devow.site +devox.site +devoz.site +devq.site +devr.site +devreg.org +devs.chat +devset.space +devswp.com +devt.site +devtestx.software +devushka-fo.com +devw.site +devweb.systems +dew.com +dew007.com +dewacapsawins.net +dewadewipoker.com +dewahkb.net +dewareff.com +dewihk.xyz +deworconssoft.xyz +dewts.net +dexamail.com +dextm.ro +dextrago.com +deyom.com +deypo.com +dezcentr56.ru +dezd.freeml.net +dezedd.com +dezzire.ru +df.spymail.one +dfagsfdasfdga.com +dfat0fiilie.ru +dfat0zagruz.ru +dfat1zagruska.ru +dfatt6zagruz.ru +dfb55.com +dfbdfbdzb.tech +dfdd.com +dfdfdfdf.com +dfdgfsdfdgf.ga +dfdh.dropmail.me +dfesc.com +dfet356ads1.cf +dfet356ads1.ga +dfet356ads1.gq +dfet356ads1.ml +dfet356ads1.tk +dff.emltmp.com +dff55.dynu.net +dffwer.com +dfg456ery.ga +dfg6.kozow.com +dfgdfg.dropmail.me +dfgds.in +dfgeqws.com +dfgfg.com +dfgggg.org +dfgh.net +dfghj.ml +dfghsdfgsdfgdsf.fun +dfgtbolotropo.com +dfhdfh.laste.ml +dfhgh.com +dfigeea.com +dfjunkmail.co.uk +dfkdkdmfsd.com +dfllbaseball.com +dfmdsdfnd.com +dfoofmail.com +dfoofmail.net +dfooshjqt.pl +dfre.ga +dfremails.com +dfsdf.com +dfsdfsdf.com +dftrekp.com +dfvez.anonbox.net +dfwautodetailing.com +dfwdaybook.com +dfwlqp.com +dfworld.net +dfy2413negmmzg1.ml +dfy2413negmmzg1.tk +dfyxmwmyda.pl +dg.emlhub.com +dg8899.com +dg9.org +dgbhhdbocz.pl +dgbor.anonbox.net +dgcustomerfirst.site +dgd.mail-temp.com +dgdbmhwyr76vz6q3.cf +dgdbmhwyr76vz6q3.ga +dgdbmhwyr76vz6q3.gq +dgdbmhwyr76vz6q3.ml +dgdbmhwyr76vz6q3.tk +dgdf.cc +dget1fajli.ru +dget8fajli.ru +dgfghgj.com.us +dgget0zaggruz.ru +dgget1loaadz.ru +dghetian.com +dghi.laste.ml +dgjhg.com +dgjhg.net +dgnghjr5ghjr4h.cf +dgnoble.shop +dgo.emlpro.com +dgob.emltmp.com +dgpoker88.online +dgpqdpxzaw.cf +dgpqdpxzaw.ga +dgpqdpxzaw.gq +dgpqdpxzaw.ml +dgpqdpxzaw.tk +dgseoorg.org +dh.emltmp.com +dh.yomail.info +dh05.xyz +dh07.xyz +dhabamax.com +dhain.com +dhakasun.com +dhamsi.com +dhapy7loadzzz.ru +dharmatel.net +dhb.spymail.one +dhbusinesstrade.info +dhcustombaling.com +dhdhdyald.com +dhead3r.info +dhgbeauty.info +dhii5.anonbox.net +dhindustry.com +dhkf.com +dhl-uk.cf +dhl-uk.ga +dhl-uk.gq +dhl-uk.ml +dhl-uk.tk +dhlkurier.pl +dhm.ro +dhmu5ae2y7d11d.cf +dhmu5ae2y7d11d.ga +dhmu5ae2y7d11d.gq +dhmu5ae2y7d11d.ml +dhmu5ae2y7d11d.tk +dhnow.com +dhobilocker.com +dhruvseth.com +dhshdj.freeml.net +dhsjyy.com +dhun.us +dhuns.wiki +dhy.cc +diablo3character.com +diablo3goldsite.com +diablo3goldsupplier.com +diabloaccounts.net +diablocharacter.com +diablogears.com +diablogold.net +diacamelia.online +diadehannku.com +diademail.com +diadia.tk +diadiemmuasambienhoa.com +diadiemquanan.com +diadisolmi.xyz +diafporidde.xyz +diahpermatasari.art +dialogus.com +dialogzerobalance.ml +dialysis-attorney.com +dialysis-injury.com +dialysis-lawyer.com +dialysisattorney.info +dialysislawyer.info +diamantservis.ru +diamondbroofing.com +diamondfacade.net +dian.ge +dianaspa.site +diane35.pl +dianhabis.ml +dianlanwangtao.com +diannsahouse.co +diapaulpainting.com +diaperbagbackpacks.info +diariodigital.info +diarioretail.com +diaryofsthewholesales.info +diascan24.de +dibbler1.pl +dibbler2.pl +dibbler3.pl +dibbler4.pl +dibbler5.pl +dibbler6.pl +dibbler7.pl +dibon.site +dibteam.xyz +dibtec.store +dicerollplease.com +diceservices.com +dichalorli.xyz +dichvudaorut247.com +dichvumxh247.top +dichvuseothue.com +dichvuxe24h.com +dick.com +dicknose.com +dicksinhisan.us +dicksinmyan.us +dicksoncountyag.com +dickydick.xyz +dickyvps.com +dicopto.com +dicountsoccerjerseys.com +dicyemail.com +did.net +didarcrm.com +didikselowcoffee.cf +didikselowcoffee.ga +didikselowcoffee.gq +didikselowcoffee.ml +didix.ru +didncego.ru +die-besten-bilder.de +die-genossen.de +die-optimisten.de +die-optimisten.net +diecasttruckstop.com +diedfks.com +dieforheaven.web.id +diegewerbeseiten.com +diegobahu.com +diemailbox.de +diemhenvn.com +diendanhocseo.com +diendanit.vn +diennuocnghiahue.com +dier.com +dietacudischudl.pl +dietamedia.ru +dietingadvise.club +dietinsight.org +dietna.com +dietpill-onlineshop.com +dietsecrets.edu +dietsolutions.com +dietysuplementy.pl +dietzwatson.com +dieukydieuophonggiamso7.com +diffamr.com +diffamr.net +difficalite.site +difficanada.site +diflucanrxmeds.com +difz.de +digaswow.club +digaswow.online +digaswow.site +digaswow.xyz +digdig.org +digdown.xyz +digdy.com +diggcrypto.com +diggmail.club +digi-value.fr +digiactiveai.com +digibeat.pl +digicures.com +digier365.pl +digiglobalai.com +digihairstyles.com +digikala.myvnc.com +digimexplus.com +digimuse.org +digimusics.com +diginey.com +digiprice.co +digisnaxxx.com +digistatpure.com +digital-bank.com +digital-designs.ru +digital-email.com +digital-everest.ru +digital-filestore.de +digital-frame-review.com +digital-ground.info +digital-kitchen.tech +digital-message.com +digital-work.net +digital10network.com +digitalbankingsummits.com +digitalbloom.tech +digitalbristol.org +digitalbull.net +digitaldefencesystems.com +digitaldron.com +digitalesbusiness.info +digitalfocuses.com +digitalforge.studio +digitalmail.info +digitalmariachis.com +digitalnewspaper.de +digitalnomad.exchange +digitalobscure.info +digitaloutrage.com +digitalpigg.com +digitalproductprovider.com +digitalryno.net +digitalsanctuary.com +digitalsc.edu +digitalseopackages.com +digitalshopkita.com +digitalshopkita.my.id +digitalsole.info +digitaltransarchive.net +digitalwebus.com +digitava.com +digitchernob.xyz +digitex.ga +digitex.gq +digiuoso.com +digopm.com +digsandcribs.com +digsignals.com +digtalk.com +dih.emlhub.com +diide.com +diifo.com +diigo.club +diiq.emltmp.com +dijitalmesele.network +dikeyzebraperde.com +dikitin.com +dikixty.gr +diklo.website +dikriemangasu.cf +dikriemangasu.ga +dikriemangasu.gq +dikriemangasu.ml +dikriemangasu.tk +diks.spymail.one +diksmet.cloud +dikybuyerj.com +dikydik.com +dilanfa.com +dilayda.com +dildosfromspace.com +dileway.com +dilherute.pl +dililimail.com +dilkis.buzz +dillibemisaal.com +dillimasti.com +dilpik.com +dilts.ru +dilusol.cf +dim-coin.com +dimalk.com +dimana.live +dimaskwk.tech +dimensi.me +dimimail.ga +diminbox.info +dimnafin.ml +dinadina.cloud +dinarsanjaya.com +dindasurbakti.art +dindon4u.gq +dinero-real.com +dineroa.com +dingbat.com +dingbone.com +dinhtuan02.shop +dinkmail.com +dinksai.ga +dinksai.ml +dinkysocial.com +dinlaan.com +dinnnnnnnnnnna.cloud +dinocheap.com +dinogam.com +dinomail.cf +dinomail.ga +dinomail.gq +dinomail.ml +dinomail.tk +dinorc.com +dinoschristou.com +dinotek.top +dinoza.pro +dinozy.net +dinris.co +dint.site +dinteria.pl +dinuspbw.fun +diokgadwork.ga +diolang.com +diomandreal.online +diornz.com +diosasdelatierra.com +dioxm.emltmp.com +dipan.xyz +dipath.com +dipes.com +diplayedt.com +diplease.site +diplo.edu.pl +diplom-voronesh.ru +diplomnaya-rabota.com +dipoelast.ru +diqalaciga.warszawa.pl +dir43.org +diranybooks.site +diranyfiles.site +diranytext.site +diratu.com +dirawesomebook.site +dirawesomefiles.site +dirawesomelib.site +dirawesometext.site +dirding.com +direcaster.buzz +direct-mail.info +direct-mail.top +direct.ditchly.com +directbox.com +directionetter.info +directmail.top +directmail24.net +directmonitor.nl +directoryanybooks.site +directoryanyfile.site +directoryanylib.site +directoryanytext.site +directoryawesomebooks.site +directoryawesomefile.site +directoryawesomelibrary.site +directoryawesometext.site +directoryblog.info +directoryfreefile.site +directoryfreetext.site +directoryfreshbooks.site +directoryfreshlibrary.site +directorygoodbooks.site +directorygoodfile.site +directorynicebook.site +directorynicefile.site +directorynicefiles.site +directorynicelib.site +directorynicetext.site +directoryrarebooks.site +directoryrarelib.site +directpaymentviaach.com +directpmail.info +direktorysubcep.com +diremaster.click +direugg.cc +dirfreebook.site +dirfreebooks.site +dirfreelib.site +dirfreelibrary.site +dirfreshbook.site +dirfreshbooks.site +dirfreshfile.site +dirfreshfiles.site +dirfreshtext.site +dirgoodfiles.site +dirgoodlibrary.site +dirgoodtext.site +dirksop.com +dirnicebook.site +dirnicefile.site +dirnicefiles.site +dirnicelib.site +dirnicetext.site +diromail29.biz +dirrarefile.site +dirrarefiles.site +dirraretext.site +dirtmail.ga +dirtymailer.cf +dirtymailer.ga +dirtymailer.gq +dirtymailer.ml +dirtymailer.tk +dirtymax.com +dirtysex.top +dis.hopto.org +disappointments.cloud +disaq.com +disario.info +disbox.com +disbox.net +disbox.org +discard-email.cf +discard.cf +discard.email +discard.ga +discard.gq +discard.ml +discard.tk +discardmail.com +discardmail.computer +discardmail.de +discardmail.live +discardmail.ninja +discartmail.com +discdots.com +discolive.online +discolive.site +discolive.store +discolive.website +discolive.xyz +disconorma.pl +discopied.com +discoplus.ca +discord-club.space +discord.ml +discord.watch +discorded.io +discordglft.ga +discordmail.com +discos4.com +discotlanne.site +discounp.com +discount-allopurinol.com +discountappledeals.com +discountbuyreviews.org +discountcouponcodes2013.com +discountequipment.com +discountmall.site +discountnikejerseysonline.com +discountoakleysunglassesokvip.com +discounts5.com +discountsmbtshoes.com +discountsplace.info +discovenant.xyz +discoverccs.com +discovercheats.com +discoverwatch.com +discoverylanguages.com +discreetfuck.top +discretevtd.com +discrip.com +discslot.com +discus24.de +discusseism.xyz +discussion.website +discussmusic.ru +disdraplo.com +disfrut.es +dish-tvsatellite.com +dishcatfish.com +dishow.net +dishtvpackage.com +disign-concept.eu +disign-revelation.com +disipulo.com +diskilandcruiser.ru +diskslot.com +dislike.cf +disnan.com +disneyfox.cf +disneystudioawards.com +dispand.site +dispatchsolutions.club +dispemail.com +displaylightbox.com +displays2go.com +displayside.com +displaystar.com +dispmail.org +dispmailproject.info +dispo.in +dispomail.eu +dispomail.ga +dispomail.win +dispomail.xyz +disposable-1.net +disposable-2.net +disposable-3.net +disposable-4.net +disposable-e.ml +disposable-email.ml +disposable-mail.com +disposable.al-sudani.com +disposable.cf +disposable.dhc-app.com +disposable.ga +disposable.ml +disposable.nogonad.nl +disposable.site +disposableaddress.com +disposableemail.co +disposableemail.org +disposableemail.us +disposableemailaddresses.com +disposableemailaddresses.emailmiser.com +disposableinbox.com +disposablemail.com +disposablemail.space +disposablemail.top +disposablemails.com +dispose.it +disposeamail.com +disposely.xyz +disposemail.com +disposemymail.com +disposly.com +dispostable.com +disppropli.ga +disputespecialists.com +disruptionlabs.com +dissloo.com +dist-vmax.com +dist.com +distance-education.cf +distdurchbrumi.xyz +distorestore.xyz +distrackbos.com +distraplo.com +distributeweb.com +distributorphuceng.online +distrify.net +ditac.site +ditusuk.com +ditzmagazine.com +diujungsenja.online +divad.ga +divalia.cf +divan-matras.info +divaphone.com +divaphone.net +divasdestination.com +diveexpeditions.com +divermail.com +diverseness.ru +diverseperdiawan.co +diversification.store +diversify.us +diversitycheckup.com +divestops.com +dividendxk.com +divinois.com +divismail.ru +divorsing.ru +divulgabrasil.com +divulgamais.com +divulgasite.com +divuva.click +diw.emlpro.com +diwaq.com +diwjsk21.com +dixiser.com +dixz.net +dixz.org +diy-seol.net +diyarbakirengelliler.xyz +diyixs.xyz +diyombrehair.com +dizaer.ru +dizainburo.ru +dizigg.com +diztan.cfd +dizzygals.com +dj.emltmp.com +dj.laste.ml +djan.de +djcrazya.com +djdaj.cloud +djdwzaty3tok.cf +djdwzaty3tok.ga +djdwzaty3tok.gq +djdwzaty3tok.ml +djdwzaty3tok.tk +djejgrpdkjsf.com +djemail.net +djerseys.com +djiv.xyz +djk.emltmp.com +djkux.com +djmftaggb.pl +djmiamisteve.com +djmoon.ga +djmoon.ml +djmv.yomail.info +djnast.com +djnkkout.tk +djpich.com +djrobbo.net +djrpdkjsf.com +djsjfmdfjsf.com +djskd.com +djuncan-shop.online +djwjdkdx.com +djx.spymail.one +djypz.anonbox.net +dk.emlhub.com +dk3vokzvucxolit.cf +dk3vokzvucxolit.ga +dk3vokzvucxolit.gq +dk3vokzvucxolit.ml +dk3vokzvucxolit.tk +dkb3.com +dkcgrateful.com +dkdjfmkedjf.com +dkdkdk.com +dkert2mdi7sainoz.cf +dkert2mdi7sainoz.ga +dkert2mdi7sainoz.gq +dkert2mdi7sainoz.ml +dkert2mdi7sainoz.tk +dkfksdff.com +dkgr.com +dkhm.spymail.one +dkinodrom20133.cx.cc +dkjl.mimimail.me +dkkffmail.com +dkljdf.eu +dkmont.dk +dko.kr +dkpnpmfo2ep4z6gl.cf +dkpnpmfo2ep4z6gl.ga +dkpnpmfo2ep4z6gl.gq +dkpnpmfo2ep4z6gl.ml +dkpnpmfo2ep4z6gl.tk +dkqqpccgp.pl +dksureveggie.com +dkt1.com +dkt24.de +dkuinjlst.shop +dkuy.yomail.info +dkvmwlakfrn.com +dkywquw.pl +dl.blatnet.com +dl.marksypark.com +dl.ploooop.com +dl.yomail.info +dl163.com +dl812pqedqw.cf +dl812pqedqw.ga +dl812pqedqw.gq +dl812pqedqw.ml +dl812pqedqw.tk +dlbazi.com +dlberry.com +dlcn.dropmail.me +dld.freeml.net +dldweb.info +dle.funerate.xyz +dlemail.ru +dlfiles.ru +dlfkfsdkdx.com +dlgx.yomail.info +dliiv71z1.mil.pl +dlink.cf +dlink.gq +dlj6pdw4fjvi.cf +dlj6pdw4fjvi.ga +dlj6pdw4fjvi.gq +dlj6pdw4fjvi.ml +dlj6pdw4fjvi.tk +dll32.ru +dlman.site +dlmkme.ga +dlmkme.ml +dloadanybook.site +dloadanylib.site +dloadawesomefiles.site +dloadawesomelib.site +dloadawesometext.site +dloadfreetext.site +dloadfreshfile.site +dloadfreshlib.site +dloadgoodfile.site +dloadgoodfiles.site +dloadgoodlib.site +dloadnicebook.site +dloadrarebook.site +dloadrarebooks.site +dloadrarefiles.site +dloadrarelib.site +dloadrarelibrary.site +dlpt7ksggv.cf +dlpt7ksggv.ga +dlpt7ksggv.gq +dlpt7ksggv.ml +dlpt7ksggv.tk +dlq.freeml.net +dlroperations.com +dlserial.site +dltv.site +dluerei.com +dlvr.us.to +dlwdudtwlt557.ga +dly.net +dlyemail.com +dlzltyfsg.pl +dm.cab +dm.emlpro.com +dm.emltmp.com +dm9bqwkt9i2adyev.ga +dm9bqwkt9i2adyev.ml +dm9bqwkt9i2adyev.tk +dma.in-ulm.de +dma2x7s5w96nw5soo.cf +dma2x7s5w96nw5soo.ga +dma2x7s5w96nw5soo.gq +dma2x7s5w96nw5soo.ml +dma2x7s5w96nw5soo.tk +dmail.kyty.net +dmail.mx +dmail.unrivaledtechnologies.com +dmail1.net +dmaildd.com +dmailpro.net +dmailx.com +dmaji.ddns.net +dmaji.ml +dmarc.ro +dmc-12.cf +dmc-12.ga +dmc-12.gq +dmc-12.ml +dmc-12.tk +dmc4u.tk +dmcd.ctu.edu.gr +dmdfmdkdx.com +dmdmsdx.com +dmeproject.com +dmfjrgl.turystyka.pl +dmfq.freeml.net +dmftfc.com +dmgc.laste.ml +dmial.com +dminutesfb.com +dmitext.net +dmm.pp.ua +dmmhosting.co.uk +dmo3.club +dmoffers.co +dmong.cloud +dmonies.com +dmosi.com +dmosoft.com +dmp.emltmp.com +dmsdmg.com +dmskdjcn.com +dmslovakiat.com +dmtc.edu.pl +dmtorg.ru +dmts.fr.nf +dmtu.ctu.edu.gr +dmtubes.com +dmxs8.com +dn.emlpro.com +dn.freeml.net +dna.mdisks.com +dnabgwev.pl +dnaindebouw.com +dnakeys.com +dnatechgroup.com +dnatestingforyou.live +dnawr.com +dnd.simplus.com.br +dndbs.net +dndent.com +dndfkdkdx.com +dndl.site +dneg.yomail.info +dnek.com +dnestrauto.com +dnetwork.site +dnflanddl.com +dni8.com +dnitem.com +dnlien.com +dnmb.yomail.info +dnmh.spymail.one +dnor.emltmp.com +dnrc.com +dns-cloud.net +dns-privacy.com +dns123.org +dnsabr.com +dnsclick.com +dnsdeer.com +dnsdujeskd.com +dnses.ro +dnsguard.net +dntsmsekjsf.com +dntts.pics +dnwh.yomail.info +dnzj.laste.ml +do.cowsnbullz.com +do.emlpro.com +do.heartmantwo.com +do.marksypark.com +do.oldoutnewin.com +do.ploooop.com +do.popautomated.com +doanart.com +doanhnhanfacebook.com +doatre.com +doawaa.com +dob.jp +dobitocudeponta.com +dobleveta.com +doboinusunny.com +dobrainspiracja.pl +dobramama.pl +dobrapoczta.com +dobroholod.ru +dobroinatura.pl +dobry-procent-lokaty.com.pl +dobryinternetmobilny.pl +dobrytata.pl +doc-mail.net +doc-spesialis.com +doca.press +docasnyemail.cz +docasnymail.cz +docb.site +docbao7.com +docconnect.com +docd.site +docent.ml +doces.site +docesgourmetlucrativo.com +docf.site +docg.site +doch.site +docinsider.com +docj.site +dock.city +dockeroo.com +docknke.com +docl.site +docm.site +docmaangers.com +docmail.com +docmail.cz +docn.site +doco.site +docp.site +docprepassist.com +docq.site +docs.blatnet.com +docs.coms.hk +docs.marksypark.com +docs.martinandgang.com +docs.oldoutnewin.com +docs.poisedtoshrike.com +docs.qwertylock.com +docsa.site +docsb.site +docsc.site +docsd.site +docse.site +docsf.site +docsfy.com +docsh.site +docsi.site +docsis.ru +docsj.site +docsk.site +docsl.site +docsn.site +docso.site +docsp.site +docsq.site +docsr.site +docss.site +docst.site +docsu.site +docsv.site +docsx.site +doctordieu.xyz +doctorfitness.net +doctorlane.info +doctormail.info +doctorsmb.info +doctovc.com +doctroscares.shop +doctroscares.world +docu.me +docusign-enterprise.com +docv.site +docw.site +docwl.com +docx-expert.online +docx.press +docx.site +docxa.site +docxb.site +docxc.site +docxd.site +docxe.site +docxf.site +docxg.site +docxh.site +docxi.site +docxj.site +docxk.site +docxl.site +docxm.site +docxn.site +docxo.site +docxp.site +docxr.site +docxs.site +docxt.site +docxu.site +docxv.site +docxx.site +docxy.site +docxz.site +docy.site +docza.site +doczb.site +doczc.site +doczd.site +docze.site +doczf.site +doczg.site +dod.laste.ml +dodachachayo.com +dodashel.store +dodgeit.com +dodgemail.de +dodgit.com +dodgit.org +dodgitti.com +dodi157855.site +dodnitues.gr +dodoberat.com +dodode.com +dodsi.com +doemx.com +doerma.com +doesnment.com +dofuskamasgenerateurz.fr +dofutlook.com +dog-n-cats-shelter.ru +dog.coino.pl +dog0006mine.ml +dogbackpack.net +dogclothing.org +dogcrate01.com +dogdee.com +dogemn.com +dogfishmail.com +doggy-lovers-email.bid +doggyloversemail.bid +doghairprotector.com +dogiloveniggababydoll.com +dogit.com +dogn.com +dogonoithatlienha.com +dogsandpuppies.info +dogsportshop.de +dogstarclothing.com +dogsupplies4sale.com +dogtrainingobedienceschool.com +doh.yomail.info +dohien.pw +dohien.site +dohmail.info +doibaietisiofatafoxy.com +doid.com +doiea.com +doimmn.com +dointo.com +doipor.site +doitagile.com +doitall.tk +doitups.com +doix.com +doj.one +dokankoi.site +dokhanan.com +dokifriends.info +dokin.store +dokisaweer.cz.cc +dokmatin.com +doksan12.com +doktoremail.eu +dolcemia.net +dolimite.com +dolkepek87.usa.cc +dollalive.com +dollargiftcards.com +dollargoback.com +dollarrrr12.com +dollicons.com +dollpolemicdraw.website +dollscountry.ru +dollstore.org +dolnaa.asia +dolofan.com +dolphiding.icu +dolphinmail.org +dolphinnet.net +dom-mo.ru +dom-okna.com +domaaaaaain7.shop +domaaain13.online +domaaain6.online +domaain17.online +domaain19.online +domaain29.online +domaco.ga +domail.info +domailnew.com +domain1dolar.com +domainaing.cf +domainaing.ga +domainaing.gq +domainaing.ml +domainaing.tk +domainleak.com +domainlease.xyz +domainmail.cf +domainnamemobile.com +domaino.uk +domainploxkty.com +domainsayaoke.art +domainscan.ro +domainseoforum.com +domainssssssss.services +domainwizard.host +domainwizard.win +domajabro.ga +domasticosdl.co.uk +domasticosdl.org.uk +domasticosdl.uk +domastoritc.co.uk +domastoritc.uk +domby.ru +domce.com +domdomsanaltam.com +domeerer.com +domenkaa.com +domforfb1.tk +domforfb18.tk +domforfb19.tk +domforfb2.tk +domforfb23.tk +domforfb27.tk +domforfb29.tk +domforfb3.tk +domforfb4.tk +domforfb5.tk +domforfb6.tk +domforfb7.tk +domforfb8.tk +domforfb9.tk +dominatingg.top +dominickgatto.com +dominikan-nedv.ru +dominionbotarena.com +dominiquecrenn.art +dominiquejulianna.chicagoimap.top +dominmail.top +dominobr.cf +dominoitu.com +dominoqq855.live +dominosind.co.uk +dominosind.uk +dominosindr.co.uk +dominototo.com +domitai.org +domofony.info.pl +domorefilms.com +domozmail.com +domssmail.me +domy-balik.pl +domy.me +domyou.site +domywokolicy.com.pl +domywokolicy.pl +domyz-drewna.pl +don-m.online +don.edu.pl +dona.one +dona.pw +dona.rip +donagoyas.info +donaldchen.com +donaldduckmall.com +donat.club +donate-car-to-charity.net +donations.com +donationsworld.online +donbas.in +dondom.ru +donebyngle.com +donemail.my.id +donemail.ru +dongaaaaaaa.cloud +dongbeiyujie.sbs +dongeng.site +dongginein.com +dongqing365.com +dongraaa12.com +dongramii.com +dongru.top +dongxicc.cn +donkey.com +donkihotes.com +donlg.top +donmah.com +donmail.mooo.com +donmaill.com +donnyandmarietour.com +donot-reply.com +dons.com +donsroofing.com +dontdemoit.com +dontrackme.com +dontreg.com +dontsendmespam.de +dontsentmespam.de +dontsleep404.com +donusumekatil.com +donutpalace.com +donymails.com +doobb.com +dooboop.com +doodj.com +doodooexpress.com +doodooli.xyz +doodrops.org +doods7.com +dooglecn.com +doojazz.com +doolanlawoffice.com +doom.com.pl +doommail.com +doompick.co +doorandwindowrepairs.com +doorsteploansfast24h7.co.uk +dopestkicks.ru +dopic.xyz +dopisivanje.in.rs +doquier.tk +dor4.ru +dorada.ga +doradztwo-pracy.com +doramastv.com +dorchesterrmx.co.uk +dorede.com +doremifasoleando.es +doresonico.uk +doriana424.com +dorkalicious.co.uk +dorodred.com +dorukhansozer.cfd +dorywalski.pl +dosait.ru +dosan12.com +dosas54.shop +doscobal.com +dosonex.com +dostatniapraca.pl +dostupnaya-ipoteka.ru +dosug-kolomna.ru +dot-coin.com +dot-mail.top +dot-ml.ml +dot-ml.tk +dota2bets.net +dota2walls.com +dotabet118.com +dotaja.store +dotapa.shop +dotapodemail.com +doteluxe.com +dotfixed.com +dothivinhomescangio.vn +dotland.net +dotlvay3bkdlvlax2da.cf +dotlvay3bkdlvlax2da.ga +dotlvay3bkdlvlax2da.gq +dotlvay3bkdlvlax2da.ml +dotlvay3bkdlvlax2da.tk +dotmail.cf +dotman.de +dotmsg.com +dotoctuvo.com +dotos.dev +dotpars.com +dotslashrage.com +dotspe.info +dottyproducts.com +dotumbas.online +dotup.net +dotvilla.com +dotvu.net +dotxan.com +dotzi.net +dotzq.com +doublebellybuster.com +doublemail.com +doublemail.de +doublemoda.com +doubletale.com +doublewave.ru +doubtfirethemusical.com +douchelounge.com +doudoune-ralphlauren.com +doudounecanadagoosesoldesfrance.com +doudouneemonclermagasinfr.com +doudounemoncledoudounefr.com +doudounemoncleenligne2012.com +doudounemoncler.com +doudounemonclerbouituque.com +doudounemonclerdoudounefemmepascher.com +doudounemonclerdoudounefrance.com +doudounemonclerdoudounespascher.com +doudounemonclerenlignepascherfra.com +doudounemonclerfemmefr.com +doudounemonclermagasinenfrance.com +doudounemonclerpascherfra.com +doudounemonclerrpaschera.com +doudounemonclerrpaschera1.com +doudounemonclersiteofficielfrance.com +doudounepaschermonclerpascher1.com +doudounesmonclerfemmepascherfrance.com +doudounesmonclerhommefr.com +doudounesmonclerrpascher.com +doudounmonclefrance.com +doudounmonclepascher1.com +doughmaine.xyz +doughmaker.com +doulas.org +dourdneis.gr +doutaku.ml +doutlook.com +douwx.com +dov86hacn9vxau.ga +dov86hacn9vxau.ml +dov86hacn9vxau.tk +doveify.com +dovereducationlink.com +dovesilo.com +dovinou.com +dovusoyun.com +dovz.emlhub.com +dowesync.com +dowlex.co.uk +dowment.site +down.favbat.com +downforest.online +downlayer.com +download-hub.cf +download-master.net +download-privat.de +download-software.biz +download-warez.com +downloadarea.net +downloadbaixarpdf.com +downloadbtn.target +downloadcatbooks.site +downloadcatstuff.site +downloaddirbooks.site +downloaddirfile.site +downloaddirstuff.site +downloaddirtext.site +downloadeguide.mywire.org +downloadfreshbooks.site +downloadfreshfile.site +downloadfreshfiles.site +downloadfreshstuff.site +downloadfreshtext.site +downloadfreshtexts.site +downloadlibtexts.site +downloadlistbook.site +downloadlistbooks.site +downloadlistfiles.site +downloadlisttext.site +downloadmortgage.com +downloadmoviefilm.net +downloadnewstuff.site +downloadnewtext.site +downloadspotbook.site +downloadspotbooks.site +downloadspotfiles.site +downlor.com +downlowd.com +downportal.tk +downside-pest-control.co.uk +downsmail.bid +downtownairportlimo.com +downtowncoldwater.com +dowohiho.ostrowiec.pl +doxcity.net +doxkj.anonbox.net +doxn.spymail.one +doxsale.top +doxy124.com +doxy77.com +doy.kr +doyouneedrenovation.id +doyouneedrenovation.net +dozvon-spb.ru +dp76.com +dp84vl63fg.cf +dp84vl63fg.ga +dp84vl63fg.gq +dp84vl63fg.ml +dp84vl63fg.tk +dpa.emltmp.com +dpad.fun +dpafei.buzz +dpam.com +dpanel.site +dpbbo5bdvmxnyznsnq.ga +dpbbo5bdvmxnyznsnq.ml +dpbbo5bdvmxnyznsnq.tk +dpcdn.cn +dpconline.com +dpcos.com +dpics.fun +dpkqoi.freeml.net +dplb2t.emlhub.com +dpmurt.my +dpom.com +dpp7q4941.pl +dpptd.com +dprasu.sbs +dprinceton.edu +dprots.com +dpryz.anonbox.net +dpscompany.com +dpsindia.com +dpsk12.com +dpsols.com +dpttso8dag0.cf +dpttso8dag0.ga +dpttso8dag0.gq +dpttso8dag0.ml +dpttso8dag0.tk +dpwev.com +dpwlvktkq.pl +dpxqczknda.pl +dpyae.emltmp.com +dpyq.freeml.net +dq.emlhub.com +dqchx.com +dqcy.emltmp.com +dqhp.spymail.one +dqhs.site +dqi.laste.ml +dqiw.spymail.one +dqkerui.com +dqmail.org +dqnwara.com +dqpw7gdmaux1u4t.cf +dqpw7gdmaux1u4t.ga +dqpw7gdmaux1u4t.gq +dqpw7gdmaux1u4t.ml +dqpw7gdmaux1u4t.tk +dqsoft.com +dqun.mimimail.me +dr-mail.net +dr.emlhub.com +dr.laste.ml +dr0m.ru +dr0pb0x.ga +dr69.site +drablox.com +drabmail.top +draduationdresses.com +draftanimals.ru +dragcok2.cf +dragcok2.gq +dragcok2.ml +dragcok2.tk +dragence.com +dragonads.net +dragonaos.com +dragonballxenoversecrack.com +dragonextruder.com +dragonfly.africa +dragonhospital.net +dragonmail.live +dragonmint.info +dragonmintv2hub.online +dragons-spirit.org +dragonsborn.com +dragonzmart.com +drakorfor.me +dralselahi.com +drama.tw +dramamixio.icu +dramashow.ru +dramaticallors.co.uk +dramaticallors.org.uk +dramor.com +drar.de +draviero.info +draviero.pw +dravizor.ru +drawfixer.com +drawing-new.ru +drawinginfo.ru +drawings101.com +draylaw.com +drdeals.site +drdrb.com +drdrb.net +drdreoutletstores.co.uk +dreamact.com +dreambangla.com +dreambooker.ru +dreamcatcher.email +dreamclarify.org +dreamfuture.tech +dreamgreen.fr.nf +dreamhostcp.info +dreamingtrack.com +dreamleaguesoccer2016.gq +dreammatchup.lat +dreamsale.info +dreamscape.marketing +dreamshare.info +dreamweddingplanning.com +dreamworlds.club +dreamworlds.site +dreamworlds.website +dreamyshop.club +dreamyshop.fun +dreamyshop.site +dreamyshop.space +dred.ru +dreesens.com +dremixd.com +drempleo.com +dreplei.site +dreric-es.com +dress9x.com +dresscinderella.com +dresselegant.net +dressesbubble.com +dressesbubble.net +dressescelebrity.net +dressesflower.com +dressesflower.net +dressesgrecian.com +dressesgrecian.net +dresseshappy.com +dresseshappy.net +dressesmodern.com +dressesmodern.net +dressesnoble.com +dressesnoble.net +dressesromantic.com +dressesromantic.net +dressesunusual.com +dressesunusual.net +dressmail.com +dresssmall.com +dressswholesalestores.info +dressupsummer.com +dretnar.com +drevo.si +drewhousethe.com +drewna24.pl +drewnianachata.com.pl +drewry.info +drewzen.com +drf.email +drfsmail.com +drg.email +drgmail.fr +drhinoe.com +drhoangsita.com +drhope.tk +drhorton.co +dricca.com +drid1gs.com +driely.com +driems.org +driftrs.org +driftz.net +drigez.com +drikeyyy.com +drill8ing.com +drillbitcrypto.info +drinkbride.com +drinkingcoffee.info +dripovin.ml +dripzgaming.com +drireland.com +drisd.com +drishvod.ru +dristypat.com +drivecompanies.com +drivelander.com +drivelinegolf.com +driversgood.ru +driverstorage-bokaxude.tk +drivesotp7.com +drivetagdev.com +drivetomz.com +drivingjobsinindiana.com +drivz.net +drixmail.info +drizz.pro +drkenfreedmanblog.xyz +drlatvia.com +drlexus.com +drluotan.com +drmail.in +drmail.net +drmail.pw +drmorvbmice.store +drnatashafinlay.com +drnetworkdds.com +drobosucks.info +drobosucks.net +drobosucks.org +droid3.net +droidcloud.mobi +droidemail.projectmy.in +droider.name +dromancehu.com +dron.mooo.com +dronesmart.net +dronespot.net +dronetm.com +dronetz.com +droolingfanboy.de +drop-max.info +drop.ekholm.org +dropcake.de +dropcode.ru +dropd.ru +drope.ml +dropedfor.com +dropeso.com +dropfresh.net +dropinboxes.com +dropjar.com +droplar.com +droplister.com +dropmail.cf +dropmail.ga +dropmail.gq +dropmail.me +dropmail.ml +dropmail.tk +dropmeon.com +dropons.com +dropshippingrich.com +dropsin.net +dropstart.site +dropthespot.com +drorevsm.com +droverpzq.com +drovharsubs.gq +drovi.cf +drovi.ga +drovi.gq +drovi.ml +drovi.tk +drovyanik.ru +drowblock.com +drpf.mimimail.me +drr.pl +drrieca.com +drsafir.com +drsick.xyz +drsiebelacademy.com +drstranst.xyz +drstshop.com +drthedf.org +drthst4wsw.tk +dru.spymail.one +drublowjob20138.cx.cc +druckpatronenshop.de +druckt.ml +druckwerk.info +drugca.com +drugnorx.com +drugordr.com +drugsellers.com +drugsellr.com +drugssquare.com +drugvvokrug.ru +drukarniarecept.pl +drumimul.gq +drunkentige.com +drupaladdons.brainhard.net +drupalek.pl +drupaler.org +drupalmails.com +drussellj.com +druz.cf +druzik.pp.ua +drvcognito.com +drwo.de +drxdvdn.pl +drxepingcosmeticsurgery.com +dry3ducks.com +dryingsin.com +drynic.com +dryoneone.com +drypipe.com +dryriverboys.com +drywallassociation.com +drywallevolutions.com +drzwi.edu +drzwi.turek.pl +ds-3.cf +ds-3.ga +ds-3.gq +ds-3.ml +ds-3.tk +ds-love.space +ds-lover.ru +ds4cz.anonbox.net +ds4kojima.com +dsaca.com +dsad.de +dsadsdas.tech +dsafsa.ch +dsafsdf.dropmail.me +dsaj.mailpwr.com +dsajdhjgbgf.info +dsantoro.es +dsapoponarfag.com +dsas.de +dsasd.com +dsda.de +dsddded.cloud +dsddgtt.cc +dsdfg-dsfg.info +dsecurelyx.com +dsejfbh.com +dsfdeemail.com +dsfdsv12342.com +dsfgasdewq.com +dsfgdsgmail.com +dsfgdsgmail.net +dsfgerqwexx.com +dsfsd.com +dsfsfdsfds.shop +dsfvwevsa.com +dsg.emlhub.com +dsgate.online +dsgawerqw.com +dsgdafadfw.shop +dsgdsgds.dropmail.me +dsgfdsg.dropmail.me +dsgfdsgf.dropmail.me +dsgs.com +dsgvo.party +dsgvo.ru +dshfjdafd.cloud +dshqughcoin9nazl.cf +dshqughcoin9nazl.ga +dshqughcoin9nazl.gq +dshqughcoin9nazl.ml +dshqughcoin9nazl.tk +dshznonline.cc +dsiay.com +dsitip.com +dsjie.com +dskqidlsjf.com +dsleeping09.com +dspwebservices.com +dsresearchins.org +dsrgarg.site +dstchicago.com +dstefaniak.pl +dsvgfdsfss.tk +dswz.emlpro.com +dsy.freeml.net +dszg2aot8s3c.cf +dszg2aot8s3c.ga +dszg2aot8s3c.gq +dszg2aot8s3c.ml +dszg2aot8s3c.tk +dt3456346734.ga +dtab.emltmp.com +dtaz.emlhub.com +dtcleanertab.site +dtcuawg6h0fmilxbq.ml +dtcuawg6h0fmilxbq.tk +dtdns.us +dte.laste.ml +dte3fseuxm9bj4oz0n.cf +dte3fseuxm9bj4oz0n.ga +dte3fseuxm9bj4oz0n.gq +dte3fseuxm9bj4oz0n.ml +dte3fseuxm9bj4oz0n.tk +dteesud.com +dtfa.site +dth.laste.ml +dtheatersn.com +dthlxnt5qdshyikvly.cf +dthlxnt5qdshyikvly.ga +dthlxnt5qdshyikvly.gq +dthlxnt5qdshyikvly.ml +dthlxnt5qdshyikvly.tk +dti.emlhub.com +dtigr.xyz +dtkursk.ru +dtml.com +dtmricambi.com +dtools.info +dtrspypkxaso.cf +dtrspypkxaso.ga +dtrspypkxaso.gq +dtrspypkxaso.ml +dtrspypkxaso.tk +dtspf8pbtlm4.cf +dtspf8pbtlm4.ga +dtspf8pbtlm4.gq +dtspf8pbtlm4.ml +dtspf8pbtlm4.tk +dttt9egmi7bveq58bi.cf +dttt9egmi7bveq58bi.ga +dttt9egmi7bveq58bi.gq +dttt9egmi7bveq58bi.ml +dttt9egmi7bveq58bi.tk +dtv.emlhub.com +dtv42wlb76cgz.cf +dtv42wlb76cgz.ga +dtv42wlb76cgz.gq +dtv42wlb76cgz.ml +dtv42wlb76cgz.tk +dtw.freeml.net +du.dropmail.me +duacgel.info +dualscreenplayer.com +duam.net +duanehar.pw +dubilowski.com +dubokutv.com +dubstepthis.com +dubu.tech +dubukim.me +duc.freeml.net +ducclone.com +duccong.pro +ducenc.com +duck2.club +duckadventure.site +duckmail.sbs +ducl.laste.ml +ducruet.it +ducutuan.cn +ducvdante.pl +dudi.com +dudinkonstantin.ru +dudleymail.bid +dudmail.com +dudscc.com +dudscc.sbs +dudscuapcut.store +duetube.com +dufeed.com +dugmail.com +dugq.dropmail.me +duhocnhatban.org +dui-attorney-news.com +duiter.com +duivavlb.pl +dujc.yomail.info +duk33.com +dukcapiloganilir.cloud +dukedish.com +dukeoo.com +dukunmodern.id +dulanfs.com +dulei.ml +dulich84.com +duluaqpunyateman.com +dumail.com +dumalu.com +dumasnt.org +dumbass.nl +dumbdroid.info +dumbledore.cf +dumbledore.ga +dumbledore.gq +dumbledore.ml +dumbrepublican.info +dumena.com +dumlipa.ga +dummie.com +dummymails.cc +dumoac.net +dumonyal.biz.id +dump-email.info +dumpandjunk.com +dumpmail.com +dumpmail.de +dumpyemail.com +duncancorp.usa.cc +dundee.city +dundeeusedcars.co.uk +dundo.tk +dunefee.com +dunhamsports.com +dunhila.com +dunhila.online +dunia-maya.net +duniakeliling.com +duniavpn.email +dunkos.xyz +dunyaright.xyz +duo-alta.com +duobp.com +duoduo.cafe +duol3.com +duolcxcloud.com +duoley.com +duongtrinh.xyz +duosakhiy.com +dupa.pl +dupaemailk.com.uk +dupazsau2f.cf +dupazsau2f.ga +dupazsau2f.gq +dupazsau2f.ml +dupazsau2f.tk +dupontmails.com +durablecanada.com +durandinterstellar.com +durhamtrans.com +durici.com +duriduri.me +duringly.site +durttime.com +duscore.com +dushirts.com +duskmail.com +dusnedesigns.ml +dusrui.com +dust.marksypark.com +dusting-divas.com +dustinry.com +dustysyawalina.biz +dusyum.com +dutchconnie.com +dutchfemales.info +dutiesu0.com +dutybux.info +duxer.top +duxi.spymail.one +duybuy.com +duydeptrai.xyz +duypro.online +duzgun.net +duzybillboard.pl +dv2.host +dv2wa.anonbox.net +dv6w2z28obi.pl +dv7.com +dv8student.com +dvakansiisochi20139.cx.cc +dvcc.com +dvcu.com +dvd.dns-cloud.net +dvd.dnsabr.com +dvd315.xyz +dvdallnews.com +dvdcloset.net +dvdexperts.info +dvdjapanesehome.com +dvdkrnbooling.com +dvdnewshome.com +dvdnewsonline.com +dvdoto.com +dvdpit.com +dvdrezensionen.com +dvdxpress.biz +dveri5.ru +dverishpon.ru +dvery35.ru +dvfb.asia +dvfdsigni.com +dvfgadvisors.com +dvi-hdmi.net +dviuvbmda.pl +dvkt.emlhub.com +dvlikegiare.com +dvlotterygreencard.com +dvmap.ru +dvn.spymail.one +dvsdg34t6ewt.ga +dvseeding.vn +dvspitfuh434.cf +dvspitfuh434.ga +dvspitfuh434.gq +dvspitfuh434.ml +dvspitfuh434.tk +dvx.dnsabr.com +dw.emltmp.com +dw.now.im +dwa.wiadomosc.pisz.pl +dwakm.com +dwango.cf +dwango.ga +dwango.gq +dwango.ml +dwango.tk +dwarfpools.online +dwdpoisk.info +dweezlemail.crabdance.com +dweezlemail.ufodns.com +dwellingmedicine.com +dwgtcm.com +dwipalinggantengyanglainlewat.cf +dwipalinggantengyanglainlewat.ga +dwipalinggantengyanglainlewat.gq +dwipalinggantengyanglainlewat.ml +dwipalinggantengyanglainlewat.tk +dwisstore.site +dwj.emlpro.com +dwn2ubltpov.cf +dwn2ubltpov.ga +dwn2ubltpov.gq +dwn2ubltpov.ml +dwn2ubltpov.tk +dwnf.emlpro.com +dwraygc.com +dwrf.net +dwse.edu.pl +dwswd8ufd2tfscu.cf +dwswd8ufd2tfscu.ga +dwswd8ufd2tfscu.gq +dwswd8ufd2tfscu.ml +dwswd8ufd2tfscu.tk +dwt-damenwaeschetraeger.org +dwtu.com +dwugio.buzz +dwukwiat4.pl +dwukwiat5.pl +dwukwiat6.pl +dwul.org +dwutuemzudvcb.cf +dwutuemzudvcb.ga +dwutuemzudvcb.gq +dwutuemzudvcb.ml +dwutuemzudvcb.tk +dwwen.com +dwyj.com +dx.abuser.eu +dx.allowed.org +dx.awiki.org +dx.ez.lv +dx.sly.io +dx.spymail.one +dxaw.emlhub.com +dxdblog.com +dxecig.com +dxi.spymail.one +dxice.com +dxirl.com +dxlenterprises.net +dxmk148pvn.cf +dxmk148pvn.ga +dxmk148pvn.gq +dxmk148pvn.ml +dxmk148pvn.tk +dxpz.emltmp.com +dxuroa.xyz +dxuxay.xyz +dxzx.dropmail.me +dxzx.spymail.one +dy7fpcmwck.cf +dy7fpcmwck.ga +dy7fpcmwck.gq +dy7fpcmwck.ml +dy7fpcmwck.tk +dyceroprojects.com +dyclsr.xyz +dye.emlhub.com +dygovil.com +dyi.com +dyi.emlhub.com +dyj.pl +dylans.email +dymnawynos.pl +dynabird.com +dynainbox.com +dynamic-domain-ns1.ml +dynamitemail.com +dynastyantique.com +dyndns.org +dynofusion-developments.com +dynohoxa.com +dynu.net +dyoeii.com +dyru.site +dyskretna-pomoc.pl +dyskretny.com +dyting.com +dyx9th0o1t5f.cf +dyx9th0o1t5f.ga +dyx9th0o1t5f.gq +dyx9th0o1t5f.ml +dyx9th0o1t5f.tk +dyyar.com +dyyfin.shop +dz-geek.org +dz.dropmail.me +dz.emlpro.com +dz.freeml.net +dz.usto.in +dz0371.com +dz17.net +dz4ahrt79.pl +dz57taerst4574.ga +dzalaev-advokat.ru +dzb.laste.ml +dzewa6nnvt9fte.cf +dzewa6nnvt9fte.ga +dzewa6nnvt9fte.gq +dzewa6nnvt9fte.ml +dzewa6nnvt9fte.tk +dzfphcn47xg.ga +dzfphcn47xg.gq +dzfphcn47xg.ml +dzfphcn47xg.tk +dzfse.anonbox.net +dzg.emlhub.com +dzgiftcards.com +dzhinsy-platja.info +dzi.emltmp.com +dzidmcklx.com +dziecio-land.pl +dziekan1.pl +dziekan2.pl +dziekan3.pl +dziekan4.pl +dziekan5.pl +dziekan6.pl +dziekan7.pl +dziesiec.akika.pl +dzimbabwegq.com +dzinoy58w12.ga +dzinoy58w12.gq +dzinoy58w12.ml +dzinoy58w12.tk +dznf.net +dzoefxifzd.ga +dzsyr.com +dzw.fr +dzye.com +e-b-s.pp.ua +e-bazar.org +e-bhpkursy.pl +e-cigarette-x.com +e-cigreviews.com +e-clip.info +e-drapaki.eu +e-factorystyle.pl +e-filme.net +e-horoskopdzienny.pl +e-jaroslawiec.pl +e-mail.cafe +e-mail.com +e-mail.comx.cf +e-mail.edu.pl +e-mail.igg.biz +e-mail.net +e-mail.org +e-mail365.eu +e-mailbox.comx.cf +e-mailbox.ga +e-mails.site +e-marketstore.ru +e-mbtshoes.com +e-moje-inwestycje.pl +e-mule.cf +e-mule.ga +e-mule.gq +e-mule.ml +e-mule.tk +e-n-facebook-com.cf +e-n-facebook-com.gq +e-news.org +e-numizmatyka.pl +e-pierdoly.pl +e-pool.co.uk +e-pool.uk +e-poradnikowo24.pl +e-postkasten.com +e-postkasten.de +e-postkasten.eu +e-postkasten.info +e-prima.com.pl +e-record.com +e-s-m.ru +e-swieradow.pl +e-swojswiat.pl +e-tikhvin.ru +e-tomarigi.com +e-torrent.ru +e-trend.pl +e-vents2009.info +e-w.live +e-wawa.pl +e.amav.ro +e.arno.fi +e.barbiedreamhouse.club +e.beardtrimmer.club +e.benlotus.com +e.bestwrinklecreamnow.com +e.bettermail.website +e.blogspam.ro +e.captchaeu.info +e.coloncleanse.club +e.crazymail.website +e.discard-email.cf +e.dogclothing.store +e.garciniacambogia.directory +e.gsamail.website +e.gsasearchengineranker.pw +e.gsasearchengineranker.site +e.gsasearchengineranker.space +e.gsasearchengineranker.top +e.gsasearchengineranker.xyz +e.mediaplayer.website +e.milavitsaromania.ro +e.mylittlepony.website +e.nodie.cc +e.ouijaboard.club +e.polosburberry.com +e.seoestore.us +e.shapoo.ch +e.socialcampaigns.org +e.uhdtv.website +e.virtualmail.website +e.waterpurifier.club +e.wupics.com +e052.com +e0yk-mail.ml +e13100d7e234b6.noip.me +e1r2qfuw.com +e1y4anp6d5kikv.cf +e1y4anp6d5kikv.ga +e1y4anp6d5kikv.gq +e1y4anp6d5kikv.ml +e1y4anp6d5kikv.tk +e27hi.anonbox.net +e2qoitlrzw6yqg.cf +e2qoitlrzw6yqg.ga +e2qoitlrzw6yqg.gq +e2qoitlrzw6yqg.ml +e2qoitlrzw6yqg.tk +e2trg8d4.priv.pl +e3b.org +e3jh2.anonbox.net +e3z.de +e4ivstampk.com +e4t5exw6aauecg.ga +e4t5exw6aauecg.ml +e4t5exw6aauecg.tk +e4ward.com +e4wfnv7ay0hawl3rz.cf +e4wfnv7ay0hawl3rz.ga +e4wfnv7ay0hawl3rz.gq +e4wfnv7ay0hawl3rz.ml +e4wfnv7ay0hawl3rz.tk +e501eyc1m4tktem067.cf +e501eyc1m4tktem067.ga +e501eyc1m4tktem067.ml +e501eyc1m4tktem067.tk +e52.ru +e52gr.anonbox.net +e56r5b6r56r5b.cf +e56r5b6r56r5b.ga +e56r5b6r56r5b.gq +e56r5b6r56r5b.ml +e57.pl +e5a7fec.icu +e5by64r56y45.cf +e5by64r56y45.ga +e5by64r56y45.gq +e5by64r56y45.ml +e5by64r56y45.tk +e5ki3ssbvt.cf +e5ki3ssbvt.ga +e5ki3ssbvt.gq +e5ki3ssbvt.ml +e5ki3ssbvt.tk +e5r6ynr5.cf +e5r6ynr5.ga +e5r6ynr5.gq +e5r6ynr5.ml +e5r6ynr5.tk +e5v7tp.pl +e6hq33h9o.pl +e77s6.anonbox.net +e7n06wz.com +e84ywua9hxr5q.cf +e84ywua9hxr5q.ga +e84ywua9hxr5q.gq +e84ywua9hxr5q.ml +e84ywua9hxr5q.tk +e89fi5kt8tuev6nl.cf +e89fi5kt8tuev6nl.ga +e89fi5kt8tuev6nl.gq +e89fi5kt8tuev6nl.ml +e89fi5kt8tuev6nl.tk +e8dymnn9k.pl +e8g93s9zfo.com +e90.biz +ea.emltmp.com +eaa620.org +eaadresddasa.cloud +eabm.yomail.info +eabockers.com +eacademia.uk +eachart.com +eachence.com +eachera.com +eacprsspva.ga +eadvertsyst.com +eafabet.com +eafence.net +eafrem3456ails.com +eaganapartments.com +eagledigitizing.net +eaglefight.top +eaglehandbags.com +eagleinbox.com +eaglemail.top +eagleracingengines.com +eaglesfootballpro.com +eaglesnestestates.org +eahe.com +eaib.freeml.net +eail.com +eajfciwvbohrdbhyi.cf +eajfciwvbohrdbhyi.ga +eajfciwvbohrdbhyi.gq +eajfciwvbohrdbhyi.ml +eajfciwvbohrdbhyi.tk +eak.emlpro.com +eake.yomail.info +ealea.fr.nf +eamail.com +eamale.com +eamarian.com +eami85nt.atm.pl +eamil.com +eamrhh.com +eamsurn.com +eanok.com +eaqso209ak.cf +eaqso209ak.ga +eaqso209ak.gq +eaqso209ak.ml +earachelife.com +earhlink.net +earnfrom.website +earningsph.com +earnlink.ooo +earnmoretraffic.net +earpitchtraining.info +earrthlink.net +earth.blatnet.com +earth.doesntexist.org +earth.heartmantwo.com +earth.maildin.com +earth.oldoutnewin.com +earth.ploooop.com +earthbabes.info +earthhourlive.org +earthworksyar.cf +earthworksyar.ml +earthxqe.com +eartin.net +ease.es +easiestcollegestogetinto.com +easilyremovewrinkles.com +easilys.tech +easists.site +easm.site +east3.com +easteuropepa.com +eastmm.cc +eastofwestla.com +eastriveramail.com +eastsideag.com +eastwan.net +eastwestpr.com +easy-apps.info +easy-link.org +easy-mail.top +easy-trash-mail.com +easy2ride.com +easyandhardwaysout.com +easybedb.site +easyblogs.biz +easybranches.ru +easybuygos.com +easydinnerrecipes.net +easydinnerrecipes.org +easydirectory.tk +easyemail.info +easyfbcommissions.com +easyfundplan.com +easygamingbd.com +easygbd.cn +easygbd.com +easyguitarlessonsworld.com +easyhomefit.com +easyiphoneunlock.top +easyjimmy.cz.cc +easyjiujitsu.com +easylangways.com +easylimanauw.biz +easymail.digital +easymail.ga +easymail.igg.biz +easymail.top +easymailer.live +easymailing.top +easymails.cc +easymarry.com +easymbtshoes.com +easynetwork.info +easyonlinecollege.com +easyonlinemail.net +easypaperplanes.com +easyrecipetoday.com +easysetting.org +easytrashmail.com +easyxsnews.club +eatdrink518.com +eatingexperiences.com +eatlikeahuman.com +eatlogs.com +eatlove.com +eatme69.top +eatmea2z.club +eatmea2z.top +eatneha.com +eatreplicashop.com +eatrnet.com +eatshit.org +eatsleepwoof.com +eatstopeatdiscount.org +eatthegarden.co.uk +eauie.top +eautofsm.com +eautoskup.net +eawm.de +eay.jp +eayd.emlhub.com +eazeemail.info +eazenity.com +eb-dk.biz +eb.spymail.one +eb46r5r5e.cf +eb46r5r5e.ga +eb46r5r5e.gq +eb46r5r5e.ml +eb46r5r5e.tk +eb4te5.cf +eb4te5.ga +eb4te5.gq +eb4te5.ml +eb4te5.tk +eb56b45.cf +eb56b45.ga +eb56b45.gq +eb56b45.ml +eb56b45.tk +eb609s25w.com +eb655b5.cf +eb655b5.ga +eb655b5.gq +eb655b5.ml +eb655b5.tk +eb655et4.cf +eb655et4.ga +eb655et4.gq +eb655et4.ml +eb7gxqtsoyj.cf +eb7gxqtsoyj.ga +eb7gxqtsoyj.gq +eb7gxqtsoyj.ml +eb7gxqtsoyj.tk +eba.emlpro.com +ebaja.com +ebaldremal.shop +ebano.campano.cl +ebarg.net +ebaymail.com +ebbob.com +ebbrands.com +ebctc.com +ebdbuuxxy.pl +ebeards.com +ebeelove.com +ebek.com +ebeschlussbuch.de +ebestaudiobooks.com +ebg.laste.ml +ebhospitality.com +ebialrh.com +ebignews.com +ebing.com +ebith.anonbox.net +ebmail.co +ebmail.com +ebnaoqle657.cf +ebnaoqle657.ga +ebnaoqle657.gq +ebnaoqle657.ml +ebnaoqle657.tk +ebnevelde.org +ebnmg.anonbox.net +ebocmail.com +eboise.com +eboj.yomail.info +ebony.monster +ebookbiz.info +ebookway.us +ebookwiki.org +ebop.pl +ebqxczaxc.com +ebr.yomail.info +ebradt.org +ebrker.pl +ebruummuhantarak.cfd +ebs.com.ar +ebsitv.store +ebsitvarketing.store +ebsitvmarketing.store +ebtukukxnn.cf +ebtukukxnn.ga +ebtukukxnn.gq +ebtukukxnn.ml +ebtukukxnn.tk +ebu.laste.ml +ebuthor.com +ebuyfree.com +ebv9rtbhseeto0.cf +ebv9rtbhseeto0.ga +ebv9rtbhseeto0.gq +ebv9rtbhseeto0.ml +ebv9rtbhseeto0.tk +ebworkerzn.com +ebyjeans.com +ebzb.com +ec2providershub.de +ec556.anonbox.net +ec97.cf +ec97.ga +ec97.gq +ec97.ml +ec97.tk +ecallen.com +ecallheandi.com +ecanc.com +ecawuv.com +eccfilms.com +eccgulf.net +eccr.dropmail.me +ecea.de +echeaplawnmowers.com +echt-mail.de +echta.com +echtacard.com +echtzeit.website +ecigarettereviewonline.net +ecimail.com +ecipk.com +eclair.minemail.in +eclcyre.com +eclipseye.com +ecmail.com +ecmax.de +ecn37.ru +eco-88brand.com +eco-crimea.ru +eco.ilmale.it +ecoblogger.com +ecocap.cf +ecocap.ga +ecocap.gq +ecocap.ml +ecocap.tk +ecoco.space +ecocryptolab.com +ecodark.com +ecoe.de +ecoforfun.website +ecofreon.com +ecohut.xyz +ecoimagem.com +ecoisp.com +ecolaundrysystems.com +ecolo-online.fr +ecomail.com +ecomaj.cfd +ecomdaily.com +ecomediahosting.net +ecommerceservice.cc +ecomyst.com +econeom.com +econvention2007.info +ecopressmail.us +ecoright.ru +ecossr.site +ecoverseworld.com +ecowisehome.com +ecpsscardshopping.com +ecsspay.com +ecstor.com +ectong.xyz +ecuadorianhands.com +ecuasuiza.com +ecudeju.olkusz.pl +ecuwmyp.pl +ecv.yomail.info +ecy.freeml.net +ecybqsu.pl +eczaj.anonbox.net +ed-hardybrand.com +ed-pillole.it +ed.laste.ml +ed1crhaka8u4.cf +ed1crhaka8u4.ga +ed1crhaka8u4.gq +ed1crhaka8u4.ml +ed1crhaka8u4.tk +edaikou.com +edalist.ru +edat.site +edaup.com +edbnu.com +edcar-sacz.pl +edcs.de +ede.dropmail.me +edealgolf.com +edeals420.com +edectus.com +edf.ca.pn +edfast-medrx.com +edfdiaryf.com +edfore.cloud +edfore.site +edfromcali.info +edge.blatnet.com +edge.cowsnbullz.com +edge.marksypark.com +edge.ploooop.com +edgenestlab.com +edgepodlab.com +edger.dev +edgetopgrid.com +edgex.ru +edgw.com +edhardy-onsale.com +edhardy886.com +edhardyfeel.com +edhardyown.com +edhardypurchase.com +edhardyuser.com +edialdentist.com +edicalled.site +edifice.ga +edikmail.com +edilm.site +edimail.com +edinarfinancial.com +edinburgh-airporthotels.com +edinel.com +edirasa.com +edirectai.com +edit-2ch.biz +editariation.xyz +edithis.info +editicon.info +editoraprilianti.biz +edkvq9wrizni8.cf +edkvq9wrizni8.ga +edkvq9wrizni8.gq +edkvq9wrizni8.ml +edkvq9wrizni8.tk +edmail.com +edmnierutnlin.store +edmondpt.com +edmondventures.com +edmontonportablesigns.com +edn.laste.ml +edny.net +edoamb.site +edomail.com +edotzxdsfnjvluhtg.cf +edotzxdsfnjvluhtg.ga +edotzxdsfnjvluhtg.gq +edotzxdsfnjvluhtg.ml +edotzxdsfnjvluhtg.tk +edouardloubet.art +edovqsnb.pl +edpillfsa.com +edpillsrx.us +edrishn.xyz +edsdf.dropmail.me +edsindia.com +edsr.com +edu-it.site +edu-paper.com +edu.aiot.ze.cx +edu.auction +edu.cowsnbullz.com +edu.dmtc.dev +edu.email.edu.pl +edu.hstu.eu.org +edu.lakemneadows.com +edu.net +edu.universallightkeys.com +eduanswer.ru +educaix.com +education.eu +educationleaders-ksa.com +educationmail.info +educationvn.cf +educationvn.ga +educationvn.gq +educationvn.ml +educationvn.tk +educharved.site +educhat.email +educourse.xyz +edudigy.cc +edudingy.cfd +edugonext.in +eduhed.com +eduheros.com +eduinfoline.com +edukacyjny.biz +edukansassu12a.cf +edulena.com +edultry.com +edumaga.com +edumail.edu.pl +edumail.edu.rs +edumail.fun +edumail.icu +edumail.store +edumail.su +edume.me +edumomtalk.com +edunk.com +edupolska.edu.pl +edupost.pl +edurealistic.ru +edus.works +edusamail.net +edusath.com +edusch.id +edusch.site +edushort.me +edusmart.website +edv.to +edvzz.com +edwardnmkpro.design +edxplus.com +ee-papieros.pl +ee.anglik.org +ee.spymail.one +ee1.pl +ee2.pl +eeaaites.com +eeagan.com +eeauqspent.com +eeb4b.anonbox.net +eee.emlpro.com +eee.net +eeedv.de +eeeeeeee.pl +eeemail.pl +eeemail.win +eeetivsc.com +eefrngzi.com +eegxvaanji.pl +eehfmail.org +eeiv.com +eek.codes +eek.rocks +eellee.org +eelmail.com +eelraodo.com +eelrcbl.com +eenul.com +eeothno.com +eep.freeml.net +eepaaa.com +eeppai.com +eepulse.info +eerees.com +eetieg.com +eeuasi.com +eevnxx.gq +eewmaop.com +eezojq3zq264gk.cf +eezojq3zq264gk.ga +eezojq3zq264gk.gq +eezojq3zq264gk.ml +eezojq3zq264gk.tk +ef.emlhub.com +ef.emlpro.com +ef2qohn1l4ctqvh.cf +ef2qohn1l4ctqvh.ga +ef2qohn1l4ctqvh.gq +ef2qohn1l4ctqvh.ml +ef2qohn1l4ctqvh.tk +ef9ppjrzqcza.cf +ef9ppjrzqcza.ga +ef9ppjrzqcza.gq +ef9ppjrzqcza.ml +ef9ppjrzqcza.tk +efacs.net +efan.shop +efastes.com +efasttrackwatches.com +efatt2fiilie.ru +efc.spymail.one +efepala.kazimierz-dolny.pl +efetusomgx.pl +effect-help.ru +effective-pheromones.info +effective-thai.com +effexts.com +effffffo.shop +effobe.com +effortance.xyz +effortlessinneke.io +efgh.dropmail.me +efhuxvwd.pl +efishdeal.com +eflfnskgw2.com +efmo.laste.ml +efmsts.xyz +efo.kr +efpaper.com +efreaknet.com +efreet.org +efremails.com +eft.one +efu114.com +efundpro.com +efva.com +efx.freeml.net +efxs.ca +eg.laste.ml +eg0025.com +eg66cw0.orge.pl +egames20.com +egames4girl.com +egbs.com +egc89vmz.shop +egdr.spymail.one +egear.store +egela.com +egepalat.cfd +eget1loadzzz.ru +eget9loaadz.ru +egfe.dropmail.me +egget4fffile.ru +egget8zagruz.ru +eggharborfesthaus.com +eggnova.com +eggrockmodular.com +eggscryptoinvest.xyz +eggur.com +eggviews.xyz +egibet101.com +egipet-nedv.ru +egirl.help +eglft.in +eglftn.web.id +egn.freeml.net +egodmail.com +egofan.ru +egsolucoes.shop +egsouq.shop +eguccibag-sales.com +egumail.com +egute.space +egvgtbz.xorg.pl +egvoo.com +egypthacker.com +egzmail.top +egzones.com +eh.yomail.info +ehealthic.de +ehfk.freeml.net +ehfx.emlhub.com +ehhd.laste.ml +ehhxbsbbdhxcsvzbdv.ml +ehhxbsbbdhxcsvzbdv.tk +ehivut.ink +ehk.spymail.one +ehlio.com +ehmail.com +ehmail.fun +ehmhondajazz.buzz +ehmwi6oixa6mar7c.cf +ehmwi6oixa6mar7c.ga +ehmwi6oixa6mar7c.gq +ehmwi6oixa6mar7c.ml +ehmwi6oixa6mar7c.tk +ehne.laste.ml +ehnorthernz.com +eho.emltmp.com +eho.kr +ehoie03og3acq3us6.cf +ehoie03og3acq3us6.ga +ehoie03og3acq3us6.gq +ehoie03og3acq3us6.ml +ehoie03og3acq3us6.tk +ehomeconnect.net +ehowtobuildafireplace.com +ehstock.com +ehvgfwayspsfwukntpi.cf +ehvgfwayspsfwukntpi.ga +ehvgfwayspsfwukntpi.gq +ehvgfwayspsfwukntpi.ml +ehvgfwayspsfwukntpi.tk +ei.spymail.one +eiakr.com +eiandayer.xyz +eicircuitm.com +eids.de +eidumail.com +eidzone.com +eight.emailfake.ml +eight.fackme.gq +eightset.com +eigoemail.com +eihnh.com +eiibps.com +eiid.org +eiis.com +eik3jeha7dt1as.cf +eik3jeha7dt1as.ga +eik3jeha7dt1as.gq +eik3jeha7dt1as.ml +eik3jeha7dt1as.tk +eik8a.avr.ze.cx +eil.spymail.one +eilnews.com +eimadness.com +eimail.com +eimatro.com +eindowslive.com +eindstream.net +einfach.to +einmalmail.de +einrot.com +einrot.de +eins-zwei.cf +eins-zwei.ga +eins-zwei.gq +eins-zwei.ml +eins-zwei.tk +einsteinaccounting.com +einsteino.com +einsteino.net +eintagsmail.de +eircjj.com +eireet.site +eirtsdfgs.co.cc +eise.es +eisenhauercars.com +eitherium.com +eiveg.com +eixdeal.com +ej.emltmp.com +ej.mimimail.me +ej.opheliia.com +ejaculationbycommandreviewed.org +ejaculationprecoce911.com +ejaculationtrainerreviewed.com +ejajmail.com +ejapangirls.com +ejby3.anonbox.net +ejdy1hr9b.pl +eje.dropmail.me +ejez.com +ejh3ztqvlw.cf +ejh3ztqvlw.ga +ejh3ztqvlw.gq +ejh3ztqvlw.ml +ejh3ztqvlw.tk +ejkovev.org +ejmcuv7.com.pl +ejrt.co.cc +ejrtug.co.cc +eju.emltmp.com +ek.emltmp.com +ek.laste.ml +ek8wqatxer5.cf +ek8wqatxer5.ga +ek8wqatxer5.gq +ek8wqatxer5.ml +ek8wqatxer5.tk +ekamaz.com +ekameal.ru +ekapoker.com +ekata.tech +ekatalogstron.ovh +ekb-nedv.ru +ekbasia.com +ekcsoft.com +ekd.mimimail.me +ekf.yomail.info +ekgh.laste.ml +ekgz.emlhub.com +eki.emlhub.com +ekii.cf +ekiiajah.ga +ekiibete.ml +ekiibeteaja.cf +ekiibetekorea.tk +ekiikorea99.cf +ekiikorea99.ga +ekiilinkinpark.ga +ekipatonosi.cf +ekipatonosi.gq +ekipatonosi.ml +ekipatonosi.tk +ekkoboss.com.ua +eklyj.emlhub.com +ekmail.com +ekmd.freeml.net +ekmektarifi.com +eko-europa.com +ekonu.com +ekor.info +ekot.xyz +ekpn.freeml.net +ekposta.com +ekredyt.org +eksprespedycja.pl +ekstra.pl +ektjtroskadma.com +eku.emlhub.com +ekuali.com +ekumail.com +ekurhuleni.co.za +ekvg3.anonbox.net +ekwmail.com +ekxf.spymail.one +el-kassa.info +el-x.tech +el.cash +el.efast.in +el.freeml.net +elabmedia.com +elafans.com +elahan.com +elaine1.xyz +elaineshoes.com +elancreditcards.net +elastit.com +elatter.com +elaven.cf +elavilonlinenow.com +elavmail.com +elbenyamins.com +elcajonrentals.com +elcarin.store +elchato.com +elderflame.xyz +eldobhato-level.hu +eldoradoschool.org +eldv.com +elearningjournal.org +elearnuk.co +eleccionesath.com +eleccionnatural.com +electica.com +electionwatch.info +electriccarvehicle.com +electricianhelp.site +electricistaurgente.net +electricswitch.info +electro.mn +electrofunds.com +electrolabx.com +electromax.us +electronic-smoke.com +electronic-stores.org +electronicaentertainment.com +electronicdirectories.com +electronicearprotection.net +electronicmail.us +electroproluxex.eu +electrostaticdisinfectantsprayers.site +eledeen.org +elefonica.com +elegantthemes.top +eleganttouchlinens.com +elektrische-auto.info +elektro-grobgerate.com +elektroniksigara.xyz +elementaltraderforex.com +elementlounge.com +elenafuriase.com +elenagolunova.site +elenotoneshop.com +elerrisgroup.com +elerso.com +elesb.net +elevareurhealth.com +elevatn.net +elevatorshoes-wholesalestores.info +elevens4d.net +elex-net.ru +elexbetgunceladres.com +elexbetguncelgiris.com +elfox.net +elftraff.com +elhadouta.store +elhammam.com +elhida.com +elhidamadaninusantara.online +elifart.net +eligibilitysolutions.com +eligou.com +eligou.store +elilogan.us +elimam.org +elinbox.com +elinore1818.site +eliotkids.com +elisejoanllc.com +elisestyle.shop +elisione.pl +elite-altay.ru +elite-jibbo.com +elite-seo-marketing.com +elite.gold.edu.pl +elite12.mygbiz.com +elite21miners.site +eliteavangers.pl +eliteesig.org +elitemotions.com +elitemp.xyz +elitepond.com +elitescortistanbul.net +eliteseo.net +elitevipatlantamodels.com +elitokna.com +eliwakhaliljbqass.online +eliwakhaliljbqass.site +elix.freeml.net +elixirsd.com +elizabethlacio.com +elizabethroberts.org +elizabethscleanremedy.com +elkgroveses.com +elki-mkzn.ru +ellahamid.art +elle-news.com +ellebox.com +ellesecret.com +ellesspromotion.co.uk +elletsigns.com +ellight.ru +ellineswitzerland.com +ellipticalmedia.com +ellisontraffic.com +elloboxlolongti.com +elmarquesbanquetes.com +elmcreekcoop.com +elmiracap.com +elmmccc.com +elmos.es +elmoscow.ru +elny.emlpro.com +elobits.com +eloelo.com +elograder.com +elohellplayer.com +elokalna.pl +eloltsf.com +elpatevskiy.com +elraenv2.ga +elraigon.com +elregresoinc.com +elrfwpel.com +els396lgxa6krq1ijkl.cf +els396lgxa6krq1ijkl.ga +els396lgxa6krq1ijkl.gq +els396lgxa6krq1ijkl.ml +els396lgxa6krq1ijkl.tk +elsdrivingschool.net +elsetos.biz +elsevierheritagecollection.org +elsexo.ru +elt.emlhub.com +elteh.me +eltombis.pl +elumail.com +eluvit.com +eluxurycoat.com +elv.emlhub.com +elva.app +elwatar.com +ely.kr +elygifts.com +elyse.mallory.livefreemail.top +elysium.ml +elysiumfund.net +elzire.com +em-meblekuchenne.pl +em-solutions.com +em2lab.com +em4.rejecthost.com +ema-sofia.eu +emaagops.ga +emaail.com +emagrecendocomrenata.com +emagrecerdevezbr.com +emai.cz +emai.eee.u.emlhub.com +emai.emlhub.com +emaiden.com +emaigops.ga +email-24x7.com +email-4-everybody.bid +email-68.com +email-9.com +email-bomber.info +email-boxes.ru +email-brasil.com +email-fake.cf +email-fake.com +email-fake.ga +email-fake.gq +email-fake.ml +email-fake.tk +email-free.online +email-host.info +email-jetable.fr +email-lab.com +email-list.online +email-me.bid +email-premium.com +email-server.info +email-sms.com +email-sms.net +email-t.cf +email-t.ga +email-t.gq +email-t.ml +email-t.tk +email-temp.com +email-very.com +email-wizard.com +email.cbes.net +email.com.co +email.comx.cf +email.cykldrzewa.pl +email.edu.pl +email.freecrypt.org +email.gen.tr +email.infokehilangan.com +email.ml +email.net +email.net.tr +email.omshanti.edu.in +email.org +email.ucms.edu.pk +email.wassusf.online +email0.cf +email0.ga +email0.gq +email0.ml +email0.tk +email1.com +email1.gq +email1.io +email1.pro +email10p.org +email2.cf +email2.gq +email2.ml +email2.tk +email2an.ga +email2twitter.info +email3.cf +email3.ga +email3.gq +email3.ml +email3.tk +email4.in +email42.com +email4all.info +email4everybody.bid +email4everyone.co.uk +email4everyone.com +email4spam.org +email4u.info +email4work.xyz +email5.net +email60.com +email64.com +email84.com +emailabox.pro +emailage.cf +emailage.ga +emailage.gq +emailage.ml +emailage.tk +emailaing.com +emailanto.com +emailaoa.pro +emailappp.com +emailapps.in +emailapps.info +emailat.website +emailate.com +emailawb.pro +emailax.pro +emailay.com +emailbaruku.com +emailbbox.pro +emailbeauty.com +emailber.com +emailbin.net +emailbooox.gq +emailboot.com +emailbot.org +emailbox.click +emailbox.comx.cf +emailboxa.online +emailboxi.live +emailcbox.pro +emailchepas.cf +emailchepas.ga +emailchepas.gq +emailchepas.ml +emailchepas.tk +emailcoffeehouse.com +emailcom.org +emailcoordinator.info +emailcu.icu +emaildark.fr.nf +emaildbox.pro +emaildfga.com +emaildienst.de +emaildrop.io +emaildublog.com +emailed.com +emailedu.tk +emaileen.com +emailertr.com +emailfacil.ml +emailfake.cf +emailfake.com +emailfake.ga +emailfake.gq +emailfake.ml +emailfake.nut.cc +emailfake.usa.cc +emailfalsa.cf +emailfalsa.ga +emailfalsa.gq +emailfalsa.ml +emailfalsa.tk +emailforme.pl +emailforyou.info +emailforyounow.com +emailfowarding.com +emailfoxi.pro +emailfreedom.ml +emailgap.com +emailgen.uk +emailgenerator.de +emailgo.de +emailgo.tk +emailgot.com +emailgotty.xyz +emailgratis.info +emailgsio.us +emailhearing.com +emailhook.site +emailhost99.com +emailhosts.org +emailhot.com +emailias.com +emailigo.de +emailinbox.xyz +emailinfive.com +emailirani.ir +emailismy.com +emailist.tk +emailisvalid.com +emailjetable.icu +emailjonny.net +emailke.live +emailket.online +emailkg.com +emailkjff.com +emailko.in +emailkoe.com +emailkoe.xyz +emailkom.live +emailkp.com +emaill.app +emaill.host +emaill.webcam +emaillab.xyz +emaillalala.org +emaillime.com +emailll.org +emailly.co +emailmarket.fun +emailmc2.com +emailme.accountant +emailme.bid +emailme.men +emailme.racing +emailme.win +emailmenow.info +emailmiser.com +emailmobile.net +emailmonkey.club +emailmultimedia.com +emailmynn.com +emailmysr.com +emailna.co +emailna.life +emailnator.com +emailnax.com +emailno.in +emailnode.net +emailnope.com +emailnow.net +emailnow.one +emailnow.ru +emailnube.com +emailo.pro +emailofnd.cf +emailondeck.com +emailonlinefree.com +emailonn.in +emailoo.cf +emailpalbuddy.com +emailpop.eu +emailpop3.eu +emailpops.cz.cc +emailportal.info +emailpro.cf +emailpro.ml +emailproxsy.com +emailr.win +emailrac.com +emailracc.com +emailrambler.co.tv +emailrecup.info +emailreg.org +emailresort.com +emailreviews.info +emailrii.com +emailrod.com +emailrtg.org +emails-like-snails.bid +emails.ga +emails92x.pl +emailsalestoday.info +emailsecurer.com +emailsendingjobs.net +emailsensei.com +emailsforall.com +emailsinfo.com +emailsingularity.net +emailsky.info +emailslikesnails.bid +emailsolutions.xyz +emailspam.cf +emailspam.ga +emailspam.gq +emailspam.ml +emailspam.tk +emailspot.org +emailspro.com +emailsquick.com +emailss.com +emailsteel.com +emailswhois.com +emailsy.info +emailsys.co.cc +emailt.com +emailtaxi.de +emailtea.com +emailtech.info +emailtemporanea.com +emailtemporanea.net +emailtemporar.ro +emailtemporario.com.br +emailtex.com +emailthe.net +emailtik.com +emailtmp.com +emailto.de +emailtoo.ml +emailtoshare.com +emailtown.club +emailtrain.ga +emailure.net +emailvb.pro +emailvenue.com +emailviettel.my +emailvnpt.online +emailwarden.com +emailworldwide.info +emailworth.com +emailwww.pro +emailx.at.hm +emailx.org +emailxfer.com +emailxpress.co.cc +emaily.pro +emailz.cf +emailz.ga +emailz.gq +emailz.ml +emaim.com +emakmintadomain.co +emall.ml +emanual.site +emaomail.com +emapmail.com +emaxasp.com +embaeqmail.com +embalaje.us +embaramail.com +embarq.net +embarqumail.com +embatqmail.com +embekhoe.com +embergone.cf +embergone.ga +embergone.gq +embergone.ml +embergone.tk +embergonebro.cf +embergonebro.ga +embergonebro.gq +embergonebro.ml +embergonebro.tk +emberhookah.com +emblemail.com +embrapamail.pw +embrille.com +embuartesdigital.site +emcinfo.pl +emdwgsnxatla1.cf +emdwgsnxatla1.ga +emdwgsnxatla1.gq +emdwgsnxatla1.ml +emdwgsnxatla1.tk +emedia.nl +emeil.cf +emeil.in +emeil.ir +emenage.com +emeraldcluster.com +emeraldwebmail.com +emergedi.com +emergencymail.site +emergentvillage.org +emext.com +emeyle.com +emfunhigh.tk +emg.pw +emhelectric.net +emi.pine-and-onyx.pine-and-onyx.xyz +emi360.net +emial.com +emil.com +emila.com +emiliacontessaresep.art +emilydates.review +emilykistlerphoto.com +eminempwu.com +eminilathe.info +emiratestravelslk.com +emirati-nedv.ru +emirmail.ga +emiro.ru +emjigaz.ovh +emka3.vv.cc +emkei.cf +emkei.ga +emkei.gq +emkei.ml +emkei.tk +emkunchi.com +eml.pp.ua +emlagops.ga +emlhot.com +emlhub.com +emlo.ga +emlppt.com +emlpro.com +emlt.xyz +emltmp.com +emmail.com +emmail.info +emmailoon.com +emmajulissa.kyoto-webmail.top +emmandus.com +emmasart.com +emmasmale.com +emmastyle.shop +emms.freeml.net +emmx.emltmp.com +emmys.life +emocan.name.tr +emohawk.xyz +emold.eu +emops.net +emoreforworkx.com +emoreno.tk +emoshin.com +emotionalhealththerapy.com +emotionengineering.com +emovern.site +emozoro.de +emp4lbr3wox.ga +empaltahu24best.gq +empek.tk +emperatedly.xyz +emperormoh.fun +empireanime.ga +empireapp.org +empiremail.de +empireofbeauty.co.uk +empiresro.com +empletely.xyz +employes.tech +empondica.site +empower-solar.com +empowerbyte.com +empowerelec.com +empowering.zapto.org +empregoaqui.site +empregosempre.club +empresagloriasamotderoman.com +emptyji.com +emptylousersstop.com +empurarefrigeration.com +emran.cf +emsapp.net +emsq.laste.ml +emstjzh.com +emtelrilan.xyz +emtrn9cyvg0a.cf +emtrn9cyvg0a.ga +emtrn9cyvg0a.gq +emtrn9cyvg0a.ml +emtrn9cyvg0a.tk +emule.cf +emule.ga +emule.gq +emunmail.com +emvil.com +emvps.xyz +emw.yomail.info +emwe.ru +emy.kr +emz.net +en.spymail.one +en565n6yt4be5.cf +en565n6yt4be5.ga +en565n6yt4be5.gq +en565n6yt4be5.ml +en565n6yt4be5.tk +en5ew4r53c4.cf +en5ew4r53c4.ga +en5ew4r53c4.gq +en5ew4r53c4.ml +en5ew4r53c4.tk +en7ys.anonbox.net +enables.us +enaksekali.ga +enaktu.eu +enamelme.com +enattendantlorage.org +enayu.com +encloudhd.com +encrot.uk.ht +encrypted4email.com +encryptedmail.xyz +encryptedonion.com +encrytech.com +encuentra24.app +encuestan.com +encuestas.live +end.tw +endangkusdiningsih.art +endeavorla.com +endeavorsllc.com +endelite.com +endergraph.com +endflash.com +endibit.com +endob.com +endosferes.ru +endrix.org +endymion-numerique.com +eneko-atxa.art +enemyth.com +enercranyr.eu +energen.live +energetus.pl +energiadeportugal.com +energon-co.ru +energy69.com +energymail.co.cc +energymails.com +energymonitor.pl +enersets.com +enestmep.com +enewheretm.tk +enewsmap.com +eneyatokar12.com +enfane.com +enfermedad.site +enforkatoere.com +enfsmq2wel.cf +enfsmq2wel.ga +enfsmq2wel.gq +enfsmq2wel.ml +enfsmq2wel.tk +engagecoin.net +engagecoin.org +engagefmb.com +engagingwebsites.com +engary.site +enggalman.ga +enggalman.ml +engineemail.com +engineering-ai.com +enginemail.co.cc +enginemail.top +enginwork.com +englewoodedge.net +englishfiles.ml +englishfiles.tk +englishlearn.org +englishteachingfriends.com +englishtib.website +engsafe.xyz +enh.emlhub.com +enha.tk +enhancedzoom.com +enhancemalepotency.com +enhanceronly.com +enhdiet.com +enhytut.com +enigmagames.net +enj4ltt.xorg.pl +enjoy-lifestyle.us +enjoypixel.com +enlargement-xl.com +enlargementz.com +enlerama.eu +enmail.com +enmail1.com +enmaila.com +enml.net +enmtuxjil7tjoh.cf +enmtuxjil7tjoh.ga +enmtuxjil7tjoh.gq +enmtuxjil7tjoh.ml +enmtuxjil7tjoh.tk +enn.spymail.one +ennemail.ga +enometry.com +enotj.com +enpa.rf.gd +enpaypal.com +enpeezslavefarm.ml +enpremium.cf +enput.com +enra.com +enricocrippa.art +enron.cf +enron.ga +enron.gq +enron.ml +enroncorp.cf +enroncorp.ga +enroncorp.gq +enroncorp.ml +enroncorp.tk +enroskadma.com +ensis.site +ensudgesef.com +enteremail.us +enterprise-secure-registration.com +entertainment-database.com +entertainmentcentral.info +enterto.com +entipat.com +entirelynl.nl +entitle.laste.ml +entlc.com +entobio.com +entrastd.com +entregandobiblia.com.br +entrens.com +entreum.com +entribod.xyz +entropy.email +entuziast-center.ru +enu.kr +env.tools +enveicer.com +envelop2.tk +envirophoenix.com +envolplus.com +envy17.com +envysa.com +envywork.ru +enwi7gpptiqee5slpxt.cf +enwi7gpptiqee5slpxt.ga +enwi7gpptiqee5slpxt.gq +enwi7gpptiqee5slpxt.ml +enwi7gpptiqee5slpxt.tk +enwsueicn.com +eny.kr +eo-z.com +eo.emlhub.com +eob6sd.info +eocoqoeoto.com +eodfku.info +eodocmdrof.com +eoemail.com +eoffice.top +eogaf.com +eok.dropmail.me +eokc.dropmail.me +eolot.site +eols.freeml.net +eomail.com +eona.me +eoncasino.com +eonmech.com +eonohocn.com +eooo.mooo.com +eoooodid.com +eoopy.com +eopn.com +eoqx.emltmp.com +eorbs.com +eorjdgogotoy.com +eos2mail.com +eosada.com +eosatx.com +eosbuzz.com +eoscast.com +eosfeed.com +eoslux.com +eotoplenie.ru +eoutrbl.com +eovdfezpdto8ekb.cf +eovdfezpdto8ekb.ga +eovdfezpdto8ekb.gq +eovdfezpdto8ekb.ml +eovdfezpdto8ekb.tk +eowifjjgo0e.com +eowlgusals.com +eownerswc.com +eozxzcbqm.pl +ep.yomail.info +ep77.com +epam-hellas.org +eparis.pl +eparts1.com +epb.ro +epbox.ru +epbox.store +epem.freeml.net +epenpoker.com +epeva.com +epewmail.com +epfy.com +epglassworks.com +eph.laste.ml +ephemail.net +ephemeral.black +ephemeral.email +ephrine.com +epi-tech.com +epiar.net +epic.swat.rip +epicfalls.com +epicgamers.mooo.com +epicgrp.com +epicmoney.gold +epictv.pl +epicwave.desi +epicwebdesigners.com +epideme.xyz +epieye.com +epigeneticstation.com +episodekb.com +epit.info +epitin.tk +epitom.com +epizmail.com +epmail.com +epomail.com +eporadnictwo.pl +eposredniak.pl +eposta.buzz +eposta.work +epostal.ru +epostal.store +epostamax.com +epostmail.comx.cf +epot.ga +epowerhousepc.com +eppik.ru +epppl.com +eppvcanks.shop +epr49y5b.bee.pl +eprofitacademy.net +eproudlyey.com +eproyecta.com +eps.mimimail.me +epsilon.indi.minemail.in +epsilonzulu.webmailious.top +epubb.site +epubc.site +epubd.site +epube.site +epubea.site +epubeb.site +epubec.site +epubed.site +epubee.site +epubef.site +epubeh.site +epubei.site +epubek.site +epubel.site +epubem.site +epuben.site +epubep.site +epubeq.site +epuber.site +epubes.site +epubet.site +epubeu.site +epubev.site +epubf.site +epubg.site +epubh.site +epubi.site +epubj.site +epubk.site +epubl.site +epubla.site +epublb.site +epublc.site +epubld.site +epublg.site +epublh.site +epubli.site +epublj.site +epublk.site +epubll.site +epublm.site +epubln.site +epublo.site +epublp.site +epublq.site +epubls.site +epublt.site +epublu.site +epublv.site +epublx.site +epubly.site +epublz.site +epubm.site +epubn.site +epubo.site +epubp.site +epubq.site +epubr.site +epubs.site +epubt.site +epubu.site +epubv.site +epuqah.team +epwenner.de +epwwrestling.com +epx.spymail.one +eq-trainer.ru +eq2shs5rva7nkwibh6.cf +eq2shs5rva7nkwibh6.ga +eq2shs5rva7nkwibh6.gq +eq2shs5rva7nkwibh6.ml +eq2shs5rva7nkwibh6.tk +eq3cx.anonbox.net +eqador-nedv.ru +eqag.emlhub.com +eqasmail.com +eqbo62qzu2r8i0vl.cf +eqbo62qzu2r8i0vl.ga +eqbo62qzu2r8i0vl.gq +eqbo62qzu2r8i0vl.ml +eqbo62qzu2r8i0vl.tk +eqeqeqeqe.tk +eqh.emltmp.com +eqhm.emlhub.com +eqibodyworks.com +eqiluxspam.ga +eqimail.com +eqk.emltmp.com +eql.mailpwr.com +eqntfrue.com +eqptv.online +eqrq.spymail.one +eqrsxitx.pl +eqsaucege.com +eqstqbh7hotkm.cf +eqstqbh7hotkm.ga +eqstqbh7hotkm.gq +eqstqbh7hotkm.ml +eqstqbh7hotkm.tk +equalityautobrokers.com +equalla.icu +equestrianjump.com +equiapp.men +equiemail.com +equilibriumfusion.com +equinemania.com +equinoitness.com +equipcare.ru +equityen.com +equityoptions.io +equonecredite.com +eqv.laste.ml +eqvox.com +era7mail.com +eragan.com +erahelicopter.com +erahods.com +erailcomms.net +eramis.ga +eramupload.website +erasedebt.gq +eraseo.com +erasf.com +erathlink.net +erbendao.com +erbschools.org +erdemtemizler.shop +erdfg-sa.top +erds.com +ereaderreviewcentral.com +erec-dysf.com +erectiledysf.com +erectiledysfunctionpillsest.com +erectiledysfunctionpillsonx.com +erection-us.com +ereirqu.com +ereivce.com +ereplyzy.com +erermail.com +erersaju.xyz +erertmail.com +eret.com +erexcolbart.eu +erexcolbart.xyz +erfep.emltmp.com +erfer.com +erfoer.com +ergb.com +ergo-design.com.pl +ergopsycholog.pl +ergowiki.com +ergregro.tech +erhoei.com +ericjohnson.ml +ericreyess.com +ericsreviews.com +erindog.shop +erinnfrechette.com +eripo.net +erizon.net +erjit.in +erk7oorgaxejvu.cf +erk7oorgaxejvu.ga +erk7oorgaxejvu.gq +erk7oorgaxejvu.ml +erk7oorgaxejvu.tk +erkjhgbtert.online +erlsitn.com +ermael.com +ermail.cf +ermail.ga +ermail.gq +ermail.ml +ermail.tk +ermailo.com +ermeson.tk +ermtia.com +ero-host.ru +ero-tube.org +erodate.com +erodate.fr +eroererwa.vv.cc +erofree.pro +eroker.pl +eromail.com +eroticadultdvds.com +eroticplanet24.de +erotubes.pro +erotyczna.eu +erotyka.pl +eroyal.net +erpd.mailpwr.com +erpin.org +erpipo.com +erpolic.site +erpressungsge.ml +err.emltmp.com +errals.com +erreemail.com +erreur.info +error-codexx159.xyz +error57.com +errorid.com +errorstud.io +ersatzs.com +ersineruzun.shop +ersmqccojr.ga +erssuperbowlshop.com +ersxdmzzua.pl +ertemaik.com +ertewurtiorie.co.cc +erth.nl +erti.de +ertki.online +ertrterwe.com +ertsos.online +ertuet5.tk +ertytyf.ml +ertyuio.pl +eruj33y5g1a8isg95.cf +eruj33y5g1a8isg95.ga +eruj33y5g1a8isg95.gq +eruj33y5g1a8isg95.ml +eruj33y5g1a8isg95.tk +erw.com +erx.mobi +erynka.com +eryod.com +eryoritwd1.cf +eryoritwd1.ga +eryoritwd1.gq +eryoritwd1.ml +eryoritwd1.tk +erythromycin.website +es-depeso.site +es2wyvi7ysz1mst.com +esacrl.com +esadverse.com +esanmail.com +esatsoyad.cfd +esbano-magazin.ru +esbano-ru.ru +esboba.store +esbuah.nl +esc.la +escanor99.com +escapehatchapp.com +escb.com +escholcreations.com +escholgroup.com.au +escocompany.com +escoltesiguies.net +escomprarcamisetas.es +escortankara06.com +escortbayanport.com +escortcumbria.co.uk +escorthatti.com +escorts-in-prague.com +escortsaati.com +escortsdudley.com +escortvitrinim.com +escuelanegociodigital.com +esdruns.com +ese.kr +esearb.com +esemay.com +esenal.com +esender18.com +esenlee.com +esenu.com +esenyurt-travesti.online +eseoconsultant.org +eseod.com +esgame.pl +esgebe.email +esgeneri.com +eshimod.com +eshta.com +eshtanet.com +eshtapay.com +esiix.com +esik.com +esimpleai.com +esjweb.com +esk.yomail.info +eskile.com +eskisehirdizayn.com +eslb.spymail.one +esm.com +esmaczki.pl +esmeraldamagina.com +esmoud.com +esmuse.me +esmyar.ir +esoetge.com +esotericans.ru +esoumail.com +espadahost.com +espaintimestogo.us +espamted3kepu.cf +espamted3kepu.ga +espamted3kepu.gq +espamted3kepu.ml +espamted3kepu.tk +espana-official.com +espanatabs.com +esparkpayments.co.uk +especially-beam.xyz +espil-place-zabaw.pl +espinozamail.men +esportenanet.com +espritblog.org +esprity.com +esquir3.com +esquiresubmissions.com +essaouira.xyz +essay-introduction-buy.xyz +essay-top.biz +essayhelp.top +essaypian.email +essaypromaster.com +essayssolution.com +essentialsecurity.com +esseriod.com +essh.ca +est.une.victime.ninja +estate-invest.fr +estatenearby.com +estatepoint.com +estebanmx.com +esteem.emltmp.com +esteembpo.com +estehgass.one +estelove.com +esterace.com +esteticaunificada.com +estimatd.com +estltd.com +estonia-nedv.ru +estopg.com +estrate.ga +estrate.tk +estress.net +estuaryhealth.com +estudent.edu.pl +estudys.com +esxgrntq.pl +esy.es +esyline.com +esyn.emlpro.com +et.emltmp.com +et.yomail.info +et4veh6lg86bq5atox.cf +et4veh6lg86bq5atox.ga +et4veh6lg86bq5atox.gq +et4veh6lg86bq5atox.tk +etaalpha.spithamail.top +etabox.info +etaetae46gaf.ga +etalase1.com +etang.com +etanker.com +etas-archery.com +etaxmail.com +etbclwlt.priv.pl +etc.xyz +etchingdoangia.com +etcone.net +etcvenues.com +etdcr5arsu3.cf +etdcr5arsu3.ga +etdcr5arsu3.gq +etdcr5arsu3.ml +etdcr5arsu3.tk +etechnc.info +etempmail.com +etempmail.net +etenx.com +eternalist.ru +etfstudies.com +etgdev.de +etghecnd.com +eth00010mine.cf +eth0001mine.cf +eth0002mine.cf +eth0003mine.cf +eth0004mine.cf +eth0005mine.cf +eth0006mine.cf +eth0007mine.cf +eth0008mine.cf +eth0009mine.cf +eth2btc.info +ether123.net +etherage.com +etherbackup.com +ethereal.email +etherealgemstone.site +etherealplunderer.com +ethereum1.top +ethersports.org +ethersportz.info +ethicaldhinda.biz +ethicalencounters.org.uk +ethiccouch.xyz +ethicy.com +ethiopia-nedv.ru +ethsms.com +etics.us +etiqets.biz +etlgr.com +etm.com +etmail.com +etmail.top +etno.mineweb.in +etochq.com +etoic.com +etondy.com +etonracingboats.co.uk +etopmail.com +etopys.com +etotvibor.ru +etovar.net.ua +etoymail.com +etramay.com +etranquil.com +etranquil.net +etranquil.org +etravelgo.info +etrytmbkcq.pl +ets-products.ru +etszys.com +ett.laste.ml +ettasalsab1l4.online +ettatct.com +ettke.com +etubemail.com +etw.laste.ml +etwienmf7hs.cf +etwienmf7hs.ga +etwienmf7hs.gq +etwienmf7hs.ml +etxe.com +etxm.gq +etzdnetx.com +eu.blatnet.com +eu.cowsnbullz.com +eu.dlink.cf +eu.dlink.gq +eu.dns-cloud.net +eu.dnsabr.com +eu.igg.biz +eu.lakemneadows.com +eu.oldoutnewin.com +eu.spymail.one +eu3ih.anonbox.net +eu6genetic.com +euabds.com +euamanhabr.com +euaqa.com +eubicgjm.pl +eubonus.com +euchante.com +eucw.com +eudoxus.com +eue51chyzfil0.cf +eue51chyzfil0.ga +eue51chyzfil0.gq +eue51chyzfil0.ml +eue51chyzfil0.tk +euesolucoes.online +eujweu3f.com +eulabs.eu +euleina.com +eulopos.com +eumail.p.pine-and-onyx.xyz +eumail.tk +euneeedn.com +euphoriaworld.com +eupin.site +eur-rate.com +eur-sec1.cf +eur-sec1.ga +eur-sec1.gq +eur-sec1.ml +eur-sec1.tk +eur0.cf +eur0.ga +eur0.gq +eur0.ml +eurazx.com +eure-et-loir.pref.gouvr.fr +euro-reconquista.com +eurobenchmark.net +eurocuisine2012.info +eurodmain.com +eurogenet.com +eurokool.com +eurolinx.com +euromail.tk +euromillionsresults.be +europartsmarket.com +europastudy.com +europearly.site +europesmail.gdn +euroweb.email +eurox.eu +euu.dropmail.me +euucn.com +euwbvkhuqwdrcp8m.cf +euwbvkhuqwdrcp8m.ml +euwbvkhuqwdrcp8m.tk +euxn.freeml.net +ev.emltmp.com +eva.bigmail.info +evacarstens.fr +evafan.com +evaforum.info +evamail.com +evanferrao.ga +evanfox.info +evansind.com +evansville.com +evarosdianadewi.art +evascxcw.com +evasea.com +evasud.com +evavoyance.com +evbholdingsllc.com +evcmail.com +evcr8twoxifpaw.cf +evcr8twoxifpaw.ga +evcr8twoxifpaw.gq +evcr8twoxifpaw.ml +evcr8twoxifpaw.tk +evdnbppeodp.mil.pl +evdy5rwtsh.cf +evdy5rwtsh.ga +evdy5rwtsh.gq +evdy5rwtsh.ml +evdy5rwtsh.tk +eveadamsinteriors.com +eveav.com +eveb5t5.cf +eveb5t5.ga +eveb5t5.gq +eveb5t5.ml +eveb5t5.tk +eveflix.com +evelinecharlespro.com +evelinjaylynn.mineweb.in +even.ploooop.com +event-united.com +eventa.site +eventplay.info +ever-child.com +ever.favbat.com +evercountry.com +everestgenerators.com +evergo.igg.biz +everifies.com +everleto.ru +everotomile.com +evertime-revolution.biz +everto.us +everybabes.com +everybes.tk +everybody22.com +everybodyone.org.ua +everydaybiz.com +everydroid.com +everynewr.tk +everyoneapparel.com +everytg.ml +everythingcqc.org +everythinger.store +everythingisnothing.com +everythinglifehouse.com +everythingtheory.org +evgeniyvis.website +evhx.emlhub.com +evhybrid.club +evidenceintoaction.org +evilant.com +evilbruce.com +evilcomputer.com +evilgodshop.com +evilgodshop.uk +evilgodshop1.uk +evilgodshop2.uk +evilin-expo.ru +evimzo.com +evkiwi.de +evliyaogluotel.com +evluence.com +evmail.com +evnft.com +evnyq.anonbox.net +evoaled091h.cf +evoaled091h.ga +evoaled091h.gq +evoaled091h.ml +evoaled091h.tk +evoandroidevo.me +evobmail.com +evodok.com +evoiceeeeee.blog +evoiceeeeee.world +evokewellnesswithin.com +evolution24.de +evolutionary-wealth.net +evolutioncatering.com +evolutiongene.com +evolutionofintelligence.com +evomindset.org +evonb.com +evopo.com +evoro.eu +evortal.eu +evou.com +evoxury.com +evrnext.com +evropost.top +evropost.trade +evsmpi.net +evt5et4.cf +evt5et4.ga +evt5et4.gq +evt5et4.ml +evt5et4.tk +evu.com +evusd.com +evuwbapau3.cf +evuwbapau3.ga +evuwbapau3.gq +evuwbapau3.ml +evvgo.com +evxmail.net +evyush.com +ew-purse.com +ewa.kr +ewarjkit.in +ewatchesnow.com +ewebpills.com +ewebrus.com +eweemail.com +ewer.ml +ewh.spymail.one +ewhmt.com +ewmb.emlhub.com +ewo.spymail.one +ewofjweooqwiocifus.ru +ewroteed.com +ewt35ttwant35.tk +ewumail.com +ewuobxpz47ck7xaw.cf +ewuobxpz47ck7xaw.ga +ewuobxpz47ck7xaw.gq +ewuobxpz47ck7xaw.ml +ewuobxpz47ck7xaw.tk +eww.ro +ewwq.eu +ex-you.com +exactmail.com +exaggreath.site +exahut.com +exaltatio.com +exaltedgames.com +exaltic.com +examole.com +exampe.com +examplefirem.org.ua +exampleforall.org.ua +exams.gng.edu.pl +examstudy.xyz +exatpay.tk +exboxlivecodes.com +exbte.com +exbts.com +excavatea.com +excel-medical.com +exceladv.com +excelente.ga +excelente.ml +excellenthrconcept.net +excellx.com +excelwfinansach.pl +exceptionance.xyz +exchangefinancebroker.org +excipientnetwork.com +excitedchat.com +excitingsupreme.info +exclusivewebhosting.co.uk +exclussi.com +exdisplaykitchens1.co.uk +exdonuts.com +exdr.com +exe.emlhub.com +exectro.xyz +executive.name +executivetoday.com +exelica.com +exemetr.com +exems.net +exeneli.com +exercisetrainer.net +exertwheen.com +exhaycle.com +exi.kr +exi8tlxuyrbyif5.cf +exi8tlxuyrbyif5.ga +exi8tlxuyrbyif5.gq +exi8tlxuyrbyif5.ml +exia00.biz.st +exile.my.id +eximail.com +exiq0air0ndsqbx2.cf +exiq0air0ndsqbx2.ga +exiq0air0ndsqbx2.ml +exirinc.com +existiert.net +existrons.site +exitbit.com +exitings.com +exitstageleft.net +exja.emltmp.com +exju.com +exmab.com +exmail.com +exmoordistillery.com +exnx.emlpro.com +exo-eco-photo.net +exoa0vybjxx.emltmp.com +exoacre.com +exois.life +exoly.com +exostream.xyz +exoticcloth.net +exoular.com +exoxo.com +expaaand.com +expanda.net +expatinsurances.com +expecters.site +expectingvalue.com +expeight.com +expense-monitor.ml +expensemanager.xyz +experienceamg.com +experiencesegment.com +expert-gpt.app +expertadnt.com +expertadvisormt4ea.com +expertgpt.page +expertgpt.tech +expertmobi.com +expertroofingbrisbane.com +expertsfdd.com +expirebox.com +expirebox.email +expirebox.me +expirebox.net +expirebox.org +expiredtoaster.org +expirio.info +expl0rer.cf +expl0rer.ga +expl0rer.gq +expl0rer.ml +expl0rer.tk +explainednicely.com +explainmybusiness.com +explodemail.com +exploit-pack.net +exploitingmoms.pro +explorativeeng.com +exploraxb.com +expltain.com +exporthailand.com +expreset.click +express-mail.info +express.net.ua +expressambalaj.com +expressbahiscasino.xyz +expressbuy2011.info +expressbuynow.com +expresscafe.info +expressemail.org +expressgopher.com +expresslan24.eu +expressletter.net +expressvpna.com +expresumen.site +expub.info +expvtinboxcentral.com +expwebdesign.com +exq.emlpro.com +exq.freeml.net +exr.spymail.one +exserver.top +extanewsmi.zzux.com +extemer.com +extendaried.xyz +extendmale.com +extensionespremium.com +extentionary.xyz +extenwer.com +extenzereview1.net +extgeo.com +extic.com +extra-breast.info +extra-penis-enlargement.info +extra.droidpic.com +extra.lakemneadows.com +extra.oscarr.nl +extra.ploooop.com +extra.poisedtoshrike.com +extraaaa.tk +extraaaa2.ga +extraaaa2.tk +extraale.com +extraam.loan +extracccolorrfull.com +extracoloorfull.com +extractbags.com +extracurricularsociety.com +extradingsystems.com +extradouchebag.tk +extraku.com +extraku.net +extraku.shop +extrarole.com +extrasba.com +extrasize.biz +extrasize.info +extravagandideas.com +extravagant.pl +extremail.ru +extremangola.com +extremcase.com +extreme-trax.com +extremebacklinks.info +extremegrowing.com +extrset.com +exuge.com +exuom.com +exweme.com +exxon-mobil.tk +exy.email +ey.freeml.net +ey5kg8zm.mil.pl +eyal-golan.com +eyandex.ru +eycegru.site +eyecaredoctors.net +eyeemail.com +eyefullproductions.com +eyelashextensionsinottawa.com +eyelidsflorida.com +eyemany.com +eyepaste.com +eyeremind.com +eyes2u.com +eyesandfeet.com +eyesofnoctumofficial.com +eyeword.biz +eyeysdc.com +eyimail.com +eymail.com +eynlong.com +eyr.emlpro.com +eyso.de +eysoe.com +eytetlne.com +eyv.emltmp.com +eyw.freeml.net +ez.lv +ez.yomail.info +ezacc.shop +ezaklady.net.pl +ezamirawedding.me +ezanalytics.info +ezbizz.com +ezboost.tk +ezcreditwarehouse.com +ezeca.com +ezehe.com +ezen43.pl +ezen74.pl +ezernet.lv +ezers.blog +ezfill.club +ezfill.com +ezfree.online +ezgaga.com +ezgiant.com +ezhandui.com +ezhj.com +ezhulenev.fvds.ru +eziegg.com +ezimail.com +ezip.site +ezisource.com +ezjh.dropmail.me +ezlo.co +ezmail.top +ezmailbox.info +ezmails.info +ezmtp.com +ezns.emlpro.com +ezonemail.com +ezoworld.info +ezpara.com +ezprice.co +ezprvcxickyq.cf +ezprvcxickyq.ga +ezprvcxickyq.gq +ezprvcxickyq.ml +ezprvcxickyq.tk +ezstest.com +eztam.xyz +ezth.com +ezua.com +ezy2buy.info +ezya.com +ezybarber.com +ezyone.app +ezz.bid +ezzk.laste.ml +ezztt.com +ezzzi.com +f-aq.info +f-best.net +f-best.org +f-hanayoshi.com +f-wheel.com +f.asiamail.website +f.barbiedreamhouse.club +f.bestwrinklecreamnow.com +f.captchaeu.info +f.coloncleanse.club +f.dogclothing.store +f.fastmail.website +f.garciniacambogia.directory +f.gsasearchengineranker.pw +f.gsasearchengineranker.site +f.gsasearchengineranker.space +f.gsasearchengineranker.top +f.gsasearchengineranker.xyz +f.mediaplayer.website +f.moza.pl +f.mylittlepony.website +f.polosburberry.com +f.searchengineranker.email +f.seoestore.us +f.teemail.in +f.uhdtv.website +f.waterpurifier.club +f.yourmail.website +f0205.trustcombat.com +f0d1rdk5t.pl +f1files.com +f1kzc0d3.cf +f1kzc0d3.ga +f1kzc0d3.gq +f1kzc0d3.ml +f1kzc0d3.tk +f1xm.com +f2021.me +f2dzy.com +f2ksirhlrgdkvwa.cf +f2ksirhlrgdkvwa.ga +f2ksirhlrgdkvwa.gq +f2ksirhlrgdkvwa.ml +f2ksirhlrgdkvwa.tk +f2movies.xyz +f2pools.info +f2pools.online +f36a3.anonbox.net +f39mltl5qyhyfx.cf +f39mltl5qyhyfx.ga +f39mltl5qyhyfx.gq +f39mltl5qyhyfx.ml +f3a2kpufnyxgau2kd.cf +f3a2kpufnyxgau2kd.ga +f3a2kpufnyxgau2kd.gq +f3a2kpufnyxgau2kd.ml +f3a2kpufnyxgau2kd.tk +f3osyumu.pl +f4k.es +f5.si +f53tuxm9btcr.cf +f53tuxm9btcr.ga +f53tuxm9btcr.gq +f53tuxm9btcr.ml +f53tuxm9btcr.tk +f5foster.com +f5url.com +f6w0tu0skwdz.cf +f6w0tu0skwdz.ga +f6w0tu0skwdz.gq +f6w0tu0skwdz.ml +f6w0tu0skwdz.tk +f7scene.com +f8bet.zapto.org +f97vfopz932slpak.cf +f97vfopz932slpak.ga +f97vfopz932slpak.gq +f97vfopz932slpak.ml +f97vfopz932slpak.tk +fa23d12wsd.com +fa23dfvmlp.com +faaakb000ktai.ga +faaliyetim.xyz +faan.de +faawaiver.net +fabaos.com +fabiopisani.art +fabioscapella.com +fabonata.website +fabook.com +fabricoak.com +fabricsukproperty.com +fabricsvelvet.com +fabricsxla.com +fabricszarin.com +fabrykakadru.pl +fabrykakoronek.pl +fabtivia.com +fabtours.live +fabtours.online +fabtours.site +fabtours.xyz +fabulouslifestyle.tips +fac.emlhub.com +facais.com +facd.spymail.one +facebaby.life +facebook-egy.com +facebook-email.cf +facebook-email.ga +facebook-email.ml +facebook-net.gq +facebook-net.ml +facebookcom.ru +facebookmail.gq +facebookmail.ml +facedook-com.ga +facedook-com.gq +faceepicentre.com +faceimagebook.com +facemac.website +facemail.store +facenewsk.fun +facepook-com.cf +facepook-com.ga +facepook-com.tk +faceporn.me +facestate.com +facetek.club +facetek.online +facetek.site +facetek.store +facetek.xyz +facialboook.site +facilesend.com +facilityservices24.de +fackme.gq +facteye.us +factionsdark.tk +factopedia.pl +factoryburberryoutlet.com +factorydrugs.com +factsofturkey.net +facturecolombia.info +faculty.emlhub.com +fada55.com +fadilec.com +fadingemail.com +fadsfavvzx.online +fadsfg1d.shop +fae412wdfjjklpp.com +fae42wsdf.com +fae45223wed23.com +fae4523edf.com +fae452we334fvbmaa.com +fae4dew2vb.com +faea2223dddfvb.com +faea22wsb.com +faea2wsxv.com +faeaswwdf.com +faecesmail.me +fafacheng.com +fafafafscxs.com +fafamai.com +faformerly.com +fafrem3456ails.com +fag.wf +fagbxy1iioa3ue.cf +fagbxy1iioa3ue.ga +fagbxy1iioa3ue.gq +fagbxy1iioa3ue.ml +fagbxy1iioa3ue.tk +fahadfaryadlimited.co +fahih.com +fahmi-amirudin.tech +fahr-zur-hoelle.org +fahrgo.com +fahrizal.club +failance.com +failbone.com +failinga.nl +faiphoge.ml +fair-paski.pl +fairandcostly.com +fairleigh15733.co.pl +fairocketsmail.com +fairvoteva.org +fairymails.net +faithfulheatingandair.com +faithin.org +faithkills.com +faithkills.org +faithmail.org +faithswayfitness.com +fajnadomena.pl +fajskdlh.top +fake-box.com +fake-email.pp.ua +fake-foakleys.org +fake-mail.cf +fake-mail.ga +fake-mail.gq +fake-mail.ml +fake-mail.tk +fake-raybans.org +fakedemail.com +fakedoctorsnote.net +fakeemail.de +fakeemail.ml +fakeemail.tk +fakeg.ga +fakeid.club +fakeinbox.cf +fakeinbox.com +fakeinbox.ga +fakeinbox.info +fakeinbox.ml +fakeinbox.tk +fakeinformation.com +fakelouisvuittonrun.com +fakemail.com +fakemail.fr +fakemail.intimsex.de +fakemail.io +fakemail.net +fakemail.top +fakemail.win +fakemail93.info +fakemailgenerator.com +fakemailgenerator.net +fakemails.cf +fakemails.ga +fakemails.gq +fakemails.ml +fakemailz.com +fakemyinbox.com +fakeoakleys.net +fakeoakleysreal.us +fakermail.com +fakeswisswatchesreviews.xyz +faketemp.email +fakher.dev +fakiralio.ga +fakiralio.ml +fakyah.ga +fakyah.ml +falazone.com +falcondip.store +falconheavylaunch.net +falconsportsshop.com +falconsproteamjerseys.com +falconsproteamsshop.com +falconssportshoponline.com +falguckpet.tk +falixiao.com +falkyz.com +fallin1.ddns.me.uk +fallin2.dyndns.pro +fallinhay.com +fallinlove.info +fallloveinlv.com +fallmt2.com +falltrack.net +faloliku.cf +falrxnryfqio.cf +falrxnryfqio.ga +falrxnryfqio.gq +falrxnryfqio.ml +falrxnryfqio.tk +falseaddress.com +falsepeti.shop +fam.emlpro.com +famachadosemijoias.com +famail.win +famamail.com +famiender.site +familiaresiliente.com +familie-baeumer.eu +familiekersten.tk +familienhomepage.de +famillet.com +familylist.ru +familypart.biz +familyright.ru +familytoday.us +familytown.club +familytown.site +familytown.store +famisanar.com +fammix.com +famoustwitter.com +famytown.club +famytown.online +famytown.site +famytown.xyz +fanbasesports.co +fanbasic.org +fancinematoday.com +fanclub.pm +fancoder.xyz +fancycarnavalmasks.com +fancynix.com +fancyzuhdi.net +fandamtastic.info +fandemic.co +fandoe.com +fandsend.com +fandua.com +fangeradelman.com +fangoh.com +fangzi.cf +fanicle.com +fanlvr.com +fanneat.com +fannny.cf +fannny.ga +fannny.gq +fannny.ml +fannyfabriana.art +fanoysramadan.site +fanpagenews.com +fanpoosh.net +fanqiegu.cn +fans2fans.info +fansub.us +fansworldwide.de +fantastu.com +fantasyfootballhacks.com +fantasymail.de +fantelamoh.site +fantomail.tk +fanwn.com +fanymail.com +fanz.info +fanzer.com +faoo.laste.ml +fapa.com +fapfl1.us +faphd.pro +fapinghd.com +fapment.com +fapxxx.pro +fapzo.com +fapzy.com +farah.rip +farahmeuthia.art +faraon.biz.pl +farbodbarsum.com +fardainc.net +fardevice.com +farebus.com +farego.ltd +farerata.com +farewqessz.com +farfar.ml +farfurmail.tk +fargus.eu +farifluset.mailexpire.com +farma-shop.tk +farmaciaporvera.com +farmakoop.com +farmamail.pw +farmatsept.com +farmdeu.com +farmer.are.nom.co +farmerlife.us +farmerrr.tk +farmersargent.com +farmtoday.us +farr.dropmail.me +farrse.co.uk +farshadtan.cfd +farsite.tk +fartcompany.com +farteam.ru +farthy.com +fartovoe1.fun +fartwallet.com +farwestforge.com +farwqevovox.com +fasa.com +fasciaklinikerna.se +fascinery.com +fasdrgaf5.shop +fashion-hairistyle.org +fashion-handbagsoutlet.us +fashionactivist.com +fashionans.ru +fashiondesignclothing.info +fashiondesignershoes.info +fashionfwd.net +fashionglobe.com +fashionhandbagsgirls.info +fashionhandbagsonsale.info +fashionlibrary.online +fashionmania.club +fashionmania.site +fashionmania.store +fashionsealhealthcareuniforms.net +fashionsell.club +fashionsell.fun +fashionsell.online +fashionsell.site +fashionsell.store +fashionsell.website +fashionsell.xyz +fashionshoestrends.info +fashionsportsnews.com +fashionvogueoutlet.com +fashionwallets2012.info +fashionwatches2012.info +fashionwomenaccessories.com +fashionzone69.com +fashlend.com +fasigula.name +fask2.anonbox.net +fassagforpresident.ga +fasssd.ru +fasssd.store +fast-breast-augmentation.info +fast-coin.com +fast-content-producer.com +fast-email.info +fast-isotretinoin.com +fast-loans-uk.all.co.uk +fast-mail.fr +fast-mail.host +fast-mail.one +fast-mail.pw +fast-max.ovh +fast-sildenafil.com +fast-slimming.info +fast-weightloss-methods.com +fast.ruimz.com +fast4me.info +fastacura.com +fastair.info +fastbigfiles.ru +fastboattolembongan.com +fastcash.net +fastcash.org +fastcash.us +fastcashloannetwork.us +fastcashloans.us +fastcashloansbadcredit.com +fastcdn.cc +fastchevy.com +fastchrysler.com +fastdating.lat +fastddns.net +fastddns.org +fastdeal.com.br +fastdownloadcloud.ru +fastee.edu +fastemails.us +fastermail.com +fastermand.com +fasternet.biz +fastestflex.com +fastestsmtp.com +fastestwayto-losebellyfat.com +fastfitnessroutine.com +fastfoodrecord.com +fastgetsoft.tk +fastgoat.com +fastgotomail.com +fastgrowthpodcast.com +fastight.com +fastkawasaki.com +fastleads.in +fastloans.org +fastloans.us +fastloans1080.co.uk +fastmail.edu.pl +fastmail.name +fastmailer.cf +fastmailforyou.net +fastmailnode.com +fastmailnow.com +fastmailplus.com +fastmails.club +fastmails.info +fastmailservice.info +fastmailtoyougo.site +fastmazda.com +fastmessaging.com +fastmitsubishi.com +fastmobileemail.win +fastmoney.pro +fastnissan.com +fastoutlook.ga +fastpass.com +fastpayday-loanscanada.info +fastpaydayloan.us +fastpaydayloans.com +fastpaydayloans.org +fastpaydayloans.us +fastpochta.cf +fastpochta.ga +fastpochta.gq +fastpochta.ml +fastpochta.tk +fastricket.site +fastsearcher.com +fastsent.gq +fastseoaudit.com +fastshipcialis.com +fastslimming.info +fastsms.my +fastsms24.shop +fastsubaru.com +fastsuzuki.com +fasttoyota.com +fastwbnet.it +fastwebnwt.it +fastwebpost.com.pl +fastweightlossplantips.com +fastwenet.it +fastxtech.com +fasty.site +fasty.xyz +fastyamaha.com +fatalisto.tk +fatalorbit.com +fate.emlpro.com +fatejcz.tk +fatfinger.co +fatflap.com +fatguys.pl +fathir.cf +fathlets.site +fathoni.info +fatihd.com +fatjukebox.com +fatloss9.com +fatlossdietreviews.com +fatlossfactorfacts.com +fatlossspecialist.com +fatmagulun-sucu-ne.com +fatmize.com +fatraplzmac.cfd +fattahkus.app +fatty10.online +fatty14.online +fatty15.online +fatty22.online +fatty36.online +fatty7.online +fatub.org +fatunaric.cfd +faturadigital.online +faucetpay.ru +faultydeniati.net +fauxemail.com +fauzanstore.me +fav.org +favilu.com +favochat.com +favorbag.site +favoribahis79.com +favsin.com +favxgh.tech +fawwaz.cf +fawwaz.ga +fawwaz.gq +fawwaz.ml +fax.dix.asia +fax4sa.com +faxapdf.com +faxico.com +faxjet.com +faxzu.com +faybe.anonbox.net +faybetsy.com +fazdnetc.com +faze.biz +fazeclan.space +fazendabrasil1.com +fazer-site.net +fazmail.net +fb.laste.ml +fb.opheliia.com +fb2a.site +fb2aa.site +fb2ab.site +fb2ac.site +fb2ad.site +fb2ae.site +fb2af.site +fb2ag.site +fb2ah.site +fb2ai.site +fb2aj.site +fb2ak.site +fb2al.site +fb2am.site +fb2an.site +fb2ao.site +fb2ap.site +fb2aq.site +fb2ar.site +fb2as.site +fb2at.site +fb2au.site +fb2av.site +fb2aw.site +fb2ax.site +fb2ay.site +fb2az.site +fb2b.site +fb2ba.site +fb2bb.site +fb2bc.site +fb2bd.site +fb2be.site +fb2bf.site +fb2bg.site +fb2bh.site +fb2bi.site +fb2bj.site +fb2bk.site +fb2bm.site +fb2bn.site +fb2bo.site +fb2bp.site +fb2bq.site +fb2br.site +fb2bs.site +fb2bt.site +fb2bu.site +fb2c.site +fb2d.site +fb2e.site +fb2f.site +fb2g.site +fb2h.site +fb2i.site +fb2j.site +fb2k.site +fb2l.site +fb2m.site +fb2n.site +fb2o.site +fb2p.site +fb2q.site +fb2s.site +fb2t.site +fb2u.site +fb3s.com +fbanalytica.site +fbc.laste.ml +fbckyqxfn.pl +fbclone.com +fbeaveraqb.com +fbf24.de +fbfree.ml +fbft.com +fbfubao.com +fbhive.com +fbhotro.com +fbi.coms.hk +fbi.one +fbiagent.cyou +fbins607.com +fbinsta.click +fbkubro2024.cloud +fblo.com +fbma.tk +fbmail.usa.cc +fbmail1.ml +fbn.spymail.one +fbomultinational.com +fboss3r.info +fbpoint.net +fbq.freeml.net +fbq4diavo0xs.cf +fbq4diavo0xs.ga +fbq4diavo0xs.gq +fbq4diavo0xs.ml +fbq4diavo0xs.tk +fbs-investing.com +fbshirt.com +fbstigmes.gr +fbsturkiye.com +fbtiktok.store +fbtop1.com +fbviamail.com +fbw.laste.ml +fc.dropmail.me +fc.emlpro.com +fc.opheliia.com +fc66998.com +fca-nv.cf +fca-nv.ga +fca-nv.gq +fca-nv.ml +fca-nv.tk +fcemarat.com +fcgfdsts.ga +fchbe3477323723423.epizy.com +fchief3r.info +fcit.de +fckgoogle.pl +fckrylatskoe2000.ru +fcl.emltmp.com +fclone.net +fcmi.com +fcml.mx +fcplanned.com +fcrpg.org +fcs.dropmail.me +fcth.com +fcwnfqdy.pc.pl +fd.emltmp.com +fd.laste.ml +fd21.com +fd99nhm5l4lsk.cf +fd99nhm5l4lsk.ga +fd99nhm5l4lsk.gq +fd99nhm5l4lsk.ml +fd99nhm5l4lsk.tk +fdasf.com +fdaswmail.com +fdbtv.online +fdc.emlpro.com +fddeutschb.com +fddns.ml +fdehrbuy2y8712378123879.zya.me +fdev.info +fdf.emltmp.com +fdfdsfds.com +fdfggh-df5.cc +fdfriend.store +fdgdfgdfgf.ml +fdger.com +fdgfd.com +fdgh.com +fdkgf.com +fdkmenxozh.ga +fdlsmp.club +fdmail.net +fdn1if5e.pl +fdollsy.com +fdownload.net +fdsag.com +fdsfdsf.com +fdsgfdgfdgd.online +fdsgsd.org +fdsweb.com +fdtntbwjaf.pl +fdvdvfege.online +fdyzeakrwb.ga +fe.yomail.info +fea2fa9.servebeer.com +feaethplrsmel.cf +feaethplrsmel.ga +feaethplrsmel.gq +feaethplrsmel.ml +feaethplrsmel.tk +feamail.com +feanzier.com +featcore.com +feates.site +febbraio.cf +febbraio.gq +febeks.com +febmail.com +febrance.site +febula.com +febyfebiola.art +fechl.com +fecrbook.ga +fecrbook.gq +fecrbook.ml +fectode.com +fecupgwfd.pl +federal-rewards.com +federal.us +federalcash.com +federalcash.us +federalcashagency.com +federalcashloannetwork.com +federalcashloans.com +federalheatingco.com +federalloans.com +federalloans.us +federalpamulang.ga +fedf.com +fedipom.site +feedbackadvantage.com +feeder-club.ru +feedmecle.com +feedon.emlhub.com +feeladult.com +feelgoodsite.tk +feelingion.com +feelingity.com +feelitall.org.ua +feelmyheartwithsong.com +feelyx.com +feemail.club +feerock.com +feesearac.gq +feespayments.online +feetiture.site +fefewew.spymail.one +fegdemye.ru +fehepocyc.pro +fehuje.ru +fei.mailpwr.com +feidnepra.com +feifan123.com +feinripptraeger.de +feiqilai.lol +feistyfemales.com +fejm.pl +felenem.club +felibg.com +felipecorp.com +felixkanar.ru +felixkanar1.ru +felixkanar2.ru +fellon49.freshbreadcrumbs.com +fellow-me.pw +fellowme.pw +fellowtravelers.com +felphi.com +femail.com +femailtor.com +femainton.site +femalefemale.com +femalepayday.net +femaletary.com +fembat.com +femboy.ga +femdomfree.net +feminaparadise.com +femingwave.xyz +femme-cougar.club +femmestyle.name +femmestyle.or.at +fencesrus.com +fenceve.com +fenexy.com +fengting01.mygbiz.com +fengyun.net +fenionline.com +fenixmail.pw +fenkpeln.club +fenkpeln.online +fenkpeln.site +fenkpeln.xyz +fennel.team +fentaoba.com +fenty-puma.us +fenwazi.com +fenxz.com +fer-gabon.org +feralrex.com +ferastya.cf +ferastya.ga +ferastya.gq +ferastya.ml +ferastya.tk +ferdionsad.me +ferdomnermail.com +ferdysabon.shop +ferencikks.org +fergetic.com +fergley.com +feriwor.com +fermaxxi.ru +fermer1.ru +fermiro.com +fern2b.site +fernet89.com +fernl.pw +ferochwilowki.pl +feroxid.com +feroxo.com +ferragamobagsjp.com +ferragamoshoesjp.com +ferragamoshopjp.com +ferrer-lozano.es +ferrexalostoc-online.com +ferryardianaliasemailgenerator.cf +ferryardianaliasemailgenerator.ga +ferryardianaliasemailgenerator.gq +ferryardianaliasemailgenerator.ml +ferryardianaliasemailgenerator.tk +fertiary.xyz +fertigschleifen.de +fervex-lek.pl +fervex-stosowanie.pl +ferwords.online +ferwords.store +fesabok.ru +fesgrid.com +fesmel.xyz +fesr.com +festivarugs.com +festivuswine.com +festoolrus.ru +fesung.com +fet8gh7.mil.pl +fetchnet.co.uk +fetishpengu.com +fetko.pl +fettabernett.de +fettometern.com +feuerwehr-weiten.de +fewdaysmoney.com +fewfwe.com +fewfwefwef.com +fewminor.men +fex.plus +fexbox.org +fexbox.ru +fexpost.com +fextemp.com +feyerhermt.ws +feylstqboi.ga +feynorasu.dev +ff-flow.com +ff7j4.anonbox.net +ffamilyaa.com +ffbz.emlhub.com +ffc.spymail.one +ffddowedf.com +ffdeee.co.cc +ffdh.mimimail.me +ffeast.com +fff.emlhub.com +ffff.emlhub.com +ffffw.club +ffgarenavn.com +ffgrn.com +ffilledf.com +ffmovies.su +ffmsc.com +ffo.kr +ffsmortgages.com +ffssddcc.com +fft-mail.com +fft.edu.do +fftube.com +ffuqzt.com +ffwebookun.com +fgagay.buzz +fgcart.com +fgcj.yomail.info +fgdg.de +fgfstore.info +fgfydgfft.com +fggjghkgjkgkgkghk.ml +fgh8.com +fghfg.com +fghfgh.com +fghmail.net +fgmu.com +fgn.yomail.info +fgr.scoldly.com +fgrx.laste.ml +fgsd.de +fgsfg.com +fgsoas.top +fgsradffd.com +fgvod.com +fgxpt.anonbox.net +fgz.pl +fhccc37.com +fhccc41.com +fhccc44.com +fhccc79.com +fhdt0xbdu.xorg.pl +fhead3r.info +fheiesit.com +fhfdh.emlpro.com +fhm.emlhub.com +fhm.emltmp.com +fhn.freeml.net +fhollandc.com +fhoxe.works +fhpfhp.fr.nf +fhqtmsk.pl +fhs.spymail.one +fhsd-gd.top +fhsn.com +fhsuh3.site +fhsysa.com +fhvxkg2t.xyz +fi-pdl.cf +fi-pdl.ga +fi-pdl.gq +fi-pdl.ml +fi-pdl.tk +fiacre.tk +fiallaspares.com +fiam.club +fianance4all.com +fiat-chrysler.cf +fiat-chrysler.ga +fiat-chrysler.gq +fiat-chrysler.ml +fiat-chrysler.tk +fiat500.cf +fiat500.ga +fiat500.gq +fiat500.ml +fiat500.tk +fiatgroup.cf +fiatgroup.ga +fiatgroup.gq +fiatgroup.ml +fiberckb.com +fibered763aa.online +fiberglassshowerunits.biz +fiberoptics4tn.com +fiberyarn.com +fiberzonewest.com +fibimail.com +fibmail.com +fibram.tech +fibringlue.net +fica.ga +fica.gq +fica.ml +fica.tk +fichet-lisboa.com +fichetlisboa.com +fichetservice.com +fickdate-lamou.de +ficken.de +fickfotzen.mobi +fictionsite.com +fidelium10.com +fidesrodzinna.pl +fido.be +fidod.com +fidoomail.xyz +field.bthow.com +fieldleaf.com +fierymeets.xyz +fiestaamerica.com +fifa555.biz +fifacity.info +fifecars.co.uk +fificorp.com +fifthdesign.com +fifthleisure.com +fightallspam.com +fighter.systems +fightwrinkles.edu +figjs.com +figmail.me +figshot.com +figureance.com +figureout.emlpro.com +figurescoin.com +figuriety.site +fihcana.net +fiifke.de +fiikra.tk +fiikranet.tk +fiim.emlpro.com +fiji-nedv.ru +fik.yomail.info +fikachovlinks.ru +fiklis.website +fikrihidayah.cf +fikrihidayah.ga +fikrihidayah.gq +fikrihidayah.ml +fikrihidayah.tk +fikrinhdyh.cf +fikrinhdyh.ga +fikrinhdyh.gq +fikrinhdyh.ml +fikrinhdyh.tk +fikstore.com +fikumik97.ddns.info +fikus.work.gd +filbert4u.com +filberts4u.com +filcowanie.net +fildena-us.com +file-load-free.ru +file2drive.com +filea.site +filebuffer.org +filecat.net +filed.press +filed.space +filee.site +filef.site +fileg.site +fileh.site +filei.site +filel.site +filel.space +fileli.site +fileloader.site +filem.space +filemovers.online +filen.site +fileo.site +fileprotect.org +filera.site +filerb.site +filerc.site +filere.site +filerf.site +filerg.site +filerh.site +fileri.site +filerj.site +filerk.site +filerl.site +filerm.site +filern.site +filero.site +filerp.site +filerpost.xyz +filerq.site +filerr.site +filers.site +filert.site +files-host-box.info +files-usb-drive.info +files.vipgod.ru +filesa.site +filesb.site +filesc.site +filesd.site +filese.site +filesf.site +filesh.site +filesi.site +filesj.site +filesk.site +filesl.site +filesm.site +filesn.site +fileso.site +filesp.site +filespike.com +filespure.com +filesq.site +filesr.site +filest.site +filesu.site +filesv.site +filesw.site +filesx.site +filesy.site +filesz.site +filet.site +filetodrive.com +fileu.site +filevino.com +filewise.biz.id +filex.site +filey.site +fileza.site +filezb.site +filezc.site +filezd.site +fileze.site +filezf.site +filezg.site +filezh.site +filezi.site +filezj.site +filezk.site +filezl.site +filezm.site +filezn.site +filezo.site +filezp.site +filezq.site +filezr.site +filezs.site +filezt.site +filezu.site +filezv.site +filezw.site +filezx.site +filezy.site +filf.spymail.one +filhobicho.com +filipinoweather.info +filipx.com +filix.xyz +fillallin.com +fillnoo.com +film-blog.biz +film-tv-box.ru +filmak.pl +filmaticsvr.com +filmbak.com +filmemack.com +filmenstreaming.esy.es +filmharatis.xyz +filmhd720p.co +filmixco.ru +filmla.org +filmlicious.site +filmmodu.online +filmporno2013.com +filmstreamingvk.ws +filmvf.stream +filmyerotyczne.pl +filmym.pl +filozofija.info +filtracoms.info +filu.site +filzmail.com +fin-assistant.ru +finacenter.com +final.blatnet.com +final.com +final.marksypark.com +final.ploooop.com +final.poisedtoshrike.com +finalfour.site +finaljudgedomain.com +finaljudgeplace.com +finaljudgesite.com +finaljudgewebsite.com +finalndcasinoonline.com +financas.online +financaswsbz.com +finance.blatnet.com +finance.lakemneadows.com +finance.popautomated.com +finance.uni.me +financehowtolearn.com +financehy.com +financeideas.org +financeland.com +financetutorial.org +financialabundance.org +financialfreedomeducation.com +financialmomentum.com +finansomania.com.pl +finansowa-strona.pl +fincaduendesmagicos.com +fincainc.com +finckl.com +find-brides.org +find-me-watch.com +find.cy +findbankrates.com +findbesthgh.com +findcoatswomen.com +findemail.info +finder.laste.ml +finderman.systems +finderme.me +findhotmilfstonight.com +findicing.com +findids.net +findingcomputerrepairsanbernardino.com +findlocalusjobs.com +findlowprices.com +findmovingboxes.net +findmyappraisal.com +findnescort.com +findours.com +findtempmail.best +findtempmail.com +findu.pl +fineartadoption.net +finecardio.com +finecraft.company +finegoldnutrition.com +finejewler.com +finek.net +fineloans.org +finemail.org +fineoak.org +fineproz.com +finery.pl +finesseindia.in +finews.biz +finexhouse.com +finfave.com +fingermail.top +fingermouse.org +finghy.com +fingso.com +finioios.gr +finishingtouchfurniturerepair.com +finiteagency.com +finkin.com +finland-nedv.ru +finloe.com +finnahappen.com +finovatechnow.com +finpar.ru +finspirations.com +finsta.cyou +fintechistanbul.net +fintehs.com +fintnesscent.com +fintning.com +finxmail.com +finxmail.net +finzastore.com +fionawear.shop +fioo.fun +fiorino.glasslightbulbs.com +fipuye.top +fir.hk +fira.my +firain.com +firamax.club +firasbizzari.com +firatsari.cf +fire.favbat.com +fireblazevps.com +fireboxmail.lol +firechecker.systems +fireconsole.com +firecookie.ml +fireden.net +firef0x.cf +firef0x.ga +firef0x.gq +firef0x.ml +firef0x.tk +fireflies.edu +fireinthemountain.me +fireiptv.net +firekassa.com +firema.cf +firemail.com.br +firemail.org.ua +firemail.uz.ua +firemailbox.club +firematchvn.cf +firematchvn.ga +firematchvn.gq +firematchvn.ml +firematchvn.tk +firemymail.co.cc +firestore.pl +firestryke.com +firestylemail.tk +firevine.net +firevisa.com +firewallremoval.com +firma-frugtordning.dk +firma-remonty-warszawa.pl +firmaa.pl +firmaogrodniczanestor.pl +firmfinancecompany.org +firmjam.com +firmspp.com +fironia.com +firrior.ru +first-email.net +first-mail.info +first-state.net +first.baburn.com +first.lakemneadows.com +firstaidkit.services +firstaidtrainingmelbournecbd.com.au +firstcal.net +firstcapitalfibers.com +firstclassarticle.com +firstclassemail.online +firstcount.com +firstdibz.com +firste.ml +firstexpertise.com +firsthome.shop +firsthyip.com +firstin.ca +firstinforestry.com +firstk.co.cc +firstlawyer.org +firstmail.website +firstmeta.com +firstmistake.com +firstnamesmeanings.com +firstpageranker.com +firstpaydayloanuk.co.uk +firstpuneproperties.com +firstranked.com +firststopmusic.com +firsttimes.in +firsttradelimited.info +firt.site +fisanick88.universallightkeys.com +fischkun.de +fish.skytale.net +fishdating.net +fisherinvestments.site +fisherman.emlpro.com +fishfortomorrow.xyz +fishfuse.com +fishing.cam +fishingleisure.info +fishingmobile.org +fishmail.mineweb.in +fishpomd.com +fishslack.com +fishtropic.com +fishyes.info +fistikci.com +fit.bthow.com +fit.favbat.com +fitanu.info +fitbloomlab.com +fitbuybid.com +fitconsulting.com +fitflopsandals-us.com +fitflopsandalsonline.com +fitfopsaleonline.com +fitheads.com +fitmapgate.com +fitmapsmart.com +fitmindgate.com +fitnesrezink.ru +fitness-exercise-machine.com +fitness-india.xyz +fitness-weight-loss.net +fitness-wolke.de +fitnessjockey.org +fitnessmojo.org +fitnessreviewsonline.com +fitnessstartswithfood.com +fitnesstender.us +fitnesszbyszko.pl +fito.de +fitop.com +fitprowear.us +fitschool.be +fitschool.space +fitshot.xyz +fittinggeeks.pl +fitwl.com +fitzgeraldforjudge.com +fitzinn.com +fitzola.com +fiuedu.com +fiuwhfi212.com +five-club.com +five-plus.net +five.emailfake.ml +five.fackme.gq +fivedollardivas.com +fivedollardomains.com +fivefineshine.org +fivefriendsmail.com +fivemail.de +fivemails.com +fiver5.ru +fivermail.com +fiverrfan.com +fivesmail.org.ua +fivestarclt.com +fiwatani.com +fix-phones.ru +fixedfor.com +fixkauf24.de +fixmail.tk +fixthiserror.com +fixthisrecipe.com +fixwap.com +fixwindowserror-doityourself.com +fixxashop.xyz +fixyourbrokenrelationships.com +fizelle.com +fizjozel.pl +fizmail.com +fizmail.win +fizo.edu.com +fizxo.com +fizzyroute66.xyz +fj.laste.ml +fj1971.com +fjenfuen.freeml.net +fjfj.de +fjfjfj.com +fjfnmalcyk.ga +fjh.dropmail.me +fjj.emltmp.com +fjkwerhfui.com +fjo.freeml.net +fjo2q.anonbox.net +fjqbdg5g9fycb37tqtv.cf +fjqbdg5g9fycb37tqtv.ga +fjqbdg5g9fycb37tqtv.gq +fjqbdg5g9fycb37tqtv.ml +fjqbdg5g9fycb37tqtv.tk +fjr.emlhub.com +fjradvisors.net +fjumlcgpcad9qya.cf +fjumlcgpcad9qya.ga +fjumlcgpcad9qya.gq +fjumlcgpcad9qya.ml +fjumlcgpcad9qya.tk +fk.dropmail.me +fkainc.com +fkcod.com +fkdsloweqwemncasd.ru +fkel.laste.ml +fkfgmailer.com +fkg3w.anonbox.net +fkksol.com +fkla.com +fklbiy3ehlbu7j.cf +fklbiy3ehlbu7j.ga +fklbiy3ehlbu7j.gq +fklbiy3ehlbu7j.ml +fklbiy3ehlbu7j.tk +fkljhnlksdjf.cf +fkljhnlksdjf.ga +fkljhnlksdjf.ml +fkljhnlksdjf.tk +fknblqfoet475.cf +fkoljpuwhwm97.cf +fkoljpuwhwm97.ga +fkoljpuwhwm97.gq +fkoljpuwhwm97.ml +fkq.emlpro.com +fkqgz.anonbox.net +fkrcdwtuykc9sgwlut.cf +fkrcdwtuykc9sgwlut.ga +fkrcdwtuykc9sgwlut.gq +fkrcdwtuykc9sgwlut.ml +fkrcdwtuykc9sgwlut.tk +fkughosck.pl +fkuih.com +fl.com +fl.emlpro.com +fl.freeml.net +fl.hatberkshire.com +flageob.info +flagyl-buy.com +flaimenet.ir +flameoflovedegree.com +flamingbargains.com +flammablekarindra.io +flamonis.tk +flarmail.ga +flas.net +flash-mail.pro +flash-mail.xyz +flashdelivery.com +flashearcelulares.com +flashgoto.com +flashingboards.net +flashmail.co +flashmail.pro +flashonlinematrix.com +flashpdf.com +flashpost.net +flashsaletoday.com +flashu.nazwa.pl +flat-whose.win +flatfile.ws +flatidfa.org.ua +flatoledtvs.com +flatteringsandita.biz +flauntify.com +flavor.market +flavourity.com +flax3.com +flaxpeople.info +flaxpeople.org +flaxx.ru +flcarpetcleaningguide.org +fleckens.hu +fleeebay.com +fleetcommercialfinance.org +fleetcor.careers +flektel.com +flemail.com +flemail.ru +flemieux.com +flemist.com +flesh.bthow.com +flester.igg.biz +fletesya.com +fleuristeshwmckenna.com +flevelsg.com +flexbeltcoupon.net +flexreicnam.tk +flexrosboti.xyz +flexvio.com +flibu.com +flickshot.id +flidel.xyz +fliegender.fish +flier345xr.online +fliesgen.com +flightdart.ir +flightjungle.ir +flightkit.ir +flightmania.ir +flightmatic.ir +flightpad.ir +flightpage.ir +flightpoints.ir +flightscout.ir +flightsland.ir +flightspy.ir +flighttogoa.com +flightzy.ir +flimty-slim.com +flinttone.xyz +fliperama.org +flipssl.com +flirtey.pw +flitafir.de +flitify.com +fliveu.site +flixluv.com +flixsu.fun +flixtrend.net +flledge.com +flmail.info +flmcat.com +flmmo.com +flnm1bkkrfxah.cf +flnm1bkkrfxah.ga +flnm1bkkrfxah.gq +flnm1bkkrfxah.ml +flnm1bkkrfxah.tk +float.blatnet.com +float.cowsnbullz.com +float.ploooop.com +floatpools.com +flobo.fr.nf +flock84.uk +floodbrother.com +flooded.site +floodment.com +floorcoveringsinternational.co +flooringbestoptions.com +flooringuj.com +floorlampinfo.com +floorsonly.com +floorsqueegee.org +floranswer.ru +floresans.com +florida-nedv.ru +floridacnn.com +floridafleeman.com +floridaharvard.com +floridastatevision.info +floridavacationsrentals.org +floridianprints.com +floris.sa.com +florium.ru +flormidabel.com +flosek.com +flossuggboots.com +flotprom.ru +flotwigisapunkbusta.com +flotwigsucks.com +flour.icu +flow2word.com +flowbolt.com +flowercouponsz.com +flowermerry.com +flowermerry.net +flowersetcfresno.com +flowerss.website +flowersth.com +flowerwyz.com +flowexa.com +flowmeterfaq.com +flowminer.com +flowu.com +floyd-mayweather.info +floyd-mayweather2011.info +floydmayweathermarcosmaidana.com +flpaverpros.com +flpay.org +flpyun.online +flry.com +fls4.gleeze.com +flsb03.com +flsb06.com +flsb08.com +flsb11.com +flsb19.com +flschools.org +flskdfrr.com +flsxnamed.com +flu-cc.flu.cc +flu.cc +flucas.eu +flucassodergacxzren.eu +flucc.flu.cc +fluefix.com +fluidforce.net +fluidsoft.us +flukify.com +flurostation.com +flurre.com +flurred.com +flush.emlpro.com +flushpokeronline.com +flutiner.tk +flutred.com +flv.freeml.net +flw.freeml.net +fly-ts.de +flybymail.info +flyeragency.com +flyernyc.com +flyerzwtxk.com +flyfrv.tk +flyinggeek.net +flyingjersey.info +flyjet.net +flymail.tk +flynnproductions.com +flynsail.com +flyoveraerials.com +flyovertrees.com +flypicks.com +flyrics.ru +flyrine.com +flyriseweb.com +flyrutene.ml +flyspam.com +flyvisa.ir +flywaverun.com +flyxnet.pw +flyymail.com +flyzy.net +fm.cloudns.nz +fm365.com +fm69.cf +fm69.ga +fm69.gq +fm69.ml +fm69.tk +fm88vn.net +fm90.app +fmail.online +fmail.ooo +fmail.party +fmail.pw +fmail10.de +fmailx.tk +fmailxc.com +fmailxc.com.com +fman.site +fmc.dropmail.me +fmfmk.com +fmgroup-jacek.pl +fmial.com +fmproworld.com +fmserv.ru +fmsuicm.com +fmt.laste.ml +fmuss.com +fmv13ahtmbvklgvhsc.cf +fmv13ahtmbvklgvhsc.ga +fmv13ahtmbvklgvhsc.gq +fmv13ahtmbvklgvhsc.ml +fmv13ahtmbvklgvhsc.tk +fmx.at +fmz.laste.ml +fmzhwa.info +fn.spymail.one +fna6.com +fnaul.com +fnb.ca +fncp.ru +fncp.store +fndvote.online +fnmail.com +fnnus3bzo6eox0.cf +fnnus3bzo6eox0.ga +fnnus3bzo6eox0.gq +fnnus3bzo6eox0.ml +fnnus3bzo6eox0.tk +fnord.me +fnzm.net +fo9t34g3wlpb0.cf +fo9t34g3wlpb0.ga +fo9t34g3wlpb0.gq +fo9t34g3wlpb0.ml +fo9t34g3wlpb0.tk +foakleyscheap.net +foamform.com +foboxs.com +fobsos.ml +focolare.org.pl +focusapp.com +focusdezign.com +focussedbrand.com +fod.laste.ml +fod.yomail.info +fodl.net +fog.freeml.net +fog.one +fogdiver.com +fogeakai.tk +fogkkmail.com +fogmart.com +foistercustomhomes.com +fokakmeny.site +folardeche.com +foleyarmory.com +foliaapple.pl +folianokia.pl +folifirvi.net +folk97.glasslightbulbs.com +follazie.site +follegelian.site +follis23.universallightkeys.com +folllo.com +followerfilter.com +fom8.com +fomalhaut.lavaweb.in +fombog.com +fomentify.com +fondationdusport.org +fondato.com +fondgoroddetstva.ru +fonmail.com.br +fontainbleau.com +fontfee.com +foobarbot.net +food-discovery.net +food-facts.ru +food4kid.ru +food4thoughtcuisine.com +foodbooto.com +foodcia.com +foodezecatering.com +foodieinfluence.online +foodieinfluence.pro +foodkachi.com +foodrestores.com +foodslosebellyfat.com +foodtherapy.top +foodyuiw.com +fooface.com +foohurfe.com +foooq.com +foopets.pl +foorama.com +footard.com +footbal.app +football-zone.ru +footballan.ru +footfown.store +foothillsurology.com +footiethreads.com +footmassage.club +footmassage.online +footmassage.website +footmassage.world +fopa.pl +fopamarkets.site +fopjgudor.ga +fopjgudor.gq +fopjgudor.ml +fopjgudor.tk +fopliyter.cf +fopliyter.ga +fopliyter.ml +fopliyter.tk +foquita.com +for-all.pl +for.blatnet.com +for.favbat.com +for.lakemneadows.com +for.marksypark.com +for.martinandgang.com +for.oldoutnewin.com +for.ploooop.com +for1mail.tk +for4.com +for4mail.com +foragentsonky.com +forapps.shop +foraro.com +forcelons.xyz +forcrack.com +ford-edge.club +ford-flex.club +foreastate.com +forecastertests.com +foreclosurefest.com +foreskin.cf +foreskin.ga +foreskin.gq +foreskin.ml +foreskin.tk +forestar.edu +forestcrab.com +forestermail.info +foresthope.com +forestonline.top +foreverall.org.ua +forewa.ml +forex-for-u.net +forexbudni.ru +forexhub.online +forexjobing.ml +forexlist.in +forexnews.bg +forexpro.re +forexru.com +forexshop.website +forexsite.info +forexsu.com +forextradingsystemsreviews.info +forextrendtrade.com +forexzig.com +forffives.casa +forfity.com +forgetmail.com +forgetmenotbook.com +forklift.edu +forkshape.com +forliion.com +form.bthow.com +formail22.dlinkddns.com +formatmail.com +formatpoll.net +formdmail.com +formdmail.net +formedisciet.site +formilaraibot.vip +formodapk.com +formserwis.pl +formsphk.com +fornow.eu +forore.ru +forotenis.com +forprice.co +forread.com +forrealnetworks.com +forserumsif.nu +forsofort.info +forspam.net +fortforum.org +forth.bthow.com +forthebestsend.com +fortitortoise.com +fortlauderdaledefense.com +fortniteskill.com +fortpeckmarinaandbar.com +fortressfinancial.biz +fortressfinancial.co +fortressfinancial.xyz +fortressgroup.online +fortresssecurity.xyz +fortuna7.com +fortunatelady.com +fortunatelady.net +fortune-free.com +fortzelhost.me +forum-mocy.pl +forum.defqon.ru +forum.minecraftplayers.pl +forum.multi.pl +forumbacklinks.net +forumbens.online +forumbens.shop +forumbens.site +forumbens.space +forumbens.store +forumbens.website +forumbens.xyz +forumfreeai.com +forummaxai.com +forumoxy.com +forward.cat +forward4families.org +forwardemail.net +forzadenver.com +forzataraji.com +foshata.com +fosil.pro +fosiq.com +fossimaila.info +fossimailb.info +fossimailh.info +foster137.store +foto-videotrak.pl +foto-znamenitostei31.ru +fotoespacio.net +fotografiaslubnawarszawa.pl +fotoksiazkafotoalbum.pl +fotokults.de +fotoliegestuhl.net +fotonmail.com +fotoplik.pl +fotorezensionen.info +fouadps.cf +fouae.anonbox.net +fouan.ddns.net +fouddas.gr +foundationbay.com +foundents.site +foundiage.site +foundme.site +foundtoo.com +four.emailfake.ml +four.fackme.gq +fourcafe.com +fouristic.us +foursubjects.com +fourth.bgchan.net +foxanaija.site +foxiomail.com +foxja.com +foxmaily.com +foxnetwork.com +foxnew.info +foxroids.com +foxschool.edu +foxspizzanorthhuntingdon.com +foxtrotter.info +foxwoods.com +foy.kr +foz.freeml.net +fozmail.info +fp.freeml.net +fpe.laste.ml +fpf.team +fpfc.cf +fpfc.ga +fpfc.gq +fpfc.ml +fpfc.tk +fphiulmdt3utkkbs.cf +fphiulmdt3utkkbs.ga +fphiulmdt3utkkbs.gq +fphiulmdt3utkkbs.ml +fphiulmdt3utkkbs.tk +fpj.spymail.one +fplq.xyz +fpmh.mimimail.me +fpn.laste.ml +fpnf.emltmp.com +fpol.com +fq1my2c.com +fq65d.anonbox.net +fq8sfvpt0spc3kghlb.cf +fq8sfvpt0spc3kghlb.ga +fq8sfvpt0spc3kghlb.gq +fq8sfvpt0spc3kghlb.ml +fq8sfvpt0spc3kghlb.tk +fqdu.com +fqke.dropmail.me +fqm.laste.ml +fqnz.emlhub.com +fqreleased.com +fqtxjxmtsenq8.cf +fqtxjxmtsenq8.ga +fqtxjxmtsenq8.gq +fqtxjxmtsenq8.ml +fqtxjxmtsenq8.tk +fqwvascx.com +fqyk0o.dropmail.me +fr-air-max.org +fr-air-maxs.com +fr-airmaxs.com +fr.cr +fr.emltmp.com +fr.nf +fr33mail.info +fr3546ruuyuy.cf +fr3546ruuyuy.ga +fr3546ruuyuy.gq +fr3546ruuyuy.ml +fr3546ruuyuy.tk +fr4.site +fr4nk3nst3inersenuke22.com +fr4nk3nst3inerweb20.com +frackinc.com +fractal.international +fractalauto.com +fractalblocks.com +fraddyz.ru +fragileferonova.biz +fragilenet.com +fragolina2.tk +framail.net +frame.favbat.com +framemail.cf +francanet.com.br +france-monclers.com +france-nedv.ru +francemel.com +francemonclerpascherdoudoune1.com +francepoloralphlaurenzsgpascher.com +francestroyed.xyz +franchiseworkforce.com +francina.pine-and-onyx.xyz +francisca.com +franco.com +frandin.com +franek.pl +frank-girls.com +frankcraf.icu +frankshome.com +franksunter.ml +frapmail.com +frappina.tk +frappina99.tk +frarip.site +frason.eu +fraudattorneys.biz +fraudcaller.com +frdk.emlpro.com +freadingsq.com +freakmail.co.cc +freakmails.club +freans.com +freclockmail.co.cc +freddie.berry.veinflower.xyz +freddymail.com +freddythebiker.com +frederictonlawyer.com +fredperrycoolsale.com +free-4-everybody.bid +free-backlinks.ru +free-chat-emails.bid +free-classifiedads.info +free-dl.com +free-email-address.info +free-email.cf +free-email.ga +free-episode.com +free-flash-games.com +free-ipad-deals.com +free-mail.bid +free-mails.bid +free-max-base.info +free-names.info +free-server.bid +free-softer.cu.cc +free-ssl.biz +free-store.ru +free-temp-mail.eu.org +free-temp.net +free-web-mails.com +free-webmail1.info +free.yhstw.org +free123mail.com +free2ducks.com +free2mail.xyz +free4everybody.bid +freeaa317.xyz +freeaccnt.ga +freeadultcamtocam.com +freeadultsexcams.com +freeail.hu +freeallapp.com +freealtgen.com +freebabysittercam.com +freebeats.com +freebee.com +freebin.ru +freeblackbootytube.com +freeblogger.ru +freebullets.net +freebusinessdomains.info +freecams4u.com +freecapsule.com +freecat.net +freechargevn.cf +freechargevn.ga +freechargevn.gq +freechargevn.ml +freechargevn.tk +freechatcamsex.com +freechatemails.bid +freechatemails.men +freechatemails.website +freechickenbiscuit.com +freechristianbookstore.com +freeclassifiedsonline.in +freecodebox.com +freecontests.xyz +freecontractorfinder.com +freecoolemail.com +freecustom.email +freedgiftcards.com +freedivorcelawyers.net +freedom-mail.ga +freedom.casa +freedom4you.info +freedomanybook.site +freedomanylib.site +freedomanylibrary.site +freedomawesomebook.site +freedomawesomebooks.site +freedomawesomefiles.site +freedomfreebook.site +freedomfreebooks.site +freedomfreefile.site +freedomfreefiles.site +freedomfreshbook.site +freedomfreshfile.site +freedomgoodlib.site +freedompop.us +freedomweb.org +freedownloadmedicalbooks.com +freedrops.org +freeeducationvn.cf +freeeducationvn.ga +freeeducationvn.gq +freeeducationvn.ml +freeeducationvn.tk +freeelf.com +freeemail.online +freeemail4u.org +freeemailnow.info +freeemailproviders.info +freeemails.ce.ms +freeemails.racing +freeemails.website +freeemailservice.info +freeessaywriter.com +freefattymovies.com +freeforall.site +freegetvpn.com +freehealthadvising.info +freehosting.men +freehosting2010.com +freehosty.xyz +freehotmail.net +freeimagehosts.org +freeimeicheck.com +freeimghost.org +freeimtips.info +freeinbox.cyou +freeinbox.email +freeindexer.com +freeinstallssoftwaremine.club +freeinvestoradvice.com +freeipadnowz.com +freelail.com +freelance-france.eu +freelance-france.euposta.store +freelancejobreport.com +freelanceposition.com +freelasvegasshowtickets.net +freeletter.me +freelibraries.info +freeliveadultcams.com +freeliveadultchat.com +freelivenudechat.com +freelivesex1.info +freelivesexonline.com +freelivesexporn.com +freelivesexycam.com +freelymail.com +freemail-host.info +freemail.best +freemail.bid +freemail.biz.st +freemail.co.pl +freemail.is +freemail.men +freemail.ms +freemail.nx.cninfo.net +freemail.online.tj.cn +freemail.trade +freemail.trankery.net +freemail.tweakly.net +freemail.waw.pl +freemail000.pl +freemail3949.info +freemail4.info +freemailboxy.com +freemailertree.tk +freemaillink.com +freemailmail.com +freemailnow.net +freemailonline.us +freemails.bid +freemails.cf +freemails.download +freemails.ga +freemails.men +freemails.ml +freemails.pp.ua +freemails.stream +freemails.us +freemailservice.tk +freemailsrv.info +freemailto.cz.cc +freemeil.ga +freemeil.gq +freemeil.ml +freemeil.tk +freemeilaadressforall.net +freeml.net +freemommyvids.com +freemoney.pw +freemymail.org +freemyworld.cf +freemyworld.ga +freemyworld.gq +freemyworld.ml +freemyworld.tk +freenail.ga +freenail.hu +freenfulldownloads.net +freenudevideochat.com +freeo.pl +freeoffers123.com +freeolamail.com +freeonlineke.com +freeonlineporncam.com +freeonlinewebsex.com +freepalestine.id +freephonenumbers.us +freephotoretouch.com +freeplumpervideos.com +freepoincz.net +freepop3.co.cc +freepornbiggirls.com +freeporncamchat.com +freepost.cc +freeprice.co +freeread.co.uk +freeringers.in +freeroid.com +freerubli.ru +freerunproshop.com +freerunprostore.com +freesamplesuk2014.co.uk +freeschoolgirlvids.com +freeserver.bid +freesexchats24.com +freesexshows.us +freesexvideocam.com +freeshemaledvds.com +freesistercam.com +freesistervids.com +freesmsvoip.com +freesourcecodes.com +freestuffonline.info +freesubs.me +freetds.net +freeteenbums.com +freetemporaryemail.com +freethought.ml +freetimer.online +freetipsapp.com +freetmail.in +freetmail.net +freetubearchive.com +freeunlimitedebooks.com +freevipbonuses.com +freeweb.email +freewebcamsexchat.com +freewebmaile.com +freewebpages.bid +freewebpages.stream +freewebpages.top +freewebpages.website +freexms.com +freexrumer.com +freeze.onthewifi.com +freezeast.co.uk +freezeion.com +freezzzm.site +freizeit-sport.eu +fremail.hu +fremails.com +fremontcountypediatrics.com +frenchbedsonline777.co.uk +frenchconnectionantiques.com +frenchcuff.org +frenee.r-e.kr +frenteadventista.com +freomos.co.uk +freomos.uk +frepsalan.club +frepsalan.site +frepsalan.store +frepsalan.website +frepsalan.xyz +frequential.info +frequiry.com +fresclear.com +fresec.com +fresent.com +freshattempt.com +freshautonews.ru +freshbreadcrumbs.com +freshevent.store +freshfromthebrewery.com +freshline.store +freshmail.com +freshmail4you.site +freshmassage.club +freshmassage.website +freshnews365.com +freshnewspulse.com +freshnewssphere.com +freshnewswave.com +freshsmokereview.com +freshspike.com +freshviralnewz.club +fresnokitchenremodel.com +freson.info +fressmind.us +fretice.com +freudenkinder.de +freunde.ru +freundin.ru +frexmail.co.cc +freyaglam.shop +frez.com +frgd.emltmp.com +frgviana-nedv.ru +friarystudentmail.com +fridaymovo.com +fridaypzy.com +friedfriedfrogs.info +friedfyhu.com +frienda.site +friendlymail.co.uk +friendlynewscorner.com +friendlynewsinsight.com +friendlynewslink.com +friendlynewswire.com +friendsack.com +frisbook.com +friscaa.cf +friscaa.ga +friscaa.gq +friscaa.ml +friscaa.tk +friteuseelectrique.net +fritolay.net +frizbi.fr +frizzart.ru +frm.ovh +frmonclerinfo.info +frnla.com +froks.xyz +from.blatnet.com +from.eurasia.cloudns.asia +from.inblazingluck.com +from.lakemneadows.com +from.onmypc.info +from.ploooop.com +fromater.site +fromater.xyz +fromina.site +fromru.com +front14.org +frontarbol.com +frontierfactions.org +frontiergoldprospecting.com +frontiers.com +frontirenet.net +frontlinemanagementinstitute.com +frooogle.com +fror.com +frost-online.de +frost2d.net +frostmail.fr.nf +frostmail.site +frostyonpoint.site +frouse.ru +froyles.com +froyo.imap.mineweb.in +frozen.com +frozenfoodbandung.com +frozenfund.com +frpascherbottes.com +frre.com +frrotk.com +frshstudio.com +fruertwe.com +frugalpens.com +fruitandvegetable.xyz +frutti-tutti.name +frwdmail.com +frwqg.anonbox.net +frxx.site +frycowe.pl +fryferno.com +fryshare.com +fryzury-krotkie.pl +frz.freeml.net +frza.me +fs-fitzgerald.cf +fs-fitzgerald.ga +fs-fitzgerald.gq +fs-fitzgerald.ml +fs-fitzgerald.tk +fs16dubzzn0.cf +fs16dubzzn0.ga +fs16dubzzn0.gq +fs16dubzzn0.ml +fs16dubzzn0.tk +fs2002.com +fsadgdsgvvxx.shop +fsagc.xyz +fsasdafdd.cloud +fsdf.freeml.net +fsdfs.com +fsdfsd.com +fsdfsdgsdgs.com +fsdgs.com +fsdh.site +fsercure.com +fsercure.online +fsf.emltmp.com +fsfsdf.org +fsfsdfrsrs.ga +fsfsdfrsrs.gq +fsfsdfrsrs.ml +fsfsdfrsrs.tk +fsfsfascc.shop +fshare.ootech.vn +fsist.org +fsitip.com +fskk.pl +fslm.de +fsmilitary.com +fsociety.org +fsouda.com +fsrfwwsugeo.cf +fsrfwwsugeo.ga +fsrfwwsugeo.gq +fsrfwwsugeo.ml +fsrfwwsugeo.tk +fssh.ml +fsxflightsimulator.net +fsze.emlhub.com +ft.newyourlife.com +ft0wqci95.pl +fteenet.de +ftfp.com +ftg8aep4l4r5u.cf +ftg8aep4l4r5u.ga +ftg8aep4l4r5u.gq +ftg8aep4l4r5u.ml +ftg8aep4l4r5u.tk +ftgb2pko2h1eyql8xbu.cf +ftgb2pko2h1eyql8xbu.ga +ftgb2pko2h1eyql8xbu.gq +ftgb2pko2h1eyql8xbu.ml +ftgb2pko2h1eyql8xbu.tk +ftgg.emltmp.com +fthcapital.com +ftm2n.anonbox.net +ftnupdatecatalog.ru +ftoflqad9urqp0zth3.cf +ftoflqad9urqp0zth3.ga +ftoflqad9urqp0zth3.gq +ftoflqad9urqp0zth3.ml +ftoflqad9urqp0zth3.tk +ftp.sh +ftpbd.com +ftpinc.ca +ftrans.net +ftsecurity.com +ftvhpdidvf.ga +ftwapps.com +ftye.emltmp.com +fu.laste.ml +fu.spymail.one +fu6znogwntq.cf +fu6znogwntq.ga +fu6znogwntq.gq +fu6znogwntq.ml +fu6znogwntq.tk +fuadd.me +fuasha.com +fubkdjkyv.pl +fubsale.top +fubx.com +fuckedupload.com +fuckingduh.com +fuckinhome.com +fuckme69.club +fucknloveme.top +fuckoramor.ru +fuckrosoft.com +fucktuber.info +fuckxxme.top +fuckyou.co +fuckyou.com +fuckyoumomim10.com +fuckyoumotherfuckers.com +fuckzy.com +fucsovics.com +fudanwang.com +fuddruckersne.com +fudgerub.com +fudier.com +fuelesssapi.xyz +fufaca.date +fufrh4xatmh1hazl.cf +fufrh4xatmh1hazl.ga +fufrh4xatmh1hazl.gq +fufrh4xatmh1hazl.ml +fufrh4xatmh1hazl.tk +fufuf.bee.pl +fugdfk21.shop +fuglazzes.com +fuhoy.com +fuirio.com +fujitv.cf +fujitv.ga +fujitv.gq +fukaru.com +fuklor.me +fukm.com +fukolpza.com.pl +fukrworoor.ga +fuktard.co.in +fukurou.ch +fukyou.com +fuli1024.biz +fullalts.cf +fullangle.org +fullclone.xyz +fulledu.ru +fullen.in +fullepisodesnow.com +fullermail.men +fullfilmizle2.com +fullhds.com +fullhomepacks.info +fulljob.online +fulljob.store +fullmails.com +fullmoonlodgeperu.com +fullsoftdownload.info +fullsupport.cd +fullzero.com.ar +fuluj.com +fulvie.com +fulwark.com +fumemail.com +fumio12.hensailor.xyz +fumio33.hensailor.xyz +fumio86.eyneta.site +fumw7idckt3bo2xt.ga +fumw7idckt3bo2xt.ml +fumw7idckt3bo2xt.tk +fumxq.anonbox.net +fun-images.com +fun2.biz +fun2night.club +fun417.xyz +fun4k.com +fun5k.com +fun64.com +fun64.net +fun88entrance.com +funandrun.waw.pl +funbetiran.com +funblog.club +funboxcn.com +functionalneurocenters.com +functionrv.com +fundaciontarbiat.org +fundament.site +fundapk.com +fundedfgq.com +fundgrowth.club +fundingair.com +fundingajc.com +fundproceed.com +fundraisingtactics.com +funeemail.info +funfar.pl +funfoodmachines.co.uk +funklinko.com +funkoo.xyz +funktales.com +funkyboxer.com +funkybubblegum.com +funkyhall.com +funkyjerseysof.com +funkytesting.com +funmail.xyz +funniestonlinevideos.org +funnycodesnippets.com +funnyfrog.com.pl +funnymail.de +funnyrabbit.icu +funnysmell.info +funplus.site +funteka.com +funtv.site +funvane.com +funxmail.ga +funxxxx.xyz +fuqus.com +furaz.com +furkanozturk.cfd +furnato.com +furnitt.com +furnituregm.com +furnitureinfoguide.com +furnitureliquidationconsultants.com +furniturm.com +fursee.com +fursuit.info +further-details.com +furthermail.com +furusato.tokyo +furycraft.ru +furzauflunge.de +fus-ro-dah.ru +fuse-vision.com +fusedlegal.com +fusion.marksypark.com +fusion.oldoutnewin.com +fusioninbox.com +fusiontalent.com +fusixgasvv1gbjrbc.cf +fusixgasvv1gbjrbc.ga +fusixgasvv1gbjrbc.gq +fusixgasvv1gbjrbc.ml +fusixgasvv1gbjrbc.tk +fusskitzler.de +futbolcafe11.xyz +futebr.com +futilesandilata.net +futk.laste.ml +futuramarketing.we.bs +futuramind.com +futuraseoservices.com +futurebuckets.com +futuredvd.info +futuregenesplicing.in +futuregold68.com +futuregood.pw +futurejs.com +futuremail.info +futureof2019.info +futuresoundcloud.info +futuresports.ru +futuristicplanemodels.com +fuugmjzg.xyz +fuup.laste.ml +fuvk.ru +fuvk.store +fuvptgcriva78tmnyn.cf +fuvptgcriva78tmnyn.ga +fuvptgcriva78tmnyn.gq +fuvptgcriva78tmnyn.ml +fuw.emltmp.com +fuw65d.cf +fuw65d.ga +fuw65d.gq +fuw65d.ml +fuw65d.tk +fuwa.be +fuwa.li +fuwamofu.com +fuwari.be +fux0ringduh.com +fuzitea.com +fuzmail.info +fv.dropmail.me +fv.emlhub.com +fv.emltmp.com +fvgk.yomail.info +fvgs.com +fvguj.anonbox.net +fvhnqf7zbixgtgdimpn.cf +fvhnqf7zbixgtgdimpn.ga +fvhnqf7zbixgtgdimpn.gq +fvhnqf7zbixgtgdimpn.ml +fvhnqf7zbixgtgdimpn.tk +fvia.app +fviadropinbox.com +fviainboxes.com +fviamail.com +fviamail.work +fviatool.com +fvqpejsutbhtm0ldssl.ga +fvqpejsutbhtm0ldssl.ml +fvqpejsutbhtm0ldssl.tk +fvr.freeml.net +fvsu.com +fvsxedx6emkg5eq.gq +fvsxedx6emkg5eq.ml +fvsxedx6emkg5eq.tk +fvuch7vvuluqowup.cf +fvuch7vvuluqowup.ga +fvuch7vvuluqowup.gq +fvuch7vvuluqowup.ml +fvuch7vvuluqowup.tk +fvurtzuz9s.cf +fvurtzuz9s.ga +fvurtzuz9s.gq +fvurtzuz9s.ml +fvurtzuz9s.tk +fvxq.yomail.info +fvzx.dropmail.me +fw-nietzsche.cf +fw-nietzsche.ga +fw-nietzsche.gq +fw-nietzsche.ml +fw-nietzsche.tk +fw.moza.pl +fw025.com +fw2.me +fw5pd.anonbox.net +fw6m0bd.com +fwbr.com +fwd2m.eszett.es +fwenz.com +fwfr.com +fwhyhs.com +fwmuqvfkr.pl +fwmv.com +fwnu.emlhub.com +fws.fr +fwu.dropmail.me +fwumoy.buzz +fwxr.com +fwxzvubxmo.pl +fwza.yomail.info +fx-banking.com +fx-brokers.review +fx8333.com +fxavaj.com +fxch.com +fxcomet.com +fxcoral.biz +fxd.freeml.net +fxfe.laste.ml +fxfhvg.xorg.pl +fxjnupufka.ga +fxmail.ws +fxnxs.com +fxprix.com +fxpu.emlhub.com +fxseller.com +fxsuppose.com +fxtubes.com +fxzig.com +fycloud.online +fyh.in +fyhrw.anonbox.net +fyii.de +fyij.com +fyk.emlpro.com +fynix.sbs +fynuas6a64z2mvwv.cf +fynuas6a64z2mvwv.ga +fynuas6a64z2mvwv.gq +fynuas6a64z2mvwv.ml +fynuas6a64z2mvwv.tk +fyromtre.tk +fys2zdn1o.pl +fyvznloeal8.cf +fyvznloeal8.ga +fyvznloeal8.gq +fyvznloeal8.ml +fyvznloeal8.tk +fywe.com +fyx.emlhub.com +fyziotrening.sk +fz.emltmp.com +fz.yomail.info +fzbwnojb.orge.pl +fzkl4.anonbox.net +fzoe.com +fzsv.com +fztj.emltmp.com +fztvgltjbddlnj3nph6.cf +fztvgltjbddlnj3nph6.ga +fztvgltjbddlnj3nph6.gq +fztvgltjbddlnj3nph6.ml +fzyutqwy3aqmxnd.cf +fzyutqwy3aqmxnd.ga +fzyutqwy3aqmxnd.gq +fzyutqwy3aqmxnd.ml +fzyutqwy3aqmxnd.tk +g-mail.gq +g-mail.kr +g-mailix.com +g-meil.com +g-o-o-g-l-e.cf +g-o-o-g-l-e.ga +g-o-o-g-l-e.gq +g-o-o-g-l-e.ml +g-srv.systems +g-starblog.org +g-timyoot.ga +g.bestwrinklecreamnow.com +g.captchaeu.info +g.coloncleanse.club +g.gsasearchengineranker.pw +g.gsasearchengineranker.space +g.hmail.us +g.polosburberry.com +g.seoestore.us +g.sportwatch.website +g.ycn.ro +g00g.cf +g00g.ga +g00g.gq +g00g.ml +g00gl3.gq +g00gl3.ml +g00glechr0me.cf +g00glechr0me.ga +g00glechr0me.gq +g00glechr0me.ml +g00glechr0me.tk +g00gledrive.ga +g00qle.ru +g05zeg9i.com +g0ggle.tk +g0mail.com +g0zr2ynshlth0lu4.cf +g0zr2ynshlth0lu4.ga +g0zr2ynshlth0lu4.gq +g0zr2ynshlth0lu4.ml +g0zr2ynshlth0lu4.tk +g14l71lb.com +g1kolvex1.pl +g1xmail.top +g2.brassneckbrewing.com +g212dnk5.com +g27kj.anonbox.net +g2m5d.anonbox.net +g2tpv9tpk8de2dl.cf +g2tpv9tpk8de2dl.ga +g2tpv9tpk8de2dl.gq +g2tpv9tpk8de2dl.ml +g2tpv9tpk8de2dl.tk +g2xmail.top +g3nk2m41ls.ga +g3nkz-m4ils.ga +g3nkzmailone.ga +g3xmail.top +g4hdrop.us +g4qna.anonbox.net +g4rm1nsu.com +g4zk7mis.mil.pl +g50hlortigd2.ga +g50hlortigd2.ml +g50hlortigd2.tk +g7kgmjr3.pl +g7lkrfzl7t0rb9oq.cf +g7lkrfzl7t0rb9oq.ga +g7lkrfzl7t0rb9oq.gq +g7lkrfzl7t0rb9oq.ml +g7lkrfzl7t0rb9oq.tk +g8e8.com +gaail.com +gaairlines.com +gaal.emlpro.com +gaanerbhubon.net +gabalot.com +gabbygiffords.com +gabesdownloadsite.com +gabfests.ml +gabon-nedv.ru +gabox.store +gabuuddd.ga +gabuuddd.gq +gabuuddd.ml +gabuuddd.tk +gachupa.com +gadget-space.com +gadgetreviews.net +gadgetsfair.com +gadum.site +gaeil.com +gaf.oseanografi.id +gafrem3456ails.com +gafy.net +gag16dotw7t.cf +gag16dotw7t.ga +gag16dotw7t.gq +gag16dotw7t.ml +gag16dotw7t.tk +gagahsoft.software +gagcalculator.me +gage.ga +gagged.xyz +gaggle.net +gagokaba.com +gai18.xyz +gail.com +gailiuzi.icu +gailna.asia +gainready.com +gainweu.com +gaiti-nedv.ru +gajesajflk.cf +gajesajflk.gq +gakbec.us +gakhum.com +gakkurang.com +galablogaza.com +galactofa.ga +galactofa.tk +galamail.biz +galaxim.fr.nf +galaxy-s9.cf +galaxy-s9.ga +galaxy-s9.gq +galaxy-s9.ml +galaxy-s9.tk +galaxy-tip.com +galaxy.emailies.com +galaxy.emailind.com +galaxy.maildin.com +galaxy.marksypark.com +galaxy.martinandgang.com +galaxy.oldoutnewin.com +galaxy.tv +galaxyarmy.tech +galaxys8giveaway.us +galcake.com +galco.dev +galenparkisd.com +galerielarochelle.com +galismarda.com +gallery-des-artistes.com +gallerys.blog +gallowaybell.com +gallowspointgg.com +gally.jp +galmarino.com +galotv.com +galvanitrieste.it +galvanizefitness.com +galvanmail.men +gam1fy.com +gamail.com +gamail.emlhub.com +gamail.emlpro.com +gamail.freeml.net +gamail.mimimail.me +gamail.net +gamail.top +gamakang.com +gamale.com +gamamail.tk +gamdspot.com +game-drop.ru +game-with.com +game-world.pro +game.blatnet.com +game.bthow.com +game.com +game.emailies.com +game.servebeer.com +game2.de +game4hr.com +gamearea.site +gamebcs.com +gamebuteasy.xyz +gamecheatfree.xyz +gamecodebox.com +gamecodesfree.com +gameconsole.site +gamecoutryjojo.com +gamedaytshirt.com +gamedeal.ru +gamededezod.com +gamegoldies.org +gamegregious.com +gamegta.com +gameme.men +gamening.com +gameover-shop.de +gamepec.com +gamepi.ru +gameqo.com +gamercosplay.pl +gamerentalreview.co.uk +gamersdady.com +games-online24.co.uk +games-zubehor.com +games0.co.uk +games4free.flu.cc +games4free.info +gamesbrands.space +gamescentury.com +gameschool.online +gamesev.ml +gamesev.tk +gamesforgirl.su +gamesonlinefree.ru +gamesonlinez.co.uk +gamesoonline.com +gamesportal.me +gameszox.com +gamevillage.org +gamewedota.co.cc +gamexshop.online +gamezalo.com +gamezli.com +gamgling.com +gamil.co.in +gamil.com +gaminators.org +gamingday.com +gamintor.com +gamip.com +gamis-premium.com +gamma.org.pl +gammaelectronics.com +gammafoxtrot.ezbunko.top +gamno.config.work +gamom.com +gamora274ey.cf +gamora274ey.ga +gamora274ey.gq +gamora274ey.ml +gamora274ey.tk +gamuci.com +gamutimaging.com +gamzwe.com +gan.lubin.pl +gang-waw.xyz +gangazimyluv.com +gangu.cf +gangu.gq +gangu.ml +ganihomes.com +ganjipakhsh.shop +ganm.com +gannoyingl.com +ganoderme.ru +ganol.online +ganslodot.top +gantorbaz.cloud +gantraca.ml +gaolrer.com +gaosuamedia.com +gapemail.ga +gapo.vip +gappk89.pl +gaqa.com +garage46.com +garagedoormonkey.com +garagedoorschina.com +garasikita.pw +garaze-blaszaki.pl +garaze-wiaty.pl +garbagecollector.org +garbagemail.org +garciniacambogia.directory +garciniacambogiaextracts.net +garcio.com +garden-plant.ru +gardenans.ru +gardenpavingonline.net +gardenscape.ca +gardepot.com +gardercrm.ru +garderoba-retro.pw +gardsiir.com +gardu.codes +gareascx.com +garenaa.vn +garenagift.vn +garglob.com +garibomail2893.biz +gariepgliding.com +garillias22.net +garingsin.cf +garingsin.ga +garingsin.gq +garingsin.ml +garizo.com +garlanddusekmail.net +garliclife.com +garmingpsmap64st.xyz +garnett.us +garnettmailer.com +garnoisan.xyz +garnous.com +garoofinginc.com +garrifulio.mailexpire.com +garrymccooey.com +garrynacov.cf +gartenarbeiten-muenchen.ovh +garudaesports.com +garyoliver.es +garyschollmeier.com +gas-avto.com +gas-spark-plugs.pp.ua +gasan12.com +gasbin.com +gaselectricrange.com +gasil.com +gasken.online +gaskuy.me +gaskuy93.art +gasocin.pl +gassfey.com +gassmail.com +gasss.net +gasss.us +gasss.wtf +gasssboss.club +gassscloud.net +gasssmail.com +gasto.com +gastroconsultantsqc.com +gastroplasty.icu +gasuda.com +gatamala.com +gatases.ltd +gatdau.com +gaterremeds1975.eu +gateway3ds.eu +gathelabuc.almostmy.com +gati.tech +gato.com +gauche1.online +gaumontleblanc.com +gav0.com +gavail.site +gavrom.com +gawab.com +gawai-nedv.ru +gawe.works +gawmail.com +gawte.com +gaxetovemail.com +gayana-nedv.ru +gaydatingheaven.com +gayluspjex.ru +gaymail2020.com +gaymoviedome.in +gaynewworkforce.com +gayol.com +gazanfersoylemez.cfd +gazebostoday.com +gazetapracapl.pl +gazetawww.pl +gazetecizgi.com +gazettenews.info +gb.emlpro.com +gbcdanismanlik.net +gbcmail.win +gberos-makos.com +gbf48123.com +gbfashions.com +gbhh.freeml.net +gbmail.top +gbmb.com +gbmods.net +gbn.laste.ml +gbnbancorp.com +gbouquete.com +gbp.freeml.net +gbpartners.net +gbq.emltmp.com +gbs7yitcj.pl +gbtxtloan.co.uk +gbubrook.com +gc2nl.anonbox.net +gcantikored.pw +gcaoa.org +gcasino.fun +gcaw.yomail.info +gcbcdiet.com +gcej.emltmp.com +gcfleh.com +gcfyyek.emltmp.com +gch.emlhub.com +gchatz.ga +gcheck.xyz +gclv.emlhub.com +gcmail.top +gcordobaguerrero.com +gcpainters.com +gcyacademy.com +gcznu5lyiuzbudokn.ml +gcznu5lyiuzbudokn.tk +gd.laste.ml +gd.spymail.one +gd6ubc0xilchpozgpg.cf +gd6ubc0xilchpozgpg.ga +gd6ubc0xilchpozgpg.gq +gd6ubc0xilchpozgpg.ml +gd6ubc0xilchpozgpg.tk +gdatingq.com +gdb.armageddon.org +gdcac.com +gdcmedia.info +gdcpx.anonbox.net +gddao.com +gddcorp.com +gddp2018.edu.vn +gdemoy.site +gdfgergrer.shop +gdfgsd.cloud +gdfretertwer.com +gdienter.com +gdiey.freeml.net +gdmail.top +gdmalls.com +gdofui.xyz +gdqoe.net +gdradr.com +gdsutzghr.pl +gdsygu433t633t81871.luservice.com +gdziearchitektura.biz +geail.com +geal.com +geamil.com +gear.bthow.com +geararticles.com +geardos.net +geargum.com +gearhead.app +gearine.xyz +gears4camping.com +gearstag.com +geartower.com +geaviation.cf +geaviation.ga +geaviation.gq +geaviation.ml +geaviation.tk +gebaeudereinigungsfirma.com +gebicy.info +gebrauchtwarencenter.com +geburtstags.info +geburtstagsgruesse.club +geburtstagsspruche24.info +gebyarpoker.com +gecchatavvara.art +gecici.email +gecici.ml +gecicimail.co +gecicimail.com.tr +gecigarette.co.uk +geckoshadesolutions.com +gecotspeed04flash.ml +ged.laste.ml +ged34.com +geda.fyi +gedagang.co +gedagang.com +gedhemu.ru +gedleon.com +gedmail.win +gedsmail.com +geeee.me +geekale.com +geekchicpro.com +geekemailfreak.bid +geekforex.com +geekjun.com +geekpc-international.com +geekpro.org +geeky83.com +geemale.com +geew.ru +geezmail.ga +geforce-drivers.com +gefriergerate.info +gefvert.com +gegearkansas.com +geggos673.com +gehensiemirnichtaufdensack.de +gehnkwge.com +gek.laste.ml +gekk.edu +gekme.com +gekokerpde.tk +gekury4221mk.cf +gekury4221mk.ga +gekury4221mk.gq +gekury4221mk.ml +gekury4221mk.tk +gelarqq.com +gelatoprizes.com +geldwaschmaschine.de +gelitik.in +gelnhausen.net +geludkita.cf +geludkita.ga +geludkita.gq +geludkita.ml +geludkita.tk +gemail.co +gemail.com +gemail.ru +gemails.online +gemapan.com +gemar-qq.live +gemarbola.life +gemarbola.link +gemarbola.news +gembul.site +gemil.com +geminicg.com +gemsbooster.com +gemsgallerythailand.ru +gemsofaspen.com +gemtar.com +gemuk.buzz +gen.uu.gl +gen16.me +genbyou.ml +gencaysoker.cfd +genderfuck.net +genderuzsk.com +genebag.com +general-electric.cf +general-electric.ga +general-electric.gq +general-electric.ml +general-motors.tk +general.blatnet.com +general.lakemneadows.com +general.oldoutnewin.com +general.ploooop.com +general.popautomated.com +generalbatt.com +generateaeg.com +generateyourclients.com +generatoa.com +generator.email +generator1email.com +generic-phenergan.com +genericaccutanesure.com +genericcialis-usa.net +genericcialissure.com +genericcialisusa.net +genericclomidsure.com +genericdiflucansure.com +genericflagylonline24h.com +genericlasixsure.com +genericlevitra-usa.com +genericprednisonesure.com +genericpropeciaonlinepills.com +genericpropeciasure.com +genericretinaonlinesure.com +genericretinasure.com +genericsingulairsure.com +genericviagra-onlineusa.com +genericviagra-usa.com +genericviagra69.bid +genericviagraonline-usa.com +genericwithoutaprescription.com +genericzithromaxonline.com +genericzoviraxsure.com +genericzyprexasure.com +geneseeit.com +genesis-digital.net +genesvjq.com +genetiklab.com +genf20plus.com +genf20review1.com +gengkapak.ml +genjosam.com +genk5mail2.ga +genkibit.com +gennaromatarese.ml +gennox.com +genotropin.in +genoutdo.eu +genpc.com +genrephotos.ru +genteymac.net +gentlemancasino.com +gentlemansclub.de +gentrychevrolet.com +genturi.it +genuinemicrosoftkeyclub.com +genuspbeay.space +genuss.ru +genvia01.com +genx-training.com +genzmaile.com +genznet.me +genzotp.com +genztrang.com +geo-crypto.com +geoclsbjevtxkdant92.cf +geoclsbjevtxkdant92.ga +geoclsbjevtxkdant92.gq +geoclsbjevtxkdant92.ml +geoclsbjevtxkdant92.tk +geodezjab.com +geoffhowe.us +geofinance.org +geoglobe.com +geoinbox.info +geokomponent.ru +geolocalroadmap.com +geomail.win +geometricescape.com +geomets.xyz +geop.com +georedact.com +georgehood.com +georights.net +geospirit.de +geotemp.de +gepatitu-c.net +geposel.ml +gepr.freeml.net +gepx.laste.ml +gerakandutawisata.com +geraldlover.org +geratisan.ga +geremail.info +geri.live +geriatricos.page +germainarena.com +germanmail.de.pn +germanmails.biz +germanozd.com +germanycheap.com +germanyxon.com +germemembranlar.com +germetente.com +gero.us +geroev.net +geronra.com +gerovarnlo.com +gers-phyto.com +gerties.com.au +gervc.com +ges-online.ru +geschent.biz +gesthedu.com +gestioncolegio.online +get-bitcoins.club +get-bitcoins.online +get-dental-implants.com +get-mail.cf +get-mail.ga +get-mail.ml +get-mail.tk +get-more-leads-now.com +get-temp-mail.biz +get-whatsapp.site +get.cowsnbullz.com +get.marksypark.com +get.oldoutnewin.com +get.ploooop.com +get.poisedtoshrike.com +get.pp.ua +get1mail.com +get2israel.com +get2mail.fr +get30daychange.com +get365.pw +get365.tk +get42.info +getahairstyle.com +getairmail.cf +getairmail.com +getairmail.ga +getairmail.gq +getairmail.ml +getairmail.tk +getamailbox.org +getamalia.com +getamericanmojo.com +getanyfiles.site +getapet.net +getasolarpanel.co.uk +getaviciitickets.com +getawesomebook.site +getawesomebooks.site +getawesomelibrary.site +getbackinthe.kitchen +getbloomdata.com +getbreathtaking.com +getburner.email +getbusinessontop.com +getcashstash.com +getcatbook.site +getcatbooks.site +getcatstuff.site +getchina.ru +getcleanskin.info +getcode1.com +getcoolmail.info +getcoolstufffree.com +getcraftbeersolutions.com +getdarkfast.com +getdeadshare.com +getdirbooks.site +getdirtext.site +getdirtexts.site +getechnologies.net +getedoewsolutions.com +geteit.com +getek.tech +getemail.tech +getfollowers24.biz +getfreebook.site +getfreecoupons.org +getfreefile.site +getfreefollowers.org +getfreetext.site +getfreshbook.site +getfreshtexts.site +getfun.men +getgoodfiles.site +getgymbags.com +gethelpnyc.com +gethexbox.com +gethimbackforeverreviews.com +getimell.com +getimell.store +getinboxes.com +getincostume.com +getinharvard.com +getinsuranceforyou.com +getintopci.com +getippt.com +getitfast.com +getjar.pl +getjulia.com +getladiescoats.com +getlibbook.site +getlibstuff.site +getlibtext.site +getlistbooks.site +getlistfile.site +getliststuff.site +getlisttexts.site +getmail.fun +getmail.lt +getmail.pics +getmail1.com +getmailfree.cc +getmails.eu +getmails.pw +getmails.tk +getmailsonline.com +getmba.ru +getmeed.com +getmethefouttahere.com +getmola.com +getmoziki.com +getmule.com +getmy417.xyz +getnada.cc +getnada.cf +getnada.com +getnada.ga +getnada.gq +getnada.ml +getnada.tk +getnewfiles.site +getnewnecklaces.com +getnicefiles.site +getnicelib.site +getnowdirect.com +getnowtoday.cf +getocity.com +getol.pro +getonemail.com +getonemail.net +getover.de +getpaidoffmyreferrals.com +getpaulsmithget.com +getphysical.com +getprivacy.xyz +getqueenbedsheets.com +getrarefiles.site +getresearchpower.com +getridofacnecure.com +getridofherpesreview.org +getsaf.email +getsewingfit.website +getsimpleemail.com +getsingspiel.com +getsmag.co +getsoberfast.com +getspotfile.site +getspotstuff.site +getstructuredsettlement.com +getsuz.com +gett.icu +gettempmail.com +gettempmail.site +gettycap.com +getupagain.org +getvid.me +getvmail.net +getwomenfor.me +geupo.com +gewqsza.com +gexige.com +gexik.com +gf-roofing-contractors.co.uk +gf.laste.ml +gf.wlot.in +gfacc.net +gfbysaints.com +gfcnet.com +gfcom.com +gfdrwqwex.com +gffcqpqrvlps.cf +gffcqpqrvlps.ga +gffcqpqrvlps.gq +gffcqpqrvlps.tk +gfgfgf.org +gfh522xz.com +gfhgfhgf.dropmail.me +gfhjk.com +gflwpmvasautt.cf +gflwpmvasautt.ga +gflwpmvasautt.gq +gflwpmvasautt.ml +gflwpmvasautt.tk +gfmail.cf +gfmail.ga +gfmail.gq +gfmail.tk +gfmewrsf.com +gfounder.org +gfremail4u3.org +gfsw.de +gftm.com +gfv.dropmail.me +gfvgr2.pl +gg-byron.cf +gg-byron.ga +gg-byron.gq +gg-byron.ml +gg-byron.tk +gg-squad.ml +gg-zma1lz.ga +gg.alfatv.biz.id +gg.freeml.net +ggbags.info +ggbh.com +ggck.com +ggezme.shop +ggfd.de +ggfm.com +ggfutsal.cf +ggg.pp.ua +gggggg.com +ggggk.com +gggmail.pl +gggmarketingmail.com +gggo.emltmp.com +gggt.de +gghb.freeml.net +gghfjjgt.com +gglorytogod.com +ggmaail.com +ggmail.biz.st +ggmail.cloud +ggmail.com +ggmail.guru +ggmail.lol +ggmal.ml +ggmmails.com +ggmob-us.fun +ggo.one +ggomi12.com +ggooglecn.com +ggrainn.com +ggrreds.com +ggsel.ml +ggtoll.com +ggvk.ru +ggvk.store +ggxhunter.com +ggxx.com +gh-stroy.ru +gh-v.me +gh.emltmp.com +gh.wlot.in +gh.yomail.info +gh2xuwenobsz.cf +gh2xuwenobsz.ga +gh2xuwenobsz.gq +gh2xuwenobsz.ml +gh2xuwenobsz.tk +ghamil.com +ghan.com +ghanalandbank.com +ghastlynursyahid.biz +ghcptmvqa.pl +ghcrublowjob20127.com +ghdfinestore.com +ghdhairstraighteneraq.com +ghdhairstraightenersuk.info +ghdlghdldyd.com +ghdpascheresfrfrance.com +ghdsaleukstore.com +ghdshopnow.com +ghdshopuk.com +ghdstraightenersukshop.com +ghdstraightenersz.com +ghea.ml +ghehop.com +ghfh.de +ghgluiis.tk +ghid-afaceri.com +ghk55.us +ghkoyee.com.uk +ghlg.spymail.one +gholar.com +ghost-mailer.com +ghost-squad.eu +ghostadduser.info +ghosttexter.de +ghot.online +ghs.laste.ml +ghtreihfgh.xyz +ghuandoz.xyz +ghv.pics +ghvv.click +ghymail.com +ghyzeeavge.ga +gi-pro.org +gi.freeml.net +giachic.shop +giacmosuaviet.info +giaiphapmuasam.com +giala.com +giallo.tk +giaminhmmo.top +gianes.com +giangcho2000.asia +giangholang.xyz +gianna1121.club +giantmail.de +giantwebs2010.info +gianunzio34.spicysallads.com +giaoisgla35ta.cf +giaotiep.xyz +giaovienvn.gq +giaovienvn.tk +giayhieucu.com +gibit.us +giblpyqhb.pl +gibme.com +gibsonmail.men +gicua.com +gidok.info +gids.site +gieldatfi.pl +gienig.com +giessdorf.eu.org +gifenix.com.mx +gifexpress.com +gifmehard.ru +gifora.com +gift-link.com +gift.favbat.com +giftcv.com +gifteame.com +giftelope.com +gifto12.com +giftonlinezyz.online +gifts4homes.com +giftsales.store +giftscrafts2012.info +giftspec.com +giftwatches.info +giftyello.ga +gigabitstreaming.com +gigantix.co.uk +gigapesen.ru +gigatribe.com +gigauoso.com +gigavault.live +gigs.craigslist.org +gihyuw23.com +gijj.com +gijode.click +gijurob.info +gikemart.site +gikmail.com +gilaayam.com +gilababi1.ml +gilbertpublicschools.org +gilby.limited +gilfun.com +gilmoreforpresident.com +giln2.anonbox.net +gilray.net +gimail.cloud +gimail.com +gimaile.com +gimaill.com +gimal.com +gimamd.com +gimayl.com +gimbmail.com +gimel.net +gimesson.pe.hu +gimme-cooki.es +gimmehits.com +gimn.su +gimpmail.com +gimpu.ru +gimuemoa.fr.nf +gindatng.ga +gine.com +ginearr.com +ginel.com +ginn.cf +ginn.gq +ginn.ml +ginn.tk +ginnio.com +ginnygorgeousleaf.com +gins.com +gintd.site +ginxmail.com +ginzi.be +ginzi.co.uk +ginzi.es +ginzi.eu +ginzi.net +ginzy.co.uk +ginzy.eu +ginzy.org +giochi0.it +giochiz.com +giodaingan.com +giofiodl.gr +giogio.cf +giogio.gq +giogio.ml +gioidev.news +giondo.site +giooig.cf +giooig.ga +giooig.gq +giooig.ml +giooig.tk +giorgio.ga +gipitiw.tech +giplwsaoozgmmp.ga +giplwsaoozgmmp.gq +giplwsaoozgmmp.ml +giplwsaoozgmmp.tk +gipsowe.waw.pl +giran.club +giratex.com +girl-beautiful.com +girl-cute.com +girl-nice.com +girla.club +girla.site +girlbo.shop +girlcosmetic.info +girleasy.com +girlemail.org +girlfriend.ru +girlmail.win +girlncool.com +girls-stars.ru +girls-xs.ru +girlsdate.online +girlsforfun.tk +girlsindetention.com +girlstalkplay.com +girlsu.com +girlsundertheinfluence.com +girlt.site +giromail.info +girtipo.com +gishpuppy.com +gispgeph6qefd.cf +gispgeph6qefd.ga +gispgeph6qefd.gq +gispgeph6qefd.ml +gispgeph6qefd.tk +gitarrenschule24.de +gitated.com +gitcoding.me +githabs.com +gitpost.icu +gitumau.ga +gitumau.ml +gitumau.tk +giulieano.xyz +giuras.club +giuypaiw8.com +give.marksypark.com +give.poisedtoshrike.com +giveflix.me +giveh2o.info +givehit.com +givememail.club +givemeturtle.com +givemeyourhand.info +givenchyblackoutlet.us.com +givethefalconslight.com +givmail.com +givmy.com +giwf.com +giwwoljvhj.pl +gixenmixen.com +giyam.com +giyanigold.com +giyoyogangzi.com +gizleyici.tk +gizmona.com +gj.emlpro.com +gjbg.spymail.one +gjg.dropmail.me +gjgjg.pw +gjjm5.anonbox.net +gjkk.de +gjozie.xyz +gjr.freeml.net +gjva.spymail.one +gjvek.anonbox.net +gjz.freeml.net +gk-konsult.ru +gkjeee.com +gkl.dropmail.me +gkohau.xyz +gkolimp.ru +gkorii.com +gkp.spymail.one +gkqil.com +gksmftlx.com +gksqjsejf.com +gkuaisyrsib8fru.cf +gkuaisyrsib8fru.ga +gkuaisyrsib8fru.gq +gkuaisyrsib8fru.ml +gkuaisyrsib8fru.tk +gkwerto4wndl3ls.cf +gkwerto4wndl3ls.ga +gkwerto4wndl3ls.gq +gkwerto4wndl3ls.ml +gkwerto4wndl3ls.tk +gkworkoutq.com +gkxa.spymail.one +gky.emlhub.com +gkyyepqno.pl +gl.freeml.net +gladehome.com +gladogmi.fr.nf +gladwithbooks.site +gladysh.com +glalen.com +glamourbeauty.org +glamourcow.com +glampiredesign.com +glamurr-club.ru +gland.xxl.st +glaptopsw.com +glaringinfuse.ml +glasgowmotors.co.uk +glaslack.com +glasnik.info +glasrose.de +glassaas.site +glassandcandles.com +glasscanisterheaven.com +glasses88.com +glassesoutletsaleuk.co.uk +glassesoutletuksale.co.uk +glassworks.cf +glastore.ar +glastore.uno +glaszakelijk.com +glavsg.ru +glaziers-erith.co.uk +glaziers-waterloo.co.uk +glc.emltmp.com +glcspp.top +gldavmah.xyz +gldj.freeml.net +gle.emltmp.com +gledsonacioli.com +gleeze.com +glendalepaydayloans.info +glendalerealestateagents.com +glennvhalado.tech +glenwillowgrille.com +glenwoodave.com +glgi.net +glick.tk +glissinternational.com +glitch.sx +glitchwave.it +glitteringmediaswari.io +glitzyadelia.io +gliwicemetkownice.pl +gljc.emlhub.com +glmail.ga +glmail.top +glmux.com +glnf.emlpro.com +global-airlines.com +global-work.app +global1trader.com +global2.xyz +globalbizflow.com +globalcarinsurance.top +globaldatingclub.lat +globaleuro.net +globalinkerpro.com +globaljetconcept.media +globalkino.ru +globalmillionaire.com +globalmodelsgroup.com +globalpayments.careers +globalpuff.org +globalsilverhawk.com +globalsites.site +globaltouron.com +globalwork.dev +globetele.com +globomail.co +glockneronline.com +glocknershop.com +glome.world +gloom.org +gloport.com +gloria-tours.com +gloriousfuturedays.com +glorysteak.email +gloservma.com +glossier-group.com +glossybee.com +glovebranders.com +glovesprotection.info +glowend.online +glowend.xyz +glowible.com +glowinbox.info +glowingsyabrianty.biz +glownymail.waw.pl +glqbsobn8adzzh.cf +glqbsobn8adzzh.ga +glqbsobn8adzzh.gq +glqbsobn8adzzh.ml +glqbsobn8adzzh.tk +glrbio.com +glspring.com +glsupposek.com +gltrrf.com +glubex.com +glucosegrin.com +glumark.com +glutativity.xyz +glv.dropmail.me +glyctistre.ga +glyctistre.gq +glynda.space +gm9ail.com +gma2il.com +gmaail.net +gmabrands.com +gmaeil.com +gmai.com +gmai1.ga +gmai1.kr +gmai9l.com +gmaieredd.com +gmaii.click +gmaiiil.live +gmaiil.com +gmaiil.ml +gmaiil.top +gmaiilll.cf +gmaiilll.gq +gmaik.com +gmail-b.lol +gmail-box.com +gmail-c.cc +gmail-fiji.gq +gmail.ax +gmail.bangjo.eu.org +gmail.com.bellwellcharters.com +gmail.com.bikelabel.com +gmail.com.cad.creou.dev +gmail.com.co +gmail.com.commercecrypto.com +gmail.com.contractnotify.com +gmail.com.cookadoo.com +gmail.com.creditcardforums.org +gmail.com.creou.dev +gmail.com.digitalmarketingcoursesusa.com +gmail.com.dirtypetrol.com +gmail.com.elitegunshop.com +gmail.com.emltmp.com +gmail.com.facebook.com-youtube.com.facebookmail.com.gemuk.buzz +gmail.com.filemakertechniques.com +gmail.com.firstrest.com +gmail.com.gabrielshmidt.com +gmail.com.gmail.cad.creou.dev +gmail.com.gmail.gmail.cad.creou.dev +gmail.com.hassle-me.com +gmail.com.healthyheartforall.com +gmail.com.herbalsoftware.com +gmail.com.hitechinfo.com +gmail.com.homelu.com +gmail.com.keitin.site +gmail.com.matt-salesforce.com +gmail.com.networkrank.com +gmail.com.pl +gmail.com.skvorets.com +gmail.com.standeight.com +gmail.com.thetybeetimes.net +gmail.com.tokencoach.com +gmail.com.tubidu.com +gmail.com.urbanban.com +gmail.com.whatistrust.info +gmail.comicloud.com +gmail.cu.uk +gmail.dropmail.me +gmail.emlhub.com +gmail.emlpro.com +gmail.emltmp.com +gmail.freeml.net +gmail.gob.re +gmail.gr.com +gmail.keitin.site +gmail.laste.ml +gmail.meleni.xyz +gmail.mimimail.me +gmail.net +gmail.pm +gmail.pp.ua +gmail.ru.com +gmail.spymail.one +gmail.vo.uk +gmail.xo.uk +gmail.yomail.info +gmail.yopmail.fr +gmail2.gq +gmail2.shop +gmail24s.xyz +gmail4u.eu +gmailas.com +gmailasdf.com +gmailasdf.net +gmailasdfas.com +gmailasdfas.net +gmailbete.cf +gmailbox.kr +gmailbrt.com +gmailbrt.online +gmailco.ml +gmailcomcom.com +gmailcsdnetflix.com +gmaildd.com +gmaildd.net +gmaildfklf.com +gmaildfklf.net +gmaildk.com +gmaildll.com +gmaildort.com +gmaildotcom.com +gmaildottrick.com +gmaile.design +gmailer.site +gmailer.top +gmailere.com +gmailere.net +gmaileria.com +gmailerttl.com +gmailerttl.net +gmailertyq.com +gmailfe.com +gmailgirl.net +gmailgmail.com +gmailh.com +gmailhost.net +gmailhre.com +gmailhre.net +gmailines.online +gmailines.site +gmailiz.com +gmailjj.com +gmailk.com +gmaill.com +gmailldfdefk.com +gmailldfdefk.net +gmaillk.com +gmailll.cf +gmailll.ga +gmailll.gq +gmailll.org +gmailll.tech +gmaillll.ga +gmaillll.ml +gmailllll.ga +gmaills.eu +gmailmail.emlpro.com +gmailmail.ga +gmailmarina.com +gmailnator.com +gmailner.com +gmailnew.com +gmailni.com +gmailo.net +gmailom.co +gmailos.com +gmailot.com +gmailpop.ml +gmailpopnew.com +gmailppwld.com +gmailppwld.net +gmailpro.cf +gmailpro.gq +gmailpro.ml +gmailpro.tk +gmailr.com +gmails.com +gmailsdfd.com +gmailsdfd.net +gmailsdfsd.com +gmailsdfsd.net +gmailsdfskdf.com +gmailsdfskdf.net +gmailskm.com +gmailssdf.com +gmailu.ru +gmailup.com +gmailus.top +gmailvn.com +gmailvn.net +gmailvn.xyz +gmailwe.com +gmailweerr.com +gmailweerr.net +gmaily.tk +gmailya.com +gmailzdfsdfds.com +gmailzdfsdfds.net +gmain.com +gmaini.com +gmaive.com +gmajs.net +gmakl.co +gmal.com +gmali.com +gmali.my.id +gmall.com +gmamil.co +gmaolil.com +gmariil.com +gmasil.co +gmasil.com +gmatch.org +gmaul.com +gmaxgxynss.ga +gmcd.de +gmcsklep.pl +gmdabuwp64oprljs3f.ga +gmdabuwp64oprljs3f.ml +gmdabuwp64oprljs3f.tk +gmeail.com +gmeeail.com +gmeil.com +gmeil.me +gmeli.com +gmelk.com +gmial.com +gmil.com +gmisow.com +gmixi.com +gmjgroup.com +gmjy.emlpro.com +gmkail.com +gmkil.com +gmmail.coail.com +gmmail.tech +gmmails.com +gmmaojin.com +gmmx.com +gmoal.com +gmojl.com +gmpw.yomail.info +gmr.emltmp.com +gmsail.com +gmsdfhail.com +gmsol.com +gmssail.com +gmwail.com +gmx.dns-cloud.net +gmx.dnsabr.com +gmx.fit +gmx.fr.nf +gmx.plus +gmx1mail.top +gmxail.com +gmxip8vet5glx2n9ld.cf +gmxip8vet5glx2n9ld.ga +gmxip8vet5glx2n9ld.gq +gmxip8vet5glx2n9ld.ml +gmxip8vet5glx2n9ld.tk +gmxk.net +gmxmail.cf +gmxmail.gq +gmxmail.tk +gmxmail.top +gmxmail.win +gn.spymail.one +gn8.cc +gnail.com +gnajuk.me +gnctr-calgary.com +gnes.com +gnesd.com +gnetnagiwd.xyz +gnfn.com +gng.edu.pl +gni8.com +gnia.com +gnipgykdv94fu1hol.cf +gnipgykdv94fu1hol.ga +gnipgykdv94fu1hol.gq +gnipgykdv94fu1hol.ml +gnipgykdv94fu1hol.tk +gniv.freeml.net +gnjw.laste.ml +gnlk3sxza3.net +gnmai.com +gnmail.com +gnom.com +gnon.org +gnostics.com +gnplls.info +gnseagle.com +gnsk6gdzatu8cu8hmvu.cf +gnsk6gdzatu8cu8hmvu.ga +gnsk6gdzatu8cu8hmvu.gq +gnsk6gdzatu8cu8hmvu.ml +gnsk6gdzatu8cu8hmvu.tk +gnumail.com +gnwpwkha.pl +go-blogger.ru +go-daddypromocode.com +go-vegas.ru +go.blatnet.com +go.irc.so +go.marksypark.com +go.oldoutnewin.com +go.opheliia.com +go0glelemail.com +go1.site +go2021.xyz +go2022.xyz +go28.com.hk +go288.com +go2arizona.info +go2site.info +go2usa.info +go2vpn.net +go4mail.net +goaa.me +goaaogle.site +goacc.ru +goail.com +goal2.com +goaogle.online +goasfer.com +goashmail.com +goatmail.uk +goautoline.com +gobet889.online +gobet889bola.com +gobet889skor.com +goblinhammer.com +gobo-projectors.ru +goboard.pl +gobuybox.com +goc0knoi.tk +gocampready.com +gocardless.dev +gocasin.com +gochicagoroofing.com +gocyb.org +god-from-the-machine.com +god-mail.com +godaddyrenewalcoupon.net +godagoda094.store +godataflow.xyz +godev083.site +godfare.com +godjdkedd.com +godlike.us +godmail.gq +godollar.xyz +godpeed.com +godrod.gq +godsigma.com +godsofguns.com +godut.com +godyisus.xyz +goeasyhost.net +goek.emlhub.com +goemailgo.com +goentertain.tv +goerieblog.com +goeschman.com +goessayhelp.com +goffylopa.tk +goffylosa.ga +goflipa.com +gofo.com +gofsaosa.cf +gofsaosa.ga +gofsaosa.ml +gofsaosa.tk +gofsrhr.com +gofuckporn.com +gog4dww762tc4l.cf +gog4dww762tc4l.ga +gog4dww762tc4l.gq +gog4dww762tc4l.ml +gog4dww762tc4l.tk +gogge.com +gogigogiodm.com +gogimail.com +goglemail.cf +goglemail.ga +goglemail.ml +gogofone.com +gogogays.com +gogogmail.com +gogogorils.com +gogojav.com +gogolfalberta.com +gogom.pl +gogomail.org.ua +gogooglee.com +gogovintage.it +gogovn.online +gogreeninc.ga +gogreenow.us +gohalalvietnam.com +gohappybuy.com +gohappytobuy.net +gohivezone.com +goiglemail.com +goima.com +gok.kr +gokan.cf +golc.de +gold-mania.com +gold-profits.info +gold.blatnet.com +gold.edu.pl +gold.favbat.com +gold.oldoutnewin.com +goldblockdead.site +goldclassicstylerau.info +goldduststyle.com +golden-mine.site +goldenbola.com +goldenbrow.com +goldeneggbrand.com +goldenepsilon.info +goldengo.com +goldengoosesneakers13.com +goldenguy.gq +goldenhorsestravel.com +goldenllama.us +goldenmagpies.com +goldenspark.ru +goldenswamp.com +goldenusn.com +goldfieldschool.com +goldfox.ru +goldhitbtcpoolhub.cloud +goldinbox.net +goldleaftobacconist.com +goldmail.site +goldmansports.com +goldpaws.com +goldringsstore.net +golds.xin +goldtoolbox.com +goldvote.org +goldwarez.org +golead.pro +golemico.com +golems.tk +golenia-base.pl +goleudy.org.uk +golf4blog.com +golfas.com +golfblogjapan.com +golfilla.info +golfjapanesehome.com +golfnewshome.com +golfnewsonlinejp.com +golfonblog.com +golfshop.live +golfsports.info +golidi.net +golimar.com +goliokao.cf +goliokao.ga +goliokao.gq +goliokao.ml +goliszek.net +golivejasmin.com +golld.us +gollum.fischfresser.de +gollums.blog +golmail.com +golviagens.com +golviagenxs.com +gomail.in +gomail.pgojual.com +gomail.xyz +gomail4.com +gomail5.com +gomailbox.info +gomaild.com +gomaile.com +gomailgo.click +gomails.pro +gomailstar.xyz +gomei.com +gomessage.ml +gomez-rosado.com +gomigoofficial.com +gomio.biz +gomiso.com +gonaute.com +goncangan.com +gondskumis69.me +gonduras-nedv.ru +gonetor.com +gongj5.com +gongjua.com +gonida.co.uk +gonida.com +gonida.uk +gonotebook.info +gontek.pl +gontr.team +goo-gl2012.info +gooajmaid.com +goobernetworks.com +good-autoskup.pl +good-college.ru +good-digitalcamera.info +good-electronicals.edu +good-ladies.com +good-names.info +good-teens.com +good.poisedtoshrike.com +good007.net +gooday.pw +goodbakes.com +goodbayjo.ml +goodbead.biz +goodcatstuff.site +goodcattext.site +goodchange.org.ua +goodcoffeemaker.com +gooddirbook.site +gooddirfile.site +gooddirfiles.site +gooddirstuff.site +gooddirtext.site +goode.agency +goodelivery.ru +goodemail.top +goodfellasmails.com +goodfitness.us +goodfreshbook.site +goodfreshfiles.site +goodfreshtext.site +goodfreshtexts.site +goodhealthbenefits.info +goodinternetmoney.com +goodjab.club +goodjob.pl +goodlibbooks.site +goodlibfile.site +goodlifeoutpost.com +goodlistbook.site +goodlistbooks.site +goodlistfiles.site +goodlisttext.site +goodluckforu.cn.com +goodnessofgrains.com +goodnewbooks.site +goodnewfile.site +goodplugins.com +goodqualityjerseysshop.com +goodresultsduke.com +goodreviews.tk +goods.com +goods4home.ru +goodseller.co +goodshoplili.store +goodsmart.pw +goodspotfile.site +goodspottexts.site +goodstartup.biz +goodturntable.com +goodvps.us +goodymail.men +googdad.tk +googl.win +google-email.ml +google-mail.me +google-mail.ooo +google-visit-me.com +google.emlhub.com +google.emltmp.com +google2019.ru +google2u.com +googleappmail.com +googleappsmail.com +googlebox.com +googlebox.kr +googlecn.com +googledottrick.com +googlefind.com +googlegmail.xyz +googlem.ml +googlemail.cloud +googlemail.emlhub.com +googlemail.press +googlemarket.com +googlet.com +googli.com +googmail.gdn +googole.com.pl +goohle.co.ua +goomail.club +goonby.com +gooner.cat +goood-mail.com +goood-mail.net +goood-mail.org +goooogle.flu.cc +goooogle.igg.biz +goooogle.nut.cc +goooogle.usa.cc +goooomail.com +goopianazwa.com +gooptimum.com +goosebox.net +gophermail.info +gopicta.com +gopisahi.tk +goplaygame.ru +goplaytech.com.au +gopldropbox1.tk +goplf1.cf +goplf1.ga +goplmega.tk +goplmega1.tk +gopoker303.org +goposts.site +goproaccessories.us +goprovs.com +goqoez.com +goraniii.com +goranko.ga +gorankup.com +gorclub.info +gordon.prometheusx.pl +gordon1121.club +gordoncastlehighlandgames.com +gordonsa.com +gordonsmith.com +gordpizza.ru +goreadit.site +goreng.xyz +gorges-du-verdon.info +goriliaaa.com +gorilla-zoe.net +gorillaswithdirtyarmpits.com +gorizontiznaniy.ru +gorkypark.com +gorleylalonde.com +gormezamani.com +gornostroyalt.ru +goromail.ga +gorommasala.com +goround.info +gorskie-noclegi.pl +gorzowiak.info +gosarlar.com +gosearchcity.us +goseep.com +goshisolo.ru +goshoppingpants.com +gosne.com +gospel-deals.info +gospelyqqv.com +gospiderweb.net +gostbuster.site +gostodisto.biz +gosuslugg.ru +gosuslugi-spravka.ru +goswiftfix.com +gosyslugi.host +got.poisedtoshrike.com +got.popautomated.com +gotanybook.site +gotanybooks.site +gotanyfile.site +gotanylibrary.site +gotawesomefiles.site +gotawesomelibrary.site +gotc.de +gotcertify.com +gotemv.com +gotfreebooks.site +gotfreefiles.site +gotfreshfiles.site +gotfreshtext.site +gotgel.org +gotgoodbook.site +gotgoodlib.site +gotgoodlibrary.site +gothentai.com +gothere.biz +gothicdarkness.pl +gotimes.xyz +gotkmail.com +gotmail.com +gotmail.com.mx +gotmail.net +gotmail.org +gotmail.waw.pl +gotnicebook.site +gotnicebooks.site +gotnicefile.site +gotnicelibrary.site +gotoanmobile.com +gotobag.info +gotoinbox.bid +gotopbests.com +gotovte-doma.ru +gotowkowapomoc.net +gotowkowy.eu +gotrarefile.site +gotrarefiles.site +gotrarelib.site +gotspoiler.com +gottahaveitclothingboutique.com +gottakh.com +gottechcorp.com +gotti.otherinbox.com +gouapatpoa.gq +goulink.com +goultra.de +gourmetnation.com.au +gouwu116.com +gouwu98.com +gouy.com +gov-mail.com +gov.en.com +govacom.com +govcities.com +govdep5012.com +goverloe.com +governmentcomplianceservices.com +governmenteye.us +governmentsystem.us +governo.ml +govinput.com +govnomail.xyz +gowikibooks.com +gowikicampus.com +gowikicars.com +gowikifilms.com +gowikigames.com +gowikimusic.com +gowikimusic.great-host.in +gowikinetwork.com +gowikitravel.com +gowikitv.com +gowt.mimimail.me +gox2lfyi3z9.ga +gox2lfyi3z9.gq +gox2lfyi3z9.ml +gox2lfyi3z9.tk +gp.laste.ml +gp5611.com +gp6786.com +gp7777.com +gpa.lu +gpaemail.in +gpaemail.top +gpaemail.xyz +gpcharlie.com +gpg.yomail.info +gpi8eipc5cntckx2s8.cf +gpi8eipc5cntckx2s8.ga +gpi8eipc5cntckx2s8.gq +gpi8eipc5cntckx2s8.ml +gpi8eipc5cntckx2s8.tk +gpipes.com +gplvuka4fcw9ggegje.cf +gplvuka4fcw9ggegje.ga +gplvuka4fcw9ggegje.gq +gplvuka4fcw9ggegje.ml +gplvuka4fcw9ggegje.tk +gpmvsvpj.pl +gpoczt.net.pl +gpower.com +gpromotedx.com +gps.pics +gpscellphonetracking.info +gpsmobilephonetracking.info +gpssport.com +gpstrackerandroid.com +gpstrackingreviews.net +gptonesollution.live +gptpremapp.me +gptworkone.dev +gpwdrbqak.pl +gpxn.yomail.info +gqim.xyz +gqioxnibvgxou.cf +gqioxnibvgxou.ga +gqioxnibvgxou.gq +gqioxnibvgxou.ml +gqioxnibvgxou.tk +gqlsryi.xyz +gqs.emlhub.com +gqtyojzzqhlpd5ri5s.cf +gqtyojzzqhlpd5ri5s.ga +gqtyojzzqhlpd5ri5s.gq +gqtyojzzqhlpd5ri5s.ml +gqtyojzzqhlpd5ri5s.tk +gqyvuu.buzz +gr5kfhihqa3y.cf +gr5kfhihqa3y.ga +gr5kfhihqa3y.gq +gr5kfhihqa3y.ml +gr5kfhihqa3y.tk +grabdealstoday.info +grabill.org +grabitfast.co +grabkleinandgo.com +grabmail.club +graceconsultancy.com +gracefilledblog.com +gracehaven.info +gracesimon.art +gracia.bheckintocash-here.com +graffitiresin.com +grafpro.com +gragonissx.com +grain.bthow.com +grain.ruimz.com +graj-online.pl +gramail.ga +gramail.net +gramail.org +grammasystems.com +gramy24.waw.pl +gramyonlinee.pl +grand-slots.net +grandcom.net +grandecon.net +grandeikk.com +grandmamail.com +grandmasmail.com +grandmotherpics.com +grandmovement.com +grandspecs.info +grandstrandband.com +grandtechno.com +grangmi.cf +grangmi.ga +grangmi.gq +grangmi.ml +granufloclassaction.info +granuflolawsuits.info +granuflolawyer.info +grapevinegroup.com +graphic14.catchydrift.com +graphinasdx.com +graphtech.ru +graphtiobull.gq +grassdev.com +grassfed.us +grasslandmail.com +grassrootcommunications.com +grateful.adult +grateful.coach +grateful.store +gratis-gratis.com +gratisfick.net +gratislink.net +gratislose.de +gratismail.top +gratisneuke.be +gratosmail.fr.nf +gratyer.com +graur.ru +gravityengine.cc +gray.grigio.cf +graymail.ga +great-host.in +great-names.info +great-pump.com +greatcellphonedeals.info +greatcourse.xyz +greatedhardy.com +greatemail.net +greatemailfree.com +greatersalez.com +greatestfish.com +greatfish.com +greathose.site +greatlifecbd.com +greatloanscompany.co.uk +greatloansonline.co.uk +greatmedicineman.net +greatness.cc +greatservicemail.eu +greatsmails.info +greatstuff.website +greattimes.ga +greattomeetyou.com +greatwebcontent.info +grebh.com +grecc.me +grederter.org +gree.gq +greekstatues.net +green-coffe-extra.info +green.jino.ru +greenbandhu.com +greenbaypackersjerseysshop.us +greenbaypackerssale.com +greenbotanics.co.uk +greencafe24.com +greencoepoe.cf +greencoffeebeanextractfaq.com +greencoffeebeanfaq.com +greendays.pl +greendike.com +greendivabridal.com +greenekiikoreabete.cf +greeneqzdj.com +greenestaes.com +greenforce.cf +greenforce.tk +greenfree.ru +greenhousemail.com +greeninbox.org +greenkic.com +greenlivingutopia.com +greenovatelife.com +greenpips.tech +greenplanetfruit.com +greenrocketemail.com +greenrootsgh.com +greensloth.com +greenslots2017.co +greenst.info +greensticky.info +greentech5.com +greentechsurveying.com +greenwarez.org +greenwesty.com +greggamel.com +greggamel.net +gregorheating.co.uk +gregoria1818.site +gregorsky.zone +gregoryfam.org +gregorygamel.com +gregorygamel.net +gregstown.com +grek-nedv.ru +grek1.ru +grellad.com +grenada-nedv.ru +grencex.cf +grenn24.com +grenso.com +grepekhyo65hfr.tk +gresyuip.com.uk +gretl.info +greyhoundplant.com +greyjack.com +gridmauk.com +gridmire.com +gridnewai.com +griffeyjrshoesstore.com +griffeyshoesoutletsale.com +grimjjowjager.cf +grimjjowjager.ga +grimjjowjager.gq +grimjjowjager.ml +grimjjowjager.tk +grimoiresandmore.com +grindevald.ru +grinn.in +gripam.com +grish.de +gristod.my +griuc.schule +griusa.com +grizzlyfruit.gq +grizzlyracing.com +grizzlyshows.com +grjurh43473772.ultimatefreehost.in +grl.freeml.net +grn.cc +grnermail.info +grobmail.com +grodins.ml +groil.su +groklan.com +grokleft.com +grom-muzi.ru +gromac.com +grommail.fr +gronasu.com +gronn.pl +groobox.info +groomth.com +grosfillex-furniture.com +grossiste-ambre.net +groundrecruitment.com +group-llc.cf +group-llc.ga +group-llc.gq +group-llc.ml +group-llc.tk +groupbuff.com +groupd-mail.net +groupe-psa.cf +groupe-psa.gq +groupe-psa.ml +groupe-psa.tk +groups.poisedtoshrike.com +grow-mail.com +growar.com +growingunderground.com +growlcombine.com +growseedsnow.com +growsites.us +growsocial.net +growthers.com +growtopia.store +growxlreview.com +grr.la +grruprkfj.pl +grsd.com +grss.today +gru.company +grubybenekrayskiego.pl +grubymail.com +grue.de +gruene-no-thanks.xyz +grugrug.ru +grumlylesite.com +grupatworczapik.pl +grupolove.com +grupos-telegram.com +gruposayf.com +gruppies.com +gruz-m.ru +gry-logiczne-i-liczbowe.pl +gry-na-przegladarke.pl +grycjanosmail.com +grydladziewczynek.com.pl +grylogiczneiliczbowe.pl +gryonlinew.pl +gryplaystation3-fr.pl +gryx3.anonbox.net +gs-arc.org +gs-tube-x.ru +gs.laste.ml +gs.spymail.one +gsa.yesweadvice.com +gsacaptchabreakerdiscount.com +gsaemail.com +gsaprojects.club +gsasearchengineranker.pw +gsasearchengineranker.services +gsasearchengineranker.top +gsasearchengineranker.xyz +gsasearchenginerankerdiscount.com +gsasearchenginerankersocialser.com +gsaseoemail.com +gsaverifiedlist.download +gsclawnet.com +gsdafadf.shop +gsdfg.com +gsdwertos.com +gsheetpaj.com +gsibiliaali1.xsl.pt +gsinstallations.com +gsit.laste.ml +gsitc.com +gslask.net +gsmail.com +gsmails.com +gsmmodem.org +gsmseti.ru +gsmwndcir.pl +gspam.mooo.com +gspma.com +gspousea.com +gsredcross.org +gsrf.dropmail.me +gsrv.co.uk +gss.spymail.one +gssetdh.com +gssfire.com +gssindia.com +gsto.mimimail.me +gstore96.ru +gsvdwet673246176272317121821.ezyro.com +gsx.yomail.info +gsxstring.ga +gt.emlpro.com +gt446443ads.cf +gt446443ads.ga +gt446443ads.gq +gt446443ads.ml +gt446443ads.tk +gt4px.anonbox.net +gt6fn.anonbox.net +gt7.pl +gta.com +gta4etw4twtan53.gq +gta5hx.com +gtagolfers.com +gtbanger.com +gtcmnt.pl +gterebaled.com +gtgstoreid.com +gthpprhtql.pl +gthw.com +gtidf.anonbox.net +gtime.com +gtkesh.com +gtmail.com +gtmail.net +gtmail.site +gtpindia.com +gtq59.xyz +gtr8uju877821782712.unaux.com +gtrcinmdgzhzei.cf +gtrcinmdgzhzei.ga +gtrcinmdgzhzei.gq +gtrcinmdgzhzei.ml +gtrcinmdgzhzei.tk +gtrrrn.com +gtthnp.com +gtwaddress.com +gty.com +gty.spymail.one +gtymj2pd5yazcbffg.cf +gtymj2pd5yazcbffg.ga +gtymj2pd5yazcbffg.gq +gtymj2pd5yazcbffg.ml +gtymj2pd5yazcbffg.tk +gu.emlpro.com +gu3x7o717ca5wg3ili.cf +gu3x7o717ca5wg3ili.ga +gu3x7o717ca5wg3ili.gq +gu3x7o717ca5wg3ili.ml +gu3x7o717ca5wg3ili.tk +gu4wecv3.bij.pl +gu5t.com +gu788.com +guadalupe-parish.org +guag.com +guaierzi.icu +guail.com +guanshuyun.com +guarchibao-fest.ru +gubkiss.com +gucc1-magasin.com +gucci-ebagoutlet.com +gucci-eoutlet.net +guccibagshere.com +guccibagsuksale.info +gucciborseitalyoutletbags.com +guccicheapjp.com +guccihandbagjp.com +guccihandbags-australia.info +guccihandbags-onsale.us +guccihandbags-shop.info +guccihandbagsonsale.info +guccihandbagsonsaleoo.com +gucciinstockshop.com +gucciocchiali.net +gucciofficialwebsitebags.com +gucciofficialwebsitebags.com.com +guccionsalejp.com +guccioutlet-online.info +guccioutlet-onlinestore.info +guccioutlet-store.info +guccioutletmallsjp.com +guccioutletonline.info +guccioutletonlinestores.info +guccisacochepaschere.com +guccishoessale.com +guccitripwell.com +gudanglowongan.com +gudodaj-sie.pl +gudri.com +guehomo.top +guemail.com +guerillamail.biz +guerillamail.com +guerillamail.de +guerillamail.info +guerillamail.net +guerillamail.org +guerillamailblock.com +guernseynaturereserve.com +guerrillamail.biz +guerrillamail.com +guerrillamail.de +guerrillamail.info +guerrillamail.net +guerrillamail.org +guerrillamailblock.com +guess.bthow.com +guesschaussurespascher.com +guesstimatr.com +guesthousenation.com +gueto2009.com +gufum.com +gug.la +guge.de +guglator.com +gugoumail.com +gugulelelel.com +guhtr.org +guiasg.com +guide2host.net +guide3.net +guideborn.site +guideheroes.com +guidejpshop.com +guidelia.site +guidelics.site +guideline2.com +guideliot.site +guidemails.gdn +guidered.fun +guidet.site +guidevalley.com +guidezzz12.com +guidx.site +guidz.site +guild.blatnet.com +guild.cowsnbullz.com +guild.lakemneadows.com +guild.maildin.com +guild.poisedtoshrike.com +guildwars-2-gold.co.uk +guildwars-2-gold.de +guillermojakamarcus.tech +guilloryfamily.us +guineavgzo.space +guinsus.site +guitarjustforyou.com +guitarsxltd.com +gujckksusww.com +gujika.org +gukox.org +guksle.website +gulasurakhman.net +gulfbreezeradio.com +gulfofmexico.com +gulftechology.com +gulfwalkin.site +gull-minnow.top +gultalantemur.cfd +gumaygo.com +gumglue.app +gummymail.info +gunalizy.mazury.pl +gunayseydaoglu.cfd +guncelhesap.com +gundogdumobilya.cyou +gunesperde.shop +gungrate.email +gungratemail.com +gungratemail.ga +gunlukhavadurumu.net +guntherfamily.com +guqoo.com +gurpz.com +gurubooks.ru +gurulegal.ru +gurumail.xyz +gurur.store +gus.yomail.info +gusronk.com +gustavocata.org +gustidharya.com +gustore.co +gustpay.com +gustr.com +gustyjatiprakoso.co +gutechinternational.com +gutierrezmail.bid +gutmensch.foundation +gutmenschen.company +gutmorgen.moscow +gutterguard.xyz +guu.emlhub.com +guuph.com +guus02.guccisacsite.com +guvewfmn7j1dmp.cf +guvewfmn7j1dmp.ga +guvewfmn7j1dmp.gq +guvewfmn7j1dmp.ml +guvewfmn7j1dmp.tk +guybox.info +guysmail.com +guystelchim.tk +guzinduygukuka.cfd +guzqrwroil.pl +gv.laste.ml +gvatemala-nedv.ru +gvdk.com +gviagrasales.com +gvj.yomail.info +gvnuclear.com +gvpersons.com +gvpn.com +gvpn.us +gvrq.emltmp.com +gvsfn.anonbox.net +gvw.emlpro.com +gvztim.gq +gw.spymail.one +gwahtb.pl +gwehuj.com +gwenbd94.com +gwfh.cf +gwfh.ga +gwfh.gq +gwfh.ml +gwfh.tk +gwhoffman.com +gwindorseobacklink.com +gwllw.info +gwok.info +gws.emlpro.com +gwsdev4.info +gwsmail.com +gwspt71.com +gwtc.com +gwzjoaquinito01.cf +gx2k24xs49672.cf +gx2k24xs49672.ga +gx2k24xs49672.gq +gx2k24xs49672.ml +gx2k24xs49672.tk +gx7v4s7oa5e.cf +gx7v4s7oa5e.ga +gx7v4s7oa5e.gq +gx7v4s7oa5e.ml +gx7v4s7oa5e.tk +gxbarbara.com +gxbnaloxcn.ga +gxbnaloxcn.ml +gxbnaloxcn.tk +gxcpaydayloans.org +gxemail.men +gxg.laste.ml +gxg.yomail.info +gxg07.com +gxglixaxlzc9lqfp.cf +gxglixaxlzc9lqfp.ga +gxglixaxlzc9lqfp.gq +gxglixaxlzc9lqfp.ml +gxglixaxlzc9lqfp.tk +gxgxg.xyz +gxhy1ywutbst.cf +gxhy1ywutbst.ga +gxhy1ywutbst.gq +gxhy1ywutbst.ml +gxhy1ywutbst.tk +gxmail.ga +gxmail.top +gxrh.spymail.one +gxta.laste.ml +gxuzi.com +gxxx.com +gyagwgwgwgsusiej70029292228.cloudns.cl +gyahh.anonbox.net +gyan-netra.com +gycz.laste.ml +gyg.emlpro.com +gyhunter.org +gyigfoisnp560.ml +gyikgmm.pl +gyknife.com +gym1.online +gymlesstrainingsystem.com +gyn5.com +gynn.org +gynzi.co.uk +gynzi.com +gynzi.es +gynzi.nl +gynzi.org +gynzy.at +gynzy.es +gynzy.eu +gynzy.gr +gynzy.info +gynzy.lt +gynzy.mobi +gynzy.pl +gynzy.ro +gynzy.ru +gynzy.sk +gypc.yomail.info +gypsd.com +gyqa.com +gyrosmalta.com +gyrosramzes.pl +gyuio.com +gyul.ru +gyxmz.com +gz168.net +gzb.ro +gzc868.com +gzek.emlpro.com +gzesiek84bb.pl +gzk.mimimail.me +gzk2sjhj9.pl +gzley.site +gzo.laste.ml +gzq.emlhub.com +gzr.spymail.one +gztdx5.spymail.one +gztts.anonbox.net +gzuu.spymail.one +gzvmwiqwycv8topg6zx.cf +gzvmwiqwycv8topg6zx.ga +gzvmwiqwycv8topg6zx.gq +gzvmwiqwycv8topg6zx.ml +gzvmwiqwycv8topg6zx.tk +gzwivmwvrh.ga +gzxb120.com +gzxingbian.com +gzyp21.net +h-b-p.com +h-h.me +h.captchaeu.info +h.mintemail.com +h.polosburberry.com +h.thc.lv +h0116.top +h0i.ru +h0tmaii.com +h0tmail.top +h0tmal.com +h1h1sv.laste.ml +h1hecsjvlh1m0ajq7qm.cf +h1hecsjvlh1m0ajq7qm.ga +h1hecsjvlh1m0ajq7qm.gq +h1hecsjvlh1m0ajq7qm.ml +h1hecsjvlh1m0ajq7qm.tk +h1tler.cf +h1tler.ga +h1tler.gq +h1tler.ml +h1tler.tk +h1z8ckvz.com +h2-yy.nut.cc +h2.delivery +h2.supplies +h20solucaca.com +h2beta.com +h2o-gallery.ru +h2o-web.cf +h2o-web.ga +h2o-web.gq +h2o-web.ml +h2o-web.tk +h2ocn8f78h0d0p.cf +h2ocn8f78h0d0p.ga +h2ocn8f78h0d0p.gq +h2ocn8f78h0d0p.ml +h2ocn8f78h0d0p.tk +h2wefrnqrststqtip.cf +h2wefrnqrststqtip.ga +h2wefrnqrststqtip.gq +h2wefrnqrststqtip.ml +h2wefrnqrststqtip.tk +h2wkg.anonbox.net +h333.cf +h333.ga +h333.gq +h333.ml +h333.tk +h3gm.com +h3ssk4p86gh4r4.cf +h3ssk4p86gh4r4.ga +h3ssk4p86gh4r4.gq +h3ssk4p86gh4r4.ml +h3ssk4p86gh4r4.tk +h428.cf +h467etrsf.cf +h467etrsf.gq +h467etrsf.ml +h467etrsf.tk +h4tzk.anonbox.net +h546ns6jaii.cf +h546ns6jaii.ga +h546ns6jaii.gq +h546ns6jaii.ml +h546ns6jaii.tk +h5bcs.anonbox.net +h5dslznisdric3dle0.cf +h5dslznisdric3dle0.ga +h5dslznisdric3dle0.gq +h5dslznisdric3dle0.ml +h5dslznisdric3dle0.tk +h5jiin8z.pl +h5srocpjtrfovj.cf +h5srocpjtrfovj.ga +h5srocpjtrfovj.gq +h5srocpjtrfovj.ml +h5srocpjtrfovj.tk +h65syz4lqztfrg1.cf +h65syz4lqztfrg1.ga +h65syz4lqztfrg1.gq +h65syz4lqztfrg1.ml +h65syz4lqztfrg1.tk +h6657052.ga +h6gyq.anonbox.net +h7vpvodrtkfifq35z.cf +h7vpvodrtkfifq35z.ga +h7vpvodrtkfifq35z.gq +h7vpvodrtkfifq35z.ml +h7vpvodrtkfifq35z.tk +h7xbkl9glkh.cf +h7xbkl9glkh.ga +h7xbkl9glkh.gq +h7xbkl9glkh.ml +h7xbkl9glkh.tk +h8s.org +h8usp9cxtftf.cf +h8usp9cxtftf.ga +h8usp9cxtftf.gq +h8usp9cxtftf.ml +h8usp9cxtftf.tk +h9js8y6.com +ha92.store +haagsekillerclan.tk +haajawafqo.ga +haaland.click +haanhwedding.com +haanhwedding.vn +hab-verschlafen.de +habanerogh.com +habboftpcheat.com +habbuntt.com +haben-wir.com +habenwir.com +haberci.com +habibola22.com +habibulfauzan.my.id +habitue.net +habmalnefrage.de +haboty.com +habrew.de +hacccc.com +hachi.host +haciendaalcaravan.com +hack-seo.com +hackcheat.co +hackdo.pl +hacked.jp +hacker.com.se +hacker9.org +hackerious.com +hackerndgiveaway.ml +hackersquad.tk +hackertales.com +hackertrap.info +hackerzone.ro +hacking.onl +hackrz.xyz +hacksleague.ru +hackthatbit.ch +hacktivist.tech +hacktoy.com +hackva.com +hackwifi.org +hackzone.club +hactzayvgqfhpd.cf +hactzayvgqfhpd.ga +hactzayvgqfhpd.gq +hactzayvgqfhpd.ml +hactzayvgqfhpd.tk +had.twoja-pozycja.biz +hadal.net +haddenelectrical.com +haddo.eu +hadeh.xyz +hadigel.net +hadmins.com +hadvar.com +haeac.com +haechot.com +hafan.sk +hafin2.pl +hafnia.biz +hafrem3456ails.com +hafutv.com +hafzo.net +hagendes.com +hagglebeddle.com +hagha.com +hagiasophiagroup.com +hagiasophiaonline.com +hahaha.vn +hahahahah.com +hahahahaha.com +hahalla.com +hahawrong.com +haiapoteker.com +haibabon.com +haicao45.com +haicaotv2.com +haida-edu.cn +haifashaikh.com +haihan.vn +haihantnc.xyz +haihn.net +haikc.online +haikido.com +hainals.com +haiok.cf +haiqwmail.top +hair-shoponline.info +hair-stylestrends.com +hair286.ga +hairagainreviews.org +haircaresalonstips.info +hairgrowth.cf +hairgrowth.ml +hairjournal.com +hairlossmedicinecenter.com +hairlossshop.info +hairoo.com +hairremovalplus.org +hairrenvennen.com +hairs24.ru +hairsideas.ru +hairstraighteneraustralia.info +hairstraightenercheapaustralia.info +hairstraightenernv.com +hairstrule.online +hairstrule.site +hairstrule.store +hairstrule.website +hairstrule.xyz +hairstyles360.com +hairstylesbase.com +hairwizard.in +haiserat.network +haislot.com +haitaous.com +haitibox.com +haiticadeau.com +haitinn5213.com +haitmail.ga +haizail.com +haizz.com +hajckiey2.pl +hak-pol.pl +hakinsiyatifi.org +halaalsearch.com +halamanenuy.net +halbov.com +hale-namiotowe.net.pl +halidepo.com +halil.ml +halkasor.com +hallmarkinsights.com +hallo.singles +halofarmasi.com +halosauridae.ml +haltitutions.xyz +haltospam.com +halumail.com +halvfet.com +hamada2000.site +hamadr.ml +hamakdupajasia.com +hamburguesas.net +hamedahmed.cloud +hamedak.cloud +hamham.uk +hamiliton.xyz +hamkodesign.com +hammadali.com +hammerdin.com +hammerimports.com +hammerwin.com +hammlet.com +hammody.shop +hammogram.com +hamoodassaf99.shop +hamsing.com +hamsterbreeeding.com +hamstercage.online +hamstun.com +hamtwer.biz +hamusoku.cf +hamusoku.ga +hamusoku.gq +hamusoku.ml +hamusoku.tk +hamzayousfi.tk +han.emltmp.com +hanasa.xyz +hanaspa.xyz +hancack.com +handans.ru +handbagscanadastores.com +handbagscharming.info +handbagsfox.com +handbagslovers.info +handbagsluis.net +handbagsonlinebuy.com +handbagsoutlet-trends.info +handbagsshowing.com +handbagsshowingk.com +handbagsstoreslove.com +handbagstips2012.info +handbagwee.com +handbega.xyz +handcase.us +handcharities.life +handcharities.live +handcrafted.market +handcrafters.shop +handelarchitectsr.com +handelo.com.pl +handimedia.com +handleride.com +handmadeki.com +handprep.vision +handrfabrics.com +handrik.com +handwashgel.online +handyall.com +handyerrands.com +hangar18.org +hanging-drop-plates.com +hangover-over.tk +hangsuka.com +hangtaitu.com +hangxomcuatoilatotoro.cf +hangxomcuatoilatotoro.ga +hangxomcuatoilatotoro.gq +hangxomcuatoilatotoro.ml +hangxomcuatoilatotoro.tk +hangxomu.com +haniuil.com +haniv.ignorelist.com +hanjinlogistics.com +hanmama.zz.am +hannermachine.com +hanoimail.us +hanovermarinetime.com +hanqike.com +hans.mailedu.de +hansblbno.ustka.pl +hansenhu.com +hansgu.com +hansheng.org +hanson4.dynamicdns.me.uk +hanson7.dns-dns.com +hansonbrick.com +hansongu.com +hansonmu.com +hantem.bid +hanul.com +hanzganteng.tk +haodage.cc +haodewang.com +haofangsi.com +haogltoqdifqq.cf +haogltoqdifqq.ga +haogltoqdifqq.gq +haogltoqdifqq.ml +haogltoqdifqq.tk +haom7.com +haomei456.com +haoniubia.cc +haoren.lol +haosuhong.com +haotuwu.com +haoyunlaiba.cc +hapied.com +hapincy.com +happenhotel.com +happiseektest.com +happy-new-year.top +happy.maildin.com +happy.ploooop.com +happy.poisedtoshrike.com +happy2023year.com +happy9toy.com +happyalmostfriday.com +happybirthdaywishes1.info +happycat.space +happychance15.icu +happydomik.ru +happyedhardy.com +happyfreshdrink.com +happyfriday.site +happygolovely.xyz +happygoluckyclub.com +happyhealthyveggie.com +happykorea.club +happykoreas.xyz +happymail.guru +happymoments.com.pl +happynewswave.com +happypandastore.com +happyselling.com +happysinner.co.uk +happytools72.ru +happyum.com +happyyou.pw +hapremx.com +hapsomail.info +haqed.com +haqoci.com +harakirimail.com +haramod.com +haramshop.ir +harbourlights.com +harbourtradecredit.com +harcity.com +hard-life.online +hard-life.org +hardanswer.ru +hardassetalliance.com +hardenend.com +hardingpost.com +hardmail.info +hardnews.us +hardstylex.com +hardvard.edu +hardwaretech.info +hardwoodflooringinla.com +hareketliler.network +haren.uk +haresdsy.yachts +harfordpi.com +hargaanekabusa.com +hargaku.org +hargaspek.com +hargrovetv.com +haribu.com +haribu.net +harinv.com +harkincap.com +harleymoj.pl +harlingenapartments.com +harlowfashion.shop +harlowgirls.org +harmani.info +harmfulsarianti.co +harmonicanavigation.com +harmony.com +harmony.watch +harmonyst.xyz +harnosubs.tk +haroun.ga +harperforarizona.com +harperlarper.com +harpix.info +harrinbox.info +harsh1.club +harshh.cf +harshitshrivastav.me +hartaria.com +hartbot.de +hartlight.com +hartogbaer.com +haru40.funnetwork.xyz +haru66.pine-and-onyx.xyz +haruki30.hensailor.xyz +haruto.fun +harvard-ac-uk.tk +harvard.ac.uk +harvard.gq +harvesteco.com +harvesttmaster.com +harvesttraders.com +hasanmail.ml +hasark.site +hasegawa.cf +hasegawa.gq +hasehow.com +hasevo.com +hash.blatnet.com +hash.marksypark.com +hash.oldoutnewin.com +hash.ploooop.com +hash.poisedtoshrike.com +hash.pp.ua +hashg.com +hashicorp.exposed +hashicorp.ltd +hashicorp.us +hashratetest.com +hashtagblock.com +hashtagbyte.com +hashtagtesla.com +hasilon.com +hasslex.com +hassmiry.online +hastork.com +hastourandtravelss.shop +hastynurintan.io +hat-geld.de +hat-muzika.ru +hatanet.network +hatberkshire.com +hate.cf +hatespam.org +hatgiongphuongnam.info +hatitton.com.pl +hatiyangpatah.online +hatmail.com +hatmail.ir +hatomail.com +hats-wholesaler.com +hats4sale.net +haulte.com +hauptmanfamilyhealthcenter.com +hausbauen.me +hauvuong.com.vn +hauvuong.net +hauzgo.com +havadarejavan.ir +have.blatnet.com +have.inblazingluck.com +have.lakemneadows.com +have.marksypark.com +haveanotion.com +havelock4.pl +havelock5.pl +havelock6.pl +haventop.tk +havery.com +haveys.com +havilahdefilippis.com +havre.com +havwatch.com +havyrtda.com +havyrtdashop.com +haw.spymail.one +hawaiitank.com +hawdam.com +hawkspare.co.uk +hawrong.com +hawthornepaydayloans.info +hax0r.id +hax55.com +haxmail.co +hayait.com +hayalhost.com +hayastana.com +hayatdesign.com +haycoudo.gq +haydariabi.shop +haydoo.com +haydplamz.shop +haylo.network +haymondlaw.info +haynes.ddns.net +hayriafaturrahman.art +hays.ml +haysantiago.com +hazelhazel.com +hazelnut4u.com +hazelnuts4u.com +hazhab.com +hazmatshipping.org +hazytune.com +hb-3tvm.com +hbastien.com +hbccreditcard.net +hbcl.dropmail.me +hbdlawyers.com +hbdya.info +hbehs.com +hbesjhbsd.cf +hbesjhbsd.ga +hbesjhbsd.ml +hbesjhbsd.tk +hbjnhvgc.com +hbjnjaqgzv.ga +hbkio.com +hbkm.de +hbo.dns-cloud.net +hbo.dnsabr.com +hbo.laste.ml +hbocom.ru +hbontqv90dsmvko9ss.cf +hbontqv90dsmvko9ss.ga +hbontqv90dsmvko9ss.gq +hbontqv90dsmvko9ss.ml +hbontqv90dsmvko9ss.tk +hbs-group.ru +hbsc.de +hbsc.emlpro.com +hbviralbv.com +hbxcgd.website +hbxjm.anonbox.net +hbxrlg4sae.cf +hbxrlg4sae.ga +hbxrlg4sae.gq +hbxrlg4sae.ml +hbxrlg4sae.tk +hc.spymail.one +hc1118.com +hcac.net +hcaptcha.info +hcaptcha.online +hcaptcha.site +hcarter.net +hccg.laste.ml +hccmail.win +hceap.info +hcfmgsrp.com +hclonghorns.net +hclrizav2a.cf +hclrizav2a.ga +hclrizav2a.gq +hclrizav2a.ml +hclrizav2a.tk +hcoupledp.com +hcuglasgow.com +hcyughc.ml +hczx8888.com +hd-boot.info +hd-camera-rentals.com +hd-mail.com +hd3vmbtcputteig.cf +hd3vmbtcputteig.ga +hd3vmbtcputteig.gq +hd3vmbtcputteig.ml +hd3vmbtcputteig.tk +hd720-1080.ru +hdala.com +hdapps.com +hdbaset.pl +hdcroom.us +hdctjaso.pl +hdczu7uhu0gbx.cf +hdczu7uhu0gbx.ga +hdczu7uhu0gbx.gq +hdczu7uhu0gbx.ml +hdczu7uhu0gbx.tk +hddang.com +hddd54.shop +hddp.com +hddvdguide.info +hdetsun.com +hdev-storee.ml +hdexch.com +hdf6ibwmredx.cf +hdf6ibwmredx.ga +hdf6ibwmredx.gq +hdf6ibwmredx.ml +hdf6ibwmredx.tk +hdfgh45gfjdgf.tk +hdfshop.ir +hdfshsh.stream +hdhkmbu.ga +hdhkmbu.ml +hdhr.com +hdkinoclubcom.ru +hdlords.online +hdmail.com +hdmovie.info +hdmovieshouse.biz +hdmoviestore.us +hdmu.com +hdmup.com +hdo.net +hdorg.ru +hdorg1.ru +hdorg2.ru +hdparts.de +hdprice.co +hdqputlockers.com +hdrandall.com +hdrecording-al.info +hdrin.com +hdrlog.com +hdseriionline.ru +hdservice.net +hdspot.de +hdstream247.com +hdtniudn.com +hdtor.com +hdturkey.com +hdtvsounds.com +hdvideo-smotry.ru +hdz.hr +he.blatnet.com +he.emlhub.com +he.laste.ml +he.oldoutnewin.com +he.yomail.info +he2duk.net +he8801.com +headachetreatment.net +headincloud.com +headpack.org.ua +headphones.vip +headset5pl.com +headsetwholesalestores.info +headstrong.de +healbutty.info +healsy.life +healteas.com +health.edu +healthandbeautyimage.com +healthandfitnessnewsletter.info +healthandrehabsolutions.com +healthbeautynatural.site +healthbreezy.com +healthcare-con.com +healthcareworld.life +healthcareworld.live +healthcheckmate.co.nz +healthcoachpractitioner.com +healthcorp.edu +healthcureview.com +healthdelivery.info +healthfit247.com +healthforwomen.info +healthfulan.com +healthinsuranceforindividual.co.uk +healthinsurancespecialtis.org +healthinsurancestats.com +healthinventures.com +healthlifes.ru +healthmale.com +healthmeals.com +healthnewsapps.com +healthnewsfortoday.com +healthnutexpress.com +healthoriaplus.com +healthpull.com +healthsoulger.com +healthtutorials.info +healthydietplan.stream +healthyliving.tk +healthysnackfood.info +healthywelk.com +healxo.org +healyourself.xyz +hearing-protection.info +hearingaiddoctor.net +hearkn.com +hearourvoicetee.com +heart1.ga +heartbridge.lat +heartburnnomorereview.info +hearthandhomechimneys.co.uk +hearthealthy.co.uk +heartiysaa.com +heartlandexteriors.net +heartlink.lat +heartrate.com +heartratemonitorstoday.com +heartter.tk +hearttoheart.edu +heat-scape.co.uk +heathenhammer.com +heathenhero.com +heathenhq.com +heathercapture.co.uk +heatherviccaro.net +heatingcoldinc.info +heavenlyyunida.biz +heavents.fun +heavycloth.com +hebbousha.online +hebeer.com +hebgsw.com +hebohdomino88.com +hebohpkv88.net +hecat.es +hedcet.com +hedevpoc.pro +hedf.dropmail.me +hedgefundnews.info +hedgehog.us +hedotu.com +heduu.com +hedvdeh.com +hedy.gq +heeco.me +heedongs32.com +heeneman.group +heepclla.com +heeyai.ml +heframe.com +hefrent.tk +hegamespotr.com +hegeblacker.com +hegemonstructed.xyz +hegfqn.us +heheee.com +hehesou.com +hehmail.pl +hehrseeoi.com +heihamail.com +heincpa.com +heinz-reitbauer.art +heisawofa.com +heisei.be +hekarro.com +hel3aney.website +helamakbeszesc.com +hele.win +helenchongtherapy.com +helengeli-maldives.com +helenk.site +helesco.com +heli-ski.su +helia.it +heliagyu.xyz +hell.plus +hello-volgograd.ru +hello.nl +hello123.com +hellobuurman.com +hellocab.site +hellocheese.online +hellodream.mobi +hellofres.com +hellohappy2.com +hellohitech.com +hellohuman.dev +helloiamjahid.cf +hellokittyjewelrystore.com +hellokity.com +hellolive.xyz +hellomaftown.com +hellomagazined.com +hellomail.fun +hellomailo.net +hellomotos.tk +helloricky.com +hellow-man.pw +hellowman.pw +hellowperson.pw +hellsmoney.com +helm.ml +helmade.xyz +helmaliaputri.art +helotrix.com +help-medical.info +help.favbat.com +help33.cu.cc +help4entrepreneurs.co.uk +helpcryptocurrency.com +helpcustomerdepartment.ga +helpdesks-support.com +helperv.com +helperv.net +helpforstudents.ru +helpinghandtaxcenter.org +helpingpeoplegrow.club +helpingpeoplegrow.life +helpingpeoplegrow.live +helpingpeoplegrow.online +helpingpeoplegrow.shop +helpingpeoplegrow.today +helpingpeoplegrow.world +helpjobs.ru +helpmail.cf +helpman.ga +helpman.ml +helpman.tk +helpmebuysomething.com +helpmedigit.com +helpservices.services +helpwesearch.com +helrey.cf +helrey.ga +helrey.gq +helrey.ml +helthcare.store +helyraw.wiki +hemetapartments.com +heminor.xyz +hemohim-atomy.ru +hemorrhoidmiraclereviews.info +hemotoploloboy.com +hempgroups.com +hempseed.pl +hempyl.com +hen.emlpro.com +henamail.com +henceut.com +hendra.life +hendrikarifqiariza.cf +hendrikarifqiariza.ga +hendrikarifqiariza.gq +hendrikarifqiariza.ml +hendrikarifqiariza.tk +hengshinv.com +hengshuhan.com +hengyutrade2000.com +henolclock.in +henrikoffice.us +henry-mail.ml +henrydady1122.cc +hepledsc.com +hepsicrack.cf +her.cowsnbullz.com +her.net +herb-e.net +herbalanda.com +herbalsumbersehat.com +herbaworks2u.com +herbert1818.site +herbertgrnemeyer.in +hercn.com +herdtrak.com +herediumabogados.net +herediumabogados.org +heresh.info +herestoreonsale.org +hergrteyye8877.cf +hergrteyye8877.ga +hergrteyye8877.gq +hergrteyye8877.ml +hergrteyye8877.tk +heritagepoint.org +hermes-uk.info +hermesbirkin-outlet.info +hermesbirkin0.com +hermeshandbags-hq.com +hermesonlinejp.com +hermessalebagjp.com +hermestashenshop.org +hermeswebsite.com +hermitcraft.cf +hero.bthow.com +herocopters.com +heroine-cruhser.cf +heros3.com +herostartup.com +heroulo.com +herp.in +herpderp.nl +herpes9.com +herr-der-mails.de +herrain.com +herriring.ga +heryogasecretsexposed.com +hesoyam.cloud +hesoyam.shop +hesoyam.space +hessrohmercpa.com +hestermail.men +hethox.com +hetkanmijnietschelen.space +hetzez.com +hevury.xyz +heweatr.com +heweek.com +hewke.xyz +hex2.com +hexagonhost.com +hexagonmail.com +hexapi.ga +hexcl.email +hexi.pics +heximail.com +hexmail.tech +hexqr84x7ppietd.cf +hexqr84x7ppietd.ga +hexqr84x7ppietd.gq +hexqr84x7ppietd.ml +hexqr84x7ppietd.tk +hexud.com +hexv.com +heyjuegos.com +heyowaf.my.id +heytt.anonbox.net +heyveg.com +heyzane.wtf +hezarpay.com +hezemail.ga +hezll.com +hf-chh.com +hf.dropmail.me +hf.emlhub.com +hfbd.com +hfcee.com +hfcsd.com +hfdh7y458ohgsdf.tk +hfejue.buzz +hff.emlhub.com +hflk.us +hfmf.cf +hfmf.ga +hfmf.gq +hfmf.ml +hfmf.tk +hfpd.net +hfq.spymail.one +hg.freeml.net +hg6nx.anonbox.net +hg8n415.com +hg98667.com +hgarmt.com +hgfdshjug.tk +hgfh.de +hgggypz.pl +hgghjgh.laste.ml +hgh.net +hghenergizersale.com +hgid.com +hgiiu.xyz +hgjhg.tech +hgq.laste.ml +hgrmnh.cf +hgrmnh.ga +hgrmnh.gq +hgrmnh.ml +hgsygsgdtre57kl.tk +hgtabeq4i.pl +hgtech.dev +hgtt674s.pl +hgty.emltmp.com +hh.laste.ml +hh7f.com +hhcqldn00euyfpqugpn.cf +hhcqldn00euyfpqugpn.ga +hhcqldn00euyfpqugpn.gq +hhcqldn00euyfpqugpn.ml +hhcqldn00euyfpqugpn.tk +hhdp.laste.ml +hhe.org.uk +hhh.sytes.net +hhhhb.com +hhhnhned.store +hhjqahmf3.pl +hhjqnces.com.pl +hhkai.com +hhl.dropmail.me +hhmel.com +hhopy.com +hhotmail.click +hhotmail.de +hhoz.freeml.net +hhpj.emltmp.com +hhshhgh.cloud +hhtairas.club +hhvf.emltmp.com +hhyrnvpbmbw.atm.pl +hhyru.us +hi-litedentallab.com +hi-techengineers.com +hi.spymail.one +hi07zggwdwdhnzugz.cf +hi07zggwdwdhnzugz.ga +hi07zggwdwdhnzugz.gq +hi07zggwdwdhnzugz.ml +hi07zggwdwdhnzugz.tk +hi1dcthgby5.cf +hi1dcthgby5.ga +hi1dcthgby5.gq +hi1dcthgby5.ml +hi1dcthgby5.tk +hi2.in +hi5.si +hi6547mue.com +hiatrante.ml +hichristianlouboutinukdiscount.co.uk +hichristianlouboutinuksale.co.uk +hickorytreefarm.com +hidayahcentre.com +hiddencorner.xyz +hiddencovepark.com +hiddentombstone.info +hiddentragedy.com +hide-mail.net +hide.biz.st +hidebox.org +hidebro.com +hidebusiness.xyz +hideemail.net +hidefrom.us +hidekiishikawa.art +hidelux.com +hidemail.de +hidemail.pro +hidemail.us +hideme.be +hidemyass.com +hidemyass.fun +hidesmail.net +hideweb.xyz +hidezzdnc.com +hidheadlightconversion.com +hidjuhxanx9ga6afdia.cf +hidjuhxanx9ga6afdia.ga +hidjuhxanx9ga6afdia.gq +hidjuhxanx9ga6afdia.ml +hidjuhxanx9ga6afdia.tk +hidmail.org +hidzz.com +hiemail.net +hiepth.com +hieu.in +hieu0971927498.com +hieuclone.com +hieuclone.net +hieuvia.top +hif.freeml.net +high-tech.su +high.emailies.com +high.lakemneadows.com +high.ruimz.com +highbros.org +highdosage.org +higheducation.ru +highenddesign.site +highground.store +highheelcl.com +highiqsearch.info +highlevel.store +highlevelcoder.cf +highlevelcoder.ga +highlevelcoder.gq +highlevelcoder.ml +highlevelcoder.tk +highlevelgamer.cf +highlevelgamer.ga +highlevelgamer.gq +highlevelgamer.ml +highlevelgamer.tk +highmail.my.id +highme.store +highonline.store +highpointspineandjoint.com +highpressurewashers.site +highprice.store +highsite.store +highspace.store +highspeedt.club +highspeedt.online +highspeedt.site +highspeedt.xyz +highstar.shop +highstatusleader.com +highstudios.net +hight.fun +hightechmailer.com +hightechnology.info +hightri.net +highwayeqe.com +highweb.store +highwolf.com +higiena-pracy.pl +higogoya.com +hihi.lol +hiholerd.ru +hii5pdqcebe.cf +hii5pdqcebe.ga +hii5pdqcebe.gq +hii5pdqcebe.ml +hii5pdqcebe.tk +hiirimatot.com +hijj.com +hikaru.host +hikaru60.investmentweb.xyz +hikaru85.hotube.site +hikingshoejp.com +hikoiuje23.com +hikuhu.com +hikuku.com +hilandtoyota.net +hilarylondon.com +hildredcomputers.com +hiliteplastics.com +hillary-email.com +hillmail.men +hillpturser.gq +hilltoptreefarms.com +hilmipremindo.com +hiltonbettv21.com +hiltonvr.com +him.blatnet.com +him.lakemneadows.com +him.marksypark.com +him.oldoutnewin.com +him6.com +himail.infos.st +himail.monster +himail.online +himkinet.ru +himky.com +himono.site +himovies.website +himtee.com +hinata.ml +hincisy.tk +hindam.net +hinokio-movie.com +hinolist.com +hiod.tk +hiowaht.com +hiperbet.org +hipermail.co.pl +hiphopmoviez.com +hippobox.info +hiq.yomail.info +hiqd.emlhub.com +hiraku20.investmentweb.xyz +hirdwara.com +hire-odoo-developer.com +hirekuq.tk +hiremystyle.com +hirenet.net +hirikajagani.com +hirschsaeure.info +hiru-dea.com +his.blatnet.com +his.blurelizer.com +his.oldoutnewin.com +hisalotk.cf +hisalotk.ga +hisalotk.gq +hisalotk.ml +hishamm12.shop +hishescape.space +hishyau.cf +hishyau.ga +hishyau.gq +hishyau.ml +hisila.com +hisotyr.com +hisrher.com +hissfuse.com +histhisc.shop +historicstalphonsus.org +historictheology.com +historyship.ru +hisukamie.com +hit.cowsnbullz.com +hit.oldoutnewin.com +hit.ploooop.com +hitachi-koki.in +hitachirail.cf +hitachirail.ga +hitachirail.gq +hitachirail.ml +hitachirail.tk +hitbase.net +hitbtcpool.cloud +hitbts.com +hitechnew.ru +hitler-adolf.cf +hitler-adolf.ga +hitler-adolf.gq +hitler-adolf.ml +hitler-adolf.tk +hitler.rocks +hitlerbehna.com +hitmaan.cf +hitmaan.ga +hitmaan.gq +hitmaan.ml +hitmaan.tk +hitmail.co +hitmail.es +hitmail.us +hitmanz02.online +hitmildot.com +hitprice.co +hitsfit.com +hitthatne.org.ua +hitx.emlhub.com +hiusas.co.cc +hiwave.org +hix.freeml.net +hix.kr +hiyaa.site +hiyrey.cf +hiyrey.ga +hiyrey.gq +hiyrey.ml +hiytdlokz.pl +hiz.kr +hiz76er.priv.pl +hizemail.com +hizl.freeml.net +hizli.email +hizliemail.com +hizliemail.net +hj9ll8spk3co.cf +hj9ll8spk3co.ga +hj9ll8spk3co.gq +hj9ll8spk3co.ml +hj9ll8spk3co.tk +hjdosage.com +hjdzrqdwz.pl +hjfgyjhfyjfytujty.ml +hjgh545rghf5thfg.gq +hjhvj.beer +hji.laste.ml +hjirnbt56g.xyz +hjkcfa3o.com +hjkgkgkk.com +hjkhgh6ghkjfg.ga +hjoghiugiuo.shop +hjyq.emltmp.com +hk.dropmail.me +hk188188.com +hk23pools.org +hkbpoker.com +hkd6ewtremdf88.cf +hkdistro.com +hkdra.com +hke.emlpro.com +hkelectrical.com +hkft7pttuc7hdbnu.cf +hkft7pttuc7hdbnu.ga +hkft7pttuc7hdbnu.ml +hkhk.de +hkip.emlpro.com +hkirsan.com +hkllooekh.pl +hkmbqmubyx5kbk9t6.cf +hkmbqmubyx5kbk9t6.ga +hkmbqmubyx5kbk9t6.gq +hkmbqmubyx5kbk9t6.ml +hkmbqmubyx5kbk9t6.tk +hkp.emltmp.com +hku.us.to +hkvtop.us +hl-blocker.site +hl51.com +hldn.de +hldrive.com +hlf333.com +hlgjsy.com +hlife.site +hliwa.cf +hlkes.com +hlma.com +hlom.emltmp.com +hlooy.com +hlr.spymail.one +hlvt.mimimail.me +hlx02x0in.pl +hlxpiiyk8.pl +hlz.spymail.one +hm.laste.ml +hm3o8w.host +hmail.co +hmail.top +hmail.us +hmamail.com +hmdmsa77.shop +hmeo.com +hmgf.emltmp.com +hmh.ro +hmhrvmtgmwi.cf +hmhrvmtgmwi.ga +hmhrvmtgmwi.gq +hmhrvmtgmwi.ml +hmhrvmtgmwi.tk +hmjm.de +hmmbswlt5ts.cf +hmmbswlt5ts.ga +hmmbswlt5ts.gq +hmmbswlt5ts.ml +hmmbswlt5ts.tk +hmnmw.com +hmo.laste.ml +hmpoeao.com +hmrh.spymail.one +hmsale.org +hmuss.com +hmx.at +hmxmizjcs.pl +hn-skincare.com +hn.spymail.one +hnd.freeml.net +hndard.com +hngwrb7ztl.ga +hngwrb7ztl.gq +hngwrb7ztl.ml +hngwrb7ztl.tk +hnjinc.com +hnlmtoxaxgu.cf +hnlmtoxaxgu.ga +hnlmtoxaxgu.gq +hnlmtoxaxgu.tk +hnoodt.com +hntr93vhdv.uy.to +hnwf.laste.ml +ho.laste.ml +ho2.com +ho2zgi.host +ho3twwn.com +hoadataithe.site +hoail.co.uk +hoalanphidiepdotbien.com +hoamzzy.com +hoangdz11.tk +hoanggiaanh.com +hoanghainam.com +hoanglantuvi.com +hoanglantuvionline.com +hoanglong.tech +hoangsita.com +hoangtaote.com +hoangticusa.com +hoanguhanho.com +hobaaa.com +hobbitthedesolationofsmaug.com +hobbsye.com +hobby-society.com +hobbybeach.com +hobbycheap.com +hobbycredit.com +hobbydiscuss.ru +hobbyfreedom.com +hobbylegal.com +hobbyluxury.com +hobbymanagement.com +hobbymortgage.com +hobbyorganic.com +hobbyperfect.com +hobbyproperty.com +hobbyrate.com +hobbysecurity.com +hobbytraining.com +hobbywe.recipes +hobitogelapps.com +hoboc.com +hobosale.com +hobsun.com +hocgaming.com +hochsitze.com +hockeyan.ru +hockeydrills.info +hockeyskates.info +hocl.hospital +hocl.tech +hocseohieuqua.com +hocseonangcao.com +hocseotructuyen.com +hocseowebsite.com +hodgkiss.ml +hodovmail.com +hoer.pw +hoeson.top +hoesshoponline.info +hofap.com +hofffe.site +hoffren.nu +hog.blatnet.com +hog.lakemneadows.com +hog.poisedtoshrike.com +hoganoutletsiteuomomini.com +hoganrebelitalian.com +hogansitaly.com +hogansitaly1.com +hogansitoufficialeshopiit.com +hogee.com +hohoau.com +hohodormdc.com +hohohim.com +hohr.emlhub.com +hoi-poi.com +hoinu.com +hojen.site +hojfccubvv.ml +hojmail.com +hokyaa.site +hola.org +holabook.site +holaunce.site +holdembonus.com +holdrequired.club +holdup.me +hole.cf +holgfiyrt.tk +holidayinc.com +holidayloans.com +holidayloans.uk +holidayloans.us +holidaytravelresort.com +holined.site +holio.day +holisticfeed.site +holl.ga +holland-nedv.ru +hollandmail.men +holliefindlaymusic.com +hollisterclothingzt.co.uk +hollisteroutletuk4u.co.uk +hollisteroutletukvip.co.uk +hollisteroutletukzt.co.uk +hollisteroutletzt.co.uk +hollistersalezt.co.uk +hollisteruk4s.co.uk +hollisteruk4u.co.uk +hollisterukoutlet4u.co.uk +hollyvogue.shop +hollywoodbubbles.com +hollywooddreamcorset.com +hollywooddress.net +hollywoodereporter.com +hollywoodleakz.com +holmait.com +holmatrousa.com +holo.hosting +holocart.com +holpoiyrt.tk +holstenwall.top +holulu.com +holy-lands-tours.com +holycoweliquid.com +holyokepride.com +holzwohnbau.de +holzzwerge.de +hom.spymail.one +homai.com +homail.com +homail.top +homain.com +homal.com +homapin.com +home-businessreviews.com +home-tech.fun +home.glasstopdiningtable.org +homealfa.com +homeandhouse.website +homebusinesshosting.us +homecut.pro +homedecorsaleoffus.com +homedepinst.com +homedesignsidea.info +homeequityloanlive.com +homeextensionsperth.com +homefauna.ru +homehunterdallas.com +homeil.com +homeinmobiliariacr.com +homelandin.com +homelavka.ru +homemadecoloncleanse.in +homemail.gr.vu +homemailpro.com +homemarkethome.com +homemarketing.ru +homemediaworld.com +homemortgageloan-refinance.com +homenmoderno.life +homepels.ru +homequestion.us +homeremediesforacne.com +homeremediesfortoenailfungus.net +homeremedyglobal.com +homeremedylab.com +homeremedynews.com +homerepairguy.org +homerezioktaya.com +homesforsaleinwausau.com +homesrockwallgroup.com +homeswipe.com +hometheate.com +homethus.com +hometownyi.com +hometrendsdecor.xyz +homewoodareachamber.com +homeworkserver.com +homexpressway.net +homil.com +hominghen.com +hominidviews.com +homlee.com +homlee.mygbiz.com +hommold.us +hompiring.site +homstarusa.com +homtail.ca +homtail.de +homtaosim.com +homtial.co.uk +homtotai.com +homuno.com +honda.redirectme.net +hondaautomotivepart.com +hondabbs.com +hondenstore.com +hondsemi.com +honesthirianinda.net +honey.cloudns.asia +honeydresses.com +honeydresses.net +honeymail.buzz +honeys.be +hongfany.com +honghukangho.com +hongkong.com +honglove.ml +hongpress.com +hongsaitu.com +hongshuhan.com +honk.network +honkimailc.info +honkimailh.info +honkimailj.info +honl2isilcdyckg8.cf +honl2isilcdyckg8.ga +honl2isilcdyckg8.gq +honl2isilcdyckg8.ml +honl2isilcdyckg8.tk +honmme.com +honogrammer.xyz +honor-8.com +honot1.co +hooahartspace.org +hooeheee.com +hook2ad.com +hookb.site +hookerkillernels.com +hookuptohollywood.com +hoolvr.com +hoon.emlpro.com +hooohush.ai +hooooooo.store +hoopwell.com +hootail.com +hootmail.co.uk +hootspad.eu +hootspaddepadua.eu +hooverexpress.net +hop2.xyz +hopeence.com +hopemail.biz +hopesx.com +hopoverview.com +hopto.org +hoquality.com +horizen.cf +horizonspost.com +hormail.ca +hormails.com +hormannequine.com +hormuziki.ru +horn.cowsnbullz.com +horn.ploooop.com +horn.warboardplace.com +hornet.ie +horny.cf +horny.com +hornyalwary.top +hornyman.com +hornytoad.com +horoscopeblog.com +horoskopde.com +horsebarninfo.com +horsepoops.info +horserecords.net +horserecords.org +horsgit.com +horshing.site +hortmail.de +horvathurtablahoz.ml +hos24.de +hosintoy.com +hosliy.com +hospitals.solutions +hospitalvains.social +host-info.com +host-play.ru +host.favbat.com +host15.ru +host1s.com +hostb.xyz +hostbymax.com +hostbyt.com +hostcalls.com +hostchief.net +hostclick.website +hostelness.com +hostelschool.edu +hostely.biz +hosterproxse.gq +hostgatorgenie.com +hostguard.co.fi +hostguru.info +hostguru.top +hosting-vps.info +hosting.cd +hosting.ipiurl.net +hosting4608537.az.pl +hostingandserver.com +hostingarif.me +hostingcape.com +hostingdating.info +hostingmail.me +hostingninja.bid +hostingninja.men +hostingninja.top +hostingninja.website +hostingpagessmallworld.info +hostlaba.com +hostlace.com +hostload.com.br +hostly.ch +hostmail.cc +hostmail.pro +hostmailmonster.com +hostmaster.bid +hostmaster7.xyz +hostmein.bid +hostmein.top +hostmonitor.net +hostnow.bid +hostnow.men +hostnow.website +hostovz.com +hostpector.com +hostseo1.hekko.pl +hosttitan.net +hosttractor.com +hostux.ninja +hostwera.com +hostyourdomain.icu +hot-leads.pro +hot-mail.cf +hot-mail.ga +hot-mail.gq +hot-mail.ml +hot-mail.tk +hot.com +hot14.info +hotaasgrcil.com +hotail.com +hotail.de +hotail.it +hotakama.tk +hotamil.com +hotanil.com +hotbio.asia +hotbird.giize.com +hotbitt.io +hotblogers.com +hotbox.com +hotbrandsonsales1.com +hotchkin.newpopularwatches.com +hotchristianlouboutinsalefr.com +hote-mail.com +hotel-57989.com +hotel-orbita.pl +hotel-zk.lviv.ua +hotel.upsilon.webmailious.top +hotelbochum.de-info.eu +hotelbookingthailand.biz +hotelfocus.com.pl +hotelmirandadodouro.com +hotelnextmail.com +hoteloferty.pl +hotelpam.xyz +hotelpame.store +hotelpame.xyz +hotelrenaissance-bg.com +hotelreserver.ir +hotelsarabia.com +hotelsatparis.com +hotelsatudaipur.com +hotelsdot.co +hotelslens.com +hotelstart.ir +hotelurraoantioquia.com +hotelvet.com +hotelvio.ir +hotelway.ir +hotemail.com +hotemi.com +hotermail.org +hotesell.com +hotfemail.com +hotfile24h.net +hotg.com +hotilmail.com +hotjsdfefff.xyz +hotlain.com +hotlinemail.tk +hotlinkimg.com +hotlook.com +hotlowcost.com +hotlunches.ga +hotma.co.uk +hotma.com +hotma8l.com +hotmaail.co.uk +hotmai.ca +hotmai.com +hotmai.com.ar +hotmaiil.co.uk +hotmail-s.com +hotmail-us.top +hotmail.biz +hotmail.co.com +hotmail.com.hitechinfo.com +hotmail.com.plentyapps.com +hotmail.com.standeight.com +hotmail.commsn.com +hotmail.red +hotmail.work +hotmail4.com +hotmailboxlive.com +hotmailer.info +hotmailer3000.org +hotmailforever.com +hotmailhelplinenumber.com +hotmaill.com +hotmailpro.info +hotmailproduct.com +hotmails.com +hotmails.eu +hotmailse.com +hotmailspot.co.cc +hotmaim.co.uk +hotmaio.co.uk +hotmaip.de +hotmaisl.com +hotmaiul.co.uk +hotmal.com +hotmali.com +hotmanpariz.com +hotmaol.co.uk +hotmatmail.com +hotmayil.com +hotmeal.com +hotmediamail.com +hotmeil.it +hotmeil.net +hotmessage.info +hotmi.com +hotmiail.co.uk +hotmial.co.uk +hotmial.com +hotmichaelkorsoutletca.ca +hotmil.co.uk +hotmil.com +hotmil.de +hotmilk.com +hotmin.com +hotmobilephoneoffers.com +hotmodel.nl +hotmqil.co.uk +hotmulberrybags2uk.com +hotmzcil.com +hotnail.co.uk +hotnho.shop +hotoffmypress.info +hotonlinesalejerseys.com +hotpennystockstowatchfor.com +hotpop.com +hotpradabagsoutlet.us +hotprice.co +hotroactive.tk +hotrod.top +hotrodsbydean.com +hotrokh.com +hotromail.shop +hotrometa.com +hotsale.com +hotsalesbracelets.info +hotsdwswgrcil.com +hotsdwwgrcil.com +hotshoptoday.com +hotsmial.click +hotsnapbackcap.com +hotsoup.be +hotspotmails.com +hotspots300.info +hotstyleus.com +hottchurch.org.uk +hottempmail.cc +hottempmail.com +hottmat.com +hottrend.site +hottyfling.com +hottymail.mom +hotwwgrcil.com +houlad.site +houm.freeml.net +houndtech.com +hourmade.com +hous.craigslist.org +housandwritish.xyz +housat.com +housebuyerbureau.co.uk +housecentral.info +housecleaningguides.com +housecorp.me +household-go.ru +householdshopping.org +housekeyz.com +houseloaded.com +housemail.ga +housenord99.de +houseofgrizzly.pl +houseofshutters.com +houseofwi.com +housereformas.es +housesforcashuk.co.uk +housesfun.com +housetechics.ru +housewifeporn.info +housing.are.nom.co +houston-criminal-defense-lawyer.info +houstondebate.com +houstonembroideryservice.online +houstonlawyerscriminallaw.com +houstonlocksmithpro.com +houstonocdprogram.com +houtil.com +houtlook.com +houtlook.es +houtlook.xyz +hovanfood.com +hovikindustries.com +how-to-offshore.com +how.blatnet.com +how.cowsnbullz.com +how.lakemneadows.com +how.marksypark.com +how1a.site +how1b.site +how1c.site +how1e.site +how1f.site +how1g.site +how1h.site +how1i.site +how1k.site +how1l.site +how1m.site +how1n.site +how1o.site +how1p.site +how1q.site +how1r.site +how1s.site +how1t.site +how1u.site +how1v.site +how1w.site +how1x.site +how1y.site +how1z.site +how2a.site +how2c.site +how2d.site +how2e.site +how2f.site +how2g.site +how2h.site +how2i.site +how2j.site +how2k.site +how2l.site +how2m.site +how2n.site +how2o.site +how2p.site +how2q.site +how2r.site +how2s.site +how2t.site +how2u.site +how2v.site +how2w.site +how2x.site +how2y.site +how2z.site +howb.site +howe-balm.com +howellcomputerrepair.com +howeremedyshop.com +howeve.site +howf.site +howg.site +howgetpokecoins.com +howh.site +howhigh.xyz +howi.site +howicandoit.com +howj.site +howm.site +howmakeall.tk +howmuchall.org.ua +howmuchdowemake.com +hown.site +howp.site +howq.site +howquery.com +howr.site +howt.space +howta.site +howtb.site +howtc.site +howtd.site +howtd.xyz +howte.site +howtf.site +howtg.site +howth.site +howti.site +howtinzr189muat0ad.cf +howtinzr189muat0ad.ga +howtinzr189muat0ad.gq +howtinzr189muat0ad.ml +howtinzr189muat0ad.tk +howtj.site +howtk.site +howtoanmobile.com +howtobook.site +howtobuild.shop +howtobuyfollowers.co +howtodraw2.com +howtofood.ru +howtogetmyboyfriendback.net +howtogetridof-acnescarsfast.org +howtokissvideos.com +howtoknow.us +howtolastlongerinbedinstantly.com +howtolearnplaygitar.info +howtolosefatfast.org +howtolosefatonthighs.tk +howtomake-jello-shots.com +howtoranknumberone.com +howtosmokeacigar.com +howu.site +howv.site +howw.site +howx.site +howz.site +hoxds.com +hozota.com +hp.laohost.net +hp.yomail.info +hpari.com +hpc.tw +hpd7.cf +hpea.emlpro.com +hphasesw.com +hpif.com +hpluginsmm.com +hpnknivesg.com +hpotter7.com +hppg.spymail.one +hprehf28r8dtn1i.cf +hprehf28r8dtn1i.ga +hprehf28r8dtn1i.gq +hprehf28r8dtn1i.ml +hprehf28r8dtn1i.tk +hprepaidbv.com +hprintertechs.com +hpxwhjzik.pl +hq-porner.net +hqautoinsurance.com +hqcatbgr356z.ga +hqhazards.com +hqjzb9shnuk3k0u48.cf +hqjzb9shnuk3k0u48.ga +hqjzb9shnuk3k0u48.gq +hqjzb9shnuk3k0u48.ml +hqjzb9shnuk3k0u48.tk +hqnmhr.com +hqsecmail.com +hqt.one +hqv8grv8dxdkt1b.cf +hqv8grv8dxdkt1b.ga +hqv8grv8dxdkt1b.gq +hqv8grv8dxdkt1b.ml +hqv8grv8dxdkt1b.tk +hqypdokcv.pl +hqyyh.anonbox.net +hr.bcm.edu.pl +hraifi.com +hrandod.com +hrathletesd.com +hrb67.cf +hrb67.ga +hrb67.gq +hrb67.ml +hrb67.tk +hrcub.ru +hrdt.emltmp.com +hreduaward.ru +href.re +hrepy.com +hrg.laste.ml +hrgmgka.cf +hrgmgka.ga +hrgmgka.gq +hrgmgka.ml +hrgy12.com +hrip.dropmail.me +hrisland.com +hrjs.com +hrkq.emlpro.com +hrm.emlpro.com +hrma4a4hhs5.gq +hrmh.emltmp.com +hrnoedi.com +hrommail.net +hronopoulos.com +hrose.com +hroundb.com +hrrdka.us +hrrh.emlhub.com +hrtgr.cf +hrtgr.ga +hrtgr.gq +hrtgr.ml +hrtgr.tk +hrtgre4.cf +hrtgre4.ga +hrtgre4.gq +hrtgre4.ml +hrtgre4.tk +hrustalnye-shtory.ru +hruwcwooq.pl +hrvk.xyz +hrwu.mailpwr.com +hrysyu.com +hrz7zno6.orge.pl +hs-gilching.de +hs-ravelsbach.at +hs-use.top +hs.emlpro.com +hs.hainamcctv.com +hs.vc +hs130.com +hsbc.coms.hk +hsbr.net +hschool.vip +hsdgczxzxc.online +hseedsl.com +hsemonitor.com +hshhs.com +hshke.anonbox.net +hshvmail.eu.org +hsig.emlhub.com +hsjhjsjhbags.com +hsjsj.com +hsls5guu0cv.cf +hsls5guu0cv.ga +hsls5guu0cv.gq +hsls5guu0cv.ml +hsls5guu0cv.tk +hsmultirental.com +hsmw.net +hsnbz.site +hstcc.com +hstermail.com +hsts-preload-test.xyz +hstuie.com +hstutunsue7dd.ml +hsun.com +hsvn.us +hswge.anonbox.net +ht.cx +htaae8jvikgd3imrphl.ga +htaae8jvikgd3imrphl.gq +htaae8jvikgd3imrphl.ml +htaae8jvikgd3imrphl.tk +htb.yomail.info +htc-mozart.pl +htcsemail.com +htdig.org +hte.emltmp.com +htery.com +hteysy5yys66.cf +htgamin.com +hthlm.com +hthp.com +htmail.com +htmail.store +htmel.com +html5recipes.com +htndeglwdlm.pl +htoal.com +htomail.it +htpquiet.com +htsghtsd.shop +htstar.tk +http.e-abrakadabra.pl +httpboks.gq +httpdindon.ml +httpimbox.gq +httpoutmail.cf +httpqwik.ga +httpsgreenwichmeantime.in +httpsouq-dot.com +httpsu.com +httptuan.com +httpvkporn.ru +httsmvk.com +httsmvkcom.one +httu.com +htwergbrvysqs.cf +htwergbrvysqs.ga +htwergbrvysqs.gq +htwergbrvysqs.ml +htwergbrvysqs.tk +htwern.com +htzmqucnm.info +hu.dropmail.me +hu.yomail.info +hu4ht.com +hua.dropmail.me +huachichi.info +huairen.sbs +huairen5.sbs +huajiachem.cn +huang-f.top +huangboyu.com +huangniu8.com +huany.net +huationgjk888.info +hubglee.com +hubhost.store +hubii-network.com +hubinfoai.com +hubinstant.com +hublinestream.com +hubmail.info +hubopss.com +hubpro.site +hubspotmails.com +hubwebsite.tk +hubyou.site +huck.ml +huckbrry.com +huckepackel.com +hudhu.pw +hudisk.com +hudra2webs.online +hudren.com +hudsonhouseantiques.com +hudsonriverseo.com +hudsonunitedbank.com +hudspethinn.com +huecar.com +huekie.com +huekieu.com +huf.freeml.net +hugbenefits.ga +huge.ruimz.com +hugesale.in +hugofairbanks.com +hugohost.pl +huiledargane.com +huizk.com +huj.pl +hujike.org +hukkmu.tk +hukmdy92apdht2f.cf +hukmdy92apdht2f.ga +hukmdy92apdht2f.gq +hukmdy92apdht2f.ml +hukmdy92apdht2f.tk +hula3s.com +hulapla.de +hulas.co +hulas.me +hulas.us +hulaspalmcourt.com +huleos.com +hulksales.com +hull-escorts.com +hulligan.com +hulujams.org +huluwa25.life +huluwa26.life +huluwa27.life +huluwa31.life +huluwa34.life +huluwa35.life +huluwa37.life +huluwa38.life +huluwa44.life +huluwa49.life +huluwa5.life +huluwa7.life +huluwa8.life +hum9n4a.org.pl +humac5.ru +humaility.com +human-design-dizajn-cheloveka.ru +humanadventure.com +humancoder.com +humanconnect.com +humanstudy.ru +humanzty.com +humble.digital +humblegod.rocks +hummarus24.biz +hummer-h3.ml +humn.ws.gy +humorbe.com +humordaddy.ru +humorkne.com +hunaig.com +hundemassage.de +hunf.com +hung89.click +hung89.shop +hungclone.xyz +hungeral.com +hungpackage.com +hungta2.com +hungtaote.com +hungtaoteile.com +hunnur.com +hunny1.com +hunnyberry.com +hunrap.usa.cc +huntarapp.com +hunterhouse.pl +huntersfishers.ru +huntertravels.com +huntingmastery.com +huntpodiatricmedicine.com +huntubaseuh.sbs +huobipools.cloud +huongdanfb.com +huoot.com +hup.xyz +hupkn.anonbox.net +hupoi.com +hurify1.com +hurl.pro +hurramm.us +hurrijian.us +hush.ai +hush.com +hushclouds.com +hushline.com +hushmail.cf +hushmail.com +hushskinandbody.com +huskion.net +huskysteals.com +husmail.net +husng-kang.top +huston.edu +hustq7tbd6v2xov.cf +hustq7tbd6v2xov.ga +hustq7tbd6v2xov.gq +hustq7tbd6v2xov.ml +hustq7tbd6v2xov.tk +hutchankhonghcm.com +hutmails.com +hutov.com +hutudns.com +huuduc8404.xyz +huutinhrestaurant.com +huvacliq.com +huweimail.cn +huyducfullxu.cloud +huyf.com +huyuhnsj36948.ml +huyvillafb.online +huyzvip.best +hv.laste.ml +hv.yomail.info +hv112.com +hvastudiesucces.nl +hvav.spymail.one +hvh.pl +hvhcksxb.mil.pl +hvirhvi3rhui.laste.ml +hvtechnical.com +hvzoi.com +hw0.site +hw01.xyz +hwa7niu2il.com +hwa7niuil.com +hwbk.emlhub.com +hwbq.laste.ml +hwf.freeml.net +hwh.emlhub.com +hwkaaa.besaba.com +hwkvsvfwddeti.cf +hwkvsvfwddeti.ga +hwkvsvfwddeti.gq +hwkvsvfwddeti.ml +hwkvsvfwddeti.tk +hwomg.us +hwsye.net +hwudkkeejj.ga +hwxist3vgzky14fw2.cf +hwxist3vgzky14fw2.ga +hwxist3vgzky14fw2.gq +hwxist3vgzky14fw2.ml +hwxist3vgzky14fw2.tk +hwy24.com +hx.freeml.net +hx39i08gxvtxt6.cf +hx39i08gxvtxt6.ga +hx39i08gxvtxt6.gq +hx39i08gxvtxt6.ml +hx39i08gxvtxt6.tk +hxb.spymail.one +hxck8inljlr.cf +hxck8inljlr.ga +hxck8inljlr.gq +hxck8inljlr.tk +hxcvousa.store +hxdjswzzy.pl +hxf.emlhub.com +hxfe.mimimail.me +hxhbnqhlwtbr.ga +hxhbnqhlwtbr.ml +hxhbnqhlwtbr.tk +hximouthlq.com +hxisewksjskwkkww89101929.unaux.com +hxnz.xyz +hxopi.ru +hxopi.store +hxqmail.com +hxsni.com +hxvxxo1v8mfbt.cf +hxvxxo1v8mfbt.ga +hxvxxo1v8mfbt.gq +hxvxxo1v8mfbt.ml +hxvxxo1v8mfbt.tk +hxzf.biz +hy.freeml.net +hyab.de +hyayea.com +hyb.spymail.one +hybotics.net +hybridhazards.info +hybridmc.net +hycehyxyxu.today +hydim.xyz +hydrakurochka.lgbt +hydramarketsnjmd.com +hydraulicsolutions.com +hydraza.com +hydrodynamice.store +hydrogenrichwaterstick.org +hydrolinepro.ru +hydroter.cf +hydroxide-studio.com +hyf.laste.ml +hyhisla.tk +hyhsale.top +hyip.market +hyipbook.com +hyipiran.ir +hyjyja.guru +hyk.pl +hylja.net +hylja.tech +hyokyori.com +hypdoterosa.cf +hypdoterosa.ga +hypdoterosa.ml +hypdoterosa.tk +hype68.com +hypeinteractive.us +hypenated-domain.com +hyperactivist.info +hyperemail.top +hyperfastnet.info +hyperlabs.co +hypermail.top +hypermailbox.com +hyperpigmentationtreatment.eu +hypertosprsa.tk +hyphemail.com +hypo-kalkulacka.online +hypoor.live +hypoordip.live +hypori.us +hypotan.site +hypotekyonline.cz +hyprhost.com +hypteo.com +hysaryop8.pl +hysilens.store +hyt45763ff.cf +hyt45763ff.ga +hyt45763ff.gq +hyt45763ff.ml +hyt45763ff.tk +hytech.asso.st +hyteqwqs.com +hyu.emlhub.com +hyundaiaritmakusadasi.xyz +hyverecruitment.com +hyvuokmhrtkucn5.cf +hyvuokmhrtkucn5.ga +hyvuokmhrtkucn5.gq +hyvuokmhrtkucn5.ml +hyyhh.com +hyyysde.com +hz2046.com +hzdpw.com +hznth.com +hzoo.com +hzx3mqob77fpeibxomc.cf +hzx3mqob77fpeibxomc.ga +hzx3mqob77fpeibxomc.ml +hzx3mqob77fpeibxomc.tk +hzxx.dropmail.me +i-3gk.cf +i-3gk.ga +i-3gk.gq +i-3gk.ml +i-am-tiredofallthehype.com +i-booking.us +i-dont-wanna-be-a.live +i-dork.com +i-emailbox.info +i-konkursy.pl +i-love-credit.ru +i-love-you-3000.net +i-phone.nut.cc +i-phones.shop +i-slotv.xyz +i-sp.cf +i-sp.ga +i-sp.gq +i-sp.ml +i-sp.tk +i-taiwan.tv +i-trust.ru +i.cowsnbullz.com +i.e-tpc.online +i.email-temp.com +i.iskba.com +i.istii.ro +i.klipp.su +i.lakemneadows.com +i.oldoutnewin.com +i.ploooop.com +i.polosburberry.com +i.qwertylock.com +i.ryanb.com +i.shredded.website +i.wawi.es +i.xcode.ro +i03hoaobufu3nzs.cf +i03hoaobufu3nzs.ga +i03hoaobufu3nzs.gq +i03hoaobufu3nzs.ml +i03hoaobufu3nzs.tk +i11e5k1h6ch.cf +i11e5k1h6ch.ga +i11e5k1h6ch.gq +i11e5k1h6ch.ml +i11e5k1h6ch.tk +i18nwiki.com +i1oaus.pl +i1uc44vhqhqpgqx.cf +i1uc44vhqhqpgqx.ga +i1uc44vhqhqpgqx.gq +i1uc44vhqhqpgqx.ml +i1uc44vhqhqpgqx.tk +i1xslq9jgp9b.ga +i1xslq9jgp9b.ml +i1xslq9jgp9b.tk +i201zzf8x.com +i2oww.anonbox.net +i2pmail.org +i301.info +i35t0a5.com +i3d47.anonbox.net +i3pv1hrpnytow.cf +i3pv1hrpnytow.ga +i3pv1hrpnytow.gq +i3pv1hrpnytow.ml +i3pv1hrpnytow.tk +i4j0j3iz0.com +i4racpzge8.cf +i4racpzge8.ga +i4racpzge8.gq +i4racpzge8.ml +i4racpzge8.tk +i4unlock.com +i537244.cf +i537244.ga +i537244.ml +i54o8oiqdr.cf +i54o8oiqdr.ga +i54o8oiqdr.gq +i54o8oiqdr.ml +i54o8oiqdr.tk +i57l2.anonbox.net +i6.cloudns.cc +i6.cloudns.cx +i61qoiaet.pl +i66g2i2w.com +i6appears.com +i75rwe24vcdc.cf +i75rwe24vcdc.ga +i75rwe24vcdc.gq +i75rwe24vcdc.ml +i75rwe24vcdc.tk +i774uhrksolqvthjbr.cf +i774uhrksolqvthjbr.ga +i774uhrksolqvthjbr.gq +i774uhrksolqvthjbr.ml +i774uhrksolqvthjbr.tk +i83.com +i8e2lnq34xjg.cf +i8e2lnq34xjg.ga +i8e2lnq34xjg.gq +i8e2lnq34xjg.ml +i8e2lnq34xjg.tk +i8tvebwrpgz.cf +i8tvebwrpgz.ga +i8tvebwrpgz.gq +i8tvebwrpgz.ml +i8tvebwrpgz.tk +ia4stypglismiks.cf +ia4stypglismiks.ga +ia4stypglismiks.gq +ia4stypglismiks.ml +ia4stypglismiks.tk +iabundance.com +iaciu.com +iacjpeoqdy.pl +iagh5.anonbox.net +iah.emltmp.com +iahs.emltmp.com +iaindustrie.fr +iaks.dropmail.me +iamail.com +iamarchitect.com +iamawitch.com +iamcoder.ru +iamfrank.rf.gd +iamguide.ru +iamipl.icu +iamneverdefeated.com +iamnicolas.com +iamsp.ga +iamtile.com +iamvinh123.tk +iamyoga.website +ianstjames.com +ianvvn.com +ianz.pro +iaonne.com +iaoss.com +iapermisul.ro +iaptkapkl53.tk +iast.emlhub.com +iatarget.com +iatcoaching.com +iattach.gq +iautostabilbetsnup.xyz +iaw.emlpro.com +iaynqjcrz.pl +iazc.emltmp.com +iazhy.com +ib.spymail.one +ib4f.com +ib58.xyz +ib5dy8b0tip3dd4qb.cf +ib5dy8b0tip3dd4qb.ga +ib5dy8b0tip3dd4qb.gq +ib5dy8b0tip3dd4qb.ml +ib5dy8b0tip3dd4qb.tk +ibande.xyz +ibansko.com +ibaoju.com +ibarz.es +ibaxdiqyauevzf9.cf +ibaxdiqyauevzf9.ga +ibaxdiqyauevzf9.gq +ibaxdiqyauevzf9.ml +ibaxdiqyauevzf9.tk +ibcbetlink.com +ibdmedical.com +ibel-resource.com +ibelnsep.com +ibericaesgotos.com +iberplus.com +ibersys.com +ibetatest.com +ibibo.com +ibisfarms.com +ibiza-villas-spain.com +ibizaholidays.com +ibjn.emlhub.com +ibk.yomail.info +iblawyermu.com +iblbildbyra.se +ibm.coms.hk +ibm.laste.ml +ibmail.com +ibmmails.com +ibmpc.cf +ibmpc.ga +ibmpc.gq +ibmpc.ml +ibnlolpla.com +ibnuh.bz +ibolinva.com +ibookstore.co +ibr.laste.ml +ibreeding.ru +ibrilo.com +ibrx.laste.ml +ibsats.com +ibsyahoo.com +ibt7tv8tv7.cf +ibt7tv8tv7.ga +ibt7tv8tv7.gq +ibt7tv8tv7.ml +ibt7tv8tv7.tk +ibtrades.com +ibvietnamvisa.com +ibvqg.anonbox.net +iby.emlpro.com +ibymail.com +ibze.laste.ml +ibzr.yomail.info +ic-cadorago.org +ic-interiors.com +ic-osiosopra.it +ic-vialaurentina710-roma.it +ic.emltmp.com +ic.laste.ml +ica.freeml.net +icampinga.com +icanav.net +icanfatbike.com +icantbelieveineedtoexplainthisshit.com +icao6.us +icarevn.com +icaruslegend.com +icashsurveys.com +icbr.us +iccmail.men +iccmail.ml +iccon.com +ice52751.ga +iceburgsf.com +icegeos.com +iceland-is-ace.com +icelogs.com +icemail.club +icemails.top +icemovie.link +icenhl.com +icesilo.com +icetmail.ga +icevex.com +icfai.com +icfu.mooo.com +icgs.de +icgu.emlpro.com +ich-bin-verrueckt-nach-dir.de +ich-essen-fleisch.bio +ich-will-net.de +ichairscn.com +ichatz.ga +ichbinvollcool.de +ichecksdqd.com +ichehol.ru +ichichich.faith +ichics.com +ichigo.me +ichimail.com +ichkoch.com +ichstet.com +icidroit.info +icingrule.com +icircearth.com +ickx.de +icl.freeml.net +iclo1d.kr +iclolud.com +iclou1d.com +iclou1d.kr +icloud.do +icloudbusiness.net +icloudemail.kr +icloudmail.kr +icloulb.com +icloulb.kr +icluoud.com +icmail.com +icmans.com +icmarottabasile.it +icmartiriliberta.it +icmocozsm.pl +icnwte.com +icodimension.com +icon.foundation +icon256.info +icon256.tk +iconda.site +iconedit.info +iconfile.info +iconicompany.com +iconmal.com +iconmle.com +iconpo.com +iconslibrary.com +iconsultant.me +iconzap.com +iconze.com +icoom.com +icotype.info +icould.co +icousd.com +icoworks.com +icpst.org +icraftx.net +icrr2011symp.pl +icsfinomornasco.it +icshu.com +icsint.com +icsitc.com +icslecture.com +icstudent.org +ict0crp6ocptyrplcr.cf +ict0crp6ocptyrplcr.ga +ict0crp6ocptyrplcr.gq +ict0crp6ocptyrplcr.ml +ict0crp6ocptyrplcr.tk +ictuber.info +icu.ovh +icubik.com +icunet.icu +icvq.laste.ml +icx.in +icx.ro +icznn.com +id-ins.com +id.emlhub.com +id.laste.ml +id.pl +id.semar.edu.pl +id10tproof.com +id7ak.com +idapplevn.co +idat.site +idawah.com +idcbill.com +idclips.com +idea-mail.com +idea-mail.net +idea.bothtook.com +idea.emailies.com +idea.warboardplace.com +ideadrive.com +ideagmjzs.pl +idealencounters.com +idealengineers.com +idealpersonaltrainers.com +idearia.org +ideascapitales.com +ideasplace.ru +ideenbuero.de +ideenx.site +ideepmind.pw +ideer.msk.ru +ideer.pro +identitaskependudukan.digital +iderf-freeuser.ml +iderfo.com +idesigncg.com +ideuse.com +idf.ovh +idfd.live +idi-k-mechte.ru +idieaglebit.com +idigo.org +idihgabo.cf +idihgabo.gq +idiotmails.com +idlapak.com +idlemailbox.com +idmail.com +idmail.me +idn.vn +idnaco.ml +idnaco.tk +idnaikw.homes +idnkil.cf +idnkil.ga +idnkil.gq +idnkil.ml +idnpoker.link +idobrestrony.pl +idoc.com +idoidraw.com +idolsystems.info +idomail.com +idomain24.pl +idont.date +idotem.cf +idotem.ga +idotem.gq +idotem.ml +idownload.site +idpoker99.org +idrct.com +idrifla.com +idropshipper.com +idrotherapyreview.net +idrrate.com +idsho.com +idssh.net +idt8wwaohfiru7.cf +idt8wwaohfiru7.ga +idt8wwaohfiru7.gq +idt8wwaohfiru7.ml +idt8wwaohfiru7.tk +idtv.site +iduitype.info +idurse.com +idvdclubs.com +idvinced.com +idwager.com +idx4.com +idxue.com +idy.spymail.one +idy1314.com +idyllwild.vacations +idyro.com +ie.laste.ml +ieahhwt.com +ieasymail.net +ieatspam.eu +ieatspam.info +ieattach.ml +iecj.dropmail.me +iecrater.com +iecusa.net +iedindon.ml +iee.emlhub.com +ieellrue.com +iefbcieuf.cf +iefbcieuf.ml +iefbcieuf.tk +ieh-mail.de +ieid.dropmail.me +ieit9sgwshbuvq9a.cf +ieit9sgwshbuvq9a.ga +ieit9sgwshbuvq9a.gq +ieit9sgwshbuvq9a.ml +ieit9sgwshbuvq9a.tk +iel.pw +iemail.online +iemitel.gq +iemm.ru +ien.emltmp.com +iencm.com +ienergize.com +iennfdd.com +ieoan.com +ieolsdu.com +ieorace.com +iephonam.cf +ieremiasfounttas.gr +ieryweuyeqio.tk +ierywoeiwura.tk +ies76uhwpfly.cf +ies76uhwpfly.ga +ies76uhwpfly.gq +ies76uhwpfly.ml +ies76uhwpfly.tk +iexh1ybpbly8ky.cf +iexh1ybpbly8ky.ga +iexh1ybpbly8ky.gq +iexh1ybpbly8ky.ml +iexh1ybpbly8ky.tk +iez.emlpro.com +if.lakemneadows.com +if.martinandgang.com +if58.cf +if58.ga +if58.gq +if58.ml +if58.tk +ifamail.com +ifastmail.pl +ifavorsprt.com +ifchuck.com +ifd8tclgtg.cf +ifd8tclgtg.ga +ifd8tclgtg.gq +ifd8tclgtg.ml +ifd8tclgtg.tk +ifdamagesn.com +ifeaturefr.com +ifem.spymail.one +iffygame.com +iffymedia.com +ifgz.com +ifile.com +ifjn.com +iflix4kmovie.us +ifly.cf +ifmail.com +ifneick22qpbft.cf +ifneick22qpbft.ga +ifneick22qpbft.gq +ifneick22qpbft.ml +ifneick22qpbft.tk +ifoam.ru +ifomail.com +ifoodpe19.ml +ifoxdd.com +ifrghee.com +ifruit.cf +ifruit.ga +ifruit.gq +ifruit.ml +ifruit.tk +iftmmbd.org +ifufejy.com +ifvx.com +ifwda.co.cc +ify.laste.ml +ifyourock.com +ig98u4839235u832895.unaux.com +ig9kxv6omkmxsnw6rd.cf +ig9kxv6omkmxsnw6rd.ga +ig9kxv6omkmxsnw6rd.gq +ig9kxv6omkmxsnw6rd.ml +ig9kxv6omkmxsnw6rd.tk +igalax.com +igamawarni.art +igcl5axr9t7eduxkwm.cf +igcl5axr9t7eduxkwm.gq +igcl5axr9t7eduxkwm.ml +igcl5axr9t7eduxkwm.tk +igcwellness.us +igdinhcao.click +igdinhcao.com +igdinhcao.shop +igdinhcao.site +ige.emlhub.com +ige.es +igeb.freeml.net +igeekmagz.pw +igelonline.de +igenservices.com +igfnicc.com +igg.biz +iggqnporwjz9k33o.ga +iggqnporwjz9k33o.ml +ighjbhdf890fg.cf +igimail.com +igintang.ga +iginting.cf +igiveu.win +igk.freeml.net +igla.freeml.net +igluanalytics.com +igmail.com +igniter200.com +ignoremail.com +igoodmail.pl +igoqu.com +igqtrustee.com +igrat-v-igrovie-avtomati.com +igri.cc +igrovieavtomati.org +igsvmail.com +igtook.org +igvaku.cf +igvaku.ga +igvaku.gq +igvaku.ml +igvaku.tk +igvevo.com +igwnsiojm.pl +igxppre7xeqgp3.cf +igxppre7xeqgp3.ga +igxppre7xeqgp3.gq +igxppre7xeqgp3.ml +igxppre7xeqgp3.tk +ih2vvamet4sqoph.cf +ih2vvamet4sqoph.ga +ih2vvamet4sqoph.gq +ih2vvamet4sqoph.ml +ih2vvamet4sqoph.tk +ihairbeauty.us +ihalematik.net +ihamail.com +ihappytime.com +ihateyoualot.info +ihavedildo.tk +ihavenomouthandimustspeak.com +ihaxyour.info +ihazspam.ca +iheartdog.info +iheartspam.org +ihehmail.com +ihgu.info +ihhjomblo.online +ihimsmrzvo.ga +ihnpo.com +ihnpo.food +ihocmail.com +ihomail.com +ii47.com +iicuav.com +iidiscounts.com +iidiscounts.org +iidzlfals.pl +iigmail.com +iigo.de +iigtzic3kesgq8c8.cf +iigtzic3kesgq8c8.ga +iigtzic3kesgq8c8.gq +iigtzic3kesgq8c8.ml +iigtzic3kesgq8c8.tk +iihonfqwg.pl +iiicloud.asia +iiicloud.best +iill.cf +iimbox.cf +iimlmanfest.com +iipl.de +iipre.com +iiron.us +iirport.com +iiryys.com +iissugianto.art +iistoria.com +iitdmefoq9z6vswzzua.cf +iitdmefoq9z6vswzzua.ga +iitdmefoq9z6vswzzua.gq +iitdmefoq9z6vswzzua.ml +iitdmefoq9z6vswzzua.tk +iiuba.com +iiunited.pl +iiuurioh89.com +iiwumail.com +ij3zvea4ctirtmr2.cf +ij3zvea4ctirtmr2.ga +ij3zvea4ctirtmr2.gq +ij3zvea4ctirtmr2.ml +ij3zvea4ctirtmr2.tk +ijerj.co.cc +ijg.laste.ml +ijhi.laste.ml +iji.emlpro.com +ijmafjas.com +ijmail.com +ijmxty3.atm.pl +ijointeract.com +ijr.emlpro.com +ijsdiofjsaqweq.ru +ik.emlhub.com +ik.yomail.info +ik7gzqu2gved2g5wr.cf +ik7gzqu2gved2g5wr.ga +ik7gzqu2gved2g5wr.gq +ik7gzqu2gved2g5wr.ml +ik7gzqu2gved2g5wr.tk +ikanchana.com +ikangou.com +ikanid.com +ikanteri.com +ikaza.info +ikbalsongur.cfd +ikbenspamvrij.nl +ikelsik.cf +ikelsik.ga +ikelsik.gq +ikelsik.ml +ikewe.com +ikhyebajv.pl +iki.kr +ikimaru.com +ikingbin.com +ikke.win +ikkjacket.com +ikl.dropmail.me +ikomail.com +ikoplak.cf +ikoplak.ga +ikoplak.gq +ikoplak.ml +ikowat.com +ikpz6l.pl +ikq.emlpro.com +iku.emlpro.com +iku.us +ikumaru.com +ikuromi.com +ikuzus.cf +ikuzus.ga +ikuzus.gq +ikuzus.ml +ikuzus.tk +ikv.spymail.one +ikwdf.anonbox.net +ikwo.dropmail.me +ikxr.freeml.net +il.edu.pl +ilamseo.com +ilandingvw.com +ilavana.com +ilaws.work +ilayda.cf +ilazero.com +ilboard.r-e.kr +ilbombardone.com +ilcapriccio-erding.de +ilcommunication.com +ildz.com +ilencorporationsap.com +ileqmail.com +iletity.com +ilh.laste.ml +ilico.info +ilike168.com +iliken.com +ilikespam.com +iliketndnl.com +ilikeyoustore.org +ilink.ml +ilinkelink.com +ilinkelink.org +iljmail.com +ilkoiuiei9.com +ilkoujiwe8.com +illinoisscno.org +illistnoise.com +illnessans.ru +illnessth.com +illubd.com +illumsphere.com +ilmail.com +ilmale.it +ilmiogenerico.it +ilmuanmuda.com +ilnostrogrossograssomatrimoniomolisano.com +ilobi.info +iloov.eu +iloplr.com +ilopopolp.com +ilove.com +ilovebh.ml +ilovecorgistoo.com +iloveearthtunes.com +iloveiandex.ru +iloveion.com +iloveitaly.tk +ilovemail.fr +ilovemyniggers.club +iloverio.ml +ilovespam.com +ilowbay.com +ilpiacere.it +ilqb.emlhub.com +ilrlb.com +ils.net +ilsaas.com +ilt.ctu.edu.gr +iltmail.com +iluck68.com +iludir.com +ilumail.com +ilur.emltmp.com +ilusale.com +ilustrosonic.com +ilvquhbord.ga +ilvwe.anonbox.net +ilyasov.tk +ilydeen.org +im-irsyad.tech +im4ever.com +im5z.com +ima-md.com +imaanpharmacy.com +imabandgeek.com +imacal.site +imacpro.ml +image.favbat.com +image24.de +imageevolutions.com +imagehostfile.eu +imagepoet.net +images-spectrumbrands.com +images.makingdomes.com +images.novodigs.com +images.ploooop.com +images.poisedtoshrike.com +imaginged.com +imagiscape.us +imail.autos +imail.edu.vn +imail.seomail.eu +imail1.net +imail5.net +imail8.net +imailbox.org +imailcloud.net +imaild.com +imailfree.cc +imailnet.com +imailpro.net +imails.asso.st +imails.info +imailt.com +imailto.net +imailweb.top +imailzone.ml +imajl.pl +imalias.com +imallas.com +imamail1928.cf +imamsrabbis.org +imankul.com +imap.fr.nf +imap521.mineweb.in +imapiphone.minemail.in +imaracing.com +imarkconsulting.com +imasser.info +imaterrorist.com +imationary.site +imayji.com +imbetain.com +imboate.com +imbricate.xyz +imd044u68tcc4.cf +imd044u68tcc4.ga +imd044u68tcc4.gq +imd044u68tcc4.ml +imd044u68tcc4.tk +imdbplus.com +imdutex.com +imedgers.com +imeil.tk +imeit.com +imeng.store +imenuvacoh.wiki +imexcointernational.com +imfaya.com +imfsiteamenities.com +img-free.com +imgcdn.us +imgjar.com +imgmark.com +imgof.com +imgrpost.xyz +imgsources.com +imgtokyo.com +imgv.de +imhtcut.xyz +imhungry.xyz +imicplc.com +iminimalm.com +iminko.com +imitrex-sumatriptan.com +imitrex.info +immail.com +immail.ml +immediategoodness.org +immigrationfriendmail.com +imminc.com +immo-gerance.info +immry.ru +immunityone.com +imnarbi.gq +imnart.com +imobiliare.blog +imos.site +imosowka.pl +imouto.pro +imovie.link +imozmail.com +impactcommunications.us +impactsc.com +impactsib.ru +impactspeaks.com +imparai.ml +impartialpriambudi.biz +impasta.cf +impastore.co +imperfectron.com +imperialcnk.com +imperialmanagement.com +imperiumstrategies.com +imperiya1.ru +impervaphc.ml +impervazxy.fun +impi.com.mx +implosblog.ru +imported.livefyre.com +importemail.com +impostero.ga +impostore.co +impotens.pp.ua +impresapuliziesea.com +imprezorganizacja.pl +imprezowy-dj.pl +imprimtout.com +imprisonedwithisis.com +improvedtt.com +improvidents.xyz +imrekoglukoleksiyon.xyz +imsave.com +imsend.ru +imstark.fun +imstations.com +imsuhyang.com +imuasouthwest.com +imul.info +imwd.emlhub.com +imyourkatieque.com +in-fund.ru +in-their-words.com +in-ulm.de +in.blatnet.com +in.cowsnbullz.com +in.laste.ml +in.mailsac.com +in.vipmail.in +in.warboardplace.com +in2reach.com +in4mail.net +in5minutes.net +inaby.com +inactivemachine.com +inacup.gq +inadtia.com +inamail.com +inapplicable.org +inappmail.com +inaremar.eu +inasoc.ga +inasoc.ml +inaytedodet.tk +inbaca.com +inbax.ga +inbax.ml +inbax.tk +inbidato.ddns.net +inbilling.be +inbix.lv +inbound.plus +inbov03.com +inbox-me.top +inbox.comx.cf +inbox.lc +inbox.loseyourip.com +inbox.si +inbox.vin +inbox2.email +inbox2.info +inbox888.com +inboxalias.com +inboxbear.com +inboxclean.com +inboxclean.org +inboxdesign.me +inboxed.im +inboxed.pw +inboxeen.com +inboxes.com +inboxhub.net +inboxkitten.com +inboxmail.life +inboxmail.world +inboxmails.co +inboxmails.de +inboxmails.net +inboxnow.ru +inboxnow.store +inboxorigin.com +inboxproxy.com +inboxstore.me +inc.ovh +incarnal.pl +incc.cf +incestry.co.uk +incgroup.com +inchence.com +incient.site +incitemail.com +inclick.net +inclusionchecklist.com +inclusioncheckup.com +inclusiveprogress.com +incognitomail.com +incognitomail.net +incognitomail.org +incomecountry.com +incompetentgracia.net +incomservice.com +incorian.ru +incorporatedmail.com +incoware.com +incq.com +increase5f.com +increasefollower.com +increater.ru +incredibility.info +incrediemail.com +inctart.com +incubic.pro +ind.st +indaclub.cfd +indd.mailpwr.com +inddweg.com +indeedlebeans.com +indeedtime.us +indefathe.xyz +indelc.pw +independentsucks.twilightparadox.com +independentvpn.com +indeptempted.site +indevgo.com +index-mail.com +indexer.pw +indexzero.dev +indi-nedv.ru +india.whiskey.thefreemail.top +india2in.com +indiacentral.in +indiamary.com +indianahorsecouncil.org +indianecommerce.com +indianview.com +indidn.xyz +indieclad.com +indieglam.shop +indiego.pw +indigobook.com +indigomail.info +indiho.info +indirect.ws +indirindir.net +indirkaydol.com +indmarsa.com +indmeds.com +indobet.com +indocarib.com +indogame.site +indohe.com +indoliqueur.com +indomaed.pw +indomina.cf +indomitableadinegara.io +indomovie21.me +indonesiaberseri.com +indonesianherbalmedicine.com +indoplay303.com +indoserver.stream +indosukses.press +indototo.club +indoxex.com +indozoom.me +indozoom.net +indtredust.com +inducasco.com +indumento.club +industrialbrushmanufacturer.us +industrialelectronica.com +industriesmyriad.site +industryleaks.com +ineec.net +ineed.emlpro.com +ineeddoshfast.co.uk +ineedmoney.com +ineedsa.com +inemaling.com +inerted.com +inertiafm.ru +inet4.info +inetlabs.es +inetworkcards.com +inetworksgroup.com +inewx.com +inexpensivejerseyofferd.com +inf-called-phone.com +infalled.com +infantshopping.com +inferno4.pl +infest.org +infideles.nu +infilddrilemail.com +infinesting.host +infinitiypoker.com +infinityacessos.lat +infinitybooksjapan.org +infinityclippingpath.com +infinitycoaching.com +infinityevolved.online +infitter.ru +info-netflix.cf +info-radio.ml +info7.eus +info89.ru +infoaccount-team.news +infoalgers.info +infobakulan.online +infobuzzsite.com +infochartsdeal.info +infochinesenyc.info +infocom.zp.ua +infogeneral.com +infogenshin.online +infoisp.me +infokehilangan.com +infolinewest.com +infolinkai.com +infomail.club +infomedia.ga +infonetco.com +infoprice.tech +inforesep.art +informasikuyuk.com +informatika.design +information-account.net +information-blog.xyz +informationispower.co.uk +informatykbiurowy.pl +informedexistence.com +infornma.com +infosdating.info +infoslot88.com +infosnet24.info +infosol.me +infossbusiness.com +infotech.info +infotoursnyc.info +infouoso.com +infowordpress.info +infphonezip.com +infqq.com +infraradio.com +infraredthermometergun.tech +infrazoom.com +ingabhagwandin.xyz +ingam.top +ingame.golffan.us +ingcoachepursesoutletusaaonline.com +ingday.com +ingemin.com +ingfix.com +ingfo.online +inggo.org +ingilterevize.eu +ingitel.com +ingles90dias.space +ingleses.articles.vip +inglewoodpaydayloans.info +ingridyrodrigo.com +ingrok.win +ingum.xyz +inhealthcds.com +inhello.com +inhomeideas.com +inhomelife.ru +inhost.systems +inibuatkhoirul.cf +inibuatsgb.cf +inibuatsgb.ga +inibuatsgb.gq +inibuatsgb.ml +inibuatsgb.tk +inijamet.fun +inikale.com +inikehere.com +inikita.online +inilas.com +inilogic.com +iniprm.com +inipunyakitasemua.cf +inipunyakitasemua.ga +inipunyakitasemua.gq +inipunyakitasemua.ml +inipunyakitasemua.tk +initialcommit.net +initwag.com +inji4voqbbmr.cf +inji4voqbbmr.ga +inji4voqbbmr.gq +inji4voqbbmr.ml +inji4voqbbmr.tk +injir.top +injureproof.com +injuryhelpnewyork.net +inkashop.org +inkerbasin.com +inkight.com +inkiny.com +inkmoto.com +inkomail.com +inlandharmonychorus.org +inlandortho.com +inlith.com +inlook.cloud +inlove.ddns.net +inlovevk.net +inlutec.com +inly.vn +inmail.com +inmail.site +inmail.xyz +inmail24.com +inmail3.com +inmail5.com +inmail7.com +inmail92.com +inmailing.com +inmailwetrust.com +inmisli.gq +inmolaryx.es +inmouncela.xyz +inmyd.ru +inmynetwork.cf +inmynetwork.ga +inmynetwork.gq +inmynetwork.ml +inmynetwork.tk +innercirclemasterminds.com +innf.com +inni-com.pl +innoberg.com +innovasolar.me +innovateccc.org +innoveax.com +innovex.co.in +innoworld.net +inoakley.com +inonezia-nedv.ru +inoshtar.online +inoue3.com +inouncience.site +inoutmail.de +inoutmail.eu +inoutmail.info +inoutmail.net +inovha.com +inox.org.pl +inpowiki.xyz +inppares.org.pe +inpsur.com +inpwa.com +inrelations.ru +inrim.cf +inrim.ga +inrim.gq +inrim.ml +inrim.tk +insane.nq.pl +insanity-workoutdvds.info +insanitydvdonline.info +insanityworkout13dvd.us +insanityworkout65.us +insanityworkoutcheap.us +insanityworkoutdvds.us +insanityworkoutinstores.us +insanony.art +insanony.one +insanony.store +insanumingeniumhomebrew.com +inscriptio.in +insellage.de +insertswork.com +insfou.com +insgogc.com +insgrmail.site +inshapeactive.ru +inshuan.com +insidegpus.com +insidershq.info +insidiousahmadi.biz +insighbb.com +insightsite.com +insischildpank.xyz +insomniade.org.ua +insorg-mail.info +inspiracjatwoja.pl +inspirationzuhause.me +inspirative.online +inspiredbyspire.com +inspiredking.com +inspirejmail.cf +inspirejmail.ga +inspirejmail.gq +inspirejmail.ml +inspirejmail.tk +inspirekmail.cf +inspirekmail.ga +inspirekmail.gq +inspirekmail.ml +inspirekmail.tk +instad4you.info +instaddr.ch +instaddr.uk +instaddr.win +instadp.site +instafun.men +instagrammableproperties.com +instaindofree.com +instakipcihilesi.com +instaku-media.com +installerflas65786.xyz +instamail.site +instamaniya.ru +instambox.com +instance-email.com +instant-email.org +instant-job.com +instant-mail.de +instantblingmail.info +instantbox.online +instantdispatch.life +instantemailaddress.com +instantgiveaway.xyz +instantinsurancequote.co.uk +instantletter.net +instantloan.com +instantloans960.co.uk +instantlove.pl +instantlyemail.com +instantmail.de +instantmail.fr +instantmailaddress.com +instantonlinepayday.co.uk +instantpost.xyz +instapay.one +instapp.top +instaprice.co +instasmail.com +instatienda.com +instatione.site +instatrendz.xyz +instdownload.com +instmail.uk +instrete.com +instronge.site +instrumentationtechnologies.com +instylerreviews.info +insurance-co-op.com +insurance-company-service.com +insurance-network.us +insuranceair.com +insurancecaredirect.com +insurancenew.org +insuranceonlinequotes.info +insurancing.ru +insvip.site +int.freeml.net +int.inblazingluck.com +int.ploooop.com +int.poisedtoshrike.com +intadvert.com +intady.com +intamo.cf +intandtel.com +intannuraini.art +intdesign.edu +intefact.ru +integrateinc.com +integrately.net +integrityonline.com +inteksoft.com +intel.coms.hk +intelligence.zone +intelligentfoam.com +intelligentp.com +intellika.digital +intempmail.com +intensediet1.com +interactio.ch +interactionpolls.com +interans.ru +interceptor.waw.pl +interceptorfordogs.info +interceramicvpsx.com +interenerational.store +interfee.it +interiorimages.in +interiorin.ru +intermax.com +intermedia-ag-limited.com +intermediateeeee.vip +internationalseo-org.numisdaddy.com +internationalvilla.com +internaut.us.to +internet-marketing-companies.com +internet-search-machine.com +internet-v-stavropole.ru +internet-w-domu.tk +internet.krd +internet.v.pl +internetaa317.xyz +internetallure.com +internetdladomu.pl +internetfl.com +internetkeno.com +internetmail.cf +internetmail.ga +internetmail.gq +internetmail.ml +internetmail.tk +internetnetzwerk.de +internetoftags.com +internetreputationconsultant.com +internettrends.us +internetwplusie.pl +interpath.com +interpos.world +interpretations.store +interprogrammer.com +interserver.ga +interstats.org +intersteller.com +interwin99.net +intfoam.com +inthebox.pw +inthelocalfortwortharea.com +inthenhuahanoi.com +intim-dreams.ru +intim-plays.ru +intimacly.com +intimeontime.info +intimstories.com +into.cowsnbullz.com +into.lakemneadows.com +into.martinandgang.com +into.oldoutnewin.com +intobx.com +intolm.site +intomail.bid +intomail.info +intomail.win +intopwa.com +intopwa.net +intopwa.org +intothenight1243.com +intrarmour.com +intrees.org +intrested12.uk +introace.com +introex.com +intrxi6ti6f0w1fm3.cf +intrxi6ti6f0w1fm3.ga +intrxi6ti6f0w1fm3.gq +intrxi6ti6f0w1fm3.ml +intrxi6ti6f0w1fm3.tk +intsv.net +intuthewoo.com.my +intxr.com +inunglove.cf +inupup.com +inuvu.com +invadarecords.com +invasidench.site +invecemtm.tech +invecra.com +inveitro.com +invert.us +invest-eko.pl +investering-solenergi.dk +investfxlearning.com +investingtur.com +investor.xyz +investore.co +investvvip.com +invictawatch.net +invictuswebportalservices.com +invistechitsupport.com +invodua.com +invql.com +invtribe02.xyz +invtribe04.xyz +inwagit.com +inwebmail.com +inwebtm.com +inwmail.net +inwoods.org +inxto.net +inyoung.shop +inzaq.anonbox.net +inzh-s.ru +ioad.mailpwr.com +ioangle.com +iodizc3krahzsn.cf +iodizc3krahzsn.ga +iodizc3krahzsn.gq +iodizc3krahzsn.ml +iodizc3krahzsn.tk +iodog.com +ioea.net +ioemail.win +ioenytae.com +iofij.gq +ioio.eu +iolkjk.cf +iolkjk.ga +iolkjk.gq +iolkjk.ml +iolokdi.ga +iolokdi.ml +iomail.com +ionazara.co.cc +ionb1ect2iark1ae1.cf +ionb1ect2iark1ae1.ga +ionb1ect2iark1ae1.gq +ionb1ect2iark1ae1.ml +ionb1ect2iark1ae1.tk +ione.com +ionemail.net +ionictech.com +ionot.xyz +ionq.pl +ionucated.com +ioplo.com +iopmail.com +ioqjwpoeiqpoweq.ga +iordan-nedv.ru +iosb4.anonbox.net +iosil.info +ioswed.com +iot.aiphone.eu.org +iot.ptcu.dev +iot.vuforia.us +iotatheta.wollomail.top +iotf.net +iototal.com +iotrama.com +iotrh5667.cf +iotrh5667.ga +iotrh5667.gq +iotrh5667.ml +iotu.creo.site +iotu.de.vipqq.eu.org +iotu.nctu.me +iouiwoerw32.info +iouy67cgfss.cf +iouy67cgfss.ga +iouy67cgfss.gq +iouy67cgfss.ml +iouy67cgfss.tk +iowachevron.com +iowaexxon.com +iowatelcom.net +ioxmail.net +iozak.com +ip-u.tech +ip-xi.gq +ip.emlpro.com +ip.webkrasotka.com +ip23xr.ru +ip3qc6qs2.pl +ip4.pp.ua +ip4k.me +ip6.li +ip6.pp.ua +ip60.net +ip7.win +ipad2preis.de +ipad3.co +ipad3.net +ipad3release.com +ipaddlez.info +ipaddressforme.com +ipadhd3.co +ipadzzz.com +ipahive.org +ipalexis.site +ipan.info +ipanemabeach.pics +ipark.pl +ipay-i.club +ipbeyond.com +ipdeer.com +ipemail.win +ipervo.site +ipff.spymail.one +ipgenerals.com +iphone-ipad-mac.xyz +iphone.gb.net +iphoneaccount.com +iphoneandroids.com +iphonebestapp.com +iphonemail.cf +iphonemail.ga +iphonemail.gq +iphonemail.tk +iphonemsk.com +iphoneonandroid.com +ipictures.xyz +ipimail.com +ipindetail.com +ipiranga.dynu.com +ipiurl.net +ipizza24.ru +ipjckpsv.pl +ipk.emlpro.com +iplayer.com +iplusplusmail.com +ipniel.com +ipnuc.com +ipochta.gq +ipoczta.waw.pl +ipod-app-reviews.com +ipolopol.com +ipoo.org +iposta.ml +ippals.com +ippandansei.tk +ippexmail.pw +ipriva.com +ipriva.info +ipriva.net +iprloi.com +ipsur.org +ipswell.com +iptakedownusa.com +iptonline.net +iptvforza.com +ipuccidresses.com +ipusku.com +ipvideo63.ru +ipxwan.com +ipyzqshop.com +iq.emlhub.com +iq.freeml.net +iq2fm.anonbox.net +iq2kq5bfdw2a6.cf +iq2kq5bfdw2a6.ga +iq2kq5bfdw2a6.gq +iq2kq5bfdw2a6.ml +iqamail.com +iqazmail.com +iqcfpcrdahtqrx7d.cf +iqcfpcrdahtqrx7d.ga +iqcfpcrdahtqrx7d.gq +iqcfpcrdahtqrx7d.ml +iqcfpcrdahtqrx7d.tk +iqemail.win +iqimail.com +iqje.com +iqmail.com +iqsfu65qbbkrioew.cf +iqsfu65qbbkrioew.ga +iqsfu65qbbkrioew.gq +iqsfu65qbbkrioew.ml +iqsfu65qbbkrioew.tk +iquantumdg.com +iqud.emlhub.com +iqumail.com +iqut.freeml.net +iqyw.emltmp.com +iqzpufjf.storeyee.com +iqzzfdids.pl +ir.emlpro.com +ir101.net +ir4.tech +irabops.com +irahada.com +iral.de +iralborz.bid +iran-nedv.ru +iranbourse.co +iraniandsa.org +iranluxury.tours +iranmarket.info +iraq-nedv.ru +iraqi-iod.net +iraticial.site +irc.so +ircbox.xyz +irdneh.cf +irdneh.ga +irdneh.gq +irdneh.ml +irdneh.tk +irebah.com +iredirect.info +iremail.com +iremel.cf +ireprayers.com +irgilio.it +iridales.com +irinaeunbebescump.com +irinakicka.site +irish2me.com +irishbella.art +irishspringrealty.com +iristrend.shop +irlanc.com +irland-nedv.ru +irlmail.com +irmail.com +irmh.com +irnini.com +iroid.com +iroirorussia.ru +irolpccc.com +irolpo.com +iron1.xyz +ironarmail.com +ironfire.net +ironflys.com +ironhulk.com +ironiebehindert.de +ironmantriathlons.net +ironside.systems +iroquzap.asia +irovonopo.com +irpanenjin.com +irper.com +irpine.com +irr.kr +irresistible-scents.com +irrv.emlpro.com +irsanalysis.com +irsguidelines.net +irssi.tv +irti.info +irtranslate.net +irydoidy.pl +is-halal.tk +is-zero.info +is.af +is.yomail.info +is35.com +isa.net +isabelmarant-sneaker.us +isabelmarants-neakers.us +isabelmarantshoes.us +isabelmarantsneakerssonline.info +isac-hermes.com +isachermeskelly.com +isaclongchamp.com +isafurry.xyz +isaiminii.host +isaisahaseayo.com +isamy.wodzislaw.pl +isartegiovagnoli.com +isbjct4e.com +isc2.ml +iscidayanismasi.org +isdaq.com +isdik.com +ise4mqle13.o-r.kr +isecsystems.com +isecv.com +iseeyouu.site +isellnow.com +isemail.com +isen.pl +iseovels.com +isep.fr.nf +isf4e2tshuveu8vahhz.cf +isf4e2tshuveu8vahhz.ga +isf4e2tshuveu8vahhz.gq +isf4e2tshuveu8vahhz.ml +isf4e2tshuveu8vahhz.tk +isfew.com +isfqr.anonbox.net +isfu.ru +ishan.ga +ishense.com +ishikawa28.flatoledtvs.com +ishockey.se +ishop-go.ru +ishop2k.com +ishyp.com +isi-group.ru +isi-tube.com +isis-salvatorelli.it +iskcondc.org +iskiie.com +iskiie.info +islam.igg.biz +islamm.cf +islamm.gq +islandbreeze-holidays.com +islandi-nedv.ru +islandshomecareagency.com +islelakecharles.com +islj6.anonbox.net +isluntvia.com +ismailgul.net +ismartsense.online +isncwoqga.pl +isni.net +isnote.online +isomnio.com +isophadal.xyz +isosq.com +isotretinoinacnenomore.net +isp.fun +ispbd.xyz +ispeedtest.digital +ispeshel.com +ispuntheweb.com +ispyco.ru +israel-nedv.ru +israelserver2.com +israelserver3.com +israelserver4.com +isrw.xyz +issamartinez.com +issanda.com +issfw.anonbox.net +isslab.ru +issou.cloud +issthnu7p9rqzaew.cf +issthnu7p9rqzaew.ga +issthnu7p9rqzaew.gq +issthnu7p9rqzaew.ml +issthnu7p9rqzaew.tk +ist-allein.info +ist-einmalig.de +ist-ganz-allein.de +ist-genial.at +ist-genial.info +ist-genial.net +ist-hier.com +ist-willig.de +istakalisa.club +istanbulescorthatti.com +istanbulnights.eu +istanbulsiiri.com +istcool.com +istearabul.site +istii.ro +istinaf.net +istirdad.website +istitutocomprensivo-cavaglia.it +istlecker.de +istmail.tk +istrategy.ws +istreamingtoday.com +istudey.com +isueir.com +isukrainestillacountry.com +isum.mimimail.me +iswc.info +iswire.com +isxuldi8gazx1.ga +isxuldi8gazx1.ml +isxuldi8gazx1.tk +iszkft.hu +it-erezione.site +it-everyday.com +it-italy.cf +it-italy.ga +it-italy.gq +it-italy.ml +it-italy.tk +it-service-in-heidelberg.de +it-service-sinsheim.de +it-simple.net +it-smart.org +it-vopros.ru +it.cowsnbullz.com +it.freeml.net +it.marksypark.com +it.ploooop.com +it.poisedtoshrike.com +it2-mail.tk +it2sale.com +it7.ovh +itacto.com +itailorphuket.com +italia.flu.cc +italia.igg.biz +italianspirit.pl +italiavendecommerciali.online +italkcash.com +italpostall.com +italy-mail.com +italy-nedv.ru +italyborselvoutlet.com +itaolo.com +itbury.com +itcdeganutti.it +itcess.com +itchapchap.com +itclub-smanera.tech +itcompu.com +itdesi.com +itech-versicherung.de +itecsgroup.org +itekc.com +itekcorp.com +itemailing.com +itemku.cfd +itemp.email +itempmail.tk +iteradev.com +itfast.net +itgracevvx.com +ithostingreview.com +itibmail.com +itid.info +itilchange.com +itiomail.com +itis0k.com +itis0k.org +itjustmail.tk +itk.emlhub.com +itks6xvn.gq +itl.laste.ml +itleadersfestival.com +itlrodk.com +itm311.com +itmailbox.info +itmailing.com +itmailr.com +itmaschile.site +itmtx.com +itntucson.com +itoh.de +itoup.com +itovn.net +itoxwehnbpwgr.cf +itoxwehnbpwgr.ga +itoxwehnbpwgr.gq +itoxwehnbpwgr.ml +itoxwehnbpwgr.tk +itregi.com +itrental.com +itri.de +itromail.hu +its-systems.com +its.marksypark.com +its0k.com +itsahmad.me +itsbds.com +itsdoton.org +itsecpackets.com +itsedit.click +itsfiles.com +itsgood2berich.com +itsjiff.com +itsme.edu.pl +itsmegru.com +itsmenotyou.com +itspanishautoinsuranceshub.live +itspid.com +itsrecess.com +itsuki86.bishop-knot.xyz +itt.it.com +itue33ubht.ga +itue33ubht.gq +itue33ubht.tk +itunesgiftcodegenerator.com +iturchia.com +iturkei.com +itvends.com +itvng.com +itwbuy.com +itxsector.ru +itymail.com +iu.dropmail.me +iu54edgfh.cf +iu54edgfh.ga +iu54edgfh.gq +iu54edgfh.ml +iu54edgfh.tk +iu66sqrqprm.cf +iu66sqrqprm.ga +iu66sqrqprm.gq +iu66sqrqprm.ml +iu66sqrqprm.tk +iuanhoi.store +iubridge.com +iucake.com +iuemail.men +iug.emlhub.com +iuh.laste.ml +iuj.yomail.info +iumail.com +iunicus.com +iuporno.info +iura.com +iuroveruk.com +iuse.ydns.eu +iuxi.freeml.net +iv-fr.net +ivaguide.com +ivaluandersen.me +ivalujorgensen.me +ivankasuwandi.art +ivans.me +ivbb.spymail.one +iveai.com +ivecotrucks.cf +ivecotrucks.ga +ivecotrucks.gq +ivecotrucks.ml +ivecotrucks.tk +ivii.ml +iviruseries3.ru +iviruseries4.ru +iviruseries5.ru +ivizx.com +ivlt.com +ivmail.com +ivoiviv.com +ivoricor.com +ivorynorthandoak.com +ivosimilieraucute.com +ivph.yomail.info +ivsao.com +ivtmg.anonbox.net +ivuhmail.com +ivx.emltmp.com +ivyandmarj.com +ivybotreviews.net +ivyplayers.com +ivysheirlooms.net +ivzapp.com +iw409uttadn.cf +iw409uttadn.ga +iw409uttadn.gq +iw409uttadn.ml +iw409uttadn.tk +iwakbandeng.xyz +iwanbanjarworo.cf +iwancorp.cf +iwankopi.cf +iwantmyname.com +iwanttoms.com +iwantumake.us +iwatermail.com +iwdal.com +iwebtm.com +iweu.emlpro.com +iwf.emlpro.com +iwi.net +iwin.ga +iwishiwereyoubabygirl.com +iwmfuldckw5rdew.cf +iwmfuldckw5rdew.ga +iwmfuldckw5rdew.gq +iwmfuldckw5rdew.ml +iwmfuldckw5rdew.tk +iwmq.com +iwnntnfe.com +iwoc.de +iwristlu.com +iwrk.ru +iwrservices.com +iwspcs.net +iwtclocks.com +iwv.freeml.net +iwv06uutxic3r.cf +iwv06uutxic3r.ga +iwv06uutxic3r.gq +iwv06uutxic3r.ml +iwv06uutxic3r.tk +iwykop.pl +iwyt.com +ix.emltmp.com +ix.pxwsi.com +ix.spymail.one +ixaks.com +ixdh.mailpwr.com +ixhale.com +iximhouston.com +ixjx.com +ixkrofnxk.pl +ixkxirzvu10sybu.cf +ixkxirzvu10sybu.ga +ixkxirzvu10sybu.gq +ixkxirzvu10sybu.ml +ixkxirzvu10sybu.tk +ixloud.me +ixospace.com +ixqh.emltmp.com +ixsus.website +ixtwhjqz4a992xj.cf +ixtwhjqz4a992xj.ga +ixtwhjqz4a992xj.gq +ixtwhjqz4a992xj.ml +ixtwhjqz4a992xj.tk +ixunbo.com +ixvfhtq1f3uuadlas.cf +ixvfhtq1f3uuadlas.ga +ixvfhtq1f3uuadlas.gq +ixvfhtq1f3uuadlas.ml +ixvfhtq1f3uuadlas.tk +ixwg.spymail.one +ixx.io +ixxnqyl.pl +ixxycatmpklhnf6eo.cf +ixxycatmpklhnf6eo.ga +ixxycatmpklhnf6eo.gq +ixzcgeaad.pl +iy.emltmp.com +iy47wwmfi6rl5bargd.cf +iy47wwmfi6rl5bargd.ga +iy47wwmfi6rl5bargd.gq +iy47wwmfi6rl5bargd.ml +iy47wwmfi6rl5bargd.tk +iya.emltmp.com +iya.fr.nf +iya.my.id +iyaomail.com +iyapokers.com +iyettslod.com +iyfr.es +iymail.com +iymktphn.com +iymw.yomail.info +iyo.laste.ml +iyomail.com +iyouwe.com +iysqv.com +iytt.laste.ml +iytyicvta.pl +iyumail.com +iyutbingslamet.art +iz0tvkxu43buk04rx.cf +iz0tvkxu43buk04rx.ga +iz0tvkxu43buk04rx.gq +iz0tvkxu43buk04rx.ml +iz0tvkxu43buk04rx.tk +iz3oht8hagzdp.cf +iz3oht8hagzdp.ga +iz3oht8hagzdp.gq +iz3oht8hagzdp.ml +iz3oht8hagzdp.tk +iz4acijhcxq9i30r.cf +iz4acijhcxq9i30r.ga +iz4acijhcxq9i30r.gq +iz4acijhcxq9i30r.ml +iz4acijhcxq9i30r.tk +iza.emlhub.com +izagipepy.pro +izbe.info +izc.laste.ml +izeao.com +izemail.com +izeqmail.com +izgmail.com +izhf.emlpro.com +izhowto.com +izj.emltmp.com +izkat.com +izmail.net +izmirseyirtepe.net +izn.dropmail.me +iznai.ru +izolacja-budynku.info.pl +izoli9afsktfu4mmf1.cf +izoli9afsktfu4mmf1.ga +izoli9afsktfu4mmf1.gq +izoli9afsktfu4mmf1.ml +izoli9afsktfu4mmf1.tk +izondesign.com +izooba.com +iztz.freeml.net +izub.emlhub.com +izzum.com +j-b.us +j-jacobs-cugrad.info +j-keats.cf +j-keats.ga +j-keats.gq +j-keats.ml +j-keats.tk +j-labo.com +j-p.us +j.aq.si +j.fairuse.org +j.polosburberry.com +j.rvb.ro +j.teemail.in +j0mail.net +j12345.ru +j24blog.com +j275xaw4h.pl +j2anellschild.ga +j3j.org +j3nn.net +j3rqt89ez.com +j4rang0y4nk.ga +j5abn.anonbox.net +j5vhmmbdfl.cf +j5vhmmbdfl.ga +j5vhmmbdfl.gq +j5vhmmbdfl.ml +j5vhmmbdfl.tk +j7.cloudns.cx +j7cnw81.net.pl +j8-freemail.cf +j8k2.usa.cc +j9356.com +j9rxmxma.pl +j9ysy.com +ja.laste.ml +ja.yomail.info +jaaj.cf +jaanv.com +jabberflash.info +jabl.laste.ml +jabmag.com +jabpid.com +jac.yomail.info +jaccessedsq.com +jacckpot.site +jack762.info +jackaoutlet.com +jackertamekl.site +jackets-monclers-sale.com +jacketwarm.com +jackkkkkk.com +jackleg.info +jackmailer.com +jackopmail.tk +jackpot-slot-online.com +jackqueline.com +jackreviews.com +jacksonhole.homes +jacksonhole.house +jacksonsshop.com +jacksonzje.com +jackymail.top +jacmelinter.xyz +jacob-jan-boerma.art +jacobjanboerma.art +jacoblangvad.com +jacquelx.com +jacquestorres.com +jad32.cf +jad32.ga +jad32.gq +jade.me +jadecouture.shop +jadeschoice.com +jadihost.tk +jadopado.com +jadotech.com +jadsys.com +jaelyn.amina.wollomail.top +jaeyoon.ga +jaffao.pw +jaffx.com +jafhd.com +jafps.com +jafrem3456ails.com +jaga.email +jagbreakers.com +jagdglas.de +jaggernaut-email.bid +jaggernautemail.bid +jaggernautemail.trade +jaggernautemail.website +jaggernautemail.win +jagokonversi.com +jagomail.com +jagongan.ml +jaguar-landrover.cf +jaguar-landrover.ga +jaguar-landrover.gq +jaguar-landrover.ml +jaguar-landrover.tk +jaguar-xj.ml +jaguar-xj.tk +jah8.com +jaheen.info +jahgsthvgas21231.ml +jahgsthvgas21936.cf +jahgsthvgas21936.ga +jahgsthvgas21936.ml +jahgsthvgas72260.ml +jahgsthvgas74241.ml +jahgsthvgas75373.cf +jahgsthvgas75373.ga +jahgsthvgas75373.ml +jahgsthvgas99860.cf +jahgsthvgas99860.ga +jahgsthvgas99860.ml +jahsec.com +jaijaifincham.ml +jailbreakeverything.com +jailscoop.com +jaimenwo.cf +jaimenwo.ga +jaimenwo.gq +jaimihouse.co +jaipas.lu +jaiwork-google.ml +jajomail.com +jajp.emlpro.com +jajsus.com +jajxz.com +jak-szybko-schudnac.com +jak-zaoszczedzic.pl +jakamarcusguillermo.me +jakemsr.com +jakepearse.com +jakesfamous.us +jakesfamousfoods.info +jakesfamousfoods.org +jaki-kredyt-wybrac.pl +jakjtavvtva8ob2.cf +jakjtavvtva8ob2.ga +jakjtavvtva8ob2.gq +jakjtavvtva8ob2.ml +jakjtavvtva8ob2.tk +jakobine12.me +jakschudnac.org +jakubos.yourtrap.com +jakwyleczyc.pl +jal.laste.ml +jalcemail.com +jalcemail.net +jalhaja.net +jalicodojo.com +jalunaki.com +jalushi.best +jalynntaliyah.coayako.top +jam219.gq +jam4d.asia +jam4d.biz +jam4d.store +jama.trenet.eu +jamaicaawareness.net +jamaicarealestateclassifieds.com +jamaicatirediscountergroup.com +jamalfishbars.com +jamalwilburg.com +jambcbtsoftware.com +jambuseh.info +jambuti.com +jamcatering.ru +jameagle.com +jamel.com +james-design.com +jamesbild.com +jamesbond.flu.cc +jamesbond.igg.biz +jamesbond.nut.cc +jamesbond.usa.cc +jamesbradystewart.com +jamesejoneslovevader.com +jameskutter.com +jamesorjamie.com +jameszol.net +jameszol.org +jamiecantsingbroo.com +jamieisprouknowit.com +jamiesnewsite.com +jamieziggers.nl +jamikait.cf +jamikait.ga +jamikait.gq +jamikait.ml +jaminwd.com +jamit.com.au +jamiweb.com +jamshoot.com +jamtogel.org +janavalerie.miami-mail.top +jancloud.net +jancok.co +jancok.in +jancokancene.cf +jancokancene.ga +jancokancene.gq +jancokancene.ml +jancokcp.com +jancoklah.com +jancuk.tech +jandetin.ga +jandjfloorcovering.com +janekimmy.com +janet-online.com +janewsonline.com +jangandek.space +janganiri.online +janganjadiabu1.tk +janganjadiabu10.gq +janganjadiabu2.ml +janganjadiabu3.ga +janganjadiabu4.cf +janganjadiabu5.gq +janganjadiabu6.tk +janganjadiabu7.ml +janganjadiabu8.ga +janganjadiabu9.cf +jango.wiki +janiceaja.atlanta-webmail.top +janics.com +janismedia.tk +janjiabdurrohim.biz +janmail.org +jannat.ga +jannice.com +jannyblog.space +janproz.com +jantanpoker.com +jantrawat.site +jantyworld.pl +janurganteng.com +janvan.gent +japabounter.site +japan-monclerdown.com +japan-next.online +japanawesome.com +japanesenewshome.com +japanesesexvideos.xyz +japanesetoryburch.com +japanyn7ys.com +japjap.com +japnc.com +jaqis.com +jaqs.site +jaqueline1121.club +jar-opener.info +jarilusua.com +jaringan.design +jarlo-london.com +jarringsafri.biz +jarsdigital.sbs +jarszone.online +jarumpoker1.com +jarxs-vpn.ml +jasabacklinkmurah.com +jasabacklinkpbn.co.id +jasaseomurahin.com +jasawebsitepremium.com +jasd.com +jasilu.com +jasinski-doradztwo.pl +jasmierodgers.ga +jasminsusan.paris-gmail.top +jasmne.com +jasonbella.online +jasxft.fun +jatake.online +jatmikav.top +jauhari.cf +jauhari.ga +jauhari.gq +jav8.cc +javadmin.com +javadoq.com +javaemail.com +javamail.org +javamusic.id +javbing.com +javdeno.site +javhold.com +javierllaca.com +javmail.tech +javmaniac.co +javnoi.com +jawtec.com +jaxprop.com +jaxwin.ga +jaxworks.eu +jaxxken.xyz +jay4justice.com +jaya125.com +jaygees.ml +jayjessup.com +jaylene.ashton.london-mail.top +jaypetfood.com +jaysclay.org +jaysum.com +jayz-tickets.com +jazipo.com +jazzvip.site +jb-production.com +jb.emlhub.com +jb.yomail.info +jb73bq0savfcp7kl8q0.ga +jb73bq0savfcp7kl8q0.ml +jb73bq0savfcp7kl8q0.tk +jbegn.info +jbhp.mimimail.me +jbkju-lkj.xyz +jbl-russia.ru +jbniklaus.com +jbnote.com +jbxyuoyptm.ga +jc56owsby.pl +jcal.spymail.one +jcausedm.com +jcb.laste.ml +jcbouchet.fr +jccp.emltmp.com +jcdmail.men +jcdpropainting.com +jceffi8f.pl +jcgarrett.com +jcgawsewitch.com +jcn.dropmail.me +jcnorris.com +jcpclothing.ga +jcw.dropmail.me +jd.emlhub.com +jd.spymail.one +jdas-mail.net +jdasdhj.cf +jdasdhj.ga +jdasdhj.gq +jdasdhj.ml +jdasdhj.tk +jdbzcblg.pl +jddrew.com +jde53sfxxbbd.cf +jde53sfxxbbd.ga +jde53sfxxbbd.gq +jde53sfxxbbd.ml +jde53sfxxbbd.tk +jdecorz.com +jdeeedwards.com +jdefiningqt.com +jdf.pl +jdiwop.com +jdjdj.com +jdjdjdj.com +jdl5wt6kptrwgqga.cf +jdl5wt6kptrwgqga.ga +jdl5wt6kptrwgqga.gq +jdl5wt6kptrwgqga.ml +jdl5wt6kptrwgqga.tk +jdmadventures.com +jdnjraaxg.pl +jdow.com +jdp.emltmp.com +jdtfdf55ghd.ml +jdub.de +jdvbm.anonbox.net +jdvmail.com +jdweiwei.com +jdz.ro +je-recycle.info +je7f7muegqi.ga +je7f7muegqi.gq +je7f7muegqi.ml +je7f7muegqi.tk +jeansname.com +jeansoutlet2013.com +jeanssi.com +jebqo.top +jeddahtravels.com +jeden.akika.pl +jedojour.com +jedrnybiust.pl +jeenza.com +jeep-official.cf +jeep-official.ga +jeep-official.gq +jeep-official.ml +jeep-official.tk +jeffcoscools.us +jeffersonandassociates.com +jeffersonbox.com +jefferygroup.com +jeffreypeterson.info +jehfbee.site +jeie.igg.biz +jeitodecriar.ga +jejeje.com +jeld.com +jellow.ml +jelly-life.com +jellyrollpan.net +jellyrolls.com +jelm.de +jembotbrodol.com +jembott.com +jembud.icu +jembulan.bounceme.net +jembut142.cf +jembut142.ga +jembut142.gq +jembut142.ml +jembut142.tk +jeme.com +jemmctldpk.pl +jennie.club +jenniferlillystore.com +jenniferv.emlhub.com +jensden.co.uk +jensenbeachfishingcharters.com +jensenthh.club +jensinefrederiksen.me +jensumedergy.site +jentrix.com +jenu.emlhub.com +jenz.com +jeoce.com +jeongjin12.com +jepijopiijo.cf +jepijopiijo.ga +jepijopiijo.gq +jepijopiijo.ml +jepijopiijo.tk +jepitkaki.dev +jeppeson.com +jeralo.de +jeramywebb.com +jerapah993r.gq +jerbase.site +jere.biz +jeremytunnell.net +jeremywood.xyz +jerf.de +jerk.com +jernang.com +jeromebanctel.art +jerq.space +jerryscot.site +jerseymallusa.com +jerseyonsalestorehere.com +jerseysonlinenews.com +jerseysonlinesshop.com +jerseysshopps.com +jerseysv.com +jerseysyoulikestore.com +jerseyzone4u.com +jersto.com +jescanned.com +jesdoit.com +jesien-zima.com.pl +jesocalsupply.com +jessejames.net +jessica514.cf +jessicalife.com +jessyaries.co.uk +jessyaries.com +jessyaries.uk +jestemkoniem.com.pl +jestyayin27.com +jesus.com +jesusmail.com.br +jesusnotjunk.org +jesusstatue.net +jet-renovation.fr +jet.fyi +jetable.com +jetable.de +jetable.email +jetable.fr.nf +jetable.net +jetable.org +jetable.pp.ua +jetableemail.com +jetableemails.com +jetconvo.com +jeternet.com +jetfix.ee +jetfly.media +jetqunrb.pl +jetreserve.ir +jetsay.com +jetsmails.com +jetzt-bin-ich-dran.com +jeu3ds.com +jeux-gratuits.us +jeux-online0.com +jeux3ds.org +jeuxds.fr +jevc.dropmail.me +jevc.life +jewel.ie +jewellrydo.com +jewelrycellar.com +jewelrymakingideas.site +jewishnewsdaily.com +jewu.cf +jex-mail.pl +jezykoweradio.pl +jf.emltmp.com +jfc.emlpro.com +jfdesignandweb.com +jffabrics85038.com +jfgfgfgdfdder545yy.ml +jfhuiwop.com +jfiee.tk +jfmtv.online +jforgotum.com +jfp.emlhub.com +jftruyrfghd8867.cf +jftruyrfghd8867.ga +jftruyrfghd8867.gq +jftruyrfghd8867.ml +jftruyrfghd8867.tk +jfwrt.com +jfxyl.anonbox.net +jfzq.spymail.one +jgaweou32tg.com +jgd.emlhub.com +jgeduy.buzz +jgerbn4576aq.cf +jgerbn4576aq.ga +jgerbn4576aq.gq +jgerbn4576aq.ml +jgerbn4576aq.tk +jgg.emlhub.com +jgg4hu533327872.krhost.ga +jgi21rz.nom.pl +jglopez.net +jgmkgxr83.pl +jgnij.anonbox.net +jgrchhppkr.xorg.pl +jgroupdesigns.com +jgwinindia.com +jh.dropmail.me +jh.emlpro.com +jh.spymail.one +jheardinc.com +jhgiklol.gq +jhgxy.anonbox.net +jhhgcv54367.cf +jhhgcv54367.ga +jhhgcv54367.ml +jhhgcv54367.tk +jhib.de +jhjhj.com +jhjty56rrdd.cf +jhjty56rrdd.ga +jhjty56rrdd.gq +jhjty56rrdd.ml +jhjty56rrdd.tk +jhl.laste.ml +jhonkeats.me +jhotmail.co.uk +jhow.cf +jhow.ga +jhow.gq +jhow.ml +jhp.emlhub.com +jhphoto.top +jhptraining.com +jhsn.freeml.net +jhsss.biz +jhuf.net +jhugodfng.shop +jhut.emlpro.com +jhw.spymail.one +ji-a.cc +ji.spymail.one +ji5.de +ji6.de +ji7.de +jiahyl.com +jialefujialed.info +jiancok.cf +jiancok.ga +jiancok.gq +jiancokowe.cf +jiancokowe.ga +jiancokowe.gq +jiancokowe.ml +jiang-ba.cc +jiang-vvx.shop +jiangvps.xyz +jiaotongyinhang.net +jiapai.org +jiatou123jiua.info +jiaxin8736.com +jibbanbila.com +jibbangod.biz +jibbo01.com +jibbobodi.tech +jibitpay.com +jibjabprocode.com +jicp.com +jid.li +jidanshoppu.com +jidb.emlhub.com +jieber.net +jieluv.com +jiez00veud9z.cf +jiez00veud9z.ga +jiez00veud9z.gq +jiez00veud9z.ml +jiez00veud9z.tk +jift.xyz +jiga.site +jigarvarma2005.cf +jigglypuff.com +jigjournal.org +jigsawdigitalmarketing.com +jika.gg +jikadeco.com +jikoiudi21.com +jil.kr +jilet.net +jiljadid.info +jilm.com +jilossesq.com +jimal.com +jimbow.ir +jimersons.us +jimhoyd.com +jimjaagua.com +jimmychooshoesuksale.info +jimmychoowedges.us +jimong.com +jinbeibeibagonline.com +jincer.com +jindmail.club +jinggakop.ga +jinggakop.gq +jinggakq.ml +jingjignsod.com +jinguanghu.com +jining2321.info +jinnesia.site +jinnmail.net +jinsguaranteedpaydayloans.co.uk +jinva.fr.nf +jio1.com +jiooq.com +jioso.com +jir.freeml.net +jir.su +jiskhdgbgsytre43vh.ga +jisngg-www.xyz +jitsu.my +jitsuni.net +jitteryfajarwati.co +jiuere.com +jiujitsuappreviews.com +jiujitsushop.biz +jiujitsushop.com +jiy.laste.ml +jiyankotluk.xyz +jiynw.anonbox.net +jj.freeml.net +jj456.com +jjc.laste.ml +jjchoosetp.com +jjdjshoes.com +jjdong16.com +jjdong17.com +jjdong25.com +jjdong28.com +jjdong29.com +jjdong30.com +jjdong32.com +jjdong35.com +jjdong37.com +jjdong38.com +jjdong39.com +jjdong7.com +jjdong8.com +jjdong9.com +jjeonji12.com +jjgg.de +jjhgg.com +jji.spymail.one +jjj.ee +jjjiii.ml +jjk.app +jjkgrtteee098.cf +jjkgrtteee098.ga +jjkgrtteee098.gq +jjkgrtteee098.ml +jjkgrtteee098.tk +jjl.laste.ml +jjlink.cn +jjmsb.eu.org +jjnw.mimimail.me +jjodri.com +jjohbqppg.shop +jjumples.com +jjy.freeml.net +jkautomation.com +jkcntadia.cf +jkcntadia.ga +jkcntadia.gq +jkcntadia.ml +jkcntadia.tk +jkdihanie.ru +jkhk.de +jkhx.yomail.info +jkillins.com +jkiohiuhi32.info +jkjsrdtr35r67.cf +jkjsrdtr35r67.ga +jkjsrdtr35r67.gq +jkjsrdtr35r67.ml +jkjsrdtr35r67.tk +jkk.yomail.info +jklasdf.com +jklbkj.com +jkljkl.cf +jkljkl.ga +jklsssf.com +jklthg.co.uk +jkmechanical.com +jkotypc.com +jkrowlg.cf +jkrowlg.ga +jkrowlg.gq +jkrowlg.ml +jktyres.com +jkyvznnqlrc.gq +jkyvznnqlrc.ml +jkyvznnqlrc.tk +jl.freeml.net +jlajah.com +jlegue.buzz +jlelio.buzz +jlets.com +jlmei.com +jlmz.emltmp.com +jlww.emlhub.com +jlz.emlpro.com +jlzxjeuhe.pl +jm407.ml +jm407.tk +jmail.com +jmail.fr.nf +jmail.ovh +jmail.ro +jmail7.com +jmalaysiaqc.com +jmanagersd.com +jmdx.emlpro.com +jme.emltmp.com +jmgbuilder.com +jmhprinting.com +jmjhomeservices.com +jmortgageli.com +jmpant.com +jmqtop.pl +jmsmashie.tk +jmvdesignerstudio.com +jmvoice.com +jmy829.com +jmymy.com +jn-club.de +jn.emlpro.com +jnckteam.eu +jncylp.com +jnd.freeml.net +jndu8934a.pl +jnfengli.com +jnggachoc.cf +jnggachoc.gq +jnggmysqll.com +jnhbvjjyuh.com +jnhx.dropmail.me +jnifyqit.shop +jnm.emltmp.com +jnnnkmhn.com +jnpayy.com +jnsgt66.kwikto.com +jnswritesy.com +jnthn39vr4zlohuac.cf +jnthn39vr4zlohuac.ga +jnthn39vr4zlohuac.gq +jnthn39vr4zlohuac.ml +jnthn39vr4zlohuac.tk +jnud.dropmail.me +jnww3.anonbox.net +jnxc.mimimail.me +jnxjn.com +jnyfyxdhrx85f0rrf.cf +jnyfyxdhrx85f0rrf.ga +jnyfyxdhrx85f0rrf.gq +jnyfyxdhrx85f0rrf.ml +jnyfyxdhrx85f0rrf.tk +jo-mail.com +jo.com +jo6s.com +jo8otki4rtnaf.cf +jo8otki4rtnaf.ga +jo8otki4rtnaf.gq +jo8otki4rtnaf.ml +jo8otki4rtnaf.tk +joagold.com +joakarond.tk +joannaalexandra.art +joannfabricsad.com +joanroca.art +joaquinito01.servehttp.com +joasantos.ga +job.blurelizer.com +job.cowsnbullz.com +job.craigslist.org +job.lakemneadows.com +job.yomail.info +jobbersonline.com +jobbikszimpatizans.hu +jobbrett.com +jobcheetah.com +jobdesk.org +jobeksuche.com +jobkim.com +jobku.id +joblike.com +jobmegov.com +jobo.me +jobposts.net +jobras.com +jobs-to-be-done.net +jobs.elumail.com +jobsfeel.com +jobsforsmartpeople.com +jobslao.com +jobssearch.online +jobstoknow.com +jobstreet.cam +jobstudy.us +jobsunleashed.vet +jobtsreet.my +jobzyy.com +jocksturges.in +joef.de +joelpet.com +joelstahre.com +joeltest.co.uk +joeneo.com +joergplagens.de +joeroc.com +joerty.network +joestar.us +joetestalot.com +joey.com +joeymx.com +joeypatino.com +jofap.com +jofuso.com +johanaeden.spithamail.top +johanlibearth.com +johannedavidsen.me +johannelarsen.me +johanssondeterry.es +john-doe.cf +john-doe.ga +john-doe.gq +john-doe.ml +john.emlpro.com +johnderasia.com +johndoe.tech +johnhkung.online +johnjuanda.org +johnkeellsgroup.com +johnkokenzie.com +johnnycarsons.info +johnpiser.site +johnpo.cf +johnpo.ga +johnpo.gq +johnpo.ml +johnpo.tk +johnscargall.com +johnsonmotors.com +johnswanson.com +johonkemana.com +johonmasalalu.com +joi.com +joiket.space +join-4-free.bid +join-taxi.ru +join.blatnet.com +join.emailies.com +joinemonend.com +joinm3.com +joinmenow.online +joinmenow.store +joint.website +jointcradle.xyz +jointolouisvuitton.com +jointtime.xyz +jojamail.com +jojojokeked.com +jojolouisvuittonshops.com +joke24x.ru +jokenaka.press +jokerbetgiris.info +jokerstash.cc +jolajola422.com +joliechic.shop +joliejoie.com +joliys.pro +jollyfree.com +jollymove.xyz +jolongestr.com +jombase.com +jomcs.com +jomie.club +jonathanyeosg.com +jonerumpf.co.cc +jonespal.com +jonesrv.com +jonnyanna.com +jonnyboy.com +jonnyjonny.com +jonotaegi.net +jonotaegi.org +jonrepoza.ml +jonrichardsalon.com +jonsens.cc +jonsjav.cc +jontra.com +jonuman.com +jooffy.com +jooko.info +joomla-support.com +joomla.co.pl +joomlaccano.com +joomlaemails.com +joomlaprofi.ru +joopal.app +joopeerr.com +jop.laste.ml +jopho.com +joplsoeuut.cf +joplsoeuut.ga +joplsoeuut.gq +joplsoeuut.ml +joplsoeuut.tk +joq7slph8uqu.cf +joq7slph8uqu.ga +joq7slph8uqu.gq +joq7slph8uqu.ml +joq7slph8uqu.tk +jordanflight45.com +jordanfr5.com +jordanfrancepascher.com +jordanknight.info +jordanmass.com +jordanretronikesjordans.com +jordanretrooutlet.com +jordans11.net +jordanshoesusonline.com +jordanstore.xyz +jordyn.tamia.wollomail.top +joriman.xyz +jorja344cc.tk +jorney.com +jornismail.net +jorosc.cf +jorosc.ga +jorosc.gq +jorosc.ml +jorosc.tk +jos-s.com +josadelia100.tk +josalita95.ml +josalyani102.ml +josamadea480.ga +josamanda777.tk +josangel381.ml +josasjari494.ml +josdita632.ml +josefadventures.org +joseihorumon.info +josephsu.com +josephswingle.com +joseshdecuis.com +josfitrawati410.ga +josfrisca409.tk +josgishella681.cf +joshendriyawati219.tk +joshlapham.org +joshtucker.net +joshturner.org +josivangkia341.tk +josjihaan541.cf +josjismail.com +josnarendra746.tk +josnurul491.ga +josontim2011.com +jososkkssippsos8910292992.epizy.com +josprayugo291.tk +josresa306.tk +josrustam128.cf +joss.live +joss.today +josse.ltd +josski.ml +josyahya751.tk +jotiti.cf +jottobricks.com +jotyaduolchaeol2fu.cf +jotyaduolchaeol2fu.ga +jotyaduolchaeol2fu.gq +jotyaduolchaeol2fu.ml +jotyaduolchaeol2fu.tk +jouasicni.ga +journalistuk.com +journeyliquids.com +journeys.group +jourrapide.com +jovo.app +jowabols.com +jowo.email +joy-sharks.ru +joycedu.xyz +joycfde.site +joydeal.hk +joyfullife.style +joyhivepro.com +joynet.info +joytakip.xyz +joytoc.com +joywavelab.com +joywavepoint.com +joz.emlpro.com +jp-ml.com +jp-morgan.cf +jp-morgan.ga +jp-morgan.gq +jp-morgan.ml +jp.com +jp.dropmail.me +jp.freeml.net +jp.ftp.sh +jp.hopto.org +jp6188.com +jpanel.xyz +jparaspire.com +jparksky.com +jpco.org +jpcoachoutletvip.com +jpdf.site +jpf.laste.ml +jpggh76ygh0v5don1f.cf +jpggh76ygh0v5don1f.ga +jpggh76ygh0v5don1f.gq +jpggh76ygh0v5don1f.ml +jpggh76ygh0v5don1f.tk +jpinvest.ml +jpkparishandbags.info +jpnar8q.pl +jpneufeld.com +jpo48jb.pl +jpoundoeoi.com +jppa.com +jppin.site +jppradatoyou.com +jprealestate.info +jpremium.live +jpsells.com +jptb2motzaoa30nsxjb.cf +jptb2motzaoa30nsxjb.ga +jptb2motzaoa30nsxjb.gq +jptb2motzaoa30nsxjb.ml +jptb2motzaoa30nsxjb.tk +jptunyhmy.pl +jpuggoutlet.com +jpullingl.com +jpuser.com +jpvid.net +jq.dropmail.me +jq.emlhub.com +jq600.com +jqb.dropmail.me +jqctpzwj.xyz +jqem.emlpro.com +jqgarden.com +jqgnxcnr.pl +jqjlb.com +jqku.emlpro.com +jqlk9hcn.xorg.pl +jqo6v.anonbox.net +jqtex.anonbox.net +jquerys.net +jqv.emltmp.com +jqweblogs.com +jqwgmzw73tnjjm.cf +jqwgmzw73tnjjm.ga +jqwgmzw73tnjjm.gq +jqwgmzw73tnjjm.ml +jqwgmzw73tnjjm.tk +jqyb.spymail.one +jr46wqsdqdq.cf +jr46wqsdqdq.ga +jr46wqsdqdq.gq +jr46wqsdqdq.ml +jr46wqsdqdq.tk +jralalk263.tk +jray.mimimail.me +jrcs61ho6xiiktrfztl.cf +jrcs61ho6xiiktrfztl.ga +jrcs61ho6xiiktrfztl.gq +jrcs61ho6xiiktrfztl.ml +jrcs61ho6xiiktrfztl.tk +jredm.com +jrfd.dropmail.me +jri863g.rel.pl +jrinkkang97oye.cf +jriversm.com +jrjrj4551wqe.cf +jrjrj4551wqe.ga +jrjrj4551wqe.gq +jrjrj4551wqe.ml +jrjrj4551wqe.tk +jrk.dropmail.me +jrr.laste.ml +jrs.emlhub.com +jrv.emltmp.com +jrvps.com +jryt7555ou9m.cf +jryt7555ou9m.ga +jryt7555ou9m.gq +jryt7555ou9m.ml +jryt7555ou9m.tk +js881111.com +jsc.emlhub.com +jscustomplumbing.com +jsdginfo.com +jsellsvfx.com +jset.dropmail.me +jsfc.emlhub.com +jsfc88.com +jsh55s.us +jshongshuhan.com +jshoppy.shop +jshrtwg.com +jshungtaote.com +jsjns.com +jsko.mailpwr.com +jskypedo.com +jsonp.ro +jsrsolutions.com +jst.yomail.info +jsvojfgs.pl +jswf.yomail.info +jswfdb48z.com +jszmail.com +jszuofang.com +jt.emlhub.com +jtabusschedule.info +jtb.emlpro.com +jte.spymail.one +jthoven.com +jtjmtcolk.pl +jtkgatwunk.cf +jtkgatwunk.ga +jtkgatwunk.gq +jtkgatwunk.ml +jtkgatwunk.tk +jtmalwkpcvpvo55.cf +jtmalwkpcvpvo55.ga +jtmalwkpcvpvo55.gq +jtmalwkpcvpvo55.ml +jtmalwkpcvpvo55.tk +jtmc.com +jto.kr +jtpx.xyz +jtu.org +jtw-re.com +ju.laste.ml +jual.me +jualakun.com +jualakunfb.co +jualcloud.net +jualfb.co +jualherbal.top +juarabola.org +jucatyo.com +jucky.net +judethomas.info +judglarsting.tk +judibandardomino.com +judimag.com +judisgp.info +jue.freeml.net +jue.lu +jue12s.pl +juegos13.es +juf.dropmail.me +jug.spymail.one +jug1.com +jugglepile.com +jugqsguozevoiuhzvgdd.com +jugramh.com +juh.yomail.info +juhxs.com +juicermachinesreview.com +juicervital.com +juicerx.co +juicy-couturedaily.com +juicyvogue.com +juiupsnmgb4t09zy.cf +juiupsnmgb4t09zy.ga +juiupsnmgb4t09zy.gq +juiupsnmgb4t09zy.ml +juiupsnmgb4t09zy.tk +jujinbox.info +jujitsushop.biz +jujitsushop.com +jujj6.com +jujucheng.com +jujucrafts.com +jujuinbox.info +jujusanrop.cfd +jujuso.com +jujusou.com +jukeiot.xyz +juliachic.shop +juliejeremiassen.me +juliett.november.webmailious.top +juliman.me +juliustothecoinventor.com +julsard.com +julymovo.com +jumaelda4846.ml +jumanindya8240.cf +jumaprilia4191.cf +jumass.com +jumat.me +jumbogumbo.in +jumbotime.xyz +jumbox.site +jumbunga3502.cf +jumgita6884.tk +jumlamail.ml +jumlatifani8910.tk +jummario7296.ml +jummayang1472.ml +jumnia4726.ga +jumnoor4036.ga +jumnugroho6243.cf +jumonji.tk +jumossi51.ml +jump-communication.com +jumpman23-shop.com +jumpy5678.cf +jumpy5678.ga +jumpy5678.gq +jumpy5678.ml +jumpy5678.tk +jumrestia9994.ga +jumreynard5211.ml +jumreza258.tk +jumveronica8959.tk +jun11.flatoledtvs.com +jun8yt.cf +jun8yt.ga +jun8yt.gq +jun8yt.ml +jun8yt.tk +junasboyx1.com +junclutabud.xyz +junctiondx.com +jundikrlwq.me +junemovo.com +junetwo.ru +jungemode.site +jungkamushukum.com +jungolo.com +junioretp.com +junioriot.net +juniorlinken.com +junk.beats.org +junk.googlepedia.me +junk.ihmehl.com +junk.noplay.org +junk.to +junk.vanillasystem.com +junk1e.com +junkgrid.com +junklessmaildaemon.info +junkmail.com +junkmail.ga +junkmail.gq +junoemail.com +juntadeandalucia.org +junzihaose6.com +juo.com +juoksutek.com +juormer.com +jupimail.com +jupiterblock.com +jupiterm.com +juqc.emlpro.com +juroposite.site +jurts.online +jusomoa05.com +jusomoa06.com +jussum.info +jusswanita.com +just-email.com +just-games.ru +just.lakemneadows.com +just.marksypark.com +just.ploooop.com +just.poisedtoshrike.com +just4fun.me +just4junk.com +just4spam.com +justademo.cf +justafou.com +justanotherlovestory.com +justatemp.com +justbegood.pw +justbestmail.co.cc +justbigbox.com +justclean.co.uk +justdefinition.com +justdit.id +justdoiit.com +justdoit132.cf +justdoit132.ga +justdoit132.gq +justdoit132.ml +justdoit132.tk +justdomain84.ru +justemail.ml +justep.news +justfortodaynyc.com +justfreemails.com +justinbiebershoesforsale.com +justintrend.com +justiphonewallpapers.com +justlibre.com +justmailservice.info +justmakesense.com +justnope.com +justnowmail.com +justonemail.net +justpoleznoe.ru +justrbonlinea.co.uk +justre.codes +justreadit.ru +justshoes.gq +justsvg.com +justtick.it +juusecamenerdarbun.com +juvenileeatingdisordertreatment.com +juvintagew.com +juxl.emlpro.com +juyouxi.com +juzab.com +jv.spymail.one +jv6hgh1.com +jv7ykxi7t5383ntrhf.cf +jv7ykxi7t5383ntrhf.ga +jv7ykxi7t5383ntrhf.gq +jv7ykxi7t5383ntrhf.ml +jv7ykxi7t5383ntrhf.tk +jvb.laste.ml +jvdorseynetwork.com +jvhclpv42gvfjyup.cf +jvhclpv42gvfjyup.ml +jvhclpv42gvfjyup.tk +jvimail.com +jvlicenses.com +jvptechnology.com +jvsjzndo.xyz +jvtk.com +jvucei.buzz +jvunsigned.com +jvvmfwekr.xorg.pl +jvw.emlpro.com +jw.emlpro.com +jwcemail.com +jwd.laste.ml +jweomainc.com +jwgu.com +jwguanacastegolf.com +jwi.in +jwk4227ufn.com +jwl3uabanm0ypzpxsq.cf +jwl3uabanm0ypzpxsq.ga +jwl3uabanm0ypzpxsq.gq +jwlying.com +jwork.ru +jwoug2rht98plm3ce.cf +jwoug2rht98plm3ce.ga +jwoug2rht98plm3ce.ml +jwoug2rht98plm3ce.tk +jwpemail.eu +jwpemail.in +jwpemail.top +jwpi.emlpro.com +jwsuns.com +jwtukew1xb1q.cf +jwtukew1xb1q.ga +jwtukew1xb1q.gq +jwtukew1xb1q.ml +jwtukew1xb1q.tk +jwvestates.com +jx.emltmp.com +jxb.yomail.info +jxbav.com +jxg.freeml.net +jxgrc.com +jxi.emlhub.com +jxiv.com +jxix.emlhub.com +jxo.emlhub.com +jxpomup.com +jxvu.yomail.info +jybra.com +jydp.freeml.net +jyfc88.com +jyliananderik.com +jymfit.info +jymz.xyz +jynmxdj4.biz.pl +jyplo.com +jyr.emltmp.com +jyshines2011.kro.kr +jyt.spymail.one +jytewwzz.com +jyud.mailpwr.com +jyvz.freeml.net +jyzaustin.com +jz5pr.anonbox.net +jzexport.com +jziad5qrcege9.cf +jziad5qrcege9.ga +jziad5qrcege9.gq +jziad5qrcege9.ml +jziad5qrcege9.tk +jznes.anonbox.net +jzue.emlhub.com +jzzxbcidt.pl +k-10.com +k-b.xyz +k-d-m.de +k-global.dev +k-mail.top +k-p7.top +k.edbnu.com +k.fido.be +k.polosburberry.com +k.schimu.com +k.shoqc.com +k101.hosteko.ru +k1h6cy.info +k1q4fqra2kf.pl +k2-herbal-incenses.com +k25.pl +k2dfcgbld4.cf +k2dfcgbld4.ga +k2dfcgbld4.gq +k2dfcgbld4.ml +k2dfcgbld4.tk +k2eztto1yij4c.cf +k2eztto1yij4c.ga +k2eztto1yij4c.gq +k2eztto1yij4c.ml +k2eztto1yij4c.tk +k2idacuhgo3vzskgss.cf +k2idacuhgo3vzskgss.ga +k2idacuhgo3vzskgss.gq +k2idacuhgo3vzskgss.ml +k2idacuhgo3vzskgss.tk +k34k.com +k3663a40w.com +k3opticsf.com +k3zaraxg9t7e1f.cf +k3zaraxg9t7e1f.ga +k3zaraxg9t7e1f.gq +k3zaraxg9t7e1f.ml +k3zaraxg9t7e1f.tk +k4ds.org +k4money.com +k4tbtqa7ag5m.cf +k4tbtqa7ag5m.ga +k4tbtqa7ag5m.gq +k4tbtqa7ag5m.ml +k4tbtqa7ag5m.tk +k5ia3.anonbox.net +k5vin1.xorg.pl +k60.info +k7y4f.anonbox.net +k99.fun +k9ifse3ueyx5zcvmqmw.cf +k9ifse3ueyx5zcvmqmw.ga +k9ifse3ueyx5zcvmqmw.ml +k9ifse3ueyx5zcvmqmw.tk +k9wc559.pl +ka.emlpro.com +ka1ovm.com +kaaaxcreators.tk +kaamalspa.cfd +kaansimavcan.cfd +kaaw39hiawtiv1.ga +kaaw39hiawtiv1.gq +kaaw39hiawtiv1.ml +kaaw39hiawtiv1.tk +kabamail.com +kabareciak.pl +kabarr.com +kabarunik.xyz +kabbala.com +kabinbilla.com +kabingshaw.com +kabiny-prysznicowe-in.pl +kabiny-prysznicowe.ovh +kabo-verde-nedv.ru +kabulational.xyz +kacakbudalngaji.com +kacer.store +kaciekenya.webmailious.top +kacose.xyz +kacwarriors.org +kadag.ir +kademen.com +kadokawa.cf +kadokawa.ga +kadokawa.gq +kadokawa.ml +kadokawa.tk +kadokawa.top +kaedar.com +kaelalydia.london-mail.top +kaengu.ru +kafai.net +kaffeeschluerfer.com +kaffeeschluerfer.de +kafrem3456ails.com +kaftee.com +kagi.be +kaguya.tk +kah.pw +kahase.com +kahndefense.com +kahootninja.com +kaidh.xyz +kaifuem.site +kaijenwan.com +kaiju.live +kailmacas.cfd +kaimdr.com +kaindra.art +kainkainse.com +kairosplanet.com +kaisarbahru.tech +kaisercafe.es +kaishinkaiseattle.com +kaixinpet.com +kaj3goluy2q.cf +kaj3goluy2q.ga +kaj3goluy2q.gq +kaj3goluy2q.ml +kaj3goluy2q.tk +kajasander.xyz +kajene.dev +kaka.lol +kaka0.kr +kakadua.net +kakao-mail.com +kakao-mail.kr +kakaoemail.kr +kakaofrucht.de +kakaomail.kr +kakashi1223e.cf +kakashi1223e.ga +kakashi1223e.ml +kakashi1223e.tk +kakekbet.com +kakismotors.net +kakraffi.eu.org +kaksjhdh.site +kakslsie.store +kaksmail.com +kalapi.org +kalayya.com +kaleenop.com +kalemproje.com +kalkulator-kredytowy.com.pl +kalmkampz.shop +kaloolas.shop +kalosgrafx.com +kamadoti.cyou +kamagra-lovegra.com.pl +kamagra.com +kamagra.org +kamagra100mgoraljelly.today +kamagradct.com +kamagraonlinesure.com +kamagrasklep.com.pl +kamargame.com +kamax57564.co.tv +kamazacl.cfd +kamazc.cfd +kamchajeyf.space +kameili.com +kamen-market.ru +kamenrider.ru +kamete.org +kamgorstroy.ru +kamien-naturalny.eu +kamillight.tk +kamis.me +kamismail.com +kamizellki-info.pl +kammmo.com +kammmo12.com +kampoeng3d.club +kampungberdaya.com +kampungberseri.com +kamryn.ayana.thefreemail.top +kamsg.com +kamucerdas.com +kamusinav.site +kanaatsoyulmaz.cfd +kanada-nedv.ru +kanarian-nedv.ru +kanbay.com +kanbin.info +kanciang.faith +kandymail.com +kangeasy.com +kangirl.com +kangkunk44lur.cf +kangsohang.com +kanhamods.ml +kankankankan.com +kanker.website +kannada.com +kanonmail.com +kanpress.site +kansascitystreetmaps.com +kantal.buzz +kantclass.com +kanva.site +kanzanishop.com +kaocashima.com +kaoing.com +kaovo.com +kapieli-szczecin.pl +kapikapi.info +kapitulin.ru +kappala.info +kapptiger.com +kapten.site +kapumamatata.gq +kapumamatata.ml +kara-turk.net +karadiners.site +karamelbilisim.com +karamumba.network +karaokegeeks.com +karasupost.net +karateslawno.pl +karatic.com +karatraman.ml +karavic.com +karbonaielite.com +karbonbet.com +karcherparts.info +kardelentahmez.cfd +kareemno3aa.site +karelklosse.com +karement.com +karenkey.com +karenmillendress-au.com +karenmillenoutletea.co.uk +karenmillenoutleter.co.uk +karenmillenuk4s.co.uk +karenmillenuker.co.uk +karenvest.com +kargoibel.store +karibbalakata.ml +karina-strim.ru +karinanadila.art +karinmk-wolf.eu +kariplan.com +karisss3.com +karitas.com.br +karlinainawati.art +karlov-most.ru +karmapuma.tk +karolinejensen.me +karolinekleist.me +karos-profil.de +karridea.com +karta-kykyruza.ru +karta-tahografa.ru +kartk5.com +kartsitze.de +kartu8m.com +kartuliga.poker +kartvelo.com +kartvelo.me +kartykredytowepl.info +kartyusb.pl +karya4d.org +kasandraava.livefreemail.top +kasdewhtewhrfasaea.vv.cc +kaseig.com +kashenko.site +kashi-sale.com +kasihtahuaja.xyz +kasik1250.shop +kasmabirader.com +kasmail.com +kaspar.lol +kaspecism.site +kasper.uni.me +kaspop.com +kast64.plasticvouchercards.com +kasthouse.com +kastransport.com +kat-777.com +kat-net.com +kat.freeml.net +katalogstronstron.pl +katamo1.com +katanajp.online +katanajp.shop +katanganews.cd +katanyoo.shop +katanyoo.xyz +katanyoobattery.com +katarina.maya.istanbul-imap.top +katarinalouise.com +kataskopoi.com +katcang.tk +katergizmo.de +katespade-factory.com +katgetmail.space +katharina-nebel.de +kathrinelarsen.me +kathrynowen.com +kathycashto.com +kathymackechney.com +katie11muramats.ga +katipa.pl +katipo.ru +katomcoupon.com +katonoma.com +kats.com +katsfastpaydayloans.co.uk +katsu28.xpath.site +katsui.xyz +kattmanmusicexpo.com +katuchi.com +katyisd.com +katyperrytourblog.com +katztube.com +kaudat.com +kauinginpergi.cf +kauinginpergi.ga +kauinginpergi.gq +kauinginpergi.ml +kaulananews.com +kavaint.net +kavapors.com +kavbc6fzisxzh.cf +kavbc6fzisxzh.ga +kavbc6fzisxzh.gq +kavbc6fzisxzh.ml +kavbc6fzisxzh.tk +kavxx.xyz +kawaii.vet +kawaiishojo.com +kawamoto.id +kaws4u.com +kawu.site +kawy-4.pl +kaxks55ofhkzt5245n.cf +kaxks55ofhkzt5245n.ga +kaxks55ofhkzt5245n.gq +kaxks55ofhkzt5245n.ml +kaxks55ofhkzt5245n.tk +kayatv.net +kaybooks.top +kaye.ooo +kayfilms.top +kaygroup.top +kaysartycles.com +kayserilimusti.network +kazan-hotel.com +kazan-nedv.ru +kazelink.ml +kazinkana.com +kazinoblackjack.com +kazper.net +kb.emlpro.com +kb.freeml.net +kbakvkwvsu857.cf +kbbwt.info +kbbxowpdcpvkxmalz.cf +kbbxowpdcpvkxmalz.ga +kbbxowpdcpvkxmalz.gq +kbbxowpdcpvkxmalz.ml +kbbxowpdcpvkxmalz.tk +kbdjvgznhslz.ga +kbdjvgznhslz.ml +kbdjvgznhslz.tk +kbellebeauty.com +kbgiz.anonbox.net +kbox.li +kbvehicle.com +kbw5m.anonbox.net +kc-kenes.kz +kc.spymail.one +kc8pnm1p9.pl +kcftg.emltmp.com +kcgsaudik.com +kchkch.com +kci.emltmp.com +kcib.freeml.net +kcil.com +kcmh5.anonbox.net +kcoporation.com +kcpit.anonbox.net +kcricketpq.com +kcrw.de +kcs-th.com +kcum7.anonbox.net +kd.spymail.one +kd2.org +kdc.support +kdeos.ru +kdesignstudio.com +kdfgedrdf57mmj.ga +kdg.emlpro.com +kdh.kiwi +kdhg.emlpro.com +kdjfvkdf8.club +kdjhemail.com +kdjngsdgsd.tk +kdks.com +kdl8zp0zdh33ltp.ga +kdl8zp0zdh33ltp.gq +kdl8zp0zdh33ltp.ml +kdl8zp0zdh33ltp.tk +kdmail.xyz +kdqq.laste.ml +kdrc.dropmail.me +kdrplast.com +kds55.anonbox.net +kdublinstj.com +kdv4p.anonbox.net +kdxcvft.xyz +kdzrgroup.com +ke.emlpro.com +keagenan.com +keaih.com +keatonbeachproperties.com +keauhoubaybeachresort.com +keauhoubayresort.com +keauhouresortandspa.com +kebab-house-takeaway.com +kebabhouse-kilkenny.com +kebabhouse-laois.com +kebandara.com +kebl0bogzma.ga +kebmail.com +keboloro.me +keboo.live +keboo.rocks +kec.freeml.net +kecambahijo89klp.ml +kecapasin.buzz +kedikumu.net +kedrovskiy.ru +kedy6.us +keecalculator.com +keecs.com +keeleproperties.com +keeleranderson.net +keely.johanna.chicagoimap.top +keenclimatechange.com +keepactivated.com +keeperhouse.ru +keepillinoisbeautiful.org +keepitsecurity.com +keeplucky.pw +keepmail.online +keepmoatregen.com +keepmymail.com +keepmyshitprivate.com +keepoor.com +keepsave.club +keepthebest.com +keeptoolkit.com +keepyourshitprivate.com +keevle.com +keey.freeml.net +kefb.emltmp.com +kegangraves.club +kegangraves.online +kegangraves.org +kegangraves.site +kegangraves.us +kehangatan.ga +kehonkoostumusmittaus.com +kei-digital.com +keidigital.shop +kein.date +kein.hk +keinhirn.de +keinmail.com +keinpardon.de +keio-mebios.com +keipino.de +keiraicumb.cf +keiraicumb.ga +keirron31.are.nom.co +keis.com +keistopdow.cf +keistopdow.ga +keistopdow.gq +keistopdow.ml +keithbukoski.com +keitin.site +keivosnen.online +keizercentral.com +kejenx.com +kejunihasan.me +kekecog.com +keked.com +kekemluye.cfd +kekita.com +kekote.xyz +keks.page +kelangthang.com +kelantanfresh.com +kelasbelajar.web.id +kelaskonversi.com +kelec.cf +kelec.ga +kelec.tk +kelecn.monster +kelenson.com +kelev.biz +kelev.store +kellencole.com +kelleyships.com +kelloggchurch.org +kellybagonline.com +kellychibale-researchgroup-uct.com +kellycro.ml +kellyfamily.tk +kellyodwyer.net +kellyrandin.com +kelor.ga +kelseyball.com +kelseyball.xyz +keluaranhk.online +keluruk.fun +kelvinfit.com +kelx.freeml.net +kemail.com +kemail.uk +kemailuo.com +kemaltolgauzman.buzz +kemampuan.me +kemanngon.online +kembangpasir.website +kembung.com +kemelmaka.cfd +kemeneur.org +kemfra.com +kemi.freeml.net +kemonkoreeitaholoto.tk +kemptvillebaseball.com +kemska.pw +kenal-saya.ga +kenbaby.com +kenberry.com +kendallmarshallfans.info +kendalraven.webmailious.top +kenesandari.art +kenfern.com +kengriffeyoutlet.com +kenhbanme.com +kenhdeals.com +kenhphim.net +kenmorestoveparts.com +kennebunkportems.org +kennedy808.com +kennethpaskett.name +kennie.club +kennie.com +kennyet.com +kennysmusicbox.com +kenshin67.bitgalleries.site +kenshuwo.com +kent1.rebatesrule.net +kent5.qpoe.com +kentbtt.com +kentg.co.cc +kenticocheck.xyz +kentonsawdy.com +kentspurid.cf +kentspurid.ga +kentspurid.gq +kentspurid.ml +kentucky-indianalumber.com +kentuckyadoption.org +kentuckyopiaterehab.com +kentuckyquote.com +kenvanharen.com +kenwestlund.com +kenyangsekali.com +kenyawild.life +kenyayouth.org +kenzototo.site +keobzmvii.pl +keokeg.com +keort.in +keortge.org +keosdevelopment.com +kepeznakliyat.com +kepkat.com +kepler.uni.me +kepo.ml +kepqs.ovh +keq.yomail.info +keqptg.com +keralaairport.net +keraladinam.com +keralapoliticians.com +keramzit-komi.ru +kerasine.xyz +keratinhairtherapy.com +keratontoto.info +keratosispilarisguide.info +kerchboxing.ru +kerclivhuck.cf +kerclivhuck.ga +kerclivhuck.ml +keremardatahta.shop +keremcan123.ml +kerenamiburasi.sbs +keretasakti.me +kerficians.xyz +kerfuffle.me +kerimhan.ga +kerimhanfb.ml +kerithbrookretreat.org +kerjqv.us +kerkenezali.space +kermenak.site +kerneksurucukursu.com +kernersvilleapartments.com +kernigh.org +kernuo.com +kerotu.com +kerrfamilyfarms.com +kerrilid.win +kerrmail.men +kerrytonys.info +kershostter.cf +kershostter.ga +kersp.lat +kertasqq.com +kerupukmlempem.ml +kerupukmlempem.tk +kerupukmlempem1.cf +kerupukmlempem1.ga +kerupukmlempem2.cf +kerupukmlempem3.cf +kerupukmlempem3.ml +kerupukmlempem4.cf +kerupukmlempem4.ml +kerupukmlempem5.cf +kerupukmlempem6.cf +kerupukmlempem6.ml +kerupukmlempem7.cf +kerupukmlempem7.ga +kerupukmlempem8.ga +kerupukmlempem9.cf +kes.emltmp.com +kesepara.com +kesfiru.cf +kesfiru.ga +kesfiru.gq +kesfiru.ml +keshitv.com +kespear.com +ket-qua.org +ketababan.com +ketchet.com +ketenpere.online +kethough51.tk +ketiduran.link +ketiksms.club +keto4life.media +ketoblazepro.com +ketocorner.net +ketodiet.info +ketodietbasics.org +ketodrinks.org +ketonedealer.com +ketoproteinrecipes.com +ketorezepte24.com +ketoultramax.com +ketoxprodiet.net +ketpgede.cf +ketpgede.ga +ketsode.cf +ketsode.gq +ketsode.ml +kettcopla.cf +kettcopla.ga +kettcopla.gq +kettcopla.ml +kettlebellfatburning.info +kettledesign.com +kettles.info +ketua.id +keupartlond.cf +keupartlond.ga +keupartlond.gq +keupartlond.ml +kev.com +kev7.com +keverb-vreivn-wneff.online +kevertio.cf +kevertio.ml +kevin7.com +kevincramp.com +kevinekaputra.com +kevinhanes.net +kevinhosting.dev +kevinkrout.com +kevinmalakas.com +kevinschneller.com +kevintrankt.com +kevm.org +kevu.site +kewelhidden.com +kewip.com +kewkece.com +kewl-offers.com +kewlmail.info +kewrg.com +kexi.info +kexukexu.xyz +key--biscayne.com +key-mail.net +key-windows-7.us +key2funnels.com +key2info.com +keydcatvi.cf +keydcatvi.ga +keydcatvi.ml +keyesrealtors.tk +keyforteams.com +keygenninjas.com +keyido.com +keykeykelyns.cf +keykeykelyns.ga +keykeykelyns.gq +keykeykelyns.ml +keykeykelyns.tk +keykeykelynss.cf +keykeykelynss.ga +keykeykelynss.gq +keykeykelynss.ml +keykeykelynss.tk +keykeykelynsss.cf +keykeykelynsss.ga +keykeykelynsss.gq +keykeykelynsss.ml +keykeykelynsss.tk +keykeykelynz.cf +keykeykelynz.ga +keykeykelynz.gq +keykeykelynz.ml +keykeykelynz.tk +keynoteplanner.com +keyospulsa.com +keyprestige.com +keypreview.com +keyprocal.cf +keyprocal.gq +keyprocal.ml +keyritur.ga +keyritur.gq +keyritur.ml +keyscapital.com +keysinspectorinc.com +keysky.online +keysmedia.org +keystonemoldings.com +keytarbear.net +keytostay.com +keywestmuseum.com +keyworddo.com +keywordhub.com +keywordstudy.pl +keyy.com +kf.spymail.one +kf2ddmce7w.cf +kf2ddmce7w.ga +kf2ddmce7w.gq +kf2ddmce7w.ml +kf2ddmce7w.tk +kfamilii2011.co.cc +kfark.net +kfd7a.anonbox.net +kfhgrftcvd.cf +kfhgrftcvd.ga +kfhgrftcvd.gq +kfhgrftcvd.ml +kfhgrftcvd.tk +kfjsios.com +kfmc.emlpro.com +kfoiwnps.com +kfr.spymail.one +kftcrveyr.pl +kfyudj.lol +kg.emlhub.com +kg.emlpro.com +kg1cz7xyfmps.cf +kg1cz7xyfmps.gq +kg1cz7xyfmps.tk +kg4dtgl.info +kgalagaditransfrontier.com +kgcglobal.com +kgcp11.com +kgcp55.com +kgcp88.com +kgduw2umqafqw.ga +kgduw2umqafqw.ml +kgduw2umqafqw.tk +kggrp.com +kghf.de +kghfmqzke.pl +kgjuww.best +kgohjniyrrgjp.cf +kgohjniyrrgjp.ga +kgohjniyrrgjp.gq +kgohjniyrrgjp.ml +kgohjniyrrgjp.tk +kgox.emltmp.com +kgpulse.info +kgxz6o3bs09c.cf +kgxz6o3bs09c.ga +kgxz6o3bs09c.gq +kgxz6o3bs09c.ml +kgxz6o3bs09c.tk +kh.emlpro.com +kh0hskve1sstn2lzqvm.ga +kh0hskve1sstn2lzqvm.gq +kh0hskve1sstn2lzqvm.ml +kh0hskve1sstn2lzqvm.tk +kh1uz.xyz +kh1xv.xyz +kh75g.xyz +khabmails.com +khacdauquoctien.com +khachsanthanhhoa.com +khada.vn +khadem.com +khadistate.com +khafaga.com +khagate.xyz +khaihoansk86.click +khaitulov.com +khajatakeaway.com +khakiskinnypants.info +khaledtrs.cloud +khalifahallah.com +khalilah.glasslightbulbs.com +khalinin.cf +khalinin.gq +khalinin.ml +khalpacor.cf +khalpacor.ga +khalpacor.gq +khaltoor.com +khaltor.com +khaltor.net +khaltour.net +khamu.me +khan-tandoori.com +khan007.cf +khaxan.com +khayden.com +khaze.xyz +khazeo.ml +khbfzlhayttg.cf +khbfzlhayttg.ga +khbfzlhayttg.gq +khbfzlhayttg.ml +khbfzlhayttg.tk +khbikemart.com +khe.spymail.one +khea.info +khedgeydesigns.com +kheex.xyz +kheig.ru +khel.de +khfi.net +khig.site +khmer.loan +khnews.cf +khoabung.com +khoahochot.com +khoahocseopro.com +khoahocseoweb.com +khoantuta.com +khoatoo.net +khoi-fm.org +khoigame.com +khoiho.com +khoinghiephalong.com +khoke.nl +khongsocho.xyz +khongtaothiai.com +khongtontai.tech +khotuisieucap.com +khpci.xyz +khpkufk.pl +khruyu.us +khti34u271y217271271.ezyro.com +khtyler.com +khujenao.net +khuong.store +khuongdz.club +khuonghung.com +khuyenmai.asia +khwtf.xyz +khyuz.ru +ki-sign.com +ki5co.com +ki7hrs5qsl.cf +ki7hrs5qsl.ga +ki7hrs5qsl.gq +ki7hrs5qsl.ml +ki7hrs5qsl.tk +kia-sdn.me +kiabws.com +kiabws.online +kiancontracts.com +kiani.com +kiaunioncounty.com +kiawah-island-hotels.com +kibriscontinentalbank.com +kibriscontinentalbank.xyz +kibristasirketkur.com +kibristime.com +kibwot.com +kicaubet.online +kichco.com +kickasscamera.com +kickers-world.be +kickers.online +kickex.su +kickit.ga +kickmark.com +kickmarx.net +kickmature.xyz +kickme.me +kickskshoes.com +kickstartbradford.com +kicsprems.tk +kicv.com +kid-car.ru +kidalovo.com +kidalylose.pl +kidaroa.com +kidbemus.cf +kidbemus.ml +kiddiepublishing.com +kiddon.club +kiddsdistribution.co.uk +kidesign.co.uk +kidfuture.org +kidohalgeyo.com +kids316.com +kidsarella.ru +kidscy.com +kidsenabled.org +kidsfitness.website +kidsgreatminds.net +kidsphuket.com +kidsphuket.net +kidspocketmoney.org +kidswebmo.cf +kidswebmo.ga +kidswebmo.gq +kidtoy.net +kidworksacademy.com +kiecchn.com +kieea.com +kiejls.com +kielon.pl +kienlua.xyz +kientao.online +kientao.tech +kieranasaro.com +kierastyle.shop +kieravogue.shop +kiet.freeml.net +kietnguyenisocial.com +kiflin.ml +kigonet.xyz +kigwa.com +kiham.club +kihc.com +kik-store.ru +kikie.club +kikihu.com +kikoxltd.com +kikuchifamily.com +kil.it +kil58225o.pl +kila.app +kilaok.site +kildi.store +kiliosios.gr +kill-me.tk +killarbyte.ru +killdred99.uk.com +killer-directory.com +killerelephants.com +killerlearner.ga +killerwords.com +killgmail.com +killinglyelderlawgroup.com +killlove.site +killmail.com +killmail.net +killtheinfidels.com +killyourtime.com +kilo.kappa.livefreemail.top +kilo.sigma.aolmail.top +kilocycl5.xyz +kilomerica.xyz +kilton2001.ml +kiluch.com +kily.com +kim-tape.com +kim.emltmp.com +kimachina.com +kimasoft.com +kimberly.dania.chicagoimap.top +kimberlyindustry.shop +kimberlymed.com +kimbral.umiesc.pl +kimbu.net +kimchichi.com +kimchung.xyz +kimdyn.com +kimfetme.com +kimfetsnj.com +kimgmail.com +kimhui.online +kimia.xyz +kimim.tk +kimirsen.ru +kimjongun.app +kimmygranger.xyz +kimmyjayanti.art +kimouche-fateh.net +kimsalterationsmaine.com +kimsangun.com +kimsangung.com +kimsdisk.com +kimsesiz.cf +kimsesiz.ga +kimsesiz.ml +kimssmartliving.com +kimyapti.com +kimyl.com +kin-dan.info +kinano.beauty +kinbam10.com +kinda.email +kinda.pw +kindamail.com +kindbest.com +kinderaid.ong +kinderbook-inc.com +kinderspanish4k.com +kinderworkshops.de +kindleebs.xyz +kindomd.com +kindpostcot.cf +kindpostcot.gq +kindpostcot.ml +kindtoc.com +kindtoto12.com +kindvenge.cf +kindvenge.ga +kindvenge.gq +kindvenge.ml +kindvideo.ru +kinetic.lighting +king-sniper.com +king-yaseen.cf +king.buzz +king2003.ml +king33.asia +king368aff.com +king4dstar.com +kingairpma.com +kingbaltihouse.com +kingbet99.com +kingbetting.org +kingbillycasino3.com +kingcontroller.cf +kingdentalhuntsville.com +kingding.net +kingdom-mag.com +kingdomchecklist.com +kingdomhearts.cf +kingdomthemes.net +kinger.online +kingfun.info +kingfun79.com +kingfunsg.com +kingfunvn.com +kingfuvirus.com +kinggame247.club +kingice-store.com +kinglibrary.net +kinglyruhyat.io +kingmenshealth.com +kingnesiamail.com +kingnews1.online +kingnonlei.ga +kingnonlei.gq +kingnonlei.ml +kingofmails.com +kingofmarket.ru +kingofnopants.com +kingortak.com +kingpin.fun +kingpixelbuilder.com +kingpizzatakeaway.com +kingpol.eu +kingproplan.site +kingreadse.cf +kingreadse.gq +kingreadse.ml +kings-garden-dublin.com +kings33.com +kingsbbq.biz +kingsbeachclub.com +kingsbythebay.com +kingschancecampaign.net +kingschancefree.org +kingschancemail.info +kingseo.edu.vn +kingsleyassociates.co.uk +kingsleyrussell.com +kingsooperd.com +kingspc.com +kingsq.ga +kingsready.com +kingssupportservice.com +kingssupportservices.com +kingssupportservices.net +kingstoncs.com +kingstonjugglers.org +kingswaymortgage.com +kingtigerparkrides.com +kingtornado.net +kingtornado.org +kingyslmail.com +kingyslmail.top +kingzippers.com +kinitawowis.xyz +kink4sale.com +kinky-fetish.cyou +kinkz.com +kino-100.ru +kino-kingdom.net +kino-maniya.ru +kino24.ru +kinofan-online.ru +kinoger.site +kinoggo.ru +kinogo-x.space +kinogo-xo.club +kinogo.one +kinogokinogo.ru +kinogomegogo.ru +kinogomyhit.ru +kinoiks.ru +kinokinoggo.ru +kinokradkinokrad.ru +kinolife.club +kinolive.pl +kinolublin.pl +kinomaxru.ru +kinopoisckhd.ru +kinopovtor2.online +kinosmotretonline.ru +kinovideohit.ru +kinox.life +kinox.website +kinoxa.one +kinoxaxru.ru +kinoz.pl +kinrose.care +kinsef.com +kinsil.co.uk +kintravel.com +kinx.cf +kinx.gq +kinx.ml +kinx.tk +kio-mail.com +kiohi.com +kiois.com +kiolisios.gr +kioralsolution.net +kioscapsa88.life +kip.dummyfox.com +kipavlo.ru +kipaystore.com +kipeyine.site +kipmail.xyz +kipomail.com +kipr-nedv.ru +kiprhotels.info +kipubkkk.xyz +kipv.ru +kir.ch.tc +kiranaankan.net +kiranablogger.xyz +kirchdicka.cf +kirchdicka.ga +kirchdicka.gq +kirchdicka.ml +kirklandcounselingcenter.com +kirpikcafe.com +kirrus.com +kirurgkliniken.nu +kiryubox.cu.cc +kis.freeml.net +kisan.org +kiscover.com +kishen.dev +kishu.online +kisiihft2hka.cf +kisiihft2hka.ga +kisiihft2hka.gq +kisiihft2hka.ml +kisiihft2hka.tk +kismail.com +kismail.ru +kisoq.com +kiss-klub.com +kiss918.info +kissadulttoys.com +kissgy.com +kisshq.com +kissmoncler.com +kissmyapps.store +kisstwink.com +kitap.az +kitapidea.com +kitaura-net.jp +kitchen-tvs.ru +kitchendesign1.co.uk +kitchenettereviews.com +kitchenlean.fun +kitela.work +kitesmith.com +kitesurfinguonline.pl +kitezh-grad.ru +kithjiut.cf +kithjiut.ga +kithjiut.gq +kithjiut.ml +kitiva.com +kitnastar.com +kito.emltmp.com +kitooes.com +kitools.es +kitsappowdercoating.com +kitten-mittons.com +kittenemail.com +kittenemail.xyz +kittiza.com +kiustdz.com +kiuyutre.ga +kiuyutre.ml +kivoid.blog +kiwkiw.shop +kiworegony.com +kiwsz.com +kixotic.com +kiyoapk.web.id +kiziwi.xyz +kj-a.cc +kjbw3.anonbox.net +kjcanva.tech +kjdghdj.co.cc +kjdo9rcqnfhiryi.cf +kjdo9rcqnfhiryi.ga +kjdo9rcqnfhiryi.ml +kjdo9rcqnfhiryi.tk +kjf.dropmail.me +kjhjgyht6ghghngh.ml +kjjeggoxrm820.gq +kjjit.eu +kjkjk.com +kjkszpjcompany.com +kjncascoiaf.ru +kjoiewrt.in +kjwebox.com +kjwu.emlpro.com +kjwyfs.com +kk.yomail.info +kk1.lol +kkack.com +kkbuildd.com +kkenny.com +kkgreece.com +kkh.freeml.net +kkiby2.cloud +kkjef655grg.cf +kkjef655grg.ga +kkjef655grg.gq +kkjef655grg.ml +kkjef655grg.tk +kkk.emlpro.com +kkkkkk.com +kkkmail.tk +kkkzzz.cz.cc +kkmail.be +kkmjnhff.com +kkn.spymail.one +kkokc.com +kkomimi.com +kkoup.com +kkr47748fgfbef.cf +kkr47748fgfbef.ga +kkr47748fgfbef.gq +kkr47748fgfbef.ml +kkr47748fgfbef.tk +kkreatorzyimprez.pl +kkredyt.pl +kkredyttonline.pl +kksm.be +kktt32s.net.pl +kkuj.laste.ml +kkv.emlpro.com +kkvmdfjnvfd.dx.am +kkz.laste.ml +kl.spymail.one +klabuk.pl +klaky.net +klammlose.org +klarasaty25rest.cf +klarasfree09net.ml +klassmaster.com +klassmaster.net +klasyczne.info +klate.site +klav6.com +klblogs.com +kldconsultingmn.com +klearlogistics.com +klebus.live +klebus.tech +klefv.com +klefv6.com +kleiderboutique.de +kleiderhaken.shop +kleinisd.com +klek.com +klemail.top +klemail.xyz +klembaxh23oy.gq +klemon.ru +kleogb.com +klepf.com +klerom.in +kles.info +klet.laste.ml +klhaeeseee.pl +klick-tipp.us +kligoda.com +kliknesia.io +klimatyzacjaa.pl +klimwent.pl +klinika-zdrowotna.pl +klipp.su +klipschx12.com +klji.dropmail.me +kll6g.anonbox.net +klng.com +klo.com +kloap.com +klodrter.pl +klondikestar.com +klone0rz.be +klonteskacondos.com +klopsjot.ch +kloudis.com +klovenode.com +klrrfjnk.shop +kluayprems.cf +klubnikatv.com +kludgemush.com +kludio.xyz +kluofficer.com +klvm.gq +kly.yomail.info +klytreuk.com.uk +klyum.com +klzlk.com +klzmedia.com +km.emlpro.com +km.spymail.one +km1iq.xyz +km4fsd6.pl +km6uj.xyz +km7gb.anonbox.net +kmail.li +kmail.live +kmail.mooo.com +kmail.wnetz.pl +kmav2s.shop +kmbalancedbookkeeping.com +kmbr.de +kmc.emltmp.com +kmdt.cm +kme6g.xyz +kmebk.xyz +kmecko.xyz +kmeuktpmh.pl +kmf.laste.ml +kmfdesign.com +kmhow.com +kmjy7.anonbox.net +kmkl.de +kmmhbjckaz.ga +kmoduy.buzz +kmonkeyd.com +kmonlinestore.co.uk +kmrx1hloufghqcx0c3.cf +kmrx1hloufghqcx0c3.ga +kmrx1hloufghqcx0c3.gq +kmrx1hloufghqcx0c3.ml +kmrx1hloufghqcx0c3.tk +kmuye.xyz +kmvdizyz.shop +kmwtevepdp178.gq +kn.freeml.net +kn0wme.tech +kn7il8fp1.pl +kna.emltmp.com +knaiji.com +knaq.com +kneeguardkids.ru +kneh.freeml.net +knessed.xyz +knft.spymail.one +kngl.spymail.one +kngusa.com +knickerbockerban.de +knife.ruimz.com +kniffel-online.info +kniga-galob.ru +knightsworth.com +knilok.com +knime.app +knime.online +knime.us +knleeowdg.com +knmcadibav.com +knnl.ru +knock.favbat.com +knol-power.nl +knolgy.net +knolselder.cf +knolselder.ga +knolselder.gq +knolselder.ml +knolselder.tk +know.cowsnbullz.com +know.marksypark.com +know.poisedtoshrike.com +know.popautomated.com +know.qwertylock.com +knowallergies.org +knowatef.cf +knowhowitaly.com +knowledge-from-0.com +knowledgemd.com +knowond.com +knowyourfaqs.com +knoxy.net +knptest.com +kntl.me +knw4maauci3njqa.cf +knw4maauci3njqa.gq +knw4maauci3njqa.ml +knw4maauci3njqa.tk +knymue.xyz +ko76nh.com +koa.emlhub.com +koalaltd.net +koalaswap.com +koash.com +kobessa.com +kobietaidom.pl +kobrandly.com +kobyharriman.xyz +koceng.social +koch.ml +kocheme.com +kochen24.de +kochenk.online +kochkurse-online.info +kocoks.com +kod-emailing.com +kod-maling.com +kodaka.cf +kodaka.ga +kodaka.gq +kodaka.ml +kodaka.tk +koddruay.one +kodeholik.site +kodemail.ga +kodemailing.com +kodifyqa.com +kodmailing.com +kodok.xyz +kodorsex.cf +kodpan.com +koekdmddf.com +koenigsolutions.com +koes.justdied.com +koewrt.in +kogda.online +kogojet.net +kohelps.com +kohlsprintablecouponshub.com +kohz5gxm.pl +koin-qq.top +koiqe.com +koismwnndnbfcswte.cf +koismwnndnbfcswte.ga +koismwnndnbfcswte.gq +koismwnndnbfcswte.ml +koismwnndnbfcswte.tk +kojitatsuno.com +kojon6ki.cy +kojonki.cy +kojsaef.ga +koka-komponga.site +koka.my +kokalo.store +kokinus.ro +kokkiii.com +kokocookies.com +kokokoko.com +kokonaom.website +kokorot.cf +kokorot.ga +kokorot.gq +kokorot.ml +kokorot.tk +kokosik.site +kokscheats.com +kolagenanaturalny.eu +kolasin.net +kolbasasekas.ru +kolczynka.pl +koldpak.com +kolekcjazegarkow.com +koletter.com +kolkmendbobc.tk +koloekmail.com +koloekmail.net +kolonyajel.com +kolovers.com +kolumb-nedv.ru +kolvok2.xyz +kolyasski.com +komalik.club +koman.team +kombatcopper.com +komberluxury.xyz +kombiservisler.com +komilbek90.site +kommespaeter.de +kommunity.biz +kommv.cc.be +kompakteruss.cf +kompbez.ru +komper.info +kompressorkupi.ru +komputer.design +komputrobik.pl +kon42.com +konacode.com +konbat.ru +konetas.com +konferencja-partnerstwo-publiczno-prywatne.pl +kongo.store +kongree.site +kongshuon.com +kongtoan.com +kongzted.net +konican.com +konkurrierenden.ml +konkursoteka.com +konmail.com +konne.pl +konno.tk +konoha.asia +konrad-careers.com +konsalt-proekt.ru +kontagion.pl +kontakt.imagehostfile.eu +kontaktbloxx.com +konterkulo.com +konto-w-banku.net +kontoko.org +kontol.city +kontol.co.uk +kontol.guru +kontormatik.org +konultant-jurist.ru +konveksigue.com +konwinski50.glasslightbulbs.com +konyaliservis.xyz +koochmail.info +koofy.net +kook.ml +kookabungaro.com +kookkom.com +koolm.com +koon.tools +koongyako.com +kopagas.com +kopaka.net +kopakorkortonline.com +kopeechka.store +kopher.com +kopiacehgayo15701806.cf +kopiacehgayo15701806.ga +kopiacehgayo15701806.ml +kopiacehgayo15701806.tk +kopibajawapunya15711640.cf +kopibajawapunya15711640.ga +kopibajawapunya15711640.ml +kopibajawapunya15711640.tk +kopidingin.org +kopienak.website +kopikapalapi11821901.cf +kopikapalapi11821901.ga +kopikapalapi11821901.ml +kopikapalapi11821901.tk +kopipahit.ga +kopqi.com +kor.freeml.net +korcznerwowy.com +kore-tv.com +korea-beaytu.ru +koreaautonet.com +koreamail.cf +koreamail.ml +koreautara.cf +koreautara.ga +koreaye.tk +korelmail.com +korika.com +kormail.xyz +korneri.net +korona-nedvizhimosti.ru +korozy.de +korrect.cfd +korsakov-crb.ru +korutbete.cf +korztv.click +korzystnykredyt.com.pl +kos21.com +kosamail.lol +kosay10.tk +kosay18.tk +kosay19.tk +kosay4.tk +kosay5.tk +kosay6.tk +kosay7.tk +kosay8.tk +kosay9.tk +kosciuszkofoundation.com +kosgcg0y5cd9.cf +kosgcg0y5cd9.ga +kosgcg0y5cd9.gq +kosgcg0y5cd9.ml +kosgcg0y5cd9.tk +kosherlunch.com +koshu.ru +kosiarszkont.com +kosla.pl +kosmetik-obatkuat.com +kosmetika-kr.info +kosmetika-pro.in.ua +kosmicmusic.com +kosolar.pl +kost.party +kosta-rika-nedv.ru +kostenlos-web.com +kostenlose-browsergames.info +kostenlosemailadresse.de +kostestas.co.pl +kosze-na-smieciok.pl +koszmail.pl +koszulki-swiat.pl +kotaksurat.online +kotea.pl +kotiki.pw +kotm.com +kotruyerwrwyrtyuio.co.tv +kotsu01.info +kouattre38t.cf +kouattre38t.ga +kouattre38t.gq +kouattre38t.ml +kouattre38t.tk +kouch.ml +koussay1.tk +koussayjhon.cf +koussayjhon.ga +koussayjhon.gq +koussayjhon.tk +kovezero.com +koweancenjancok.cf +koweancenjancok.ga +koweancenjancok.gq +koweancenjancok.ml +kowert.in +kox.emltmp.com +koxzo.com +koyocah.ml +koyunum.com +koyunum.net +kozacki.pl +kozow.com +kpay.be +kpbh.mimimail.me +kpddy.anonbox.net +kpgindia.com +kpgx.emltmp.com +kphk.emlpro.com +kpll.laste.ml +kplover.com +kpnaward.com +kpnmail.org +kpooa.com +kpost.be +kpp.dropmail.me +kprem.store +kpsa.laste.ml +kpsc.com +kpt.laste.ml +kpv.emlpro.com +kpxnxpkst.pl +kqc.laste.ml +kqhs4jbhptlt0.cf +kqhs4jbhptlt0.ga +kqhs4jbhptlt0.gq +kqhs4jbhptlt0.ml +kqhs4jbhptlt0.tk +kqis.de +kqku5.anonbox.net +kqo0p9vzzrj.ga +kqo0p9vzzrj.gq +kqo0p9vzzrj.ml +kqo0p9vzzrj.tk +kqr.laste.ml +kqw.emlhub.com +kqwyqzjvrvdewth81.cf +kqwyqzjvrvdewth81.ga +kqwyqzjvrvdewth81.gq +kqwyqzjvrvdewth81.ml +kqwyqzjvrvdewth81.tk +kqxi.com +krabbe.solutions +kraftdairymail.info +kraidi.com +krainafinansow.com.pl +krakenforwin.xyz +krakowpost.pl +krakowskiadresvps.com +kramatjegu.com +kramwerk.ml +krankenversicherungvergleich24.com +krapaonarak.com +kras-ses.ru +krasavtsev-ua.pp.ua +krasivie-parki.ru +kravify.com +kraxorgames.cf +kraydon.cfd +krd.ag +kre8ivelance.com +kreacja.info +kreacjainfo.net +kreasianakkampoeng.com +kreatifku.click +kreativsad.ru +kreatorzyiimprez.pl +kreatorzyimprez.pl +kredit-beamten.de +kreditmindi.org +kredyt-dla-ciebie.com.pl +kredytmaster.net +kredytnadowodbezbik.com.pl +kredytowemarzenia.pl +kredytowysklep.pl +kredytsamochodowy9.pl +kredyty-samochodowe.eu +kreines71790.co.pl +krem-maslo.info +krentery.tk +krepekraftonline.com +kresla-stulia.info +kreuiema.com +krg.yomail.info +krgyui7svgomjhso.cf +krgyui7svgomjhso.ga +krgyui7svgomjhso.gq +krgyui7svgomjhso.ml +krgyui7svgomjhso.tk +krhr.co.cc +krillio.com +krim.ws +kriptowallet.ml +kriq.emltmp.com +kriscop.online +krishnarandi.tk +krissfamily.online +krissysummers.com +kristall2.ru +kristeven.tk +kristinehansen.me +kristinerosing.me +kristy-rows.com +krnf.de +krns.com +krnuqysd.pl +krodnd2a.pl +krofism.com +krogerco.com +krogstad24.aquadivingaccessories.com +kromosom.ml +krompakan.xyz +krondon.com +kronedigits.ru +kroniks.com +krovanaliz.ru +krovatka.su +krpbroadcasting.com +krsw.sonshi.cf +krsw.tk +krte3562nfds.cf +krte3562nfds.ga +krte3562nfds.gq +krte3562nfds.ml +krte3562nfds.tk +krtjrzdt1cg2br.cf +krtjrzdt1cg2br.ga +krtjrzdt1cg2br.gq +krtjrzdt1cg2br.ml +krtjrzdt1cg2br.tk +kruay.com +krunsea.com +krupp.cf +krupp.ga +krupp.ml +krupukhslide86bze.gq +krushinem.net +kruszer.pl +krutynska.pl +krx.laste.ml +krxr.ru +krxt.com +kryptexsecuremax.club +krypton.tk +kryptonqq.com +krystallettings.co.uk +krystalresidential.co.uk +krzysztofpiotrowski.com +ks.emlpro.com +ks87.igg.biz +ks87.usa.cc +ksadkscn.com +ksadrc.com +ksb.emlpro.com +kscommunication.com +ksdssd.cc +kserokopiarki-gliwice.com.pl +kserokopiarki.pl +ksframem.com +ksgmac.com +ksiegapozycjonera.priv.pl +ksiegarniapowszechna.pl +ksiegowi.biz +ksignnews.com +ksiowlc.com +ksis.com +ksiskdiwey.cf +ksjewelryboutique.com +ksjivxt.com +ksksk.com +ksmtrck.cf +ksmtrck.ga +ksmtrck.rf.gd +ksmtrck.tk +ksnd.com +ksosmc.com +ksqmm.anonbox.net +ksqpmcw8ucm.cf +ksqpmcw8ucm.ga +ksqpmcw8ucm.gq +ksqpmcw8ucm.ml +ksqpmcw8ucm.tk +ksudnngf-jh.xyz +ksvd.yomail.info +ksxm.spymail.one +ksyhtc.com +kt-ex.site +kt.dropmail.me +kta.emlpro.com +ktajnnwkzhp9fh.cf +ktajnnwkzhp9fh.ga +ktajnnwkzhp9fh.gq +ktajnnwkzhp9fh.ml +ktajnnwkzhp9fh.tk +ktasy.com +ktbk.ru +ktbv.com +ktds.co.uk +kterer.com +ktisocial.asia +ktlx.laste.ml +ktm.yomail.info +ktmedia.asia +ktno.laste.ml +ktotey6.mil.pl +ktt.dropmail.me +ktumail.com +ktw.yomail.info +ktz.yomail.info +ktzmi.cf +ku.emlhub.com +ku1hgckmasms6884.cf +ku1hgckmasms6884.ga +ku1hgckmasms6884.gq +ku1hgckmasms6884.ml +ku1hgckmasms6884.tk +kua.emlpro.com +kuai909.com +kuaijenwan.com +kuaixueapp01.mygbiz.com +kualitasqq.com +kualitasqq.net +kuantumdusunce.tk +kuatcak.cf +kuatcak.tk +kuatkanakun.com +kuatmail.gq +kuatmail.tk +kuatocokjaran.cf +kuatocokjaran.ga +kuatocokjaran.gq +kuatocokjaran.ml +kuatocokjaran.tk +kuba-nedv.ru +kuba.rzemien.xon.pl +kuban-kirpich.ru +kubaptisto.com +kuchenmobel-berlin.ovh +kuchniee.eu +kucingarong.cf +kucingarong.ga +kucingarong.gq +kucingarong.ml +kucinge.site +kucix.com +kucoba.ml +kudaponiea.cf +kudaponiea.ga +kudaponiea.ml +kudaponiea.tk +kudaterbang.gq +kudimi.com +kudzu.info.pl +kue747rfvg.cf +kue747rfvg.ga +kue747rfvg.gq +kue747rfvg.ml +kue747rfvg.tk +kuemail.men +kuf.emltmp.com +kufrrygq.info +kugorze.com.pl +kugzk.anonbox.net +kuh.mu +kuhlgefrierkombinationen.info +kuhninazakaz.info +kuhnya-msk.ru +kuhrap.com +kui.freeml.net +kuikytut.review +kuiljunyu69lio.cf +kuingin.ml +kuiqa.com +kujztpbtb.pl +kuk.laste.ml +kukold.ru.com +kukowski.eu +kukowskikukowski.eu +kuku.lol +kuku.lu +kukuite.ch +kukuka.org +kukushoppy.site +kulapozca.cfd +kulepszejprzyszlosci.pl +kulichiki.com +kulionlen.my.id +kulitlumpia.ml +kulitlumpia1.ga +kulitlumpia2.cf +kulitlumpia3.ml +kulitlumpia4.ga +kulitlumpia5.cf +kulitlumpia6.ml +kulitlumpia7.ga +kulitlumpia8.cf +kulksttt.com +kulmeo.com +kulodgei.com +kulpik.club +kulturalneokazje.pl +kulturapitaniya.ru +kulturbetrieb.info +kum38p0dfgxz.cf +kum38p0dfgxz.ga +kum38p0dfgxz.gq +kum38p0dfgxz.ml +kum38p0dfgxz.tk +kumail8.info +kumashome.shop +kumaszade.shop +kumisgonds69.me +kumli.racing +kumpa.xyz +kumpulanmedia.com +kunderh.com +kune.app +kungfuseo.info +kungfuseo.net +kungfuseo.org +kuni-liz.ru +kunimedesu.com +kunio33.lady-and-lunch.xyz +kunio69.yourfun.xyz +kunsum.com +kuontil.buzz +kuoogle.com +kupakupa.waw.pl +kupeyka.com +kupiarmaturu.ru +kupidonapp.lat +kupiru.net +kupoklub.ru +kupsstubirfag.xyz +kupw.freeml.net +kupz.xyz +kurbieh.com +kurdit.se +kurkumazin.shn-host.ru +kuro.marver-coats.marver-coats.xyz +kurogaze.site +kurrxd.com +kursovaya-rabota.com +kuruapp.com +kuruyuk.com +kurwa.top +kurz-abendkleider.com +kurzepost.de +kusam.ga +kusaomachi.com +kusaomachi.net +kusaomachihotel.com +kusaousagi.com +kusma.org +kusrc.com +kustermail.com +kustomus.com +kusyuvalari.com +kut-mail1.com +kutahyaalyans.xyz +kutakbisadekatdekat.cf +kutakbisadekatdekat.ml +kutakbisadekatdekat.tk +kutakbisajauhjauh.cf +kutakbisajauhjauh.ga +kutakbisajauhjauh.gq +kutakbisajauhjauh.ml +kutakbisajauhjauh.tk +kutch.net +kuteotieu111.cz.cc +kutevi.site +kutsartor.shop +kuucrechf.pl +kuugyomgol.pl +kuvasin.com +kuy.systems +kuyberuntung.com +kuyzstore.com +kv.spymail.one +kv8v0bhfrepkozn4.cf +kv8v0bhfrepkozn4.ga +kv8v0bhfrepkozn4.gq +kv8v0bhfrepkozn4.ml +kv8v0bhfrepkozn4.tk +kvartagroup.ru +kvegg.com +kvhrr.com +kvhrs.com +kvhrw.com +kvr8.dns-stuff.com +kvs24.de +kvsa.com +kvtn.com +kw9gnq7zvnoos620.cf +kw9gnq7zvnoos620.ga +kw9gnq7zvnoos620.gq +kw9gnq7zvnoos620.ml +kw9gnq7zvnoos620.tk +kwa.xyz +kwadratowamaskar.pl +kwalah.com +kwalidd.cf +kwanj.ml +kwantiques.com +kwax.emlhub.com +kweci.com +kweekendci.com +kwertueitrweo.co.tv +kwestor4.pl +kwestor5.pl +kwestor6.pl +kwestor7.pl +kwestor8.pl +kwiatownik.pl +kwiatyikrzewy.pl +kwifa.com +kwift.net +kwikway.com +kwilco.net +kwinx.click +kwishop.com +kwn.emlhub.com +kwondang.com +kwontol.com +kwozy.com +kwra.laste.ml +kwtest.io +kwthr.com +kww.laste.ml +kwyv.com +kxcmail.com +kxgif.com +kxliooiycl.pl +kxmnbhm.gsm.pl +kxzaten9tboaumyvh.cf +kxzaten9tboaumyvh.ga +kxzaten9tboaumyvh.gq +kxzaten9tboaumyvh.ml +kxzaten9tboaumyvh.tk +ky-ky-ky.ru +ky.emlpro.com +ky.emltmp.com +ky019.com +kyal.pl +kyctrust.online +kycvrvax.xyz +kyfavorsnm.com +kyfeapd.pl +kygur.com +kyhuifu.site +kykareku.ru +kylemaguire.com +kylemorin.co +kylesphotography.com +kylinara.ru +kynet.be +kyny.dropmail.me +kynzxy.store +kyois.com +kyotosteakhouse.com +kyp.in +kyrescu.com +kyriake.com +kyriog.fr +kysngd.life +kystj.us +kyuusei.fr.nf +kyverify.ga +kyvtv.shop +kyy.emlpro.com +kz64vewn44jl79zbb.cf +kz64vewn44jl79zbb.ga +kz64vewn44jl79zbb.gq +kz64vewn44jl79zbb.ml +kz64vewn44jl79zbb.tk +kz7wh.anonbox.net +kza6q.anonbox.net +kzccv.com +kzcontractors.com +kzg.emltmp.com +kzif.freeml.net +kznu.freeml.net +kzp.emlpro.com +kzq6zi1o09d.cf +kzq6zi1o09d.ga +kzq6zi1o09d.gq +kzq6zi1o09d.ml +kzq6zi1o09d.tk +kzw1miaisea8.cf +kzw1miaisea8.ga +kzw1miaisea8.gq +kzw1miaisea8.ml +kzw1miaisea8.tk +kzwu.laste.ml +l-c-a.us +l-okna.ru +l.bgsaddrmwn.me +l.co.uk +l.polosburberry.com +l.safdv.com +l.searchengineranker.email +l.teemail.in +l00s9ukoyitq.cf +l00s9ukoyitq.ga +l00s9ukoyitq.gq +l00s9ukoyitq.ml +l00s9ukoyitq.tk +l0llbtp8yr.cf +l0llbtp8yr.ga +l0llbtp8yr.gq +l0llbtp8yr.ml +l0llbtp8yr.tk +l0real.net +l1rwscpeq6.cf +l1rwscpeq6.ga +l1rwscpeq6.gq +l1rwscpeq6.ml +l1rwscpeq6.tk +l2creed.ru +l2n5h8c7rh.com +l33r.eu +l3nah.anonbox.net +l3vp2.anonbox.net +l48zzrj7j.pl +l4usikhtuueveiybp.cf +l4usikhtuueveiybp.gq +l4usikhtuueveiybp.ml +l4usikhtuueveiybp.tk +l5.ca +l5prefixm.com +l6factors.com +l6hmt.us +l73x2sf.mil.pl +l745pejqus6b8ww.cf +l745pejqus6b8ww.ga +l745pejqus6b8ww.gq +l745pejqus6b8ww.ml +l745pejqus6b8ww.tk +l7b2l47k.com +l8oaypr.com +l9pdev.com +l9qwduemkpqffiw8q.cf +l9qwduemkpqffiw8q.ga +l9qwduemkpqffiw8q.gq +l9qwduemkpqffiw8q.ml +l9qwduemkpqffiw8q.tk +l9tmlcrz2nmdnppabik.cf +l9tmlcrz2nmdnppabik.ga +l9tmlcrz2nmdnppabik.gq +l9tmlcrz2nmdnppabik.ml +l9tmlcrz2nmdnppabik.tk +la-boutique.shop +la0u56qawzrvu.cf +la0u56qawzrvu.ga +la2imperial.vrozetke.com +la2walker.ru +laafd.com +laala.xyz +lab.agp.edu.pl +labara.com +labas.com +labebeh.net +labebx.com +labeledhf.com +labelsystems.eu +labeng.shop +labetteraverouge.at +labfortyone.tk +labiblia.digital +lablasting.com +labo.ch +labogili.ga +laboraryer.com +laboratortehnicadentara.ro +laboriously.com +laborstart.org +labum.com +labworld.org +lacabina.info +lacedmail.com +lacercadecandi.ml +laceylist.com +lachorrera.com +lack.favbat.com +lackmail.net +lackmail.ru +laco.fun +laconicoco.net +lacosteshoesfree.com +lacouette.glasslightbulbs.com +lacraffe.fr.nf +lacrosselocator.com +lacto.info +lada-granta-fanclub.ru +ladailyblog.com +ladapickup.ru +laddsmarina.com +ladege.gq +ladege.ml +ladege.tk +ladellecorp.com +laden3.com +laderranchaccidentlawyer.com +ladespensachicago.org +ladeweile.com +ladiabetessitienecura.com +ladiesbeachresort.com +ladieshightea.info +ladiesjournal.xyz +ladiesshaved.us +ladivinacomedia.art +ladrop.ru +laduree-dublin.com +ladusing.shop +lady-jisel.pl +lady-journal.ru +ladyanndesigns.com +ladybossesgreens.com +ladycosmetics.ru +ladydressnow.com +ladyfleece.com +ladylounge.de +ladylovable.com +ladymacbeth.tk +ladymjsantos.net +ladymjsantos.org +ladymom.xyz +ladyofamerica.com +ladyonline.com +ladyreiko.com +ladyshelly.com +ladystores.ru +ladyteals.com +ladyvictory-vlg.ru +ladz.site +laerrtytmx.ga +laerwrtmx.ga +laewe.com +laez.emlhub.com +lafani.com +lafarmaciachina.com +lafayetteweb.com +lafibretubeo.net +lafrem3456ails.com +lafta.cd +laftelgo.com +lafz2.anonbox.net +lag.tv +lagchouco.cf +lagchouco.ga +lagerarbetare.se +lageris.cf +lageris.ga +laggybit.com +lagiapa.online +lagicantiik.com +lagify.com +lagniappe-restaurant.com +lagoriver.com +lagotos.net +lagrandemutuelle.info +lags.us +lagsixtome.com +lagugratis.net +laguia.legal +lagunacottages.vacations +lagunaproducts.com +lagushare.me +lah.spymail.one +lahainataxi.com +lahamnakam.me +lahezi.world +lahi.me +lahoku.com +lahorerecord.com +lahta9qru6rgd.cf +lahta9qru6rgd.ga +lahta9qru6rgd.gq +lahta9qru6rgd.ml +lahta9qru6rgd.tk +laicai8.sbs +laika999.ml +laikacyber.cf +laikacyber.ga +laikacyber.gq +laikacyber.ml +laikacyber.tk +lailakhan.net +lain.ch +lajoska.pe.hu +lak.pp.ua +lakarstwo.info +lakarunyha65jjh.ga +lake-capital.com +lakefishingadvet.net +lakelivingstonrealestate.com +lakemneadows.com +lakeplacid2009.info +lakesidde.com +laketahoe-realestate.info +lakevilleapartments.com +lakibaba.com +laklica.com +lakngin.ga +lakngin.ml +lakqs.com +laksamantunggalakma.me +laksana.in +lal.kr +lala-mailbox.club +lala-mailbox.online +lalala-family.com +lalala.fun +lalala.site +lalala001.orge.pl +lalalaanna.com +lalalamail.net +lalalapayday.net +lalamailbox.com +laltina.store +lalune.ga +laluxy.com +lam0k.com +lamasticots.com +lambadarew90bb.gq +lambda.uniform.thefreemail.top +lambdaecho.webmailious.top +lambdasu.com +lambsauce.de +lamdep.net +lamdx.com +lamedicalbilling.com +lamepajri.co +lamgido.site +lamgme.xyz +lami4you.info +lamiproi.com +lamkslmaapl.cfd +lamongan.cf +lamongan.gq +lamongan.ml +lamore.com +lampadaire.cf +lampartist.com +lampdocs.com +lamseochuan.com +lamshop.site +lan-utan-uc-se.com +lanaa.site +lancastercoc.com +lancasterdining.net +lancasterpainfo.com +lancasterplumbing.co.uk +lancastertheguardian.com +lance7.com +lancego.space +lancekellar.com +lancelot.sbs +lancelsacspascherefr.com +lanceuq.com +lanceus.com +lanch.info +lancia.ga +lancia.gq +lancourt.com +lancsvt.co.uk +landandseabauty.com +landans.ru +landaugo.com +landfoster.com +landmail.co +landmail.nl +landmanreportcard.com +landmark.io +landmarknet.net +landmarktest.site +landmeel.nl +landonbrafford.com +landrumsupply.com +landscapesolution.com +landtinfotech.com +lane.dropmail.me +lanelofte.com +langabendkleider.com +langanswers.ru +langar.vip +langitserver.biz +langleyadvocate.net +lanipe.com +lankew.com +lantofe.ga +lanxi8.com +laocaishen.cc +laoctarine.store +laoeq.com +laoho.com +laoia.com +laokzmaqz.tech +laonanrenj.com +laoraclej.com +laoshandicraft.com +laotmail.com +lapakqu.com +lapakqu.space +lapanganrhama.biz +laparbgt.cf +laparbgt.ga +laparbgt.gq +laparbgt.ml +lapeds.com +lapetcent.gq +laporinaja.com +lapost.net +lapptoposse99.com +laptopbeddesk.net +laptopcooler.me +laptoplonghai.com +laptopnamdinh.com +laptoptechie.com +laputs.co.pl +laraes.pl +laramail.io +laraskey.com +largeformatprintonline.com +largehdube.com +largelift.com +largo.laohost.net +larisamanah.online +larisia.com +larjem.com +larland.com +laroadsigns.info +larryblair.me +larykennedy.com +lasaliberator.org +lasarusltd.com +lasde.xyz +laserevent.com +laserfratetatuaj.com +laserlip.com +laserowe-ciecie.pl +laserremovalreviews.com +lasersimage.com +lasertypes.net +lasg.info +lashyd.com +lasix4u.top +lasixonlineatonce.com +lasixonlinesure.com +lasixonlinetablets.com +lasixprime.com +lasojcyjrcwi8gv.cf +lasojcyjrcwi8gv.ga +lasojcyjrcwi8gv.gq +lasojcyjrcwi8gv.ml +lasojcyjrcwi8gv.tk +lass-es-geschehen.de +last-chance.pro +laste.ml +lastflights.ir +lasthotel.website +lastingimpactart.com +lastlone.com +lastmail.co +lastmail.com +lastmail.ga +lastminute.dev +lastmx.com +lastrwasy.co.cc +laststand.xyz +laszki.info +laszlomail.com +lat-nedv.ru +lat.yomail.info +latamdate.review +latamgateway.io +latemail.tech +latesmail.com +latestgadgets.com +latihanindrawati.net +latinchat.com +latinmail.com +latovic.com +latreat.com +latviansmn.com +laud.net +laudmed.com +laugh.favbat.com +laughingninja.com +laugor.com +launchdetectorbot.xyz +launchjackings.com +lauramiehillhomestead.com +laurelmountainmustang.com +laurenbt.com +laurenscoaching.com +laurentnay.com +laurieingramdesign.com +lauxanh.live +lauxitupvw.ga +lavabit.com +lavalagogo.com +lavarip.xyz +lavendarlake.com +lavendel24.de +lavern.com +laverneste.com +laveuseapression.shop +lavp.de +lawdeskltd.com +lawenforcementcanada.ca +lawfinancial.ru +lawhead79840.co.pl +lawicon.com +lawior.com +lawlita.com +lawlz.net +lawrence1121.club +lawsocial.ru +lawsocietyfindasolicitor.net +lawsocietyfindasolicitor.org +lawson.cf +lawson.ga +lawson.gq +lawvest.com +lawyers2016.info +lawyersworld.world +laxex.ru +laxex.store +laxiw.org +layarlebar.de +layarqq.loan +laychuatrenxa.ga +laydrun.com +laymro.com +layout-webdesign.de +lazarskipl.com +lazdmzmgke.mil.pl +lazyarticle.com +lazyfire.com +lazyinbox.com +lazyinbox.us +lazymail.me +lazymail.ooo +lazymail.win +lb.spymail.one +lb1333.com +lbdzz.anonbox.net +lbe.kr +lbg-llc.com +lbhuxcywcxjnh.cf +lbhuxcywcxjnh.ga +lbhuxcywcxjnh.gq +lbhuxcywcxjnh.ml +lbhuxcywcxjnh.tk +lbicamera.com +lbicameras.com +lbicams.com +lbitly.com +lbjmail.com +lbn10.com +lbn11.com +lbn12.com +lbn13.com +lbn14.com +lboinhomment.info +lbox.de +lbthomu.com +lbx0qp.pl +lbyp7.anonbox.net +lbzannualj.com +lc.emlhub.com +lc.laste.ml +lc3ni.anonbox.net +lcad.com +lcamywkvs.pl +lcasports.com +lccggn.fr.nf +lccteam.xyz +lcdvd.com +lce0ak.com +lcebull.com +lcedaresf.com +lceland.net +lceland.org +lcelander.com +lcelandic.com +lceqee.buzz +lcga9.site +lck.emlhub.com +lckf.spymail.one +lclaireto.com +lcleanersad.com +lcmail.ml +lcomcast.net +lcould.kr +lcrs.emltmp.com +lcshjgg.com +lctt.emlhub.com +lcx666.ml +lcyn.dropmail.me +lcyxfg.com +ldaho.biz +ldaho.net +ldaho0ak.com +ldaholce.com +ldbassist.com +ldbrr.anonbox.net +ldebaat9jp8x3xd6.cf +ldebaat9jp8x3xd6.ga +ldebaat9jp8x3xd6.gq +ldebaat9jp8x3xd6.ml +ldebaat9jp8x3xd6.tk +ldefsyc936cux7p3.cf +ldefsyc936cux7p3.ga +ldefsyc936cux7p3.gq +ldefsyc936cux7p3.ml +ldefsyc936cux7p3.tk +ldfo.com +ldgb.emltmp.com +ldkq.dropmail.me +ldmh.emlhub.com +ldnplaces.com +ldokfgfmail.com +ldokfgfmail.net +ldop.com +ldovehxbuehf.cf +ldovehxbuehf.ga +ldovehxbuehf.gq +ldovehxbuehf.ml +ldovehxbuehf.tk +ldtb.spymail.one +ldtp.com +ldwdkj.com +ldy.spymail.one +le-asi-yyyo-ooiue.com +le-diamonds.com +le-tim.ru +le.monchu.fr +le.spymail.one +lea-0-09ssiue.org +lea-ss-ws-33.org +leabro.com +leacore.com +leaddogstats.com +leaderlawabogados.com +leadersinevents.com +leaderssk.com +leadgems.com +leadingbulls.com +leadingemail.com +leadingway.com +leadlovers.site +leadssimple.com +leadwins.com +leadwizzer.com +leafrelief.org +leafzie.com +leaguecms.com +leaguedump.com +leagueofdefenders.gq +leagueoflegendscodesgratuit.fr +leaknation.com +leakydisc.com +leakygutawarness.com +leamecraft.com +leanrights.com +leapradius.com +learena.com +learnaffiliatemarketingbusiness.org +learnhowtobehappy.info +learntobeabody.com +learntofly.me +lease.com +leasecarsuk.info +leasidetoronto.com +leasnet.net +leasswsiue.org +leatherjackets99.com +leatherprojectx.com +leave-notes.com +leaver.ru +lebab.nl +lebadge.com +lebaominh.ga +lebaran.me +lebatelier.com +lebronjamessale.com +lechatiao.com +lechenie-raka.su +lecsaljuk.club +lecturebazaar.com +lectverli.tk +lecz6s2swj1kio.cf +lecz6s2swj1kio.ga +lecz6s2swj1kio.gq +lecz6s2swj1kio.ml +lecz6s2swj1kio.tk +leczycanie.pl +led-best.ru +led-mask.com +ledcaps.de +ledgardenlighting.info +ledhorticultural.com +ledinhchung.online +lediponto.com +ledmask.com +lednlux.com +ledoktre.com +ledt.laste.ml +lee.mx +leechchannel.com +leeching.net +leecountyschool.us +leeh.emlhub.com +leemail.me +leenaisiwan.pics +leerling.ml +leeseman.com +leespring.biz +leessummitapartments.com +leetmail.co +leezro.com +lefaqr5.com +lefmail.com +left-mail.com +leftsydethoughts.com +leg10.xyz +legacy-network.com +legacyfloor.com +legacymode2011.info +legacywa.com +legal.maildin.com +legal.marksypark.com +legalalien.net +legalizamei.com +legalrc.loan +legalresourcenow.com +legalsentences.com +legalsteroidsstore.info +leganimathe.site +legcramps.in +lege4h.com +legibbean.site +legitimateonline.info +legitstore.xyz +legkospet.ru +legoshi.cloud +legrdil.com +lehman.cf +lehman.ga +lehman.gq +lehman.ml +lehman.tk +lehoa.top +lehu.yomail.info +lei.kr +lei.laste.ml +leifitne.cf +leifr.com +leiteophi.gq +lejada.pl +lekeda.ru +leknawypadaniewlosow.pl +leks.me +lella.co +lellno.gq +lellolidk.de +leluconnurrima.biz +lelucoon.net +lemaxime.com +lembarancerita.ga +lembarancerita.ml +lemel.info +lemonadeka.org.ua +lemondresses.com +lemondresses.net +lemper.cf +lemurhost.net +lemycam.ml +lendfash.com +lendlesssn.com +lendoapp.co +lenfly.com +leniences.com +lenin-cola.info +leningrad.space +lenlusiana5967.ga +lenmawarni5581.ml +lennurfitria2852.ml +lennymarlina.art +lenovo.redirectme.net +lenovo120s.cf +lenovo120s.gq +lenovo120s.ml +lenovo120s.tk +lenovog4.com +lenprayoga2653.ml +lenputrima5494.cf +lensdunyasi.com +lensmarket.com +lentafor.me +lenuh.com +leoirkhf.space +leon.emltmp.com +leonberlin.site +leonebets.com +leonelahmad.cf +leonmail.men +leonorcastro.com +leonvero.com +leonyvh.art +leoparali.icu +leopardstyle.com +leos.org.uk +leparfait.net +lepavilliondelareine.com +lepdf.site +lepetitensemble.com +lephamtuki.com +lepoxo.xyz +lepszenizdieta.pl +lequangduc.cloud +lequitywk.com +lequydononline.net +lerany.com +lerbhe.com +lerch.ovh +lercjy.com +lerfuwond.com +leribigb.tk +lernerfahrung.de +lero3.com +lersptear.com +lerwfv.com +les-bouquetins.com +les-trois-cardinaux.com +les.codes +lesatirique.com +lesbugs.com +lesmail.top +lesobprovermail.com +lesoleildefontanieu.com +lesotho-nedv.ru +lesotica.com +lespassant.com +lespedia.com +lespompeurs.site +lesproekt.info +lesrecettesdebomma.com +lessgime.ga +lessonlogs.com +lessschwab.com +lestgeorges.com +lestinebell.com +lestnicy.in.ua +lestrange45.aquadivingaccessories.com +lesy.pl +lesz.com +let.favbat.com +letgo99.com +letmailme.icu +letmeinonthis.com +letmymail.com +leto-dance.ru +letpays.com +letsgo.co.pl +letsgoalep.net +letshack.cc +letsmail9.com +letsrelay.com +letterguard.net +letterhaven.net +letterprotect.net +lettersboxmail.com +lettersfxj.com +lettershield.com +letthemeatspam.com +lettrs.email +letup.com +leufhozu.com +leupus.com +levaetraz.ga +levaetraz.ml +levaetraz.tk +levank.com +level-3.cf +level-3.ga +level-3.gq +level-3.ml +level-3.tk +level3.flu.cc +level3.igg.biz +level3.nut.cc +level3.usa.cc +levelmebel.ru +levelpeptides.eu +levelupyourworld.com +levisdaily.com +levitra.fr +levitrasx.com +levitraxnm.com +levius.online +levothyroxinedosage.com +levtbox.com +levtov.net +levtr20mg.com +levy.ml +lew2sv9bgq4a.cf +lew2sv9bgq4a.ga +lew2sv9bgq4a.gq +lew2sv9bgq4a.ml +lew2sv9bgq4a.tk +lewenbo.com +lewiseffectfoundation.org +lewistweedtastic.com +lewou.com +lexi.rocks +lexigra.com +lexisense.com +lexortho.com +lexoxasnj.pl +lexpublib.com +lexu4g.com +lexyland.com +leylareylesesne.art +leysatuhell.sendsmtp.com +lez.se +lf.emlpro.com +lfc.best +lff.spymail.one +lfft.emlpro.com +lfifet19ax5lzawu.ga +lfifet19ax5lzawu.gq +lfifet19ax5lzawu.ml +lfifet19ax5lzawu.tk +lflr.freeml.net +lfmwrist.com +lfo.com +lfsvddwij.pl +lftjaguar.com +lfu.emlhub.com +lfu.spymail.one +lfyn.freeml.net +lg-g7.cf +lg-g7.ga +lg-g7.gq +lg-g7.ml +lg-g7.tk +lg88.site +lgai.mailpwr.com +lgbtqpow.com +lgeacademy.com +lgfvh9hdvqwx8.cf +lgfvh9hdvqwx8.ga +lgfvh9hdvqwx8.gq +lgfvh9hdvqwx8.ml +lgfvh9hdvqwx8.tk +lghjgbh89xcfg.cf +lgj.laste.ml +lgjiw1iaif.gq +lgjiw1iaif.ml +lgjiw1iaif.tk +lgloo.net +lgloos.com +lgmail.com +lgmodified.com +lgratuitys.com +lgt8pq4p4x.cf +lgt8pq4p4x.ga +lgt8pq4p4x.gq +lgt8pq4p4x.ml +lgt8pq4p4x.tk +lgtix.fun +lgx.dropmail.me +lgx2t3iq.pl +lgxscreen.com +lgyimi5g4wm.cf +lgyimi5g4wm.ga +lgyimi5g4wm.gq +lgyimi5g4wm.ml +lgyimi5g4wm.tk +lgyz.emltmp.com +lgz.emlpro.com +lh-properties.co.uk +lh.ro +lh2ulobnit5ixjmzmc.cf +lh2ulobnit5ixjmzmc.ga +lh2ulobnit5ixjmzmc.gq +lh2ulobnit5ixjmzmc.ml +lh2ulobnit5ixjmzmc.tk +lh451.cf +lh451.ga +lh451.gq +lh451.ml +lh451.tk +lheb.com +lhkjfg45bnvg.gq +lhkk.yomail.info +lhl4c.anonbox.net +lho.emltmp.com +lhory.com +lhpa.com +lhrnferne.mil.pl +lhsdv.com +lhslhw.com +lhtstci.com +lhu.yomail.info +lhuw.emlhub.com +lhuz.emltmp.com +lhzoom.com +liadhene.com +liamcyrus.com +liamekaens.com +liamjuniortoys.cfd +liang-tts.shop +lianhe.in +liaphoto.com +liargroup.com +liastoen.com +liawaode.art +libbywrites.com +libeoweb.info +libera.ir +liberarti.org +liberiaom.com +libertarian.network +libertyinworld.com +libertymail.info +libertymu5ual.com +libertyproperty.com +libestill.site +libfemblog.com +libinit.com +libox.fr +libra47.flatoledtvs.com +librans.co.uk +library.gng.edu.pl +librielibri.info +libriumprices.com +librthly.com +libusnusc.online +liceomajoranarho.it +licepann.com +lichten-nedv.ru +lichthidauworldcup.net +lickmyass.com +lickmyballs.com +licytuj.net.pl +lid2e.anonbox.net +lid4s.anonbox.net +lidaye-facai.xyz +lidell.com +lidely.com +lidercontabilidadebrasil.com +lidprep.vision +lidte.com +liebenswerter.de +lieboe.com +liebt-dich.info +lied.com +liefdezuste.ml +lienminhnuthan.vn +liepaia.com +life-coder.com +life-online1.ru +life-smile.ru +lifeafterlabels.org +lifebyfood.com +lifecoach4elite.net +lifeeye.us +lifefit.pl +lifeforceschool.com +lifeforchanges.com +lifeguru.online +lifejacketwhy.ml +lifemr.us +lifeofrhyme.com +lifeperformers.com +lifestitute.site +lifestyle4u.ru +lifestylemagazine.co +lifestyleunrated.com +lifetalkrc.com +lifetimefriends.info +lifetotech.com +lifewithouttanlines.com +lifezg.com +liffoberi.com +liftandglow.net +lifted.cc +lig.yomail.info +ligagnb.pl +ligai.ru +ligaku.com +liggegi.com +lightboxelectric.com +lightengroups.com +lighthouseagentbr.com +lighthousebookkeeping.com +lighthouseequity.com +lighthouseventure.com +lighting-us.info +lightningcomputers.com +lightpower.pw +lightshopindia.com +ligirls.ru +ligit.shop +ligobet56.com +ligolfcourse.online +ligsb.com +lihe555.com +lihuafeng.com +lihui.lookin.at +lii.aee.emlhub.com +liitokala.ga +lijeuki.co +like-v.ru +like.ploooop.com +likeageek.fr.nf +likeance.com +likebaiviet.com +likelystory.net +likemaxcare.com +likeme252.com +likememes23.com +likemohjooj.shop +likemovie.net +likeorunlike.info +likere.ga +likesieure.ga +likesv.com +likesyouback.com +likethat1234.com +likettt.com +likevip.net +likevipfb.cf +likevippro.site +likewayn.club +likewayn.online +likewayn.site +likewayn.space +likewayn.store +likewayn.xyz +likex.vn +lilin.pl +lilittka.tk +lillemap.net +lilly.co +lilnx.net +lilo.me +lilspam.com +lilsuite.id +lilyclears.com +lilylee.com +lilywear.shop +limahfjdhn89nb.tk +limamail.ml +limaquebec.webmailious.top +limbergrs.website +limbostudios.com +limcorp.net +limedesign.net +limeline.in +limemail.biz +limewire.one +liming.de +limit.laste.ml +limiteds.me +limitsldnh.com +limon.biz.tm +limonchilli.com +limpasseboutique.com +limsoohyang.com +limtu.com +limuzyny-hummer.pl +lin.lingeriemaid.com +lin889.com +linacit.com +lincolnag.com +lindaknujon.info +lindamedic.com +lindaramadhanty.art +lindbarsand.cf +lindbarsand.tk +linden.com +lindenbaumjapan.com +lindsayphillips.com +lindwards.info +lineansen24.flu.cc +lineking232.com +lineofequitycredit.net +lines12.com +lingayatlifesathi.com +lingdlinjewva.xyz +lingerieluna.com +linging.org +lingmarbi.cf +linguistic.ml +linguisticlast.com +linhtinh.ml +linind.ru +liningnoses.top +linjianhui.me +link-assistant.com +link-protector.biz +link.cloudns.asia +link2mail.net +link3mail.com +linkadulttoys.com +linkauthorityreview.info +linkbearer.com +linkbet88.org +linkbet88.xyz +linkbitsmart.com +linkbm.one +linkbm365.com +linkbuilding.ink +linkbuilding.pro +linkdominobet.me +linked-in.ir +linkedintuts2016.pw +linkedmails.com +linkfieldhub.com +linkfieldrun.com +linkfixweb.com +linkflowrun.com +linkhivezone.com +linki321.pl +linkinbox.lol +linkjetdata.com +linkjewellery.com +linklist.club +linkloginjoker123.com +linkmail.info +linkmailer.net +linkrer.com +linksdown.net +linkserver.es +linksgold.ru +linksnb.com +linksparkclick.com +linku.in +linkusupng.com +linkverse.ru +linkzimra.ml +linlowebp.gq +linlshe.com +linode.systems +linodecloud.tech +linodg.com +linop.online +linrani.online +linseyalexander.com +linshi-email.com +linshiyou.com +linshiyouxiang.net +linshiyouxiang.xyz +linshuhang.com +linux-mail.xyz +linux.onthewifi.com +linux0.net +linuxmail.com +linuxmail.so +linuxmail.tk +linuxpl.eu +linx.email +linxues.com +linyukaifa.com +lioasdero.tk +liocbrco.com +liofilizat.pl +liokfu32wq.com +lions.gold +lioplpac.com +liopolo.com +liopolop.com +lip3x.anonbox.net +lipitorprime.com +lipo13blogs.com +lipoaspiratie.info +liporecovery.com +liposuctionofmiami.com +lippystick.info +lipskydeen.ga +lipstickjunkiesshow.com +liq.emltmp.com +liquidacionporsuicidio.es +liquidation-specialists.com +liquidfastrelief.com +liquidherbalincense.com +liquidlogisticsmanagement.com +liquidmail.de +liquidxs.com +lirank.com +lirankk.com +lirikkuy.cf +lis.freeml.net +lisamadison.cf +lisamail.com +lisciotto.com +lisoren.com +lisseurghdpascherefr.com +lisseurghdstylers.com +lissseurghdstylers.com +list-here.com +list.elk.pl +lista.cc +listallmystuff.info +listdating.info +listentowhatisaynow.club +listomail.com +listtoolseo.info +litardo192013.club +litb.site +litbnno874tak6nc2oh.cf +litbnno874tak6nc2oh.ga +litbnno874tak6nc2oh.ml +litbnno874tak6nc2oh.tk +litd.site +lite-bit.com +lite.com +lite14.us +litea.site +liteal.com +liteb.site +litec.site +liteclubsds.com +lited.site +litedrop.com +litee.site +litef.site +liteg.site +liteh.site +litei.site +litej.site +litek.site +litem.site +liten.site +liteo.site +litep.site +litepax.com +liteq.site +literb.site +literc.site +literd.site +litere.site +literf.site +literg.site +literh.site +literi.site +literj.site +literk.site +literl.site +literm.site +litermssb.com +litet.site +liteu.site +litev.site +litew.site +litex.site +litez.site +litf.site +litg.site +litj.site +litl.site +litm.site +litn.site +litom.icu +litony.com +litp.site +litrb.site +litrc.site +litrd.site +litre.site +litrf.site +litrg.site +litrh.site +litri.site +litrj.site +litrk.site +litrl.site +litrm.site +litrn.site +litrp.site +litrq.site +litrr.site +litrs.site +litrt.site +litru.site +litrv.site +litrw.site +litrx.site +litry.site +litrz.site +littlebiggift.com +littlebuddha.info +littlefarmhouserecipes.com +littlemail.org.ua +littlepc.ru +littlestpeopletoysfans.com +litv.site +litva-nedv.ru +litw.site +litx.site +liv3jasmin.com +livakum-autolar.ru +livan-nedv.ru +live-gaming.net +live-sexycam.fr +live.encyclopedia.tw +live.vo.uk +live.xo.uk +live1994.com +livealtmail.com +livecam.edu +livecam24.cc +livecamsexshow.com +livecric.info +livecur.info +livedebtfree.co.uk +livedecors.com +liveefir.ru +liveemail.xyz +liveforms.org +livegolftv.com +livehbo.us +livehk.online +liveintv.com +livejournali.com +livelcd.com +livellyme.com +liveloveability.com +livelylawyer.com +livemail.bid +livemail.download +livemail.men +livemail.pro +livemail.stream +livemail.top +livemail.trade +livemailbox.top +livemaill.com +livemails.info +livemalins.net +livemarketquotes.com +livemoviehd.site +livenode.info +livenode.org +livens.website +liveoctober2012.info +liveonkeybiscayne.com +livepharma.org +liveproxies.info +liveradio.tk +liverbidise.site +livercirrhosishelp.info +livern.eu +liverpoolac.uk +liverpoollaser.com +liveset100.info +liveset200.info +liveset300.info +liveset404.info +liveset505.info +liveset600.info +liveset700.info +liveset880.info +livesex-camgirls.info +livesexchannel.xyz +livesexyvideochat.com +livesgp.best +livesilk.info +liveskiff.us +livestop.online +livetechhelp.com +livewebcamsexshow.com +livfdr.tech +liviahotel.net +livinginsurance.co.uk +livingmarried.com +livingmetaphor.org +livingoal.net +livingprojectcontainer.com +livingsalty.us +livingshoot.com +livingsimplybeautiful.info +livingsimplybeautiful.net +livingthere.org +livingwater.net +livingwealthyhealthy.com +livingwiththeinfidels.com +livinitlarge.net +livinwuater.com +livn.de +livrepas.club +livs.online +livzadsz.com +liwondenationalpark.com +lixian8.com +lixianlinzhang.cn +lixo.loxot.eu +liyaxiu.com +liybt.live +liza.freeml.net +lizardrich.com +lizenzzentrale.com +lizery.com +lizpafe.cf +lizpafe.gq +lizpafe.ml +lizziegraceallen.com +lj.spymail.one +ljav.mailpwr.com +ljcomm.com +ljdo.emlhub.com +ljeh.com +ljgcdxozj.pl +ljgs.emltmp.com +ljhj.com +ljhjhkrt.cf +ljhjhkrt.ga +ljhjhkrt.ml +lji.dropmail.me +ljkjouinujhi.info +ljljl.com +ljm.laste.ml +ljogfbqga.pl +ljp.laste.ml +ljpremiums.club +ljsb66ccff.top +ljsingh.com +ljybrbuqkn.ga +lk.emltmp.com +lk21.cf +lk21.website +lkamapzlc.cfd +lkasyu.xyz +lkfeybv43ws2.cf +lkfeybv43ws2.ga +lkfeybv43ws2.gq +lkfeybv43ws2.ml +lkfeybv43ws2.tk +lkfp.emltmp.com +lkgn.se +lkhcdiug.pl +lkhy.dropmail.me +lkim1wlvpl.com +lkiopooo.com +lkj.com +lkj.com.au +lkjhjkuio.info +lkjhljkink.info +lkjjikl2.info +lko.co.kr +lko.kr +lkoqmcvtjbq.cf +lkoqmcvtjbq.ga +lkoqmcvtjbq.gq +lkoqmcvtjbq.ml +lkoqmcvtjbq.tk +lkql.dropmail.me +lkscedrowice.pl +lkxloans.com +ll47.net +llac.emlpro.com +llaen.net +llaurenu.com +llcs.xyz +lldtnlpa.com +llegitnon.ga +llerchaougin.ga +llessonza.com +llfilmshere.tk +llg.freeml.net +lli.laste.ml +llil.icu +llil.info +llj59i.kr.ua +lll.laste.ml +llllll.com +llogin.ru +llotfourco.ga +llubed.com +llventures.co +llvh.com +llzali3sdj6.cf +llzali3sdj6.ga +llzali3sdj6.gq +llzali3sdj6.ml +llzali3sdj6.tk +lm0k.com +lm1.de +lm360.us +lmakzac.cfd +lmakzpcfls.cfd +lmaritimen.com +lmav59c1.xyz +lmav5ba4.xyz +lmav7758.xyz +lmav87d2.xyz +lmavbfad.xyz +lmave2a9.xyz +lmavec51.xyz +lmb.emltmp.com +lmcudh4h.com +lmialovo.com +lmkopknh.cfd +lmlx.emltmp.com +lmomentsf.com +lmqk.laste.ml +lmtb.spymail.one +lmypasla.gq +ln.dropmail.me +ln.emlhub.com +ln0hio.com +ln0rder.com +ln0ut.com +ln0ut.net +lndex.net +lndex.org +lngscreen.com +lngt.dropmail.me +lnjgco.com +lnongqmafdr7vbrhk.cf +lnongqmafdr7vbrhk.ga +lnongqmafdr7vbrhk.gq +lnongqmafdr7vbrhk.ml +lnongqmafdr7vbrhk.tk +lnovic.com +lnrq.mailpwr.com +lnsilver.com +lnvoke.net +lnvoke.org +lnwhosting.com +lnwiptv.com +lo.guapo.ro +loa22ttdnx.cf +loa22ttdnx.ga +loa22ttdnx.gq +loa22ttdnx.ml +loa22ttdnx.tk +loadby.us +loadcatbooks.site +loadcattext.site +loadcattexts.site +loaddefender.com +loaddirbook.site +loaddirfile.site +loaddirfiles.site +loaddirtext.site +loadedanyfile.site +loadedanytext.site +loadedfreshtext.site +loadedgoodfile.site +loadednicetext.site +loadedrarebooks.site +loadfreshstuff.site +loadfreshtexts.site +loadingsite.info +loadingya.com +loadlibbooks.site +loadlibfile.site +loadlibstuff.site +loadlibtext.site +loadlistbooks.site +loadlistfiles.site +loadlisttext.site +loadnewbook.site +loadnewtext.site +loadspotfile.site +loadspotstuff.site +loan101.pro +loan123.com +loancash.us +loanexp.com +loanfast.com +loanins.org +loanrunners.com +loans.com +loaoa.com +loaphatthanh.com +loapq.com +lob.com.au +loblaw.twilightparadox.com +lobs.emltmp.com +local-classifiedads.info +local.blatnet.com +local.lakemneadows.com +local.marksypark.com +local.tv +localblog.com +localbreweryhouse.info +localbuilder.xyz +localhomepro.com +localinternetbrandingsecrets.com +localintucson.com +localityhq.com +localnews2021.xyz +locals.net +localsape.com +localserv.no-ip.org +localslots.co +localss.com +localtank.com +localtenniscourt.com +localtopography.com +localwomen-meet.cf +localwomen-meet.ga +localwomen-meet.gq +localwomen-meet.ml +locanto1.club +locantofuck.top +locantospot.top +locantowsite.club +locarlsts.com +located6j.com +locateme10.com +locationans.ru +locationlocationlocation.eu +locawin.com +locb.spymail.one +locbanbekhongtuongtac.com +loccluod.me +loccomail.host +locialispl.com +lock.bthow.com +lockaya.com +lockedsyz.com +lockedyourprofile.com +locklisa.ga +lockout.com +locksis.site +locksmangaragedoors.info +lockymail.fun +locmedia.asia +locmediaxl.com +locoblogs.com +locomodev.net +locshop.me +lodevil.ga +lodiapartments.com +lodon.cc +lodores.com +lodz.dropmail.me +loehkgjftuu.aid.pl +lofi-untd.info +lofi.host +lofiey.com +loftnoire.com +log.emlhub.com +logacheva.net +logaelda603.ml +loganairportbostonlimo.com +loganisha253.ga +logardha605.ml +logartika465.ml +logatarita892.cf +logatarita947.tk +logavrilla544.ml +logdewi370.ga +logdufay341.ml +logefrinda237.ml +logertasari851.cf +logesra202.cf +logeva564.ga +logfauziyah838.tk +logfika450.cf +logfitriani914.ml +logfrisaha808.ml +loghermawaty297.ga +loghermawaty297.ml +loghermawaty297.tk +loghning469.cf +loghusnah2.cf +logicampus.live +logicfieldzen.com +logichexbox.com +logiclaser.com +logicpathbid.com +logicstreak.com +logifixcalifornia.store +logike708.cf +login-email.cf +login-email.ga +login-email.ml +login-email.tk +login-to.online +loginadulttoys.com +logincbet.asia +logingar.cf +logingar.ga +logingar.gq +logingar.ml +loginioru1.com +loginpage-documentneedtoupload.com +loginz.net +logismi227.ml +logiteech.com +logmardhiyah828.ml +logmaureen141.tk +logmoerdiati40.tk +lognadiya556.ml +lognc.com +lognoor487.cf +logodez.com +logoktafiyanti477.cf +logomarts.com +logopitop.com +logpabrela551.ml +logrialdhie62.ga +logrialdhie707.cf +logrozi350.tk +logsharifa965.ml +logsinuka803.ga +logsmarter.net +logstefanny934.cf +logsutanti589.tk +logsyarifah77.tk +logtanuwijaya670.tk +logtheresia637.cf +logtiara884.ml +logular.com +logutomo880.ml +logvirgina229.tk +logw735.ml +logwan245.ml +logwibisono870.ml +logwulan9.ml +logyanti412.ga +loh.pp.ua +lohpcn.com +loikl.joyrideday.com +loil.site +loin.in +loj.emlhub.com +lokajjfs.website +lokaperuss.com +lokata-w-banku.com.pl +lokatowekorzysci.eu +lokd.com +loker4d.pro +lokerpati.site +lokerupdate.me +lokesh-gamer.ml +loketa.com +lokingmi.gq +lokka.net +lokmynghf.com +lokote.com +lokq.yomail.info +lokum.nu +lol.it +lol.no +lol.ovpn.to +lolaamaria.art +lole.link +lolemails.pl +lolfhxvoiw8qfk.cf +lolfhxvoiw8qfk.ga +lolfhxvoiw8qfk.gq +lolfhxvoiw8qfk.ml +lolfhxvoiw8qfk.tk +lolfreak.net +loli88.space +lolianime.com +lolidze.top +lolimail.tk +lolimailer.gq +lolimailer.ml +lolio.com +lolioa.com +lolior.com +lolitka.cf +lolitka.ga +lolitka.gq +lolito.tk +lolivip.com +lolivisevo.online +lolllipop.stream +lolmail.biz +lolo1.dk +lolokakedoiy.com +lolswag.com +lolusa.ru +lolwegotbumedlol.com +lom.kr +lomaschool.org +lombaniaga.shop +lomilweb.com +lominault.com +lompaochi.com +lompikachi.com +lompocplumbers.com +london2.space +londonbridgefestival.com +londonderryretirement.com +londondotcom.com +londonescortsbabes.co +londonlocalbiz.com +londontimes.me +londonwinexperience.com +lonelybra.ml +lonestarmedical.com +long-eveningdresses.com +long.idn.vn +long.marksypark.com +longaitylo.com +longbrain.com +longchamponlinesale.com +longchampsoutlet.com +longdz.site +longio.org +longlongcheng.com +longmonkey.info +longmontpooltablerepair.com +longsbkt.xyz +longsieupham.online +lonker.net +lonrahtritrammail.com +lonthe.ml +loo.life +loofty.com +look.cowsnbullz.com +look.lakemneadows.com +look.oldoutnewin.com +lookbek.cfd +lookingthe.com +looklemsun.uni.me +lookmail.ml +looksecure.net +lookthesun.tk +lookugly.com +lookup.com +loongwin.com +loonycoupon.com +loop-whisper.tk +loopbox.store +loopemail.online +loopsnow.com +loopy-deals.com +lopeure.com +lopivolop.com +lopl.co.cc +loptagt.com +lopvede.com +loqueseve.net +loqueseve.org +loranet.pro +loranund.world +lord2film.online +lordmobilehackonline.eu +lordmoha.cloud +lordofmysteries.org +lordpopi.com +lordsofts.com +lordvold.cf +lordvold.ga +lordvold.gq +lordvold.ml +lorencic.ro +loridu.com +lorientediy.com +lorkex.com +lorotzeliothavershcha.info +lorraineeliseraye.com +lortemail.dk +losa.tr +losangeles-realestate.info +lose20pounds.info +losebellyfatau.com +losemymail.com +loseweight-advice.info +loseweightnow.tk +loskmail.com +losm.spymail.one +losowynet.com +lostfiilmtv.ru +lostfilmhd1080.ru +lostfilmhd720.ru +lostlanguage.com +lostle.site +lostpositive.xyz +losvtn.com +lotmail.net +lotteryfordream.com +lotto-wizard.net +lottoresults.ph +lottoryshow.com +lottosend.ro +lottothai888.com +lottovip900.online +lottowinnboy.com +lottowinnerboy.com +lotusloungecafe.com +lotusph.com +lotusphysicaltherapy.com +lotuzvending.com +louboinhomment.info +louboutinemart.com +louboutinkutsutenpojp.com +louboutinpascher1.com +louboutinpascher2.com +louboutinpascher3.com +louboutinpascher4.com +louboutinpascheshoes.com +louboutinshoesfr.com +louboutinshoessalejp.com +louboutinshoesstoresjp.com +louboutinshoesus.com +louder1.bid +loudmouthmag.com +loudoungcc.store +loufad.com +louieliu.com +louis-vuitton-onlinestore.com +louis-vuitton-outlet.com +louis-vuitton-outletenter.com +louis-vuitton-outletsell.com +louis-vuittonbags.info +louis-vuittonbagsoutlet.info +louis-vuittonoutlet.info +louis-vuittonoutletonline.info +louis-vuittonsac.com +louisct.com +louisvillequote.com +louisvillestudio.com +louisvuitton-handbagsonsale.info +louisvuitton-handbagsuk.info +louisvuitton-outletstore.info +louisvuitton-replica.info +louisvuitton-uk.info +louisvuittonallstore.com +louisvuittonbagsforcheap.info +louisvuittonbagsjp.org +louisvuittonbagsuk-cheap.info +louisvuittonbagsukzt.co.uk +louisvuittonbeltstore.com +louisvuittoncanadaonline.info +louisvuittonchoooutlet.com +louisvuittondesignerbags.info +louisvuittonfactory-outlet.us +louisvuittonffr1.com +louisvuittonforsalejp.com +louisvuittonhandbags-ca.info +louisvuittonhandbagsboutique.us +louisvuittonhandbagsoutlet.us +louisvuittonhandbagsprices.info +louisvuittonjpbag.com +louisvuittonjpbags.org +louisvuittonjpsale.com +louisvuittonmenwallet.info +louisvuittonmonogramgm.com +louisvuittonnfr.com +louisvuittonnicebag.com +louisvuittonofficielstore.com +louisvuittononlinejp.com +louisvuittonoutlet-store.info +louisvuittonoutlet-storeonline.info +louisvuittonoutlet-storesonline.info +louisvuittonoutlet-usa.us +louisvuittonoutletborseitaly.com +louisvuittonoutletborseiy.com +louisvuittonoutletjan.net +louisvuittonoutletonlinestore.info +louisvuittonoutletrich.net +louisvuittonoutletrt.com +louisvuittonoutletstoregifts.us +louisvuittonoutletstores-online.info +louisvuittonoutletstores-us.info +louisvuittonoutletstoresonline.us +louisvuittonoutletsworld.net +louisvuittonoutletwe.com +louisvuittonoutletzt.co.uk +louisvuittonpursesstore.info +louisvuittonreplica-outlet.info +louisvuittonreplica.us +louisvuittonreplica2u.com +louisvuittonreplicapurse.info +louisvuittonreplicapurses.us +louisvuittonretailstore.com +louisvuittonrreplicahandbagsus.com +louisvuittonsac-fr.info +louisvuittonsavestore.com +louisvuittonsbags8.com +louisvuittonshopjapan.com +louisvuittonshopjp.com +louisvuittonshopjp.org +louisvuittonshopoutletjp.com +louisvuittonsjapan.com +louisvuittonsjp.org +louisvuittonsmodaitaly1.com +louisvuittonspascherfrance1.com +louisvuittonstoresonline.com +louisvuittontoteshops.com +louisvuittonukbags.info +louisvuittonukofficially.com +louisvuittonukzt.co.uk +louisvuittonused.info +louisvuittonwholesale.info +louisvuittonworldtour.com +louisvunttonworldtour.com +louivuittoutletuksalehandbags.co.uk +loux5.universallightkeys.com +louxor.shop +lova.cam +love-brand.ru +love-fuck.ru +love-krd.ru +love-s.top +love-your.mom +love.info +love.vo.uk +love365.ru +love4writing.info +lovea.site +loveabledress.com +loveabledress.net +loveablelady.com +loveablelady.net +loveandotherstuff.co +lovebitan.club +lovebitan.online +lovebitan.site +lovebitan.xyz +lovebitco.in +lovebite.net +lovecalculatorname.org +loveconnects.lat +lovecuirinamea.com +lovediscuss.ru +lovee.club +lovefall.ml +lovefans.com +lovehaven.lat +lovejoyempowers.com +lovelacelabs.net +lovelemk.tk +lovelyaibrain.com +lovelybabygirl.com +lovelybabygirl.net +lovelybabylady.com +lovelybabylady.net +lovelycats.org +lovelyhotmail.com +lovelyladygirl.com +lovelynazar.net +lovelynhatrang.ru +lovelyshoes.net +lovemail.top +lovemark.ml +loveme.com +lovemeet.faith +lovemeleaveme.com +lovemue.com +lovemyson.site +lovepulse.lat +loves.dicksinhisan.us +loves.dicksinmyan.us +lovesea.gq +lovesoftware.net +lovesunglasses.info +lovesystemsdates.com +lovetests99.com +loveu.com +lovevista.lat +lovework.jp +loveyouforever.de +lovg.emlhub.com +loviel.com +lovingnessday.com +lovingnessday.net +lovingr3co.ga +lovisvuittonsjapan.com +lovitolp.com +lovleo.com +lovlyn.com +lovomon.com +lovxwyzpfzb2i4m8w9n.cf +lovxwyzpfzb2i4m8w9n.ga +lovxwyzpfzb2i4m8w9n.gq +lovxwyzpfzb2i4m8w9n.tk +low.pixymix.com +low.poisedtoshrike.com +low.qwertylock.com +lowcost.solutions +lowcypromocji.com.pl +lowdh.com +lowendjunk.com +lowerrightabdominalpain.org +lowes.fun +lowesters.xyz +lowestpricesonthenet.com +lowttfinin.ga +loy.kr +loyalherceghalom.ml +loyalhost.org +loyalnfljerseys.com +loyalwiranti.biz +loyalworld.com +lp6ng.anonbox.net +lpalcfaz.cfd +lpaoaoao80101919.ibaloch.com +lpd6j.anonbox.net +lpdf.site +lpefuho.com +lpfmgmtltd.com +lpi1iyi7m3zfb0i.cf +lpi1iyi7m3zfb0i.ga +lpi1iyi7m3zfb0i.gq +lpi1iyi7m3zfb0i.ml +lpi1iyi7m3zfb0i.tk +lpmwebconsult.com +lpnnurseprograms.net +lpo.ddnsfree.com +lpo.spymail.one +lpolijkas.ga +lpox.xyz +lprevin.joseph.es +lprssvflg.pl +lps.freeml.net +lpurm5.orge.pl +lpva5vjmrzqaa.cf +lpva5vjmrzqaa.ga +lpva5vjmrzqaa.gq +lpva5vjmrzqaa.ml +lpva5vjmrzqaa.tk +lpz.freeml.net +lqghzkal4gr.cf +lqghzkal4gr.ga +lqghzkal4gr.gq +lqghzkal4gr.ml +lqlz8snkse08zypf.cf +lqlz8snkse08zypf.ga +lqlz8snkse08zypf.gq +lqlz8snkse08zypf.ml +lqlz8snkse08zypf.tk +lqonrq7extetu.cf +lqonrq7extetu.ga +lqonrq7extetu.gq +lqonrq7extetu.ml +lqonrq7extetu.tk +lqsgroup.com +lqvj.emlhub.com +lr7.us +lr78.com +lrcc.com +lreh.emltmp.com +lrelsqkgga4.cf +lrelsqkgga4.ml +lrelsqkgga4.tk +lrenjg.us +lrfjubbpdp.pl +lrglobal.com +lrgrahamj.com +lrks.spymail.one +lrland.net +lrmumbaiwz.com +lroid.com +lron0re.com +lrr.dropmail.me +lrti.laste.ml +lrtptf0s50vpf.cf +lrtptf0s50vpf.ga +lrtptf0s50vpf.gq +lrtptf0s50vpf.ml +lrtptf0s50vpf.tk +lru.me +lrxu7.anonbox.net +lrz.emltmp.com +ls-server.ru +lsaar.com +lsac.com +lsadinl.com +lsd.dropmail.me +lsdny.com +lsereborn.com +lsfj.dropmail.me +lsh.my.id +lsh.spymail.one +lslconstruction.com +lsmpic.com +lsmu.com +lsnttttw.com +lsouth.net +lsrtsgjsygjs34.gq +lss176.com +lssu.com +lsubjectss.com +lsxprelk6ixr.cf +lsxprelk6ixr.ga +lsxprelk6ixr.gq +lsxprelk6ixr.ml +lsxprelk6ixr.tk +lsylgw.com +lsyx.eu.org +lsyx0.rr.nu +lsyx24.com +ltcorp.org +ltdtab9ejhei18ze6ui.cf +ltdtab9ejhei18ze6ui.ga +ltdtab9ejhei18ze6ui.gq +ltdtab9ejhei18ze6ui.ml +ltdtab9ejhei18ze6ui.tk +ltdwa.com +lteselnoc.cf +ltfg92mrmi.cf +ltfg92mrmi.ga +ltfg92mrmi.gq +ltfg92mrmi.ml +ltfg92mrmi.tk +ltg.spymail.one +ltg4q.anonbox.net +ltlseguridad.com +ltm.dropmail.me +ltnk.yomail.info +ltt0zgz9wtu.cf +ltt0zgz9wtu.gq +ltt0zgz9wtu.ml +ltt0zgz9wtu.tk +lttcourse.ir +lttmail.com +lttusers.com +ltuc.edu.eu.org +lu75m.anonbox.net +luadao.club +luakm.cfd +luarte.info +lubde.com +lubie-placki.com.pl +lubisbukalapak.tk +lubnanewyork.com +lubr.laste.ml +lubrorein.com +lucaz.com +luceudeq.ga +lucian.dev +lucianoop.com +lucidmation.com +lucifergmail.tk +lucigenic.com +luck-win.com +luckboy.pw +luckence.com +luckindustry.ru +luckjob.pw +luckmail.us +lucktoc.com +lucky.bthow.com +lucky.wiki +luckyladydress.com +luckyladydress.net +luckylooking.com +luckymail.org +lucvu.com +lucysummers.biz +lucyu.com +luddo.me +ludi.com +ludovicomedia.com +ludovodka.com +ludq.com +ludxc.com +ludziepalikota.pl +lufaf.com +luggagetravelling.info +luhman16.lavaweb.in +luhorla.cf +luhorla.gq +luilkkgtq43q1a6mtl.cf +luilkkgtq43q1a6mtl.ga +luilkkgtq43q1a6mtl.gq +luilkkgtq43q1a6mtl.ml +luilkkgtq43q1a6mtl.tk +luisdelavegarealestate.us +luisgiisjsk.tk +luisp.store +luispedro.xyz +lukaat.com +lukampocd.cfd +lukampzocl.cfd +lukamzap.cfd +lukamzcofs.cfd +lukapzca.cfd +lukasfloor.com.pl +lukaszmitula.pl +lukecarriere.com +lukemail.info +lukesrcplanes.com +lukop.dk +lulexia.com +lulf.emlhub.com +lulluna.com +lulukbuah.host +lulumelulu.org +lulumoda.com +lumao.email +lumeika.com +lumenta.net +lumg.uk +luminoustravel.com +luminoxwatches.com +luminu.com +lump.pa +lunaaabnjfk.shop +lunafireandlight.com +lunar4d.org +lunarmail.info +lunas.today +lunatos.eu +lunchdinnerrestaurantmuncieindiana.com +lund.freshbreadcrumbs.com +luno-finance.com +lunor.cfd +luo.kr +luo.today +luongbinhduong.ml +luonglanhlung.com +lupabapak.org +lupv.spymail.one +lur.emltmp.com +luravel.com +luravell.com +lureens.com +lurekmopa.cfd +lurenwu.com +luscar.com +lusernews.com +lushily.top +lushosa.com +lusianna.ml +lusmila.com +lusobridge.com +lustgames.org +lustlonelygirls.com +lutech.uk +lutherhild.ga +lutota.com +luutrudulieu.net +luutrudulieu.online +luv2.us +luvfun.site +luvju.anonbox.net +luvnish.com +luxax.com +luxehomescalgary.ca +luxeic.com +luxembug-nedv.ru +luxentic.com +luxiu2.com +luxmet.ru +luxor-sklep-online.pl +luxor.sklep.pl +luxpolar.com +luxsev.com +luxsvg.net +luxuriousdress.net +luxury-handbagsonsale.info +luxuryasiaresorts.com +luxurychanel.com +luxuryoutletonline.us +luxuryshomemn.com +luxuryshopforpants.com +luxuryspanishrentals.com +luxurytogel.com +luxusinc.com +luxusmail.ga +luxusmail.gq +luxusmail.ml +luxusmail.my.id +luxusmail.org +luxusmail.tk +luxusmail.uk +luxusmail.xyz +luxwane.club +luxwane.online +luxyss.com +luxzn.com +luyilu8.com +luzw.emltmp.com +lv-bags-outlet.com +lv-magasin.com +lv-outlet-online.org +lv.emlhub.com +lv.emlpro.com +lv2buy.net +lvbag.info +lvbag11.com +lvbags001.com +lvbagsjapan.com +lvbagsshopjp.com +lvbq5bc1f3eydgfasn.cf +lvbq5bc1f3eydgfasn.ga +lvbq5bc1f3eydgfasn.gq +lvbq5bc1f3eydgfasn.ml +lvbq5bc1f3eydgfasn.tk +lvc2txcxuota.cf +lvc2txcxuota.ga +lvc2txcxuota.gq +lvc2txcxuota.ml +lvc2txcxuota.tk +lvcheapsua.com +lvcheapusa.com +lvdev.com +lvfityou.com +lvfiyou.com +lvforyouonlynow.com +lvgp.mimimail.me +lvgreatestj.com +lvhan.net +lvhandbags.info +lvheremeetyou.com +lvhotstyle.com +lvintager.com +lvivonline.com +lvjp.com +lvmy.xyz +lvo.emlpro.com +lvory.net +lvoulet.com +lvoutlet.com +lvoutletonlineour.com +lvovnikita.site +lvpascher1.com +lvsaleforyou.com +lvsjqpehhm.ga +lvstormfootball.com +lvtimeshow.com +lvufaa.xyz +lvvd.com +lvxutizc6sh8egn9.cf +lvxutizc6sh8egn9.ga +lvxutizc6sh8egn9.gq +lvxutizc6sh8egn9.ml +lvxutizc6sh8egn9.tk +lwaa.emlpro.com +lwbmarkerting.info +lwide.com +lwmaxkyo3a.cf +lwmaxkyo3a.ga +lwmaxkyo3a.gq +lwmaxkyo3a.ml +lwmaxkyo3a.tk +lwmhcka58cbwi.cf +lwmhcka58cbwi.ga +lwmhcka58cbwi.gq +lwmhcka58cbwi.ml +lwmhcka58cbwi.tk +lwnj.emltmp.com +lwru.com +lwwz3zzp4pvfle5vz9q.cf +lwwz3zzp4pvfle5vz9q.ga +lwwz3zzp4pvfle5vz9q.gq +lwwz3zzp4pvfle5vz9q.ml +lwwz3zzp4pvfle5vz9q.tk +lxbeta.com +lxev.emltmp.com +lxjr.spymail.one +lxlxdtskm.pl +lxo.emlhub.com +lxu.spymail.one +lxupukiw4dr277kay.cf +lxupukiw4dr277kay.ga +lxupukiw4dr277kay.gq +lxupukiw4dr277kay.ml +lxupukiw4dr277kay.tk +lyalnorm.com +lybyz.com +lycis.com +lycoprevent.online +lycos.comx.cf +lycose.com +lyct.com +lydia.anjali.miami-mail.top +lydia862.sbs +lydir.com +lyfestylecreditsolutions.com +lyffo.ga +lyft.live +lygjzx.xyz +lyjnhkmpe1no.cf +lyjnhkmpe1no.ga +lyjnhkmpe1no.gq +lyjnhkmpe1no.ml +lyjnhkmpe1no.tk +lylilupuzy.pl +lynex.sbs +lynleegypsycobs.com.au +lynwise.shop +lyonsteamrealtors.com +lyqmeu.xyz +lyqo9g.xyz +lyricauthority.com +lyrics-lagu.me +lyricshnagu.com +lyricspad.net +lyunsa.com +lyustra-bra.info +lywenw.com +lyz.emlhub.com +lyzj.org +lyzzgc.com +lz.spymail.one +lza.freeml.net +lzcxssxirzj.cf +lzcxssxirzj.ga +lzcxssxirzj.gq +lzcxssxirzj.ml +lzcxssxirzj.tk +lzfkvktj5arne.cf +lzfkvktj5arne.ga +lzfkvktj5arne.gq +lzfkvktj5arne.tk +lzgyigfwf2.cf +lzgyigfwf2.ga +lzgyigfwf2.gq +lzgyigfwf2.ml +lzgyigfwf2.tk +lzm.emltmp.com +lznk.emlpro.com +lzoaq.com +lzpooigjgwp.pl +lzs94f5.pl +m-c-e.de +m-dnc.com +m-drugs.com +m-mail.cf +m-mail.ga +m-mail.gq +m-mail.ml +m-myth.com +m-p-s.cf +m-p-s.ga +m-p-s.gq +m.arkf.xyz +m.articlespinning.club +m.bccto.me +m.beedham.org +m.c-n-shop.com +m.cloudns.cl +m.codng.com +m.convulse.net +m.ddcrew.com +m.dfokamail.com +m.edvzz.com +m.heduu.com +m.kkokc.com +m.nik.me +m.nosuchdoma.in +m.polosburberry.com +m.svlp.net +m.tartinemoi.com +m.u-torrent.cf +m.u-torrent.ga +m.u-torrent.gq +m0.guardmail.cf +m00b2sryh2dt8.cf +m00b2sryh2dt8.ga +m00b2sryh2dt8.gq +m00b2sryh2dt8.ml +m00b2sryh2dt8.tk +m015j4ohwxtb7t.cf +m015j4ohwxtb7t.ga +m015j4ohwxtb7t.gq +m015j4ohwxtb7t.ml +m015j4ohwxtb7t.tk +m07.ovh +m0lot0k.ru +m0y1mqvqegwfvnth.cf +m0y1mqvqegwfvnth.ga +m0y1mqvqegwfvnth.gq +m0y1mqvqegwfvnth.ml +m0y1mqvqegwfvnth.tk +m1.blogrtui.ru +m1.guardmail.cf +m2.guardmail.cf +m2.trekr.tk +m21.cc +m27ke.anonbox.net +m2hotel.com +m2project.xyz +m2r60ff.com +m3.guardmail.cf +m3csz.anonbox.net +m3player.com +m3u5dkjyz.pl +m4il5.pl +m4ilweb.info +m4ixw.anonbox.net +m5s.flu.cc +m5s.igg.biz +m5s.nut.cc +m625.net +m6c718i7i.pl +m88laos.com +m8g8.com +m8gj8lsd0i0jwdno7l.cf +m8gj8lsd0i0jwdno7l.ga +m8gj8lsd0i0jwdno7l.gq +m8gj8lsd0i0jwdno7l.ml +m8gj8lsd0i0jwdno7l.tk +m8h63kgpngwo.cf +m8h63kgpngwo.ga +m8h63kgpngwo.gq +m8h63kgpngwo.ml +m8h63kgpngwo.tk +m8r.davidfuhr.de +m8r.mcasal.com +m8r8ltmoluqtxjvzbev.cf +m8r8ltmoluqtxjvzbev.ga +m8r8ltmoluqtxjvzbev.gq +m8r8ltmoluqtxjvzbev.ml +m8r8ltmoluqtxjvzbev.tk +m9enrvdxuhc.cf +m9enrvdxuhc.ga +m9enrvdxuhc.gq +m9enrvdxuhc.ml +m9enrvdxuhc.tk +m9so.ru +ma-boite-aux-lettres.infos.st +ma-perceuse.net +ma.ezua.com +ma.laste.ml +ma.zyns.com +ma1l.bij.pl +ma1l.duckdns.org +ma1lgen622.ga +ma2limited.com +maaail.com +maail.dropmail.me +maaill.com +maal.com +maart.ml +maatpeasant.com +maazios.com +mabal.fr.nf +mabermail.com +mabh65.ga +maboard.com +mabox.eu +mabterssur.ga +mabubsa.com +mabuklagi.ga +mabulareserve.com +mabv.club +mac-24.com +mac.hush.com +macam-ber.uk +macaniuo235.cf +macauvpn.com +macbookpro13.com +maccholnee.ga +macdell.com +macess.com +macfittest.com +machaimichaelenterprise.com +machen-wir.com +machineearning.com +machineproseo.net +machineproseo.org +machineshop.de +machinetest.com +machmeschrzec.ga +macho3.com +machunu.com +macmail.info +macmille.com +maconchesp.ga +macosnine.com +macosten.com +macpconline.com +macplus-vrn.ru +macr2.com +macromaid.com +macromice.info +macslim.com +macsoftware.de +macstoredigital.id +mactom.com +macviro.com +macwish.com +madagaskar-nedv.ru +madangteros.email +madangteros.live +madasioil.com +maddison.allison.spithamail.top +made.boutique +made7.ru +madebyfrances.com +madeforthat.org +madejstudio.com +madelhocin.xyz +madhorse.us +madiba-shirt.com +madibashirts.com +madmext.store +madnter.com +mado34.com +madridmuseumsmap.info +madriverschool.org +madrivertennis.com +madurahoki.com +madvisorp.com +maedamis.ga +maeel.com +maelcerkciks.com +maennerversteherin.com +maennerversteherin.de +maerroadoe.com +mafiaa.cf +mafiaa.ga +mafiaa.gq +mafiaa.ml +mafiken.ga +mafiken.gq +mafozex.xyz +mafrat.com +mafrem3456ails.com +mag.emlhub.com +mag.su +magamail.com +magass.store +magazin-biciclete.info +magazin-elok69.ru +magazin20000.ru +magazinfutbol.com +magbit.food +mageborn.com +magegraf.com +magetrust.com +maggie.makenzie.chicagoimap.top +maggotymeat.ga +maghyg.xyz +magia-malarska.pl +magiamgia.site +magicaiguru.com +magicaljellyfish.com +magicbeep.com +magicblocks.ru +magicbox.ro +magicbroadcast.com +magiccablepc.com +magicedhardy.com +magicflight.ir +magicftw.com +magicmail.com +magiconly.ru +magicpaper.site +magicsubmitter.biz +magicth.com +magigo.site +magim.be +magnet1.com +magneticmessagingbobby.com +magneticoak.com +magnetik.com.ua +magnetl.ink +magnoliapost.com +magnomsolutions.com +magostin.blog +magpietravel.com +magpit.com +magspam.net +magura.shop +mahan95.ir +mahazai.com +mahdevip.com +mahiidev.site +mahindrabt.com +mahmmod.tech +mahmul.com +mahoteki.com +mai1bx.ovh +mai1campzero.net.com +maia.aniyah.coayako.top +maidlow.info +maigusw.com +maikel.com +mail-2-you.com +mail-4-uk.co.uk +mail-4-you.bid +mail-4server.com +mail-9g.pl +mail-address.live +mail-amazon.us +mail-app.net +mail-apps.com +mail-apps.net +mail-box.ml +mail-boxes.ru +mail-c.cf +mail-c.ga +mail-c.gq +mail-c.ml +mail-c.tk +mail-card.com +mail-card.net +mail-cart.com +mail-click.net +mail-data.net +mail-demon.bid +mail-desk.net +mail-dj.com +mail-easy.fr +mail-fake.com +mail-file.net +mail-filter.com +mail-finder.net +mail-fix.com +mail-fix.net +mail-free-mailer.online +mail-group.net +mail-guru.net +mail-help.net +mail-hosting.co +mail-hub.info +mail-hub.online +mail-hub.top +mail-j.cf +mail-j.ga +mail-j.gq +mail-j.ml +mail-j.tk +mail-jetable.com +mail-jim.gq +mail-jim.ml +mail-lab.net +mail-line.net +mail-list.top +mail-maker.net +mail-man.com +mail-mario.fr.nf +mail-miu.ml +mail-neo.gq +mail-now.top +mail-owl.com +mail-point.net +mail-pop3.com +mail-pro.info +mail-register.com +mail-reply.net +mail-s01.pl +mail-search.com +mail-searches.com +mail-send.ru +mail-server.bid +mail-share.com +mail-share.net +mail-space.net +mail-temp.com +mail-temporaire.com +mail-temporaire.fr +mail-tester.com +mail-uk.co.uk +mail-v.net +mail-vix.ml +mail-w.cf +mail-w.ga +mail-w.gq +mail-w.ml +mail-w.tk +mail-x91.pl +mail-z.gq +mail-z.ml +mail-z.tk +mail-zone.pp.ua +mail.abnovel.com +mail.acentni.com +mail.acname.com +mail.adstam.com +mail.aersm.com +mail.agafx.com +mail.agaseo.com +mail.agromgt.com +mail.ahieh.com +mail.ailicke.com +mail.aixne.com +mail.aixnv.com +mail.albarulo.com +mail.alibrs.com +mail.alisaol.com +mail.almatips.com +mail.almaxen.com +mail.amankro.com +mail.amozix.com +mail.anawalls.com +mail.anhthu.org +mail.aniross.com +mail.ankokufs.us +mail.apdiv.com +mail.apostv.com +mail.aprte.com +mail.aramask.com +mail.arcadein.com +mail.arensus.com +mail.artgulin.com +mail.aseall.com +mail.astegol.com +mail.atomeca.com +mail.avashost.com +mail.avucon.com +mail.ayfoto.com +mail.azduan.com +mail.backflip.cf +mail.bayxs.com +mail.bccto.com +mail.bccto.me +mail.beeplush.com +mail.bentrask.com +mail.berwie.com +mail.bikedid.com +mail.binech.com +mail.bitofee.com +mail.bixolabs.com +mail.bizatop.com +mail.bsidesmn.com +mail.bsomek.com +mail.bustayes.com +mail.buzblox.com +mail.by +mail.c-n-shop.com +mail.cabose.com +mail.cengrop.com +mail.centerf.com +mail.cetnob.com +mail.cgbird.com +mail.chatfunny.com +mail.chysir.com +mail.cmheia.com +mail.cnanb.com +mail.cnieux.com +mail.com.vc +mail.comsb.com +mail.comx.cf +mail.cosxo.com +mail.crowdpress.it +mail.cubene.com +mail.cumzle.com +mail.cutsup.com +mail.cutxsew.com +mail.dabeixin.com +mail.dacgu.com +mail.darkse.com +mail.dboso.com +mail.defaultdomain.ml +mail.degcos.com +mail.deligy.com +mail.delorex.com +mail.dhnow.com +mail.dovesilo.com +mail.dpsols.com +mail.dropmail.me +mail.dxice.com +mail.eachart.com +mail.ebuthor.com +mail.effektiveerganzungen.de +mail.ekposta.com +mail.emlhub.com +mail.emlpro.com +mail.emltmp.com +mail.enmaila.com +mail.eosatx.com +mail.eoslux.com +mail.eryod.com +mail.etopys.com +mail.euucn.com +mail.evimzo.com +mail.evvgo.com +mail.facais.com +mail.fahih.com +mail.fashlend.com +mail.felibg.com +mail.fettometern.com +mail.fgoyq.com +mail.fincainc.com +mail.fkcod.com +mail.flexvio.com +mail.fm.cloudns.nz +mail.frandin.com +mail.free-emailz.com +mail.freeml.net +mail.fryshare.com +mail.fsmash.org +mail.fulwark.com +mail.funteka.com +mail.funvane.com +mail.fuzitea.com +mail.gearstag.com +mail.gen.tr +mail.getmola.com +mail.gexige.com +mail.giratex.com +mail.glaslack.com +mail.gmail.com.cad.edu.gr +mail.godsigma.com +mail.gokir.eu +mail.gonaute.com +mail.gosarlar.com +mail.goulink.com +mail.grassdev.com +mail.grupogdm.com +mail.guokse.net +mail.gw +mail.gyxmz.com +mail.haislot.com +mail.hanungofficial.club +mail.hdala.com +mail.hdrlog.com +mail.health-ua.com +mail.hidelux.com +mail.hisotyr.com +mail.hkirsan.com +mail.hotxx.in +mail.hsmw.net +mail.huizk.com +mail.huleos.com +mail.hupoi.com +mail.icdn.be +mail.iconmal.com +mail.idawah.com +mail.idsho.com +mail.igosad.me +mail.ikanid.com +mail.ikumaru.com +mail.ikuromi.com +mail.iliken.com +mail.illistnoise.com +mail.info +mail.inforoca.ovh +mail.introex.com +mail.irnini.com +mail.iswire.com +mail.itcess.com +mail.jalunaki.com +mail.jameagle.com +mail.javnoi.com +mail.jetsay.com +mail.johnscaffee.com +mail.jopasfo.net +mail.jpgames.net +mail.ju.io +mail.junwei.co +mail.kaaaxcreators.tk +mail.kakator.com +mail.koalaltd.net +mail.konican.com +mail.kravify.com +mail.kxgif.com +mail.laymro.com +mail.lendfash.com +mail.lewenbo.com +mail.lgbtiqa.xyz +mail.libivan.com +mail.lindstromenterprises.com +mail.linlshe.com +mail.losvtn.com +mail.lowestpricesonthenet.com +mail.lsaar.com +mail.lucvu.com +mail.mailifyy.com +mail.mailinator.com +mail.mailpwr.com +mail.mailsd.net +mail.mainoj.com +mail.marksia.com +mail.massefm.com +mail.mayboy.xyz +mail.mcatag.com +mail.mcenb.com +mail.mcuma.com +mail.me +mail.menitao.com +mail.mexvat.com +mail.mezimages.net +mail.mfyax.com +mail.minhlun.com +mail.misterpinball.de +mail.mixhd.xyz +mail.mjj.edu.ge +mail.mnisjk.com +mail.mnsaf.com +mail.molyg.com +mail.mposhop.com +mail.mrgamin.ml +mail.msarra.com +mail.mxvia.com +mail.myde.ml +mail.myserv.info +mail.mzr.me +mail.namewok.com +mail.nasmis.com +mail.nasskar.com +mail.negoh.me +mail.neixos.com +mail.newcupon.com +mail.nexxterp.com +mail.neynt.ca +mail.ngem.net +mail.nimadir.com +mail.notedns.com +mail.nsvpn.com +mail.nweal.com +mail.ociun.com +mail.ofirit.com +mail.omahsimbah.com +mail.ontasa.com +mail.onymi.com +mail.oprevolt.com +mail.otemdi.com +mail.oyu.kr +mail.parclan.com +mail.partskyline.com +mail.piaa.me +mail.picdv.com +mail.picvw.com +mail.prohade.com +mail.przyklad-domeny.pl +mail.ptcu.dev +mail.pursip.com +mail.qiradio.com +mail.qmeta.net +mail.rambara.com +mail.rartg.com +mail.regapts.com +mail.rehezb.com +mail.rencr.com +mail.rentaen.com +mail.ricorit.com +mail.roborena.com +mail.rohoza.com +mail.roweryo.com +mail.rthyde.com +mail.rwstatus.com +mail.saierw.com +mail.secretmail.net +mail.sentrau.com +mail.seosnaps.com +mail.sequentialx.com +mail.sfpixel.com +mail.shaflyn.com +mail.shanreto.com +mail.skrak.com +mail.skrank.com +mail.skygazerhub.com +mail.spymail.one +mail.starsita.xyz +mail.storesr.com +mail.svenz.eu +mail.syncax.com +mail.talmetry.com +mail.tanlanav.com +mail.taobudao.com +mail.tbr.fr.nf +mail.telvetto.com +mail.tggmall.com +mail.tgvis.com +mail.ticket-please.ga +mail.tm +mail.to +mail.togito.com +mail.tomsoutletw.com +mail.toprevenue.net +mail.tospage.com +mail.trackden.com +mail.tsderp.com +mail.tupanda.com +mail.tutoreve.com +mail.twfaka.com +mail.ubinert.com +mail.unionpay.pl +mail.usoplay.com +mail.vasteron.com +mail.visignal.com +mail.vuforia.us +mail.waivey.com +mail.watrf.com +mail.weekfly.com +mail.wenkuu.com +mail.wentcity.com +mail.wifwise.com +mail.wikfee.com +mail.wnetz.pl +mail.woeemail.com +mail.wtf +mail.wuzak.com +mail.wvwvw.tech +mail.xiuvi.cn +mail.xstyled.net +mail.yabes.ovh +mail.yauuuss.net +mail.ymhis.com +mail.yomail.info +mail.zinn.gq +mail.ziragold.com +mail.zp.ua +mail0.cf +mail0.ga +mail0.gq +mail0.lavaweb.in +mail0.ml +mail1.cf +mail1.drama.tw +mail1.hacked.jp +mail1.i-taiwan.tv +mail1.ismoke.hk +mail1.kaohsiung.tv +mail1.kein.hk +mail1.mungmung.o-r.kr +mail1.top +mail10.cf +mail10.ga +mail10.gq +mail10.ml +mail101.xyz +mail10m.com +mail11.cf +mail11.gq +mail11.hensailor.hensailor.xyz +mail11.ml +mail114.net +mail123.club +mail12h.com +mail14.pl +mail15.com +mail1999.cf +mail1999.ga +mail1999.gq +mail1999.ml +mail1999.tk +mail1a.de +mail1h.info +mail1web.org +mail2.cf +mail2.drama.tw +mail2.info.tm +mail2.ntuz.me +mail2.p.marver-coats.xyz +mail2.space +mail2.vot.pl +mail2.waw.pl +mail2.worksmobile.ml +mail2000.cf +mail2000.ga +mail2000.gq +mail2000.ml +mail2000.ru +mail2000.tk +mail2001.cf +mail2001.ga +mail2001.gq +mail2001.ml +mail2001.tk +mail21.cc +mail22.club +mail22.com +mail22.space +mail24.club +mail24.gdn +mail24h.top +mail2bin.com +mail2k.bid +mail2k.trade +mail2k.website +mail2k.win +mail2me.co +mail2nowhere.cf +mail2nowhere.ga +mail2nowhere.gq +mail2nowhere.ml +mail2nowhere.tk +mail2paste.com +mail2rss.org +mail2run.com +mail2tor.com +mail2world.com +mail3.activelyblogging.com +mail3.drama.tw +mail3.top +mail333.com +mail35.net +mail3plus.net +mail3s.pl +mail3tech.com +mail3x.com +mail3x.net +mail4-us.org +mail4.com +mail4.drama.tw +mail4.online +mail4.uk +mail48.top +mail4all.jp.pn +mail4biz.pl +mail4biz.sejny.pl +mail4days.com +mail4edu.net +mail4free.waw.pl +mail4gmail.com +mail4qa.com +mail4trash.com +mail4u.info +mail4uk.co.uk +mail4used.com +mail4you.bid +mail4you.men +mail4you.racing +mail4you.stream +mail4you.trade +mail4you.usa.cc +mail4you.website +mail4you.win +mail4you24.net +mail5.drama.tw +mail5.info +mail5.me +mail52.cf +mail52.ga +mail52.gq +mail52.ml +mail52.tk +mail56.me +mail6.me +mail62.net +mail666.online +mail666.ru +mail7.cf +mail7.ga +mail7.gq +mail7.io +mail7.vot.pl +mail707.com +mail72.com +mail77.top +mail777.cf +mail7d.com +mail8.ga +mail8.gq +mail8.vot.pl +mail8app.com +mail998.com +mailabconline.com +mailaccount.de.pn +mailadadad.org +mailadda.cf +mailadda.ga +mailadda.gq +mailadda.ml +mailadresi.tk +mailadresim.site +mailairport.com +mailals.com +mailanddrive.de +mailant.xyz +mailanti.com +mailapi.ru +mailapp.pro +mailapp.top +mailapps.online +mailapril.com +mailapril.org +mailapso.com +mailart.top +mailart.ws +mailasdkr.com +mailasdkr.net +mailavi.ga +mailb.info +mailb.tk +mailba.uk +mailback.com +mailbai.com +mailbali.com +mailbeaver.net +mailbehance.info +mailbidon.com +mailbiscuit.com +mailbit.online +mailbiz.biz +mailblocks.com +mailblog.biz +mailbonus.fr +mailbookstore.com +mailbosi.com +mailbox.biz.st +mailbox.blognet.in +mailbox.com.cn +mailbox.comx.cf +mailbox.drr.pl +mailbox.in.ua +mailbox.universallightkeys.com +mailbox.zip +mailbox1.gdn +mailbox1.site +mailbox24.top +mailbox2go.de +mailbox49.com +mailbox52.ga +mailbox72.biz +mailbox80.biz +mailbox82.biz +mailbox87.de +mailbox92.biz +mailbox92.com +mailboxheaven.info +mailboxhub.site +mailboxify.ru +mailboxify.store +mailboxint.info +mailboxlife.net +mailboxly.ru +mailboxly.store +mailboxmaster.info +mailboxok.club +mailboxonline.org +mailboxprotect.com +mailboxrental.org +mailboxt.com +mailboxt.net +mailboxvip.com +mailboxxx.net +mailboxxxx.tk +mailboxy.fun +mailboxy.ru +mailboxy.store +mailbrazilnet.space +mailbros1.info +mailbros2.info +mailbros3.info +mailbros4.info +mailbros5.info +mailbucket.org +mailbusstop.com +mailbuzz.buzz +mailbyemail.com +mailbyus.com +mailc.cf +mailc.gq +mailc.tk +mailcard.net +mailcat.biz +mailcatch.com +mailcatch.xyz +mailcather.com +mailcbds.site +mailcc.cf +mailcc.ga +mailcc.gq +mailcc.ml +mailcc.tk +mailcdn.ml +mailch.com +mailchop.com +mailcker.com +mailclient.com +mailclone2023.top +mailclone2024.top +mailclubonline.com +mailclubs.info +mailcok.com +mailcom.cf +mailcom.ga +mailcom.gq +mailcom.ml +mailcom.tech +mailconect.info +mailconn.com +mailcontact.xyz +mailcool45.us +mailcua.com +mailcua.cyou +mailcua.store +mailcuk.com +mailcupp.com +mailcurity.com +mailcx.cf +mailcx.ga +mailcx.gq +mailcx.ml +mailcx.tk +maildax.me +mailde.de +mailde.info +maildeck.org +maildeluxehost.com +maildemon.bid +maildepot.net +maildevelop.com +maildevteam.top +maildfga.com +maildgsp.com +maildim.com +maildivine.com +maildoc.org +maildom.xyz +maildomain.com +maildonna.space +maildot.xyz +maildrop.cc +maildrop.cf +maildrop.ga +maildrop.gq +maildrop.ml +maildrr88.shop +maildu.de +maildump.tk +maildx.com +maildy.site +maile.com +maile2email.com +maileater.com +mailed.in +mailed.ro +maileder.com +maileere.com +maileimer.de +mailelectronic.com +mailelix.space +mailell.com +maileme101.com +mailenla.network +mailer.makodon.com +mailer.net +mailer.onmypc.info +mailer2.cf +mailer2.ga +mailer2.net +mailer9.net +mailerde.com +mailerforus.com +mailergame.serveexchange.com +mailerie.com +mailermails.info +mailernam.com +maileronline.club +mailerowavc.com +mailerraas.com +mailerrtts.com +mailers.edu.pl +mailersc.com +mailersend.ru +mailert.ru +mailerv.net +mailese.ga +mailetk.com +maileto.com +maileven.com +mailex.pw +mailexpire.com +maileze.net +mailezee.com +mailf5.com +mailfa.cf +mailfa.tk +mailfake.ga +mailfall.com +mailfance.com +mailfasfe.com +mailfast.pro +mailfavorite.com +mailfen.com +mailfer.com +mailfile.net +mailfile.org +mailfirst.icu +mailfish.de +mailfix.xyz +mailflix1.it.o-r.kr +mailfm.net +mailfnmng.org +mailfob.com +mailfony.com +mailfootprint.mineweb.in +mailforall.pl +mailformail.com +mailforspam.com +mailforthemeak.info +mailframework.com +mailfranco.com +mailfree.ga +mailfree.gq +mailfree.ml +mailfreehosters.com +mailfreeonline.com +mailfs.com +mailfy.cf +mailfy.ga +mailfy.gq +mailfy.ml +mailfy.tk +mailgano.com +mailgator.org +mailgc.com +mailgen.biz +mailgen.club +mailgen.fun +mailgen.info +mailgen.io +mailgen.pro +mailgen.pw +mailgen.xyz +mailgenerator.ml +mailgetget.asia +mailgg.org +mailgia.com +mailglobalnet.space +mailglobe.club +mailglobe.org +mailgo.biz +mailgoogle.com +mailgov.info +mailguard.me +mailguard.veinflower.veinflower.xyz +mailgui.pw +mailgutter.com +mailhair.com +mailhaven.com +mailhazard.com +mailhazard.us +mailhe.me +mailherber.com +mailhero.io +mailhex.com +mailhieu.store +mailhieu1.store +mailhieu2.store +mailhole.de +mailhon.com +mailhorders.com +mailhost.bid +mailhost.com +mailhost.top +mailhost.win +mailhound.com +mailhq.club +mailhub-lock.com +mailhub.online +mailhub.pro +mailhub.pw +mailhub.top +mailhub24.com +mailhubpros.com +mailhulk.info +mailhvd.lat +mailhz.me +maili.fun +mailicon.info +mailid.info +mailify.org +mailifyy.com +mailily.com +mailimail.com +mailimails.patzleiner.net +mailimate.com +mailimpulse.com +mailin.icu +mailin8r.com +mailinatar.com +mailinater.com +mailinatior.com +mailinatoe.com +mailinator.cf +mailinator.cl +mailinator.co +mailinator.co.uk +mailinator.com +mailinator.ga +mailinator.gq +mailinator.info +mailinator.linkpc.net +mailinator.net +mailinator.org +mailinator.pl +mailinator.us +mailinator.usa.cc +mailinator0.com +mailinator1.com +mailinator2.com +mailinator2.net +mailinator3.com +mailinator4.com +mailinator5.com +mailinator6.com +mailinator7.com +mailinator8.com +mailinator9.com +mailinatorzz.mooo.com +mailinatr.com +mailinblack.com +mailinbox.cf +mailinbox.co +mailinbox.ga +mailinbox.gq +mailinbox.ml +mailinc.tech +mailincubator.com +mailindexer.com +mailinfo8.pro +mailing.o-r.kr +mailing.one +mailing.serveblog.net +mailingforever.biz +mailingmail.net +mailingo.net +mailisia.com +mailismagic.com +mailita.tk +mailivw.com +mailj.tk +mailjean.com +mailjonny.org +mailjuan.com +mailjunk.cf +mailjunk.ga +mailjunk.gq +mailjunk.ml +mailjunk.tk +mailjuose.ga +mailka.ml +mailkept.com +mailkert.com +mailking.ru +mailkita.cf +mailkom.site +mailkon.com +mailkor.xyz +mailksders.com +mailku.co +mailku.live +mailku.shop +mailkuatjku2.ga +mailkutusu.site +maill.dev +maillak.com +maillang.com +maillap.com +maillasd1.trade +maillbox.ga +maillei.com +maillei.net +mailler.cf +mailline.net +mailling.ru +maillink.in +maillink.info +maillink.live +maillink.top +maillinked.com +maillist.in +mailllc.download +mailllc.top +mailloading.com +maillog.uk +maillogin.site +maillotdefoot.com +maillote.com +maillux.online +mailluxe.com +mailly.xyz +mailmae.com +mailmag.info +mailmagnet.co +mailmail.biz +mailmailv.eu +mailmall.online +mailman.com +mailmanbeat.club +mailmassa.info +mailmate.com +mailmaxy.one +mailmay.org +mailme.gq +mailme.ir +mailme.judis.me +mailme.lv +mailme.vip +mailme24.com +mailmeanyti.me +mailmedo.com +mailmefast.info +mailmeking.com +mailmel.com +mailmenot.io +mailmerk.info +mailmetal.com +mailmetrash.com +mailmetrash.comilzilla.org +mailmink.com +mailmint.art +mailmit.com +mailmix.pl +mailmoat.com +mailmonster.bid +mailmonster.download +mailmonster.stream +mailmonster.top +mailmonster.trade +mailmonster.website +mailmoth.com +mailms.com +mailmuffta.info +mailmy.co.cc +mailmyrss.com +mailn.icu +mailn.pl +mailn.tk +mailna.biz +mailna.co +mailna.in +mailna.me +mailna.us +mailnada.cc +mailnada.com +mailnails.com +mailnator.com +mailnax.com +mailnd7.com +mailne.com +mailnesia.com +mailnesia.net +mailnest.net +mailnet.cfd +mailnet.top +mailnetter.co.uk +mailngon.top +mailngon123.online +mailni.biz +mailni.club +mailniu.com +mailnoop.store +mailnow2.com +mailnowapp.com +mailnull.com +mailnuo.com +mailnvhx.xyz +mailo.cf +mailo.icu +mailo.tk +mailof.com +mailon.ws +mailonator.com +mailonaut.com +mailondandan.com +mailone.es.vu +mailontherail.net +mailonxh.pl +mailop7.com +mailor.com +mailorc.com +mailorg.org +mailos.gq +mailosaur.net +mailosiwo.com +mailou.de +mailowanovaroc.com +mailowowo.com +mailox.biz +mailox.fun +mailp.org +mailpay.co.uk +mailphar.com +mailphu.com +mailpick.biz +mailpkc.com +mailplus.pl +mailpluss.com +mailpm.live +mailpoly.xyz +mailpooch.com +mailpoof.com +mailpost.comx.cf +mailpost.ga +mailpost.gq +mailpr3.info +mailpremium.net +mailpress.gq +mailprm.com +mailpro.icu +mailpro.lat +mailpro.live +mailpro5.club +mailprofile.website +mailprohub.com +mailproof.com +mailprotech.com +mailprotect.minemail.in +mailproxsy.com +mailps01.cf +mailps01.ml +mailps01.tk +mailps02.gq +mailps02.ml +mailps02.tk +mailps03.cf +mailps03.ga +mailps03.tk +mailpts.com +mailpull.com +mailpuppet.tk +mailpwr.com +mailquack.com +mailr24.com +mailraccoon.com +mailrard01.ga +mailrazer.com +mailrc.biz +mailreds.com +mailree.live +mailref.net +mailrerrs.com +mailres.net +mailrest.com +mailretor.com +mailretrer.com +mailrfngon.xyz +mailrnl.com +mailrock.biz +mailros.com +mailroyal.net +mailrrpost.com +mailrunner.net +mails-24.net +mails-4-mails.bid +mails.com +mails.omvvim.edu.in +mails.wf +mails4mails.bid +mailsac.cf +mailsac.com +mailsac.ga +mailsac.gq +mailsac.ml +mailsac.tk +mailsadf.com +mailsadf.net +mailsafe.fr.nf +mailsall.com +mailsaviors.com +mailsbay.com +mailscdn.com +mailschain.com +mailscheap.us +mailscode.com +mailscrap.com +mailsd.net +mailsdfd.com +mailsdfd.net +mailsdfeer.com +mailsdfeer.net +mailsdfsdf.com +mailsdfsdf.net +mailsdrop.fun +mailseal.de +mailsearch.net +mailsecv.com +mailseo.net +mailserp.com +mailserv.info +mailserv369.com +mailserv95.com +mailserver.bid +mailserver.men +mailserver2.cf +mailserver2.ga +mailserver2.ml +mailserver2.tk +mailserver89.com +mailseverywhere.net +mailseyri.net +mailsgo.online +mailshan.com +mailshell.com +mailshield.org +mailshiv.com +mailshou.com +mailshun.com +mailside.site +mailsinabox.bid +mailsinabox.club +mailsinabox.info +mailsinthebox.co +mailsiphon.com +mailsister1.info +mailsister2.info +mailsister3.info +mailsister4.info +mailsister5.info +mailska.com +mailslapping.com +mailslite.com +mailslurp.com +mailsmail.com +mailsmart.info +mailsnail.xyz +mailsnails.com +mailsolutions.dev +mailsor.com +mailsoul.com +mailsource.info +mailspam.me +mailspam.xyz +mailspeed.ru +mailspirit.info +mailspro.net +mailspru.cz.cc +mailsrv.ru +mailssents.com +mailsst.com +mailste.com +mailsuckbro.cf +mailsuckbro.ga +mailsuckbro.gq +mailsuckbro.ml +mailsuckbro.tk +mailsuckbrother.cf +mailsuckbrother.ga +mailsuckbrother.gq +mailsuckbrother.ml +mailsuckbrother.tk +mailsucker.net +mailsucker1.cf +mailsucker1.ga +mailsucker1.gq +mailsucker1.ml +mailsucker1.tk +mailsucker11.cf +mailsucker11.ga +mailsucker11.gq +mailsucker11.ml +mailsucker11.tk +mailsucker14.cf +mailsucker14.ga +mailsucker14.gq +mailsucker14.ml +mailsucker14.tk +mailsucker2.cf +mailsucker2.ga +mailsucker2.gq +mailsucker2.ml +mailsucker2.tk +mailsucker34.cf +mailsucker34.ga +mailsucker34.gq +mailsucker34.ml +mailsucker34.tk +mailsugo.buzz +mailsup.net +mailsupply.net +mailswim.com +mailswing.forum +mailswing.xyz +mailswipe.net +mailsy.top +mailt.net +mailt.top +mailtal.com +mailtamtw.top +mailtanpakaudisini.com +mailtechx.com +mailtemp.info +mailtemp.net +mailtemp.org +mailtemp1123.ml +mailtemple.xyz +mailtempmha.tk +mailtemporaire.com +mailtemporaire.fr +mailthink.net +mailthunder.ml +mailtic.com +mailtimail.co.tv +mailtmk.com +mailto.buzz +mailto.plus +mailtod.com +mailtome.de +mailtomeinfo.info +mailtop.ga +mailtothis.com +mailtouiq.com +mailtowin.com +mailtoyou.top +mailtoyougo.xyz +mailtrail.xyz +mailtraps.com +mailtrash.net +mailtrix.net +mailtub.com +mailtune.ir +mailtv.net +mailtv.tv +mailtwcom.xyz +mailtwctt.top +mailtwhaii.top +mailtwhaiz.top +mailtwmuoi.top +mailtwnqs.lat +mailu.cf +mailu.gq +mailu.ml +mailueberfall.de +mailuniverse.co.uk +mailur.com +mailure.pro +mailus.ga +mailusivip.xyz +mailvat.com +mailvip.info +mailvip.net +mailvk.net +mailvn.top +mailvq.net +mailvs.net +mailvxin.com +mailvxin.net +mailw.cf +mailw.ga +mailw.gq +mailw.info +mailw.ml +mailw.site +mailw.tk +mailwebsite.info +mailwithyou.com +mailwriting.com +mailx.click +mailxing.com +mailxtop.xyz +mailxtr.eu +maily.info +mailybest.com +mailyes.co.cc +mailymail.co.cc +mailyouspacce.net +mailyuk.com +mailz.info +mailz.info.tm +mailzen.win +mailzi.ru +mailzilla.com +mailzilla.org +mailzilla.orgmbx.cc +mailzxc.pl +mailzy.org +maimobis.com +main-tube.com +main.truyenbb.com +mainasia.systems +mainctu.com +mainequote.com +mainerfolg.info +mainkask.site +mainlandortho.com +mainmile.com +mainoj.com +mainphp.cf +mainphp.ga +mainphp.gq +mainphp.ml +mainpokerv.net +mainsews.com +mainstore.fun +mainstore.live +mainstore.space +mainstore.website +mainstreethost.company +maintainhealthfoods.ga +maintainhealthfoods.life +maintecloud.com +maintenances.us +maipersonalmail.tk +maiphuong.online +maisdeliveryapp.com +maisondesjeux.com +maisonmargeila.com +maisonprimaire.com +maito.space +maitrimony.com +maiu.tk +maivantuan1994.top +maizystore.me +majedqassem.online +majfk.com +majm.emlhub.com +majnmail.pl +major-jobs.com +major.clarized.com +major.emailies.com +major.emailind.com +major.lakemneadows.com +major.maildin.com +major.ploooop.com +majorices.site +majorleaguemail.com +majorsww.com +majuteruslah.com +makaor.com +makasarpost.cf +make-bootable-disks.com +make.marksypark.com +make.ploooop.com +makebootabledisk.com +makedon-nedv.ru +makemenaughty.club +makemetheking.com +makemnhungnong.xyz +makemoney.com +makemoneyscams.org +makemydisk.com +makente.com +makentehosting.com +makepleasure.club +makerains.tk +makerkiller.ml +makeshopping.pp.ua +makesnte.com +maketchik.info +makethebadmanstop.com +makeun.de +makeupneversleeps.com +makgying.com +makies.web.id +makinadigital.com +makingamericabetterthanever.com +makingbharat.com +makingfreebasecocaine.in +makinkuat.com +maklaca.cfd +maklacpolza.cfd +makmadness.info +makmotors.com +makotamarketing.com +makrojit.xyz +maks.com +maksap.com +makudi.com +makumba.justdied.com +mal3ab.online +malaak.site +malahov.de +malaizy-nedv.ru +malakasss.ml +malakies.tk +malamutepuppies.org +malapo.ovh +malarenorrkoping.se +malarz-mieszkaniowy.pl +malarz-remonciarz.pl +malarz-remonty-warszawa.pl +malarz-remonty.pl +malarzmieszkaniowy.pl +malawiorphancare.org +malayalamdtp.com +malaysianrealty.com +malboxe.com +malchikzer.cf +malchikzer.gq +malcolmdriling.com +maldimix.com +maldonadomail.men +male-pillsrx.info +malecigarettestore.net +maleenhancement.club +maleenhancement24.net +malegirl.com +malenalife.com +maletraveller.com +malh.site +mali-nedv.ru +maliberty.com +malibubright.org +malibucoding.com +malinagames.ru +malinatorgen.com +malioter.pro +maliyetineambalaj.xyz +malkapzolcam.cfd +mall.tko.co.kr +malldrops.com +mallinator.com +mallinco.com +maloino.store +malomies.com +malomiesed.com +malopla.cfd +malove.site +malpracticeboard.com +malrekel.ga +malrekel.tk +malta-nedv.ru +maltacp.com +maltepeingilizcekurslari.com +mam-pap.ru +mama.com +mama3.org +mamail.cf +mamail.com +mamajitu.net +mamajitu.org +mamamintaemail.com +mamasuna.com +mamazumba.com +mamba.ru +mambaru.in +mamber.net +mamejob.com +mami000.com +mami999.net +mamin-shop.ru +mamkinarbuzer.cf +mamkinarbuzer.ga +mamkinarbuzer.ml +mamkinarbuzer.tk +mamkinrazboinik.ga +mamkinrazboinik.gq +mammybagmoscow.ru +mamonsuka.com +mamulenok.ru +mamulhata.network +man-or-machine.com +man.emlhub.com +man2man.xyz +manab.site +manabisagan.com +manac.site +manad.site +manae.site +manage-11.com +managelaw.ru +managgg12.com +manam.ru +manantial20.mx +manantialwatermx2.com.mx +manapuram.com +manaq.site +manatialagua.com.mx +manatialxm.com.mx +manau.site +manaw.site +mancelipo.com +manderich.com +mandownle.ga +mandraghen.cf +mandriya.cloud +mandynmore.com +manekicasino3.com +manf.site +manghinsu.com +manglon.xyz +mangovision.com +mangroup.us +mangtinnhanh.com +manhavebig.shop +manhwamomo.com +manic-adas.ru +manifestgenerator.com +manifietso.org +maninblacktequila.com +manipurbjp.org +maniskata.online +manitowc.com +mankindmedia.com +mankyrecords.com +manlb.site +manlc.site +manld.site +manle.site +manlf.site +manlg.site +manlh.site +manli.site +manlj.site +manlk.site +manln.site +manlo.site +manlp.site +manlq.site +manlr.site +manls.site +manlt.site +manlu.site +manlv.site +manlw.site +manlx.site +manlyyeniyansyah.biz +manlz.site +manm.site +manmail.xyz +manmandirbus.com +mannawo.com +mannbdinfo.org +mannerladies.com +mannhomes.com +manocong.ga +manomangojoa.com +manp.site +manq.site +manq.space +manr.site +mansilverinsdier.com +mansiondev.com +mansonusa.com +mantap.com +mantapua.online +mantestosterone.com +mantra.ventures +mantramail.com +mantutimaison.com +mantutivi.com +mantutivie.com +manual219.xyz +manualial.site +manub.site +manuc.site +manue.site +manuh.site +manuj.site +manuka.com +manul.site +manum.site +manun.site +manuo.site +manupay.com +manuq.site +manur.site +manut.site +manuu.site +manuv.site +manuw.site +manux.site +manuy.site +manv.site +manw.site +manyb.site +manybrain.com +manyc.site +manyd.site +manye.site +manyg.site +manyh.site +manyj.site +manyk.site +manyl.site +manym.site +manyme.com +manymonymo.com +manyn.site +manyo.site +manyp.site +manyq.site +manyr.site +manys.site +manyt.site +manytan364.cf +manytan364.ga +manytan364.gq +manytan364.tk +manyw.site +manyx.site +manyy.site +manyz.site +mao.igg.biz +maoaed.site +maoaokachima.com +maobohe.com +maohe.cloud +maokai-lin.com +maokeba.com +maomaaigang.ml +maomaaigang.tk +maomaocheng.com +maonyn.com +mapa-polskii.pl +mapc.emlhub.com +mapet.pl +mapfnetpa.tk +mapfrecorporate.com +maphic.site +mapi.pl +maple.emlpro.com +mapleemail.com +mapleheightslanes.com +mapolace.xyz +maps.blatnet.com +maps.marksypark.com +maps.pointbuysys.com +mapycyfrowe-bydgoszcz.pl +mar-lacpharmacy.com +mara.jessica.webmailious.top +marabalan.ga +maraphonebet.com +maratabagamereserve.com +marathonkit.com +marattok.com +marbau-hydro.pl +marbleorbmail.bid +marc93.qpon +marcb.com +marcbymarcjacobsjapan.com +marchcats.com +marchiapohan.art +marchmovo.com +marchub.com +marciszewski.pl +marcjacobshandbags.info +marcpfitzer.com +marcsplaza.com +marcusguillermojaka.rocks +marcuswarner.com +marcwine.com +mardiek.com +mareczkowy.pl +mareno.net +marenos.com +maret-genkzmail.ga +marezindex.com +margarette1818.site +margateschoolofbeauty.net +margeguzellik.net +margel.xyz +marginsy.com +margolotta4.pl +margolotta5.pl +margolotta6.pl +marhumal.tech +marianajoelle.lavaweb.in +mariannehallberg.se +mariascloset.org +marib5ethmay.ga +mariela1121.club +marihow.ga +marijuana-delight.com +marijuana-delight.info +marijuana-delight.net +marikuza.com +marimari.website +marimas.tech +marimastu98huye.cf +marimastu98huye.gq +marinad.org +marinajohn.org +marinarlism.com +marinrestoration.com +marionsport.com.pl +marissajeffryna.art +marissasbunny.com +marizing.com +mark-compressoren.ru +mark-sanchez2011.info +mark234.info +markanpla.cfd +markapia.com +markaspoker88.com +markcharnley.website +market-re.quest +market177.ru +marketal.com +marketconow.com +markethealthreviews.info +marketingagency.net +marketingeffekt.de +marketingsolutions.info +marketlink.info +marketmail.info +marketmans.ir +marketplacedc.com +marketplaceselector.com +marketyou.fun +marketyou.site +marketyou.website +markhansongu.com +markhutchins.info +markinternet.co.uk +marklewitz.com +markmurfin.com +marksearcher.com +marksia.com +marlboro-ez-cigarettes.com +marloni.com.pl +marmaryta.com +marmaryta.email +marmaryta.space +marmotmedia.com +marnari.ga +maroonsea.com +marriagedate.net +marriedchat.co.uk +marrocomail.gdn +marromeunationalreserve.com +marrytodo.de +marryznakomstv.ru +mars.blatnet.com +mars.martinandgang.com +marstur.com +marsuniversity.com +marthaloans.co.uk +martin.securehost.com.es +martinatesela.art +martinezfamilia.com +martseapark.life +martu79.cloud +martystahl.com +martyvole.ml +marukushino.co.jp +marutv.site +marvelcomicssupers.online +marvinlee.com +marwan.shop +marwellhard.ga +maryamsupraba.art +maryjanehq.com +maryjanehq.info +maryjanehq.net +maryland-college.cf +marylandwind.org +maryrose.biz +masaaki18.marver-coats.xyz +masaaki77.funnetwork.xyz +masafiagrofood.com +masafigroupbd.com +masaindah.online +masamasa221.site +masasih.loan +mascpottho.ga +masdihoo.ga +masdo88.top +masgtd.xyz +mashed.site +mashkrush.info +mashorts.com +mashy.com +masjoco.com +mask03.ru +mask576.gq +maskbistsmar.ga +maskcnsn.com +maskedmail.net +maskedmails.com +maskelimaymun.ga +maskemail.com +maskmail.net +maskmemail.com +maskmy.id +masks-muzik.ru +masksickness.com +maslicov.biz +masok.lflinkup.com +masongazard.com +masonline.info +masrku.online +massagecentral.club +massagecentral.online +massagecentral.website +massagecentral.xyz +massagecool.club +massagecool.online +massagecool.site +massagecool.space +massagecool.website +massagecool.xyz +massagefin.club +massagefin.online +massagefin.site +massagefin.xyz +massageinsurancequote.com +massagelove.club +massagelove.online +massagelove.website +massagelove.xyz +massagelux.club +massagelux.online +massagelux.website +massagelux.xyz +massageoil.club +massagepar.fun +massagepar.online +massagepar.site +massagepar.xyz +massageshophome.com +massagetissue.com +massageway.club +massageway.online +massageway.website +massageway.xyz +massazhistki-40.com +massazhistki-50.com +massazhistki-na-dom.com +massefm.com +masseymail.men +massimiliano-alajmo.art +massmedios.ru +massrewardgiveaway.gq +mastahype.net +master-mail.net +master.veinflower.xyz +masterbuiltoutlet.com +masterbuiltoutlet.info +masterbuiltoutlet.net +masterbuiltoutlet.org +mastercard-3d.cf +mastergardens.org +masterhost.services +mastermail24.gq +mastermind911.com +mastermoh.website +masternode.online +masterofwarcraft.net +mastersduel.com +mastersstar.me +masto.link +masudcl.com +masumi19.kiesag.xyz +maswae.world +maszynkiwaw.pl +maszyny-rolnicze.net.pl +mataa.me +matamuasu.cf +matamuasu.ga +matamuasu.gq +matamuasu.ml +matchb.site +matchingwrw.com +matchmatepro.com +matchnest.lat +matchpol.net +matchsticktown.com +matchtv.pw +matchup.site +matchvibes.lat +mateb.site +materael.com +materiali.ml +materialos.com +materialresources.org +matgaming.com +mathews.com +mathildelemahieu.pine-and-onyx.xyz +mathslowsso.ga +matincipal.site +matjoa.com +matlabalpha.com +matmayer.com +matogeinou.biz +matra.site +matra.top +matratzevergleich.de +matriv.hu +matseborg.ga +mattbridgerphoto.com +mattbrock.com +mattersjf8.com +matthewservices.com +mattmason.xyz +mattress-mattress-usa.com +mattwoodrealty.com +matydezynfekcyjne.com.pl +matzan-fried.com +matzxcv.org +mau.laste.ml +mauler.ru +mauricemagazine.com +mauriss.xyz +maurya.ml +maverickcreativegroup.org +maverickdonuts.com +maviorjinal.xyz +mavriki-nedv.ru +mawpinkow.konin.pl +max-adv.pl +max-direct.com +max-mail.com +max-mail.info +max-mail.org +max-mirnyi.com +max.mailedu.de +max88.club +max99.xyz +maxamba.com +maxbetspinz.co +maxcare.app +maxcasi.xyz +maxclone.vn +maxflo.com +maxgtrend.ru +maximail.fyi +maximail.store +maximail.vip +maximalbonus.de +maximeblack.com +maximem.com +maximise.site +maximizat2k.pro +maximum10review.com +maximumcomputer.com +maxivern.com +maxmail.in +maxmail.info +maxmails.eu +maxmtc.me +maxon2.ga +maxoutmedia.buzz +maxpanel.id +maxpedia.cloud +maxpedia.ro +maxpeedia.com +maxprice.co +maxresistance.com +maxric.com +maxrollspins.co +maxsad.com +maxseeding.com +maxseeding.vn +maxsize.online +maxturns.com +maxutz.dynamailbox.com +maxxdrv.ru +mayaaaa.cf +mayaaaa.ga +mayaaaa.gq +mayaaaa.ml +mayaaaa.tk +mayacaroline.art +mayak-travel.ru +mayamode.shop +maybe.eu +maybelike.com +mayboy.xyz +maycumbtib.ga +maydayconception.com +mayflowerchristianschool.org +maygiuxecamtay.com +mayhco.com +maylx.com +maymetalfest.info +maymovo.com +mayoenak.site +mayogold.com +mayonaka.cloud +mayonesarnis.biz +mayorpoker.net +mayposre.ga +mayre.shop +mayschemical.com +maysunsaluki.com +maytinhvinhlong.info.vn +maytree.ru +mazaevka.ru +mazedojo.com +mazosdf.tech +mb.com +mb69.cf +mb69.ga +mb69.gq +mb69.ml +mb69.tk +mb7y5hkrof.cf +mb7y5hkrof.ga +mb7y5hkrof.gq +mb7y5hkrof.ml +mb7y5hkrof.tk +mba-cpa.com +mba-inc.net +mbacolleges.info +mbadvertising.com +mbahtekno.net +mbakingzl.com +mban.ml +mbangilan.ga +mbap.ml +mbcfa.anonbox.net +mbdnsmail.mooo.com +mbe.kr +mbem.spymail.one +mbfc6ynhc0a.cf +mbfc6ynhc0a.ga +mbfc6ynhc0a.gq +mbfc6ynhc0a.ml +mbfc6ynhc0a.tk +mbiq.emltmp.com +mblinuxfdp.com +mblsglobal.com +mblungsungi.com +mboled.ml +mbox.re +mbpf.dropmail.me +mbrc.dropmail.me +mbsho.com +mbsl.com +mbt-shoeshq.com +mbt01.cf +mbt01.ga +mbt01.gq +mbt01.ml +mbta.org +mbtjpjp.com +mbtsalesnow.com +mbtshoeclearancesale.com +mbtshoes-buy.com +mbtshoes-z.com +mbtshoes32.com +mbtshoesbetter.com +mbtshoesclear.com +mbtshoesclearancehq.com +mbtshoesdepot.co.uk +mbtshoesfinder.com +mbtshoeslive.com +mbtshoesmallhq.com +mbtshoeson-deal.com +mbtshoesondeal.co.uk +mbtshoesonline-clearance.net +mbtshoespod.com +mbtshoessellbest.com +mbtshoeswarehouse.com +mbutm4xjem.ga +mbx.cc +mbx.freeml.net +mbybea.xyz +mc-fly.be +mc-freedom.net +mc-ij2frasww-ettg.com +mc-s789-nuyyug.com +mc-shop.com +mc-templates.de +mc3ul.anonbox.net +mc45.club +mc8xbx5m65trpt3gs.ga +mc8xbx5m65trpt3gs.ml +mc8xbx5m65trpt3gs.tk +mcache.net +mcands.com +mcarnandgift.ga +mcatag.com +mcatay.xyz +mcatrucking.com +mcaxia.buzz +mcb64dfwtw.cf +mcb64dfwtw.ga +mcb64dfwtw.gq +mcb64dfwtw.ml +mcb64dfwtw.tk +mcbryar.com +mcbslqxtf.pl +mccarley.co.uk +mccee.org +mccluremail.bid +mccoy.com +mccs.info +mcdd.me +mcde.com +mcde1.com +mcdomaine.fr.nf +mcdonald.cf +mcdonald.gq +mcdoudounefemmefr.com +mcdrives.com +mceachern.org +mcelderry.eu +mcelderryrodiquez.eu +mcenany.freshbreadcrumbs.com +mcenb.com +mchdp.com +mchyde.com +mciek.com +mcintoshemails.com +mcjassenonlinenl.com +mcjazz.pl +mckaymail.bid +mckenzie.rebekah.miami-mail.top +mckinleymail.net +mcklinkyblog.com +mclick.click +mcmbulgaria.info +mcmillansmith.com +mcmmobile.co.uk +mcov.com +mcoveraged.com +mcpeck.com +mcpego.ru +mcpservers.one +mcpsvastudents.org +mcshan.ml +mcsoh.org +mcsweeneys.com +mctware.com +mcu.laste.ml +mcuma.com +mcvip.es +mcyo.emlhub.com +mcytaooo0099-0.com +mcyvkf6y7.pl +mcz.freeml.net +mczcpgwbsg.ga +md.emlhub.com +md5hashing.net +md7eh7bao.pl +mdaiac.org +mdamageqdz.com +mdba.com +mddatabank.com +mddwgs.mil.pl +mdeftgds.store +mdf.laste.ml +mdgmk.com +mdhc.tk +mdij.spymail.one +mdj.emlhub.com +mdjf.laste.ml +mdju.emlhub.com +mdld.emltmp.com +mdo88.com +mdoadoutnlin.store +mdoe.de +mdpc.de +mdpubqntnu.ga +mdr.emltmp.com +mdssol.com +mdsu.emltmp.com +mdt.creo.site +mdu.edu.rs +mdva.com +mdwo.com +mdz.email +me-angel.net +me-ble.pl +me.cowsnbullz.com +me.lakemneadows.com +me.oldoutnewin.com +me.ploooop.com +me2.cuteboyo.com +me2sx.anonbox.net +meachlekorskicks.com +meadowmaegan.london-mail.top +meadowutilities.com +meaghan.jasmin.coayako.top +meail.com +meaistunac.ga +mealcash.com +meangel.net +means.yomail.info +meantinc.com +meantodeal.com +mebel-atlas.com +mebeldomoi.com +mebellstore.ru +mebelnu.info +mebelwest.ru +meble-biurowe.com +meble-biurowe.eu +mebleikea.com.pl +meblevps24x.com +meboxmedia.us +mecbuc.cf +mecbuc.ga +mecbuc.gq +mecbuc.ml +mecbuc.tk +mechanicalcomfortservices.com +mechanicalresumes.com +mechanicspedia.com +mechpromo.com +mecip.net +meckakorp.site +meconomic.ru +meconstruct.com +mecs.de +mecybep.com +med-tovary.com +med.gd +meda.email +medan4d.online +medan4d.top +medanmacao.site +medapharma.us +meddepot.com +medevsa.com +medfederation.ru +medhelperssustav.xyz +media-greenhouse.com +media-one.group +media.motornation.buzz +mediacrushing.com +mediadelta.com +mediaeast.uk +mediafate.com +mediaholy.com +mediapanelhq.xyz +mediapulsetech.com +mediaresearch.cz +mediaseo.de +mediastyaa.tk +mediatui.com +mediawebhost.de +medicalfacemask.life +medicalschooly.com +medicalsels.club +medicalsels.online +medicationforyou.info +medications-shop.com +medicc.app +medicheap.co +mediciine.site +medicinemove.xyz +medicinepea.com +medicineworldportal.net +mediko.site +medimom.com +mediosbase.com +meditation-techniques-for-happiness.com +medium.blatnet.com +medium.cowsnbullz.com +medium.emlpro.com +medium.lakemneadows.com +medium.oldoutnewin.com +medkabinet-uzi.ru +medley.hensailor.xyz +medod6m.pl +medooo2.cloud +medremservis.ru +medsheet.com +medue.it +medukr.com +medyczne-odchudzanie.com +meefff.com +meenakshisilks.com +meensdert.ga +meepsheep.eu +meesterlijkmoederschap.nl +meet-me.live +meetaura.lat +meetcard.store +meethornygirls.top +meetingpoint-point.com +meetlocalhorny.top +meetmail.me +meetmeatthebar.com +meetnova.my.id +meetoricture.site +meetwave.lat +meferesdlxver.store +meflous.shop +mefp.xyz +mefvopic.com +mega-buy.vn +mega-dating-directory.com +mega.zik.dj +mega1.gdn +mega389.live +megabot.info +megaceme.bid +megaceme.top +megadex.site +megahost.info +megaklassniki.net +megalearn.ru +megalovers.ru +megamail.pl +megamailhost.com +meganscott.xyz +megape.in +megapuppies.com +megaquiet.com +megaradical.com +megasend.org +megastar.com +megatel.pw +megatraffictoyourwebsite.info +megatraherhd.ru +megavigor.info +megogonett.ru +megoqo.ru +meh.laste.ml +mehditech.info +mehmatali.tk +mehmetaktif.shop +mehr-bitcoin.de +mehrpoy.ir +meibaishu.com +meibokele.com +meidecn.com +meidir.com +meihuajun76.com +meil4me.pl +meiler.co.pl +meimanbet.com +meimeimail.cf +meimeimail.gq +meimeimail.ml +meimeimail.tk +meine-dateien.info +meine-diashow.de +meine-fotos.info +meine-urlaubsfotos.de +meineinkaufsladen.de +meinspamschutz.de +meintick.com +meisteralltrades.com +meituxiezhen.xyz +mejjang.xyz +mejlnastopro.pl +mejlowy1.pl +mejlowy2.pl +mejlowy3.pl +mejlowy4.pl +mejlowy5.pl +mejlowy6.pl +mejlowy7.pl +mejlowy8.pl +meken.ru +mekerlcs.cfd +mekhmon.com +mekikcek.network +meksika-nedv.ru +mekuron.com +melapatas.space +melatoninsideeffects.org +melbox.store +melcow.com +meldedigital.com +meldram.com +meleni.xyz +melhor.ws +melhoramentos.net +melhorvisao.online +melifestyle.ru +melikesekin.cfd +melindaschenk.com +meliput.com +melite.shop +mellieswelding.com +mellymoo.com +melodicrock.net +melodysouvenir.com +meloman.in +melonic.store +melowsa.com +melroseparkapartments.com +meltedbrownies.com +meltmail.com +meltp.com +melverly.com +melyshop.es +melzmail.co.uk +memailme.co.uk +memberheality.ga +membermail.net +memberr-garena.com +membershipse.store +memclin.com +memeazon.com +memecituenakganasli.cf +memecituenakganasli.ga +memecituenakganasli.gq +memecituenakganasli.ml +memecituenakganasli.tk +memeil.top +memek-mail.cf +memek.ml +memem.uni.me +mememail.com +memequeen.club +memhers.com +memkottawaprofilebacks.com +memonetwork.net +memoriesphotos.com +memorimail.com +memorisko.co.uk +memorisko.uk +memorizer76lw.online +memorosky.co.uk +memorosky.org.uk +memoryence.com +memorygalore.com +memosly.sbs +memp.net +memsg.site +memsg.top +memusa.dynamailbox.com +memut.nl +men.blatnet.com +men.lakemneadows.com +men.oldoutnewin.com +menatullah.art +mendoan.uu.gl +mendoanmail.club +mendoo.com +mendung.cloud +menece.com +menene.com +mengan.ga +mengarden.com +menherbalenhancement.com +menidsx.com +menitao.com +menkououtlet-france.com +menopozbelirtileri.com +mensdivorcelaw.com +menseage.ga +menshoeswholesalestores.info +menterprise.app +mentnetla.ga +mentongwang.com +mentonit.net +mentornkc.com +menu-go.com +menuyul.club +menuyul.online +menuzed.com +menviagraget.com +menx.com +menzland.online +meocon.org +meogl.com +meomo.store +meong.store +meooovspjv.pl +meox.com +mepf1zygtuxz7t4.cf +mepf1zygtuxz7t4.ga +mepf1zygtuxz7t4.gq +mepf1zygtuxz7t4.ml +mepf1zygtuxz7t4.tk +mephilosophy.ru +mephistore.co +mepost.pw +meprice.co +mepubnai.ga +meraciousmotyxskin.com +merantikk.cf +merantikk.ga +merantikk.gq +merantikk.ml +merantikk.tk +mercadostreamer.com +mercantravellers.com +mercedes.co.id +mercurials2013.com +mercurialshoesus.com +mercuryinsutance.com +mercygirl.com +merda.cf +merda.ga +merda.gq +merda.ml +merepost.com +merexaga.xyz +merfwotoer.com +merfwotoertest.com +mergame.info +merhabalarsx55996.ga +meriam.edu +mericant.xyz +meridensoccerclub.com +meridian-technology.com +meridianessentials.com +meridiaonlinesale.net +merkez34.com +merlemckinnellmail.com +merliaz.xyz +merlismt2.org +mermaidoriginal.com +mermail.info +mernaiole.website +mernerwnm.store +meroflix.ml +meroflix.shop +meroflixnepal.com +merotx.com +merrellshoesale.com +merrittnils.ga +merry.pink +merrydresses.com +merrydresses.net +merryflower.net +meruado.uk +merumart.com +mervo.site +merxo.me +mesama.ga +mesbeci.ga +mescevo.ga +mesemails.fr.nf +meshfor.com +mesili.ga +mesotheliomasrates.ml +mesrt.online +mess-mails.fr.nf +messaeg.gq +messagea.gq +messagebeamer.de +messageden.com +messageden.net +messageme.ga +messageovations.com +messageproof.gq +messageproof.ml +messager.cf +messagesafe.co +messagesafe.io +messagesafe.ninja +messagesenff.com +messagesino.xyz +messiahmbc.com +messwiththebestdielikethe.rest +mestechnik.de +mestgersta.ga +mestracter.site +met-sex.com +met5fercj18.cf +met5fercj18.ga +met5fercj18.gq +met5fercj18.ml +met5fercj18.tk +meta-support-12sk6xj81.com +meta68.xyz +metaboliccookingpdf.com +metaculol.space +metadownload.org +metaintern.net +metajeans.com +metalcasinao.com +metalike.pro +metalrika.club +metalunits.com +metamorphosisproducts.com +metamusic.blog +metaphila.com +metaping.com +metaprice.co +metaskill.games +metastudio.net +metcoat.com +methodismail.com +metin1.pl +metrika-hd.ru +metrocar.com +metropolitanmining.com +metroset.net +mettamarketingsolutions.com +metuwar.tk +metvauproph.ga +meu.yomail.info +meu2526.com +meuemail.ml +meugi.com +meulilis.ga +meuzap.ml +mev.laste.ml +mev.spymail.one +meveatan.ga +mevj.de +mevori.com +mevrouwhartman.nl +mewinsni.ga +mewiwkslasqw.me +mewnwh.cc +mewx.xyz +mex.broker +mexcool.com +mexicanonlinepharmacyhq.com +mexicobookclub.com +mexicolindo.com.mx +mexicons.com +mexicotulum.com +meximail.pl +mexvat.com +meyfugo.ga +meyfugo.gq +meyveli.site +mezimages.net +mfano.ga +mfbh.cf +mfctve.shop +mfgfx.com +mfghrtdf5bgfhj7hh.tk +mfii.com +mfil4v88vc1e.cf +mfil4v88vc1e.ga +mfil4v88vc1e.gq +mfil4v88vc1e.ml +mfil4v88vc1e.tk +mfriends.com +mfsa.info +mfsa.ru +mfsu.ru +mfunza.com +mfyax.com +mg-rover.cf +mg-rover.ga +mg-rover.gq +mg-rover.ml +mg-rover.tk +mg.dropmail.me +mg.emltmp.com +mg.yomail.info +mg2222.com +mgaba.com +mgabratzboys.info +mgdchina.com +mgeladze.ru +mgfj.emltmp.com +mggovernor.com +mgleek.com +mgmblog.com +mgnt.link +mgp.emlpro.com +mgto.emltmp.com +mgtwzp.site +mgxianlu.gq +mh.mailpwr.com +mh3fypksyifllpfdo.cf +mh3fypksyifllpfdo.ga +mh3fypksyifllpfdo.gq +mh3fypksyifllpfdo.ml +mh3fypksyifllpfdo.tk +mhail.tk +mhcolimpia.ru +mhdpower.me +mhds.ml +mhdsl.cf +mhdsl.ddns.net +mhdsl.dynamic-dns.net +mhdsl.ga +mhdsl.gq +mhdsl.ml +mhdsl.tk +mhere.info +mhmdalifaswar.org +mhmmmkumen.cf +mhmmmkumen.ga +mhmmmkumen.gq +mhmmmkumen.ml +mhorhet.ru +mhschool.info +mhtqq.icu +mhwolf.net +mhzayt.com +mhzayt.online +mi-fucker-ss.ru +mi-mails.com +mi.emlhub.com +mi.laste.ml +mi.meon.be +mi.orgz.in +mi.spymail.one +mi166.com +mia.mailpwr.com +mia6ben90uriobp.cf +mia6ben90uriobp.ga +mia6ben90uriobp.gq +mia6ben90uriobp.ml +mia6ben90uriobp.tk +miadz.com +miaferrari.com +mial.cf +mial.com.creou.dev +mial.tk +mialbox.info +miamidoc.pl +miamimetro.com +miamiquote.com +miamovies.com +miamovies.net +miankk.com +miarr.com +miauj.com +mic3eggekteqil8.cf +mic3eggekteqil8.ga +mic3eggekteqil8.gq +mic3eggekteqil8.ml +mic3eggekteqil8.tk +miccomputers.com +michaelkors4ssalestore.com +michaelkorsborsa.it +michaelkorshandbags-uk.info +michaelkorsoutletclearances.us +michaelkorss.com +michaelkorstote.org +michaellees.net +michaelrader.biz +michein.com +michelinpilotsupersport.com +michellaadlen.art +michelleziudith.art +michigan-nedv.ru +michigan-rv-sales.com +michigan-web-design.com +micicubereptvoi.com +mickaben.biz.st +mickaben.fr.nf +mickaben.xxl.st +mickey-discount.info +mickeyandjohnny.com +mickeymart.com +micksbignightout.info +micleber.tk +microcenter.io +microcreditoabruzzo.it +microfibers.info +micropul.com +microsofts.webhop.me +microsoftt.biz +microsses.xyz +microteez.com +micrrove.com +micsocks.net +mid6mwm.pc.pl +midascmail.com +midasous.com +midcoastcustoms.com +midcoastcustoms.net +midcoastmowerandsaw.com +midcoastsolutions.com +midcoastsolutions.net +middleence.com +midedf.net +mideuda.com +midfloridaa.com +midiharmonica.com +midlandquote.com +midlertidig.com +midlertidig.net +midlertidig.org +midmico.com +midpac.net +midtoys.com +miducusz.com +midv.spymail.one +midwestbeefproducer.com +mieakusuma.art +miegrg.ga +miegrg.ml +mierdamail.com +miesedap.pw +mieszkania-krakow.eu +mif.dropmail.me +mightuvi.ga +mighty.technivant.net +mightysconstruction.com +migliorisitidiincontri.com +migmail.net +migmail.pl +migonom.com +migranthealthworkers.org.uk +migratetoodoo.com +migro.co.uk +migserver2.gq +migserver2.ml +miguecunet.xyz +miguel2k.online +migumail.com +mih-team.com +mihanmail.ir +mihealthpx.com +mihep.com +mihoyo-email.ml +miim.org +miistermail.fr +mijnbestanden.shop +mijnhva.nl +mikaela.kaylin.webmailious.top +mikaela38.universallightkeys.com +mikand.com +mike.designterrarium.de +mikeblogmanager.info +mikeformat.org +mikesweb6.com +mikfarm.com +miki7.site +miki8.site +miki8.xyz +mikoeji.pro +mikrotikvietnam.com +mikrotikvn.com +mikrotikx.com +milandwi.cf +milanuncios-es.com +milavitsaromania.ro +milbox.info +milcepoun.ga +mildin.org.ua +milehiceramics.com +milenashair.com +milestoneprep.org +milfaces.com +miliancis.net +milier.website +milionkart.pl +militaryinfo.com +milited.site +miljaye.ga +milk.gage.ga +milke.ru +milkgitter.com +milkyday.space +millanefernandez.art +millband.com +millertavernbay.com +millertavernyonge.com +millimnava.info +millinance.site +millionairesocietyfree.com +millionairesweetheart.com +millions.cx +millionstars1.com +millnevi.gq +miloandpi.com +milomlynzdroj.pl +miloras.fr.nf +miltonfava.com +mimail.com +mimail.info +mimailtoix.com +mimemail.mineweb.in +mimi.mom +mimicooo.com +mimimail.me +mimlb.anonbox.net +mimo.agency +mimo.digital +mimomail.info +mimowork.com +mimpaharpur.cf +mimpaharpur.ga +mimpaharpur.gq +mimpaharpur.ml +mimpaharpur.tk +mimpi99.com +min.burningfish.net +min.edu.gov +mina.com +minadentist.com +minafter.com +minamail.info +minamitoyama.info +mind2note.com +mindcools.club +mindcools.website +mindfery.tk +mindini.com +mindmail.ga +mindoranet.com +mindoraspace.com +mindpoop.com +mindpowerup.com +mindpring.com +mindsetup.us +mindstring.com +mindthe.biz +mindyobusiness.com +mindyrose.online +mine-epic.ru +mineactivity.com +minecraft-dungeons.ru +minecraft-survival-servers.com +minecraftgo.ru +minecraftinfo.ru +minecraftrabbithole.com +minedwarfpoolstop.online +minefieldmail.com +minegiftcode.pl +mineprinter.us +mineralka1.cf +mineralka1.gq +mineralnie.com.pl +mineralshealth.com +mineralstechnology.com +mineralwnx.com +minerhouse.ru +minerpanel.com +minerscamp.org +minestream.com +minews.biz +minex-coin.com +minexpool.cloud +minexpoolprohub.cloud +minggu.me +minhaishop.click +minhanhvpn.com +minhazfb.cf +minhazfb.ga +minhazfb.ml +minhazfb.tk +minhduc.live +minhduc.shop +minhduc188bet.ga +minhducnow.xyz +minhlun.com +minhquang2000.com +mini-mail.net +mini.pixymix.com +mini.poisedtoshrike.com +minifieur.com +minii-market.xyz +minikuchen.info +minimail.eu.org +minimail.gq +minime.xyz +minimeq.com +minimoifactory.org +miniotls.gr +minipaydayloansuk.co.uk +minisers.xyz +minishop.site +miniskirtswholesalestores.info +ministry-of-silly-walks.de +ministryofcyber.net +minitmaidsofaustin.com +minivacations.com +miniwowo.com +minkh.ru +minkowitz.aquadivingaccessories.com +minnesotaquote.com +minnesotavikings-jerseys.us +minofangle.org +minor.oldoutnewin.com +minor.warboardplace.com +minorandjames.com +minsa.com +minskysoft.ru +minsmail.com +mint-space.info +mintaa.com +mintadomaindong.cf +mintadomaindong.ga +mintadomaindong.gq +mintadomaindong.ml +mintadomaindong.tk +mintcbg.com +mintconditionin.ga +mintemail.cf +mintemail.com +mintemail.ga +mintemail.gq +mintemail.ml +mintemail.tk +minterp.com +minusth.com +minuteafter.com +minuteinbox.com +minutemail.co +minutestep.com +minvolvesjv.com +minyoracle.ru +miodonski.ch +miodymanuka.com +mionavi2012.info +miopaaswod.jino.ru +mior.in +miototo.com +mipodon.ga +miptvdz.com +miqlab.com +miracle3.com +miracle5123.com +miraclegarciniareview.com +miraclemillwork.com +miracleoilhairelixir.com +mirai.re +miraigames.net +miramail.my.id +miramarmining.com +miranda.instambox.com +miranda1121.club +mirarmax.com +mirasa.site +mirbeauty.ru +mirenaclaimevaluation.com +miri.com +mirimus.org +mirkwood.io +mirmirchi.site +mironovskaya.ru +mirpiknika.ru +mirrorrr.asia +mirrorsstorms.top +mirrror.asia +mirs.com +mirsky99.instambox.com +mirstyle.ru +mirtazapine.life +mirtox.com +miruly.com +misailee.com +misc.marksypark.com +misc.ploooop.com +misc.warboardplace.com +miscalhero.com +miscbrunei.net +miscritscheats.info +misdemeanors337dr.online +misehub.com +misenaedu.co +mishreid.net +misiz.com +miskolc.club +miss.marksypark.com +miss.oldoutnewin.com +missi.fun +missing-e.com +missiongossip.com +missionsppf.com +mississaugaseo.com +misslana.ru +misslawyers.com +missouricityapartments.com +missright.co.uk +misssiliconvalley.org +missthegame.com +missyhg.com +mistakens.store +mistakesey.com +misteacher.com +mister-x.gq +misternano.nl +misterpinball.de +misterstiff.com +mistimail.com +mistressnatasha.net +mistridai.com +mistrioni.com +mistycig.com +mistyle.ru +misvetun.ga +mit.emltmp.com +mitakian.com +mitchellent.com +mitchelllx.com +mite.tk +mithiten.com +mitico.org +mitie.site +mitigado.com +mitiz.site +mitnian.xyz +mitobet.com +mitori.org +mitrabisa.com +mitrasbo.com +mitsubishi-asx.cf +mitsubishi-asx.ga +mitsubishi-asx.gq +mitsubishi-asx.ml +mitsubishi-asx.tk +mitsubishi-pajero.cf +mitsubishi-pajero.ga +mitsubishi-pajero.gq +mitsubishi-pajero.ml +mitsubishi-pajero.tk +mitsubishi2.cf +mitsubishi2.ga +mitsubishi2.gq +mitsubishi2.ml +mitsubishi2.tk +mitsuevolution.shop +mituvn.com +miucce.com +miucce.online +miucline.com +miuiqke.xyz +miumiubagjp.com +miumiubagsjp.com +miumiuhandbagsjp.com +miumiushopjp.com +miupdates.org +miur.cf +miur.ga +miur.gq +miur.ml +miur.tk +miwacle.com +miwhibi.ga +mix-good.com +mix-mail.online +mixaddicts.com +mixbiki.ga +mixbox.pl +mixchains.win +mixcoupons.com +mixfe.com +mixflosay.org.ua +mixi.gq +mixinghphw.com +mixmail.site +mixmail.veinflower.veinflower.xyz +mixmidth.site +mixtureqg.com +mixxermail.com +mixzu.net +miza.mailpwr.com +mizapol.net +mizii.eu +mizoey.com +mizugiq2efhd.cf +mizugiq2efhd.ga +mizugiq2efhd.gq +mizugiq2efhd.ml +mizugiq2efhd.tk +mj.spymail.one +mjans.com +mjce.mimimail.me +mjdfv.com +mjemail.cf +mjfitness.com +mjg24.com +mjh.spymail.one +mji.ro +mjj.edu.ge +mjjqgbfgzqup.info +mjmail.cf +mjmautohaus.com +mjmw.yomail.info +mjolkdailies.com +mjpotshop.com +mjs.dropmail.me +mjua.com +mjuifg5878xcbvg.ga +mjukglass.nu +mjut.ml +mjxfghdfe54bnf.cf +mk24.at +mk2u.eu +mkalzacp.cfd +mkalzopc.cfd +mkathleen.com +mkbmax.biz +mkbw3iv5vqreks2r.ga +mkbw3iv5vqreks2r.ml +mkbw3iv5vqreks2r.tk +mkda9884.top +mkdshhdtry546bn.ga +mkeya.com +mkfactoryshops.com +mkjhud.online +mkk83.top +mkk84.top +mkljyurffdg987.cf +mkljyurffdg987.ga +mkljyurffdg987.gq +mkljyurffdg987.ml +mkljyurffdg987.tk +mkm24.de +mkmove.tk +mkn.emlpro.com +mko.kr +mkomail.app +mkomail.cyou +mkomail.top +mkpfilm.com +mkredyt24.pl +mkshake.tk +mktblogs.com +mktmail.xyz +mkualpmzac.cfd +mkurg.com +mkwq.maximail.vip +mky.spymail.one +mkz.spymail.one +mkzaso.com +ml244.site +ml8.ca +mlanm.online +mlbjerseys-shop.us +mldl3rt.pl +mldsh.com +mlemmlem.asia +mlena.site +mlgmail.top +mlhweb.com +mliok.com +mlj101.com +mlkancelaria.com.pl +mll5e.anonbox.net +mlleczkaweb.pl +mllimousine.com +mlmail.top +mlmtips.org +mlnd8834.cf +mlnd8834.ga +mlo.kr +mlodyziemniak.katowice.pl +mlogicali.com +mlolmuyor.ga +mlpg.dropmail.me +mlpkzeck.xyz +mlq6wylqe3.cf +mlq6wylqe3.ga +mlq6wylqe3.gq +mlq6wylqe3.ml +mlq6wylqe3.tk +mlsix.ovh +mlsix.xyz +mlsmodels.com +mlu.emltmp.com +mlusae.xyz +mlvp.com +mlwxq.anonbox.net +mlx.ooo +mm.emlpro.com +mm.my +mm5.se +mmach.ru +mmail.com +mmail.igg.biz +mmail.men +mmail.org +mmail.trade +mmail.xyz +mmailinater.com +mmccproductions.com +mmcdoutpwg.pl +mmciinc.com +mmclobau.top +mmds.shop +mmemories.com +mmgaklan.com +mmkozmetik.com +mmlaaxhsczxizscj.cf +mmlaaxhsczxizscj.ga +mmlaaxhsczxizscj.gq +mmlaaxhsczxizscj.tk +mmm-invest.biz +mmmail.pl +mmmmail.com +mmneda.cloud +mmnjooikj.com +mmo01.com +mmo05.com +mmo2000.asia +mmo365.co.uk +mmo55.com +mmoaia.com +mmobackyard.com +mmoexchange.org +mmogames.in +mmoha.cloud +mmohjmoh.shop +mmoifoiei82.com +mmomismqs.biz +mmon99.com +mmonguyen.online +mmoonz.faith +mmps.org +mmsilrlo.com +mmsp38.xyz +mmtb.freeml.net +mmukmedia.net +mmvl.com +mn.curppa.com +mn.dropmail.me +mn.riaki.com +mnage-ctrl-aplex.com +mnbjkgbvikguiuiuigho.store +mnbvcxz10.info +mnbvcxz2.info +mnbvcxz5.info +mnbvcxz6.info +mnbvcxz8.info +mnemonicedu.com +mnerwdfg.com +mnexq7nf.rocks +mng2gq.pl +mnode.me +mnogikanpolit.ga +mnqlm.com +mns.ru +mnsaf.com +mnst.de +mnswp.website +mntechcare.com +mntwincitieshomeloans.com +mnvl.com +mnxv.com +mnxw3.anonbox.net +mnzipphone.com +mnzle.com +mo.emlhub.com +mo.emltmp.com +moabuild.com +moahmgstoreas.shop +moail.ru +moakt.cc +moakt.co +moakt.com +moakt.ws +moanalyst.com +moassaf2005.shop +moathrababah.com +moba.press +mobachir.site +mobanswer.ru +mobaratopcinq.life +mobc.site +mobd.site +mobelej3nm4.ga +mobf.site +mobi.web.id +mobiarmy3.vn +mobib.site +mobic.site +mobid.site +mobie.site +mobif.site +mobig.site +mobih.site +mobii.site +mobij.site +mobik.site +mobilb.site +mobilc.site +mobild.site +mobile.cowsnbullz.com +mobile.droidpic.com +mobile.emailies.com +mobile.inblazingluck.com +mobile.marksypark.com +mobile.ploooop.com +mobilebankapp.org +mobilebuysellgold.com +mobilehypnosisandcoaching.com +mobilekaku.com +mobilekeiki.com +mobilekoki.com +mobilemail365.com +mobileninja.co.uk +mobilephonecarholder.net +mobilephonelocationtracking.info +mobilephonespysoftware.info +mobilephonetrackingsoftware.info +mobilerealty.net +mobileshopdeals.info +mobilesm.com +mobilespring.com +mobilespyphone.info +mobiletracker.com +mobiletrashmail.com +mobilevents.es +mobilevpn.top +mobilf.site +mobilg.site +mobilhondasidoarjo.com +mobility.camp +mobility.energy +mobilj.site +mobilk.site +mobilm.site +mobiln.site +mobilnaja-versiya.ru +mobilo.site +mobilp.site +mobilq.site +mobilr.site +mobils.site +mobilt.site +mobilu.site +mobilv.site +mobilw.site +mobilx.site +mobilz.site +mobim.site +mobimogul.com +mobip.site +mobiq.site +mobir.site +mobis.site +mobisa.site +mobisb.site +mobisc.site +mobisd.site +mobise.site +mobisf.site +mobisg.site +mobish.site +mobisi.site +mobisj.site +mobisk.site +mobisl.site +mobism.site +mobisn.site +mobiso.site +mobisp.site +mobitifisao.com +mobitiomisao.com +mobitivaisao.com +mobitiveisao.com +mobitivisao.com +mobiu.site +mobiv.site +mobiw.site +mobiwireless.com +mobiy.site +mobk.site +moblibrary.com +mobm.site +mobo.press +moboinfo.xyz +mobotap.net +mobp.site +mobq.site +mobr.site +mobt.site +moburl.com +mobv.site +mobw.site +mobz.site +mocanh.info +mocbddelivery.com +mocg.co.cc +mochaphotograph.com +mochkamieniarz.pl +mochnad.cf +mockmyid.co +mockmyid.com +mocnyy-katalog-wp.pl +mocomorso.com +mocvn.com +mocw.ru +modaborsechane2.com +modaborseguccioutletonline.com +modaborseprezzi.com +modachane1borsee.com +modapeuterey2012.com +modapeutereyuomo.com +modapk.fun +moddema.ga +modealities.com +modebeytr.net +modejudnct4432x.cf +modelhomes.land +modelix.ru +modemtlebuka.com +modeperfect3.fr +moderatex.com +moderatex.net +modernbiznes.pl +moderne-raumgestaltung.de +modernfs.pl +modernopolis.dk +modernsailorclothes.com +modernsocialuse.co.uk +moderntransfers.info +modernx.site +modestmugnia.io +modikulp.com +modila.asia +modirosa.com +modmdmds.com +modotso.com +modujoa.com +modul-rf.ru +modulesdsh.com +modz.pro +modz.store +modz.vip +moeae.com +moebelhersteller.top +moenode.com +moeri.org +moesafv.space +moesasahmeddd.space +mofpay.com +mofu.be +mogash.com +mogcheats.com +mogcosing.ga +mogensenonline.com +mogotech.com +mogpipin.ga +mohajeh.shop +mohanje.site +mohcine.ml +mohisi.ga +mohjener.shop +mohjooj.shop +mohjooj2351.space +mohmail.com +mohmal.club +mohmal.com +mohmal.im +mohmal.in +mohmal.tech +mohmed745.fun +mohmed9alasse.fun +mohmedalasse.fun +mohmedalasse456.cloud +mohmm.cloud +mohmned.cloud +mohnedal.cloud +mohod.cloud +mohsenfb.com +mohsonjooj.site +moidolgi.org +moienerbew.com +moigauhyd.ga +moijkh.com.uk +moimoi.re +moisoveti.ru +moist.gq +moitari.ga +moitari.ml +moiv.com +mojastr.pl +mojblogg.com +mojewiki.com +mojezarobki.com.pl +mojilodayro.ga +mojiphone.pl +mojodefender.com +mojok34.xyz +mojorage.life +mojzur.com +mokook.com +molasedoitr.ga +molda.com +moldova-nedv.ru +molecadamail.pw +molineschools.com +molliwest.site +mollyposts.com +molman.top +molms.com +molten-wow.com +moltrosa.cf +moltrosa.tk +molyg.com +mom2kid.com +momalls.com +momenrt.ga +momentics.ru +momew.com +mommadeit.com +mommsssrl.com +mommyfix.com +momo365.net +momobet-8.com +momoi.uk +momomacknang.com +momonono.info +momos12.com +momoshe.com +momswithfm.com +mon-entrepreneur.com +mona.edu.kg +mona.edu.pl +mona.edu.rs +monachat.tk +monaco-nedv.ru +monadi.ml +monanana.website +monastereo.com +monawerka.pl +monchu.fr +moncker.com +monclerboutiquesenligne.com +monclercoupon.org +monclerdeinfo.info +monclerderedi.info +monclerdoudounemagasinfra.com +monclerdoudouneparis.com +monclerdoudounepascherfrance1.com +monclerfrredi.info +monclermagasinfrances.com +moncleroutwearstore.com +monclerpascherboutiquefr.com +monclerpascherrsodles.com +monclerppascherenlignefra.com +monclerredi.info +monclersakstop.com +monclersoldespascherfra.com +monclersonlinesale.com +moncoiffeuretmoi.com +moncourrier.fr.nf +moncstonar.ga +monctonlife.com +mondaylaura.com +mondial.asso.st +mone15.ru +monedix.com +monemail.fr.nf +money-drives.com +money-trade.info +money-vopros.ru +money-vsem.com +moneyandcents.com +moneyboxtvc.com +moneyhome.com +moneylogtips.com +moneypayday.biz +moneypipe.net +moneyprofit.online +moneyrobotdiagrams.club +moneyslon.ru +moneyup.club +moneywater.us +moneyzon.com +mongemsii.com +mongrec.com +mongrec.top +monica.org +monijoa.com +monikas.work +monikure.ga +monipozeo8igox.cf +monipozeo8igox.ga +monipozeo8igox.gq +monipozeo8igox.ml +monipozeo8igox.tk +monir.eu +monisee.com +monister.com +monitorcity.pro +monkeemail.info +monkey.lakemneadows.com +monkey.oldoutnewin.com +monkey4u.org +monkeyforex.com +monkeysend.com +monku.cfd +monmail.fr.nf +monnoyra.gq +monopici.ml +monoply.shop +monopolitics.xyz +monopolyempiretreasurehunt.com +monorailnigeria.com +monotheism.net +monotv.store +monporn.net +monqerz.com +monsaustraliaa.com +monsieurbiz.wtf +monsterabeatsbydre.com +monsterbeatsbydre-x.com +monsterhom.com +monsterjcy.com +monsukanews.com +monta-ellis.info +monta-ellis2011.info +montaicu.com +montana-nedv.ru +montanaquote.com +montanaweddingdjs.com +montefino.cf +montefiore.com +montepaschi.cf +montepaschi.ga +montepaschi.gq +montepaschi.ml +montepaschi.tk +monterra.tk +montevista1.com +monthesour.ga +monthlyjerky.com +monthlyseopackage.com +monthologiesmerch.com +monthsystem.us +montokop.pw +montowniafryzur.pl +montre-geek.fr +montreal.com +montrowa.ga +montsettsa.ga +monugur.com +monumentmail.com +monutri.com +monvoyantperso.com +monyal.fun +monyal.sbs +monyal.shop +mooblan.ml +moodyaofa.biz +mooecofficail.club +mookkaz.ga +moola4books.com +moolee.net +moolooku.com +moominmcn.com +moon.blatnet.com +moon.cowsnbullz.com +moonapps.org +moondoo.org +moondyal.com +moonfee.com +moonkupla.ga +moonm.review +moonpiemail.com +moonran.com +moontrack.net +moonwake.com +mooo.com +mooo.ml +moooll.site +moopzoopfeve1r.com +moorecarpentry.email +moosbay.com +moose-mail.com +mooshimity.com +moot.es +moozique.musicbooksreviews.com +mopalmeka.cfd +moparayes.site +mopjgudor.ml +mopjgudor.tk +moplaiye.cfd +mopyrkv.pl +moqf.freeml.net +mor19.uu.gl +morad.cc +morahdsl.cf +moralitas.tech +morawski.instambox.com +mordoba.network +moreablle.com +moreawesomethanyou.com +morecoolstuff.net +morefunmart.com +moreglass.vn +moregrafftsfrou.com +morekiss.online +moremobileprivacy.com +moreno1999.xyz +moreorcs.com +moreshead73.instambox.com +morethanjustavoice.info +morethanvacs.com +morethanweknow.com +morethanword.site +moretrend.shop +moretrend.xyz +moreview.xyz +morex.ga +morfelpde.ga +morganink.com +morielasd.ovh +morina.me +mormoncoffee.com +mornhfas.org.ua +morningstarlawn.com +morriesworld.ml +morrlibsu.ga +morsin.com +morteinateb.xyz +mortgagebrief.com +mortgagecalculatorwithtaxess.com +mortgagelends.com +mortgagemotors.com +mortir.com +mortire.ga +mortjusqui.ml +mortmesttesre.wikaba.com +mortystore.cf +moruzza.com +morxin.com +mos-kwa.ru +moscow-nedv.ru +moscowmail.ru +mosertelor.ga +mosheperetz.bet +mosheperetz.net +moship.com +mosmc.com +mosoconsulting.com +mosolob.ru +mosscer.cfd +mosspointhotelsdirect.com +most-wanted-stuff.com +most.blatnet.com +most.marksypark.com +most.ploooop.com +mostafapour.com +mostofit.com +mostpopulardriver.com +mot1zb3cxdul.cf +mot1zb3cxdul.ga +mot1zb3cxdul.gq +mot1zb3cxdul.ml +mot1zb3cxdul.tk +moteko.biz +mother-russia.ru +mother-russia.space +motherhand.biz +mothermonth.us +motherprogram.us +motionindustires.com +motionisme.io +motique.de +motivationalsites.com +motivue.com +moto-gosz.pl +moto4you.pl +motorcyclerow.com +motorcycleserivce.info +motorisation.ga +motorola.redirectme.net +mottel.fr +mottenarten.ga +mouadim.tk +mouadslider.site +moukrest.ru +moul.com +moulybrien.cf +moulybrien.tk +mountainmem.com +mountainregionallibrary.net +mountainviewbandb.net +mountainviewwiki.info +mountathoss.gr +mountdasw.ga +mountedxth.com +moustache-media.com +mouthube0t.com +movanfj.ml +move-meal.site +move2.ru +move2inbox.net +movedto.info +movemail.com +movfull.com +movgal.com +movicc.com +movie-ru-film.ru +movie-ru-girls.ru +movie4khd.net +movieblocking.com +movieisme.co +movienox.com +movies1.online +movies4youfree.com +movies69.xyz +moviesclab.net +moviescraz.com +moviesdirectoryplus.com +moviesjoy.online +moviesjoy.site +moviesonlinehere.com +moviespur.xyz +movietv4u.com +moviflix.tk +movingmatterkc.com +mowgli.jungleheart.com +mowline.com +mowoo.net +mowspace.co.za +mox.pp.ua +moxinbox.info +moxkid.com +moy-elektrik.ru +moydom12.tk +moyuzi.com +moyy.net +moz.emlpro.com +moza.pl +mozara.com +mozej.com +mozej.online +mozgu.com +mozillafirefox.cf +mozillafirefox.ga +mozillafirefox.gq +mozillafirefox.ml +mozillafirefox.tk +mozmail.info +mozzinovo.club +mozzzi12.com +mp-j.cf +mp-j.ga +mp-j.gq +mp-j.igg.biz +mp-j.ml +mp-j.tk +mp.dropmail.me +mp.igg.biz +mp3-world.us +mp3cc-top.biz +mp3dn.net +mp3geulis.net +mp3granie.pl +mp3hd.online +mp3hungama.xyz +mp3nt.net +mp3oxi.com +mp3sa.my.to +mp3skull.com +mp3u.us +mp3wifi.site +mp4-base.ru +mpaaf.cf +mpaaf.ga +mpaaf.gq +mpaaf.ml +mpaaf.tk +mpbtodayofficialsite.com +mpdacrylics.com +mpe.emlpro.com +mpegsuite.com +mpg.emlpro.com +mphaotu.com +mpictureb.com +mpisd.com +mpiz.com +mpjgqu8owv2.pl +mpk.ovh +mpl8.info +mplusmail.com +mpm-motors.cf +mpmps160.tk +mpo303.xyz +mpo4d.info +mpocash.club +mpoplaytech.net +mposhop.com +mpovip.com +mpsodllc.com +mpsomaha.com +mpszcsoport.xyz +mptncvtx0zd.cf +mptncvtx0zd.ga +mptncvtx0zd.gq +mptncvtx0zd.ml +mptncvtx0zd.tk +mptrance.com +mpty.mailpwr.com +mpvnvwvflt.cf +mpvnvwvflt.ga +mpvnvwvflt.gq +mpvnvwvflt.ml +mpvnvwvflt.tk +mpyaccoan.com +mpystsgituckx4g.cf +mpystsgituckx4g.gq +mpzoom.com +mq.emlhub.com +mq.orgz.in +mqg77378.cf +mqg77378.ga +mqg77378.ml +mqg77378.tk +mqhtukftvzcvhl2ri.cf +mqhtukftvzcvhl2ri.ga +mqhtukftvzcvhl2ri.gq +mqhtukftvzcvhl2ri.ml +mqhtukftvzcvhl2ri.tk +mqkivwkhyfz9v4.cf +mqkivwkhyfz9v4.ga +mqkivwkhyfz9v4.gq +mqkivwkhyfz9v4.ml +mqkivwkhyfz9v4.tk +mqneoi.spymail.one +mqs.spymail.one +mquote.tk +mqy.emlpro.com +mr-email.fr.nf +mr-manandvanlondon.co.uk +mr.dropmail.me +mr.laste.ml +mr.yomail.info +mr24.co +mr907tazaxe436h.cf +mr907tazaxe436h.ga +mr907tazaxe436h.gq +mr907tazaxe436h.tk +mracc.it +mrain.ru +mrajax.ml +mrblacklist.gq +mrcaps.org +mrchinh.com +mrcountry.biz +mrd.laste.ml +mrdashboard.com +mrdevilstore.com +mrdmn.com +mrecphoogh.pl +mrepair.com +mrflibble.icu +mrha.win +mrhbyuxh11599.ga +mrhbyuxh49348.ga +mrhbyuxh51920.ga +mrichacrown39dust.tk +mriscan.live +mriscanner.live +mrisemail.com +mrisemail.net +mrjgyxffpa.pl +mrk.laste.ml +mrmail.info +mrmail.mrbasic.com +mrmal.ru +mrmanie.com +mrmemorial.com +mrmikea.com +mrmrmr.com +mroneeye.com +mrossi.cf +mrossi.ga +mrossi.gq +mrossi.ml +mrotsiz.com +mrotzis.com +mrpara.com +mrphoto.org +mrresourcepacks.tk +mrrob.net +mrs24.de +mrsands.org +mrsfs.com +mrshok.xyz +mrsikitjoefxsqo8qi.cf +mrsikitjoefxsqo8qi.ga +mrsikitjoefxsqo8qi.gq +mrsikitjoefxsqo8qi.ml +mrsikitjoefxsqo8qi.tk +mrugesh.tk +mrunlock.run +mrvpm.net +mrvpt.com +mrzero.tk +ms.vcss.eu.org +ms365.ml +ms9.mailslite.com +msa.minsmail.com +msabate.com +msarra.com +msb.minsmail.com +msback.com +msbestlotto.com +mscbestforever.com +mscdex.com.au.pn +msdc.co +msdla.com +msdnereeemw.org +msendback.com +mseo.ehost.pl +mservices.life +msft.cloudns.asia +msg.mailslite.com +msgden.com +msgden.net +msghideaway.net +msgos.com +msgsafe.io +msgsafe.ninja +msgwire.com +msh.mailslite.com +msiofke.com +msisanitation.com +msitip.com +msivina.com +msiwkzihkqifdsp3mzz.cf +msiwkzihkqifdsp3mzz.ga +msiwkzihkqifdsp3mzz.gq +msiwkzihkqifdsp3mzz.ml +msiwkzihkqifdsp3mzz.tk +msk-farm.ru +msk-intim-dosug.ru +msk-pharm.ru +msk.ru +mskey.co +mskey.net +mskhousehunters.com +mskin.top +mskintw.top +msm.com +msmail.bid +msmail.cf +msmail.trade +msmail.website +msmail.win +msmx.site +msn.com.se +msn.edu +msn.org +msnai.com +msnblogs.info +msng.com +msnt007.com +msnviagrarx.com +msoft.com +mson.com +msotln.com +mspa.com +mspas.com +mspeciosa.com +mspforum.com +msrc.ml +msse.com +mssf.com +mssfpboly.pl +mssn.com +msssg.com +mstyfdrydz57h6.cf +msu69gm2qwk.pl +msucougar.org +msugcf.org +msvvscs6lkkrlftt.cf +msvvscs6lkkrlftt.ga +msvvscs6lkkrlftt.gq +mswebapp.com +mswork.ru +msxd.com +mt-03.ml +mt2009.com +mt2014.com +mt2015.com +mt2016.com +mt2017.com +mt66ippw8f3tc.gq +mta.com +mtasa.ga +mtc-cloud.tech +mtcox.tech +mtcx.org +mtcxmail.com +mtcz.us +mtgmogwysw.pl +mtjoy.org +mtlcz.com +mtmdev.com +mtpower.com +mtpropertyinvestments.com +mtrucqthtco.cf +mtrucqthtco.ga +mtrucqthtco.gq +mtrucqthtco.ml +mtrucqthtco.tk +mtsg.me +mtu-net.ru +mtvknzrs.xyz +mtw.yomail.info +mtyju.com +mu.dropmail.me +mu.emlpro.com +mu3dtzsmcvw.cf +mu3dtzsmcvw.ga +mu3dtzsmcvw.gq +mu3dtzsmcvw.ml +mu3dtzsmcvw.tk +mu723.anonbox.net +muabanclone.site +muadaingan.com +muagicungre.com +muahetbienhoa.com +muamuawrtcxv7.cf +muamuawrtcxv7.ga +muamuawrtcxv7.gq +muamuawrtcxv7.ml +muamuawrtcxv7.tk +muataikhoan.info +muateledrop3.asia +muathegame.com +muaviagiare.com +mucamamedia.site +mucate.com +muchami.ml +muchami.tk +muchomail.com +muchovale.com +mucincanon.com +muckersins.com +mudahmaxwin.com +mudanya118.xyz +mudanzasbaratas.biz +mudanzasbaratass.com +mudanzasbp.com +mudanzases.com +mudbox.ml +mudhighmar.ga +mudrait.com +muehlacker.tk +muell.email +muell.icu +muell.io +muell.monster +muell.ru +muell.xyz +muellemail.com +muellmail.com +muellpost.de +muetop.store +muf.spymail.one +muffinbasketap.com +mufmail.com +mufollowsa.com +mufux.com +mugadget.com +mugglenet.org +mughftg5rtgfx.gq +muglamarket.online +muglavo.ga +muhabbetkusufiyatlari.com +muhamadnurdin.us +muhammadafandi.com +muhammedzeyto.cfd +muhaos.com +muhbuh.com +muhdioso8abts2yy.cf +muhdioso8abts2yy.ga +muhdioso8abts2yy.gq +muhdioso8abts2yy.ml +muhdioso8abts2yy.tk +muhijansr.biz.id +muhoy.com +muimail.com +mujaz.net +mukaddeshanis.shop +mukaolpcal.cfd +mukarlac.cfd +mukund.info +mulars.ru +mulatera.site +mulberry.de +mulberry.eu +mulberrybags-outlet.info +mulberrybagsgroup.us +mulberrybagsoutletonlineuk.com +mulberrymarts.com +mulberrysmall.co.uk +mulfide.ga +mull.email +mullemail.com +mullerd.gq +mulligan.leportage.club +mullmail.com +mulrogar.ga +mulseehal.ga +multi-car-insurance.net +multichances.com +multicorse.com +multifitai.com +multiplanet.de +multiplayerwiigames.com +multiplexer.us +multiplusclouds.com +multireha.pl +multiscanner.org +multiwiseai.com +mulyan.top +mumbama.com +mumgoods.site +mumpangmeumpeung.space +muncieschool.org +muncloud.com +mundocripto.com +mundodigital.me +mundonetbo.com +mundopregunta.com +mundri.tk +muni-kuni-tube.ru +muniado.waw.pl +municiamailbox.com +munik.edu.pl +munj.nl +munj.shop +munjago.buzz +munoubengoshi.gq +muonwhila.com +mupick.xyz +mupload.nl +mupre.xyz +muq.orangotango.tk +muqaise.com +muqwftsjuonmc2s.cf +muqwftsjuonmc2s.ga +muqwftsjuonmc2s.gq +muqwftsjuonmc2s.ml +muqwftsjuonmc2s.tk +mur.freeml.net +murahpanel.com +murakamibooks.com +muraklimepa.cfd +muratreis.icu +murattomruk.com +mure.emlpro.com +murlioter.ga +murticans.com +murvice.com +mus-max.info +mus.email +musashiazeem.com +musclebuilding.club +musclefactorxreviewfacts.com +musclemailbox.com +musclemaximizerreviews.info +musclesorenesstop.com +museboost.com +museumpi.com +musezoo.com +musialowski.pl +music-feels-great.com +music.blatnet.com +music.droidpic.com +music.emailies.com +music.lakemneadows.com +music4buck.pl +music896.are.nom.co +musicalinstruments2012.info +musicalnr.com +musicandsunshine.com +musicbizpro.com +musiccode.me +musicdrom.com +musicfilesarea.com +musichq.xyz +musicloading.com +musicmakes.us +musicproducersi.com +musicresearch.edu +musicsdating.info +musicsoap.com +musict.net +musicvideo.icu +musicwiki.com +musikayok.ru +musiku.studio +musincreek.site +muskelshirt.de +muskgrow.com +muskify.com +must.blatnet.com +must.marksypark.com +must.poisedtoshrike.com +mustaer.com +mustafakiranatli.xyz +mustafasakarcan.sbs +mustafaturulsok.shop +mustale.com +mustbe.ignorelist.com +mustbedestroyed.org +mustbeit.com +mustillie.site +mustmails.cf +musttttaff.cloud +musttufa.site +mutant.me +mutechcs.com +mutewashing.site +muti.site +mutiglax.ga +muttonvindaloobeast.xyz +muttvomit.com +muttwalker.net +mutualmetarial.org +mutualwork.com +mutudev.com +muuyharold.com +muvilo.net +muxala.com +muymolo.com +muyoc.com +muyrte4dfjk.cf +muyrte4dfjk.ga +muyrte4dfjk.gq +muyrte4dfjk.ml +muyrte4dfjk.tk +muzan.me +muzhskaiatema.com +muzik-fermer.ru +muzikaper.ru +muzitp.com +mv1951.cf +mv1951.ga +mv1951.gq +mv1951.ml +mv1951.tk +mv6a.com +mvat.de +mvbv.mimimail.me +mvd.spymail.one +mvdsheets.com +mvee.emlhub.com +mvgn5.anonbox.net +mvl.freeml.net +mvlnjnh.pl +mvm.dropmail.me +mvmusic.top +mvo.pl +mvoa.site +mvoudzz34rn.cf +mvoudzz34rn.ga +mvoudzz34rn.gq +mvoudzz34rn.ml +mvoudzz34rn.tk +mvpalace.com +mvpdream.com +mvpmedix.com +mvres.com +mvrh.com +mvrht.com +mvrht.net +mvswydnps.pl +mvw.spymail.one +mvxtv.site +mw.orgz.in +mwabviwildlifereserve.com +mwarner.org +mwcq.com +mwdsgtsth1q24nnzaa3.cf +mwdsgtsth1q24nnzaa3.ga +mwdsgtsth1q24nnzaa3.gq +mwdsgtsth1q24nnzaa3.ml +mwdsgtsth1q24nnzaa3.tk +mwfptb.gq +mwgoqmvg.xyz +mwh.group +mwkancelaria.com.pl +mwlh.freeml.net +mwnemnweroxmn.org +mwo.laste.ml +mwo.yomail.info +mwoi.emltmp.com +mwp4wcqnqh7t.cf +mwp4wcqnqh7t.ga +mwp4wcqnqh7t.gq +mwp4wcqnqh7t.ml +mwp4wcqnqh7t.tk +mwqj.xyz +mwrd.com +mwt.freeml.net +mwx.laste.ml +mx.dropmail.me +mx.dysaniac.net +mx.emlhub.com +mx.emltmp.com +mx.freeml.net +mx.laste.ml +mx.mail-data.net +mx.mailpwr.com +mx.spymail.one +mx.yomail.info +mx0.wwwnew.eu +mx1.site +mx18.mailr.eu +mx19.mailr.eu +mx2.den.yt +mx8168.net +mxa.emlhub.com +mxbin.net +mxcdd.com +mxclip.com +mxd.freeml.net +mxdevelopment.com +mxfuel.com +mxg.mayloy.org +mxgsby.com +mxheesfgh38tlk.cf +mxheesfgh38tlk.ga +mxheesfgh38tlk.gq +mxheesfgh38tlk.ml +mxheesfgh38tlk.tk +mxndjshdf.com +mxnn.com +mxo.emlpro.com +mxp.dns-cloud.net +mxp.dnsabr.com +mxscout.com +mxvia.com +mxvq.emlhub.com +mxzvbzdrjz5orbw6eg.cf +mxzvbzdrjz5orbw6eg.ga +mxzvbzdrjz5orbw6eg.gq +mxzvbzdrjz5orbw6eg.ml +mxzvbzdrjz5orbw6eg.tk +my-001-website.ml +my-aunt.com +my-blog.ovh +my-email.gq +my-fashion.online +my-free-tickets.com +my-google-mail.de +my-great-email-address.top +my-health.site +my-link.cf +my-mail.ch +my-mail.top +my-points.info +my-pomsies.ru +my-sell-shini.space +my-server-online.gq +my-teddyy.ru +my-top-shop.com +my-turism.info +my-webmail.cf +my-webmail.ga +my-webmail.gq +my-webmail.ml +my-webmail.tk +my-world24.de +my.blatnet.com +my.cowsnbullz.com +my.efxs.ca +my.lakemneadows.com +my.laste.ml +my.longaid.net +my.makingdomes.com +my.ploooop.com +my.poisedtoshrike.com +my.safe-mail.gq +my.vondata.com.ar +my10minutemail.com +my2ducks.com +my301.info +my301.pl +my365.tw +my365office.pro +my3mail.cf +my3mail.ga +my3mail.gq +my3mail.ml +my3mail.tk +my6com.com +my6mail.com +my7km.com +myabandonware.com +myabccompany.info +myacaiberryreview.net +myacc.codes +myadult.info +myakapulko.cf +myakapulko.ga +myakapulko.gq +myalahqui.ga +myalias.pw +myallergiesstory.com +myallgaiermogensen.com +myamoria.lat +myandex.ml +myanny.ru +myautoinfo.ru +myazg.ru +mybackend.com +mybackup.com +mybackup.xyz +mybada.net +mybaegsa.xyz +mybanglaspace.net +mybathtubs.co.cc +mybeligummail.com +mybestmailbox.biz +mybestmailbox.com +mybiginbox.info +mybikinibellyplan.com +mybirthday.com +mybisnis.online +mybitcoin.com +mybitti.de +myblogmail.xyz +myblogpage.com +mybpay.shop +mybrainsme.fun +mybusinessbiz.com +mybuycosmetics.com +mybx.site +mycakil.xyz +mycard.net.ua +mycartzpro.com +mycarway.online +mycasualclothing.com +mycasualclothing.net +mycasualtshirt.com +mycatbook.site +mycattext.site +myccscollection.com +mycellphonespysoft.info +mycertnote.com +mycharming.club +mycharming.live +mycharming.online +mycharming.site +mychicagoheatingandairconditioning.com +mychildsbike.com +mychillmailgo.tk +mycityvillecheat.com +mycleaninbox.net +mycloudmail.tech +mycominbox.com +mycompanigonj.com +mycontentbuilder.com +mycoolemail.xyz +mycorneroftheinter.net +mycrazyemail.com +mycrazynotes.com +mycreativeinbox.com +mycryptocare.com +mycsbin.site +mycybervault.com +mydb.com +myde.ml +mydefipet.live +mydemo.equipment +mydesign-studio.com +mydexter.info +mydiaryfe.club +mydiaryfe.online +mydiaryfe.xyz +mydigitalhome.xyz +mydigitallogic.com +mydirbooks.site +mydirfiles.site +mydirstuff.site +mydirtexts.site +mydn.emlpro.com +mydoaesad.com +mydogspotsa.com +mydomain.buzz +mydomainc.cf +mydomainc.ga +mydomainc.gq +mydot.fun +myeacf.com +myecho.es +myedhardyonline.com +myelousro.ga +myemail.fun +myemail1.cf +myemail1.ga +myemail1.ml +myemailaddress.co.uk +myemailboxmail.com +myemailboxy.com +myemaill.com +myemailmail.com +myemailonline.info +myezymaps.com +myf.spymail.one +myfaceb00k.cf +myfaceb00k.ga +myfaceb00k.gq +myfaceb00k.ml +myfaceb00k.tk +myfake.cf +myfake.ga +myfake.gq +myfake.ml +myfake.tk +myfakemail.cf +myfakemail.ga +myfakemail.gq +myfakemail.tk +myfashionshop.com +myfavmailbox.info +myfavorite.info +myfbprofiles.info +myficials.club +myficials.online +myficials.site +myficials.website +myficials.world +myfirstdomainname.cf +myfirstdomainname.ga +myfitness24.de +myfoldingshoppingcart.com +myfortune.com +myfreemail.bid +myfreemail.download +myfreemail.space +myfreeola.uk +myfreeserver.bid +myfreeserver.download +myfreeserver.website +myfreshbook.site +myfreshbooks.site +myfreshfiles.site +myfreshlive.club +myfreshlive.online +myfreshlive.site +myfreshlive.website +myfreshlive.xyz +myfreshtexts.site +myfullstore.fun +myfunnymoney.ru +myfuturestudy.com +myfxspot.com +mygeoweb.info +myggemail.com +myglockner.com +myglocknergroup.com +myglockneronline.com +mygoldenmail.co +mygoldenmail.com +mygoldenmail.online +mygoslka.fun +mygourmetcoffee.net +mygrammarly.co +mygreatarticles.info +mygrmail.com +mygrovemail.com +mygsalife.xyz +mygsalove.xyz +myguidesx.site +myhaberdashe.com +myhagiasophia.com +myhandbagsuk.com +myhashpower.com +myhavyrtd.com +myhavyrtda.com +myhealthanswers.com +myhealthbusiness.info +myhf.de +myhiteswebsite.website +myhitorg.ru +myhoanglantuvi.com +myhobbies24.xyz +myhochzeitsfilm.de +myholidaymaldives.com +myhoroscope.com +myhost.bid +myhost.trade +myimail.bid +myimail.men +myimail.website +myinbox.com +myinbox.icu +myinboxmail.co.uk +myindohome.services +myinfoinc.com +myinterserver.ml +myjeffco.com +myjhccvdp.pl +myjobswork.store +myjointhealth.com +myjordanshoes.us +myjuicycouturesoutletonline.com +myjunkmail.ovh +myjustmail.co.cc +myk-pyk.eu +mykcloud.com +mykeiani.com +mykickassideas.com +mykidsfuture.com +mykingle.xyz +mykiss.fr +mylaguna.ru +mylameexcuses.com +mylapak.info +mylaserlevelguide.com +mylastdomainname.ga +mylastdomainname.ml +mylastdomainname.tk +mylcdscreens.com +myled68456.cf +myled68456.ml +myled68456.tk +mylenecholy.com +mylenobl.ru +myletter.online +myletters.online +mylibbook.site +mylibfile.site +mylibstuff.site +mylibtexts.site +mylicense.ga +mylistfiles.site +myliststuff.site +mylittleali.ga +mylittlebigbook.com +mylittlepwny.com +myloans.space +mylomagazin.ru +mylongemail.info +mylongemail2015.info +mylovelyfeed.info +mylovepale.live +mylovepale.store +mylovezya.my.id +myltqa.com +myluminair.site +myluvever.com +mymail-in.net +mymail.hopto.org +mymail.infos.st +mymail13.com +mymail24.xyz +mymail90.com +mymailbag.com +mymailbeast.com +mymailbest.com +mymailbox.pw +mymailbox.tech +mymailbox.top +mymailbox.xxl.st +mymailboxpro.org +mymailcr.com +mymaildo.kro.kr +mymailid.tk +mymailjos.cf +mymailjos.ga +mymailjos.tk +mymailoasis.com +mymailprotection.xyz +mymailsrv.info +mymailsystem.co.cc +mymailto.cf +mymailto.ga +mymaily.lol +mymarketinguniversity.com +mymarkpro.com +mymaskedmail.com +mymassages.club +mymassages.online +mymassages.site +mymassages.xyz +mymintinbox.com +mymitel.ml +mymobilehut.icu +mymobilekaku.com +mymogensen.com +mymogensenonline.com +mymonies.info +mymulberrybags.com +mymulberrybags.us +mymy.cf +mymymymail.com +mymymymail.net +myn4s.ddns.net +myneek.com +myneena.club +myneena.online +myneena.xyz +myneocards.cz +mynes.com +mynetsolutions.bid +mynetsolutions.men +mynetsolutions.website +mynetstore.de +mynetwork.cf +mynewbook.site +mynewemail.info +mynewfile.site +mynewfiles.site +mynewmail.info +mynewtext.site +mynning-proxy.ga +mynoop.store +myntu5.pw +myobamabar.com +myonline-services.net +myonlinetarots.com +myonlinetoday.info +myopang.com +myoverlandtandberg.com +mypacks.net +mypandoramails.com +mypartyclip.de +mypcrmail.com +mypend.fun +mypend.xyz +mypensionchain.cf +myperfumeshop.net +myphantomemail.com +myphonam.gq +myphpbbhost.com +mypieter.com +mypietergroup.com +mypieteronline.com +mypop3.bid +mypop3.trade +mypop3.website +mypop3.win +myproximity.us +myptcleaning.com +myqrops.net +myqvartal.com +myqwik.cf +myr2d.com +myrabax.space +myrabeatriz.minemail.in +myrandomthoughts.info +myraybansunglasses-sale.com +myredirect.info +myreferralconnection.com +myrentway.live +myrentway.online +myrentway.xyz +myrice.com +mysafe.ml +mysafemail.cf +mysafemail.ga +mysafemail.gq +mysafemail.ml +mysafemail.tk +mysaitenew.ru +mysamp.de +mysans.tk +mysansan.me +mysawit.web.id +mysecretnsa.net +mysecurebox.online +myself.fr.nf +myselfship.com +mysend-mailer.ru +myseneca.ga +mysent.ml +myseotraining.org +mysermail1.xyz +mysermail2.xyz +mysermail3.xyz +mysex4me.com +mysexgames.org +myshopway.xyz +mysistersvids.com +myslipsgo.ga +mysophiaonline.com +myspaceave.info +myspacedown.info +myspaceinc.com +myspaceinc.net +myspaceinc.org +myspacepimpedup.com +myspainishmail.com +myspamless.com +myspotbook.site +myspotbooks.site +myspotfile.site +myspotfiles.site +myspotstuff.site +myspottext.site +myspottexts.site +mysqlbox.com +mystartupweekendpitch.info +mystickof.com +mysticwood.it +mystiknetworks.com +mystufffb.fun +mystvpn.com +mysudo.biz +mysudo.net +mysudomail.com +mysugartime.ru +mysukam.com +mysuperwebhost.com +mytacticaldepot.com +mytaemin.com +mytandberg.com +mytandbergonline.com +mytarget.info +mytaxes.com +mytechhelper.info +mytechsquare.com +mytemails.com +mytemp.email +mytempdomain.tk +mytempemail.com +mytempmail.com +mytempmail.org +mythnick.club +mythoughtsexactly.info +mythrashmail.net +mytivilebonza.com +mytmail.in +mytmail.net +mytools-ipkzone.gq +mytop-in.net +mytopwebhosting.com +mytownusa.info +mytrashmail.com +mytrashmail.compookmail.com +mytrashmail.net +mytrashmailer.com +mytrashmailr.com +mytravelstips.com +mytrend24.info +mytrommler.com +mytrommlergroup.com +mytrommleronline.com +mytrumail.com +mytuttifruitygsa.xyz +mytvs.online +myu.emlhub.com +myugg-trade.com +myumail.bid +myumail.stream +myumail.website +myunivschool.com +myvapepages.com +myvaultsophia.com +myvensys.com +myvtools.com +mywarnernet.net +mywayzs.com +myweblaw.com +mywgi.com +mywikitree.com +myworld.edu +mywrld.site +mywrld.top +myx.yomail.info +myxl.live +myxu.info +myybloogs.com +myzat.com +myzone.press +myzx.com +myzxseo.net +mzagency.pl +mzastore.com +mzbysdi.pl +mzfactoryy.com +mzhttm.com +mzico.com +mzigg6wjms3prrbe.cf +mzigg6wjms3prrbe.ga +mzigg6wjms3prrbe.gq +mzigg6wjms3prrbe.ml +mzigg6wjms3prrbe.tk +mziqo.com +mziw.emlhub.com +mzljx.anonbox.net +mzlo.spymail.one +mzq.spymail.one +mzr.yomail.info +mztiqdmrw.pl +mzwallacepurses.info +mzzlmmuv.shop +mzzu.com +n-h-m.com +n-system.com +n.polosburberry.com +n.rugbypics.club +n.spamtrap.co +n.zavio.nl +n00btajima.ga +n0qyrwqgmm.cf +n0qyrwqgmm.ga +n0qyrwqgmm.gq +n0qyrwqgmm.ml +n0qyrwqgmm.tk +n0te.tk +n19wcnom5j2d8vjr.ga +n1buy.com +n1c.info +n1nja.org +n2fnvtx7vgc.cf +n2fnvtx7vgc.ga +n2fnvtx7vgc.gq +n2fnvtx7vgc.ml +n2fnvtx7vgc.tk +n2snow.com +n3tflx.club +n4445.com +n4e7etw.mil.pl +n4nd4.tech +n4paml3ifvoi.cf +n4paml3ifvoi.ga +n4paml3ifvoi.gq +n4paml3ifvoi.ml +n4paml3ifvoi.tk +n59fock.pl +n5tmail.xyz +n659xnjpo.pl +n7gh2.anonbox.net +n7program.nut.cc +n7s5udd.pl +n8.gs +n8he49dnzyg.cf +n8he49dnzyg.ga +n8he49dnzyg.ml +n8he49dnzyg.tk +n8tini3imx15qc6mt.cf +n8tini3imx15qc6mt.ga +n8tini3imx15qc6mt.gq +n8tini3imx15qc6mt.ml +n8tini3imx15qc6mt.tk +na-cat.com +na-raty.com.pl +na-start.com +na.com.au +na288.com +na3noo3.site +naaag6ex6jnnbmt.ga +naaag6ex6jnnbmt.ml +naaag6ex6jnnbmt.tk +naabiztehas.xyz +naaer.com +naah.ru +naah.store +naaughty.club +nab4.com +nabajin.com +nabaxox.edu.pl +nableali.ga +nabofa.com +nabomail.com +naboostso.ga +nabuma.com +nacer.com +nacho.pw +naciencia.ml +nacion.com.mx +nada.email +nada.ltd +nadailahmed.cloud +nadajoa.com +nadalaktywne.pl +nadaone.fun +nadcpexexw.pl +nadeoskab.igg.biz +nadinealexandra.art +nadinechandrawinata.art +nadmorzem.com +nadrektor4.pl +nadrektor5.pl +nadrektor6.pl +nadrektor7.pl +nadrektor8.pl +nafilllo.ga +nafko.cf +nafrem3456ails.com +nafxo.com +nagamems.com +nagapkqq.biz +nagapkqq.info +nagapokerqq.live +nagarata.com +naghini.cf +naghini.ga +naghini.gq +naghini.ml +nagi.be +nahcek.cf +nahcekm.cf +nahetech.com +nahhakql.xyz +nahsdfiardfi.ga +nai-tech.com +naiditceo.ga +naildiscount24.de +nails111.com +nailsmasters.ru +naim.mk +naipeq.com +naipode.ga +naisey.store +naiveyuliandari.biz +najko.com +najlakaddour.com +najlepszehotelepl.net.pl +najlepszeprzeprowadzki.pl +najpierw-masa.pl +najstyl.com +najverea.ga +naka.edu.pl +nakaan.com +nakam.xyz +nakammoleb.xyz +nakedlivesexcam.com +nakedtruth.biz +nakee.com +nakiuha.com +nakrutkalaykov.ru +nalafx.com +nalevo.xyz +naligi.ga +nalim.shn-host.ru +nalquitwen.ga +nalrini.ga +nalrini.ml +nalsci.com +nalsdg.com +naluzotan.com +nalwan.com +nam.su +namail.com +namakuirfan.com +nambi-nedv.ru +nameaaa.myddns.rocks +namefake.com +namemail.xyz +namemerfo.co.pl +namemerfo.com +namenan.me +nameofname.pw +nameofpic.org.ua +namepicker.com +nameplanet.com +nameprediction.com +namer17.freephotoretouch.com +nameshirt.xyz +namesloz.com +namesloz.site +namestal.com +namevn.fun +namewok.com +namilu.com +namina.com +namirapp.com +namkr.com +namlaks.com +namnerbca.com +namorandoarte.com +namtruong318.com +namunathapa.com.np +namuwikiusercontent.com +nan.us.to +nanana.uk +nanatha.academy +nanbianshan.com +nancypen.com +nando1.com +nangspa.vn +nanividia.art +nannegagne.online +nanofielznan3s5bsvp.cf +nanofielznan3s5bsvp.ga +nanofielznan3s5bsvp.gq +nanofielznan3s5bsvp.ml +nanofielznan3s5bsvp.tk +nanonym.ch +nanopools.info +nanopoolscore.online +nanoskin.vn +nanrosub.ga +nansyiiah.xyz +naobk.com +naogaon.gq +naoki51.investmentweb.xyz +naoki54.alphax.site +naoki70.funnetwork.xyz +napalm51.cf +napalm51.flu.cc +napalm51.ga +napalm51.gq +napalm51.igg.biz +napalm51.ml +napalm51.nut.cc +napalm51.tk +napalm51.usa.cc +nape.net +napj.com +naplesmedspa.com +napmails.com +napmails.online +napoleonides.xyz +naprawa-wroclaw.xaa.pl +naprb.com +napthe89.net +naptien365.com +naqulu.com +narara.su +narcoboy.sbs +nares.de +narjwoosyn.pl +narrereste.ml +narsaab.site +narsan.ru +narutogamesforum.xyz +nasamdele.ru +nascde.space +nascimento.com +nash.ml +nashvilledaybook.com +nashvillestreettacos.com +nasibasi.online +nasinyang.cf +nasinyang.ga +nasinyang.gq +nasinyang.ml +nasiputih.xyz +naskotk.cf +naskotk.ga +naskotk.ml +naslazhdai.ru +nasmis.com +nasrulfazri.com +nasskar.com +nassryyy78.lat +nastroykalinuxa.ru +nastyx.com +naszelato.pl +nat4.us +natachai.me +natachasteven.com +natafaka.online +nataliesarah.art +natashaferre.com +nate.co.kr +nathanielenergy.com +nati.com +national-escorts.co.uk +nationalchampionshiplivestream.com +nationalgardeningclub.com +nationalgerometrics.com +nationallists.com +nationalsalesmultiplier.com +nationalspeedwaystadium.co +nationwidedebtconsultants.co.uk +nativityans.ru +natmls.com +natuanal.com +natural-helpfored.site +naturalious.com +naturalnoemylo.ru +naturalsrs.com +naturalstonetables.com +naturalstudy.ru +naturalwebmedicine.net +naturazik.com +naturecoastbank.com +natureglobe.pw +naturewild.ru +naturos.xyz +natweat.com +natxt.com +naudau.com +naufra.ga +naufra.tk +naughty-blog.com +naughtyrevenue.com +nauka999.pl +nautonk.com +naux.com +navalcadets.com +navendazanist.net +naver-mail.com +naver-mail.kr +naverapp.com +naverly.com +navermail.kr +navermx.com +navientlogin.net +naviosun-ca.info +navmanwirelessoem.com +navyhodnye.ru +navyrizkytavania.art +nawa.lol +nawe-videohd.ru +nawforum.ru +nawideti.ru +nawis.online +nawmin.info +nawny.com +naxamll.com +naxx.dev +nayiye.xyz +naylonksosmed.com +naymedia.com +naymeo.com +naymio.com +nayobok.net +nazberilsuleyman.cfd +nazcaventures.com +nazimail.cf +nazimail.ga +nazimail.gq +nazimail.ml +nazimail.tk +nazuboutique.site +nazyno.com +nazzmail.com +nb.sympaico.ca +nb8qadcdnsqxel.cf +nb8qadcdnsqxel.ga +nb8qadcdnsqxel.gq +nb8qadcdnsqxel.ml +nb8qadcdnsqxel.tk +nba.emlpro.com +nbabasketball.info +nbacheap.com +nbalakerskidstshirt.info +nbc-sn.com +nbcutelemundoent.com +nbfd.com +nbhealthcare.com +nbho7.anonbox.net +nbhsssib.fun +nbmbb.com +nbnb88.com +nbnvcxkjkdf.ml +nbnvcxkjkdf.tk +nbny.com +nbobd.com +nbobd.store +nbox.lv +nbox.notif.me +nboxwebli.eu +nbpwvtkjke.pl +nbrst7e.top +nbseomail.com +nbspace.us +nbva.com +nbvojcesai5vtzkontf.cf +nbx.freeml.net +nbyongheng.com +nbzmr.com +nc.freeml.net +nc.webkrasotka.com +nca.dropmail.me +ncaaomg.com +ncced.org +nccedu.media +nccedu.team +ncco.de +nccsportsmed.com +ncdainfo.com +nce2x8j4cg5klgpupt.cf +nce2x8j4cg5klgpupt.ga +nce2x8j4cg5klgpupt.gq +nce2x8j4cg5klgpupt.ml +nce2x8j4cg5klgpupt.tk +ncedetrfr8989.cf +ncedetrfr8989.ga +ncedetrfr8989.gq +ncedetrfr8989.ml +ncedetrfr8989.tk +ncewy646eyqq1.cf +ncewy646eyqq1.ga +ncewy646eyqq1.gq +ncewy646eyqq1.ml +ncewy646eyqq1.tk +nchl.freeml.net +nciblogs.com +ncid.xyz +ncien.com +ncinema3d.ru +nclean.us +ncnmedia.net +nco.emlpro.com +nconrivnirn.site +ncordlessz.com +ncov.office.gy +ncpine.com +ncsar.com +ncsoft.top +ncstorms.com +nctime.com +nctm.de +nctuiem.xyz +ncuudwtnog.ga +ncyq5.anonbox.net +nd.emltmp.com +ndaraiangop2wae.buzz +ndarseyco.com +ndbt.click +nddgxslntg3ogv.cf +nddgxslntg3ogv.ga +nddgxslntg3ogv.gq +nddgxslntg3ogv.ml +nddgxslntg3ogv.tk +ndek4g0h62b.cf +ndek4g0h62b.ga +ndek4g0h62b.gq +ndek4g0h62b.ml +ndek4g0h62b.tk +ndemail.ga +ndenwse.com +ndeooo.com +ndeooo.xyz +ndfakemail.ga +ndfbmail.ga +ndgbmuh.com +ndhello.us +ndid.com +ndiety.com +ndif8wuumk26gv5.cf +ndif8wuumk26gv5.ga +ndif8wuumk26gv5.gq +ndif8wuumk26gv5.ml +ndif8wuumk26gv5.tk +ndinstamail.ga +ndmail.cf +ndmlpife.com +ndp.laste.ml +ndptir.com +ndrahosting.com +nds8ufik2kfxku.cf +nds8ufik2kfxku.ga +nds8ufik2kfxku.gq +nds8ufik2kfxku.ml +nds8ufik2kfxku.tk +ndut.pro +ndv.dropmail.me +ndx.emlpro.com +ndxgokuye98hh.ga +ndxmails.com +ne-neon.info +ne-rp.online +ne.emltmp.com +neaeo.com +neajazzmasters.com +nealheardtrainers.com +nearify.com +neatstats.com +nebbo.online +nebltiten0p.cf +nebltiten0p.gq +nebltiten0p.ml +nebltiten0p.tk +necalin.com +necesce.info +necessaryengagements.info +neckandbackmassager.com +necklacebeautiful.com +necklacesbracelets.com +necktai.com +neclipspui.com +nectarweb.com +necub.com +necwood.com +nedal2.tech +nedalalia.cloud +nedalalian.shop +nedalmhm.cloud +nedalned.cloud +nedalneda.cloud +nedaned.cloud +nedapa.cloud +nederchan.org +nedevit1.icu +nedf.de +nedistore.com +nedmoh.cloud +nedorogaya-mebel.ru +nedoz.com +nedrk.com +nedt.com +nedt.net +nedtwo.cloud +neeahoniy.com +need-mail.com +need53.sbs +needaprint.co.uk +needidoo.org.ua +needlegqu.com +neeman-medical.com +neenahdqgrillchill.com +neewho.pl +nefacility.com +neffsnapback.com +nefyp.com +negated.com +neghtlefi.com +negociodigitalinteligente.com +negociosyempresas.info +negrocavallo.pl +negrofilio.com +nehi.info +nehomesdeaf.org +nehzlyqjmgv.auto.pl +neibu306.com +neibu963.com +neic.com +neixos.com +nejamaiscesser.com +neko2.net +nekochan.fr +nekomi.net +nekopoker.com +nekos96.xyz +nekosan.uk +nel21.cc +nel21.me +nelcoapps.com +nellplus.club +nem.emlhub.com +nemhgjujdj76kj.tk +nemobaby.store +nenekbet.com +nenengsaja.cf +nenianggraeni.art +neoapkcc.com +neobkhodimoe.ru +neoconstruction.net +neocorp2000.com +neoeon.com +neoghost.com +neokidesu.click +neomailbox.com +neon.waw.pl +neopetcheats.org +neore.xyz +neosaumal.com +neosilico.com +neoski.tk +neosstudy.work +neotlozhniy-zaim.ru +neotrade.ru +neoven.us +nepal-nedv.ru +nepging.com +nephisandeanpanflute.com +nepnut.com +neppi.site +neptun-pro.ru +nepwk.com +neq.us +neragez.com +nerboll.com +nerd.blatnet.com +nerd.click +nerd.cowsnbullz.com +nerd.lakemneadows.com +nerd.oldoutnewin.com +nerd.poisedtoshrike.com +nerdmail.co +nerds4u.com.au +nereida.odom.marver-coats.xyz +neremail.com +nerfgunstore.com +nerftyui.online +nerg.xyz +nerimosaja.cf +nerpmail.com +nerrys.com +nerve.bthow.com +nervmich.net +nervtmich.net +nesine.fun +nesko.world +neslihanozmert.com +nesopf.com +nespf.com +nespj.com +nespressopixie.com +nestle-usa.cf +nestle-usa.ga +nestle-usa.gq +nestle-usa.ml +nestle-usa.tk +nestor99.co.uk +nestspace.co +nestvia.com +nesy.pl +net-led.com.pl +net-list.com +net-solution.info +net191.com +net1mail.com +net2mail.top +net3mail.com +net4k.ga +net8mail.com +netaccessman.com +netarchive.buzz +netcol.club +netcom.ws +netcombase.com +netcook.org +netctrcon.live +netdragon.us +netflix.ebarg.net +netflixs.redirectme.net +netflixvip.xyz +netflixweb.com +netfxd.com +netgas.info +netgia.com +netguide.com +nethermon4ik.ru +nethers.store +nethotmail.com +nethubmail.net +netinta.com +netiptv.site +netjex.xyz +netjook.com +netkao.xyz +netkiff.info +netmail-pro.com +netmail.tk +netmail3.net +netmail8.com +netmail9.com +netmails.com +netmails.info +netmails.net +netmakente.com +netmon.ir +netn.top +netntv.shop +netoiu.com +netolsteem.ru +netone.com +netpaper.eu +netpaper.ml +netplixprem.xyz +netprfit.com +netricity.nl +netris.net +netscapezs.com +netscspe.net +netsolutions.top +netstreamcore.com +netterchef.de +nettmail.com +nettrosrest.ga +netu.site +netuygun.online +netvaiclus.ga +netvemovie.com +netven.site +netveplay.com +netviewer-france.com +network-loans.co.uk +network-source.com +networkapps.info +networkbio.com +networkcabletracker.com +networkcollection.com +networker.pro +networkofemail.com +networks-site-real.xyz +networksfs.com +networksmail.gdn +netzidiot.de +netzwerk-industrie.de +neue-dateien.de +neujahrsgruesse.info +neujajunc.ga +neundetav.ga +neuraxo.com +neuro-safety.net +neurobraincenter.com +neurosystem-cool.ru +neurotransmitter.store +neusp.loan +neutralx.com +neutronmail.gdn +neuvrjpfdi.ga +nevada-nedv.ru +nevadaibm.com +nevadasunshine.info +nevanata.com +never.ga +neverapart.site +neverbox.com +neverbox.net +neverbox.org +neverenuff.com +neverit.tk +nevermail.de +nevermorsss1.ru +nevermorsss3.ru +nevermorsss5.ru +nevermosss7.ru +nevernverfsa.org.ua +neverthisqq.org.ua +neverthought.lol +nevertmail.cf +nevertoolate.org.ua +neverttasd.org.ua +neveu.universallightkeys.com +nevyxus.com +new-beats-by-dr-dre.com +new-belstaff-jackets.com +new-money.xyz +new-paulsmithjp.com +new-purse.com +new.blatnet.com +new.cowsnbullz.com +new.emailies.com +new.freeml.net +new.lakemneadows.com +newaddr.com +newagemail.com +newairmail.com +newbalanceretail.com +newbat.site +newbelstaff-jackets.com +newbpotato.tk +newbreedapps.com +newbridesguide.com +newburlingtoncoatfactorycoupons.com +newcanada-goose-outlet.com +newcentglos.ga +newchristianlouboutinoutletfr.com +newchristianlouboutinshoesusa.us +newcupon.com +newdailys.com +newdawnnm.xyz +newdaykg.tk +newdaytrip.site +newdesigner-watches.info +newdestinyhomes.com +newdiba.site +newdigitalmediainc.com +newdo.site +newdrw.com +newe-mail.com +neweffe.shop +newerasolutions.co +newestnike.com +newestpumpshoes.info +newfilm24.ru +newfishingaccessories.com +newforth.com +newgmaill.com +newgmailruner.com +newgoldkey.com +newhavyrtda.com +newhdblog.com +newhoanglantuvi.com +newhomemaintenanceinfo.com +newhopebaptistaurora.com +newhorizons.gq +newhousehunters.net +newhub.site +newideasfornewpeople.info +newjetsadabet.com +newjordanshoes.us +newkarmalooppromocodes.com +newkiks.eu +newleafwriters.com +newlifelogs.com +newljeti.ga +newlove.com +newmail.top +newmailsc.com +newmailss.co.cc +newmarketingcomapny.info +newmedicforum.com +newmesotheliomalaywers.com +newmonsteroutlet2014.co.uk +newmore.tk +newmovietrailers.biz +newmuzon.ru +newnedal.cloud +newness.info +newnime.com +newnxnsupport.ru +newo.site +newob.site +newones.com +newpk.com +newpochta.com +newportbarksnrec.com +newportbeachsup.com +newportrelo.com +newroc.info +news-online24.info +news-videohd.ru +news.mamode-amoi.fr +news3.edu +newsagencybound.online +newsagencydirection.online +newsagencyimpulse.online +newsagencypost.online +newsairjordansales.com +newscenterdecatur.com +newscoin.club +newscorp.cf +newscorp.gq +newscorp.ml +newscorpcentral.com +newscup.cf +newsdailynation.com +newsdubi.ga +newsdvdjapan.com +newsetup.site +newsforhouse.com +newsforus24.info +newsgetz.com +newsgolfjapan.com +newshbo.com +newshnb.com +newshourly.net +newshubz.tk +newsinhouse.com +newsinyourpocket.com +newsitems.com +newsm.info +newsmag.us +newsminia.site +newsms.pl +newsomerealty.com +newsonlinejapan.com +newsonlinejp.com +newsote.com +newsouting.com +newspro.fun +newssites.com +newsslimming.info +newssolor.com +newssourceai.com +newssportsjapan.com +newstantre.cf +newstantre.ga +newstarescorts.com +newstekno.review +newstyle-handbags.info +newstylecamera.info +newstylehandbags.info +newstylescarves.info +newsusfun.com +newswimwear2012.info +newtakemail.ml +newtap.site +newtempmail.com +newtestik.co.cc +newtimespop.com +newtivilebonza.com +newtmail.com +newtocode.site +newtogel.com +newtonius.net +newtours.ir +newtrea.com +newtuber.info +newuggoutlet-shop.com +newviral.fun +newvproducts.store +newwaysys.com +newx6.info +newyearfreepas.ws +newyeargreetingcard.com +newyork-divorce.org +newyorkinjurynews.com +newyorkmetro5.top +newyorkskyride.net +newyoutube.ru +newzbling.com +newzeroemail.com +newzgraph.net +nexadraw.com +nexhibit.com +nexhost.nl +nexofinance.us +nexral.com +nexshinz.app +nexsolutions.com +next-mail.info +next-mail.online +next.emailies.com +next.maildin.com +next.marksypark.com +next.net +next.oldoutnewin.com +next.ovh +next.umy.kr +next1.online +next2cloud.info +next5.online +nextag.com +nextbox.ir +nextcase.foundation +nextemail.in +nextemail.net +nextfash.com +nextgenadmin.com +nextgenmail.cf +nextmail.in +nextmail.info +nextstopvalhalla.com +nextsuns.com +nexttonorm.com +nexttrend.site +nexwp.com +nexxterp.com +neystipan.ga +nez.emltmp.com +nezdiro.org +nezid.com +nezuko.cyou +nezumi.be +nezzart.com +nf2v9tc4iqazwkl9sg.cf +nf2v9tc4iqazwkl9sg.ga +nf2v9tc4iqazwkl9sg.ml +nf2v9tc4iqazwkl9sg.tk +nf38.pl +nf5pxgobv3zfsmo.cf +nf5pxgobv3zfsmo.ga +nf5pxgobv3zfsmo.gq +nf5pxgobv3zfsmo.ml +nf5pxgobv3zfsmo.tk +nfaca.org +nfamilii2011.co.cc +nfast.net +nfd.freeml.net +nfdi.yomail.info +nfeconsulta.net +nffq.emlhub.com +nfgt.mimimail.me +nfhtbcwuc.pl +nfirmemail.com +nfkeepingz.com +nfl.name +nfl49erssuperbowlshop.com +nflbettings.info +nflfootballonlineforyou.com +nfljerseyscool.com +nfljerseysussupplier.com +nflnewsforfun.com +nflravenssuperbowl.com +nflravenssuperbowlshop.com +nflshop112.com +nfnorthfaceoutlet.co.uk +nfnov28y9r7pxox.ga +nfnov28y9r7pxox.gq +nfnov28y9r7pxox.ml +nfnov28y9r7pxox.tk +nfo.emltmp.com +nforinpo.ga +nforunen.ga +nfovhqwrto1hwktbup.cf +nfovhqwrto1hwktbup.ga +nfovhqwrto1hwktbup.gq +nfovhqwrto1hwktbup.ml +nfovhqwrto1hwktbup.tk +nfprince.com +nfs-xgame.ru +nfstripss.com +nfw.freeml.net +nfxa.dropmail.me +nfxr.ga +ng.emlhub.com +ng.spymail.one +ng9rcmxkhbpnvn4jis.cf +ng9rcmxkhbpnvn4jis.ga +ng9rcmxkhbpnvn4jis.gq +ng9rcmxkhbpnvn4jis.ml +ng9rcmxkhbpnvn4jis.tk +ngab.email +ngancuk.online +ngaydi.xyz +ngem.net +ngeme.me +ngemusic.academy +ngentodgan-awewe.club +ngentot.info +ngf1.com +ngg1bxl0xby16ze.cf +ngg1bxl0xby16ze.ga +ngg1bxl0xby16ze.gq +ngg1bxl0xby16ze.ml +ngg1bxl0xby16ze.tk +ngh.emltmp.com +nghacks.com +nghg.mimimail.me +nghienplus.io.vn +nginbox.tk +nginxphp.com +ngk.laste.ml +ngo1.com +ngobram.my.id +ngochuyen.xyz +ngocminhtv.com +ngocsita.com +ngolearning.info +ngontol.com +ngopy.com +ngowscf.pl +ngqg7.anonbox.net +ngt7nm4pii0qezwpm.cf +ngt7nm4pii0qezwpm.ml +ngt7nm4pii0qezwpm.tk +ngtierlkexzmibhv.ga +ngtierlkexzmibhv.ml +ngtierlkexzmibhv.tk +ngtix.com +ngtndpgoyp.ga +nguhoc.xyz +nguyendanhkietisocial.com +nguyenduycatp.click +nguyenduyphong.tk +nguyenlieu24h.com +nguyentienloi.email +nguyentinhblog.com +nguyentuki.com +nguyenusedcars.com +nguyenvuquocanh.com +nguyenxuandathd1994.win +ngvo.emltmp.com +ngw.emlpro.com +nh3.ro +nhacai88.online +nhadatgiaviet.com +nhanquafreefire.net +nhanqualienquan.online +nhatdinhmuaduocxe.info +nhatu.com +nhaucungtui.com +nhazmp.us +nhb6h.anonbox.net +nhdental.co +nhe.emlpro.com +nhgt.com +nhi9ti90tq5lowtih.cf +nhi9ti90tq5lowtih.ga +nhi9ti90tq5lowtih.gq +nhi9ti90tq5lowtih.tk +nhifswkaidn4hr0dwf4.cf +nhifswkaidn4hr0dwf4.ga +nhifswkaidn4hr0dwf4.gq +nhifswkaidn4hr0dwf4.ml +nhifswkaidn4hr0dwf4.tk +nhisystem1.org +nhjxwhpyg.pl +nhk.emlhub.com +nhmi1.com +nhmicrosoft.com +nhmty.com +nhmvn.com +nhn.edu.vn +nhoopmail.store +nhotv.com +nhrh.emlhub.com +nhs0armheivn.cf +nhs0armheivn.ga +nhs0armheivn.gq +nhs0armheivn.ml +nhs0armheivn.tk +nhserr.com +nhtlaih.com +nhuchienthang.com +nhungdang.xyz +nhuthi.design +nhz.dropmail.me +ni.spymail.one +niach.ga +niachecomp.ga +niang-sfx.biz +niassanationalreserve.org +niatingsun.tech +niatlsu.com +niback.com +nic.aupet.it +nic.com.au +nic58.com +nice-4u.com +nice-tits.info +nicebad.com +nicebeads.biz +nicecatbook.site +nicecatfiles.site +nicecattext.site +nicecook.top +nicedirbook.site +nicedirbooks.site +nicedirtext.site +nicedirtexts.site +nicefreshbook.site +nicefreshtexts.site +nicegarden.us +nicegashs.info +nicegirl5.me +nicejoke.ru +nicelibbook.site +nicelibbooks.site +nicelibfiles.site +nicelibtext.site +nicelibtexts.site +nicelistbook.site +nicelistbooks.site +nicelistfile.site +nicelisttext.site +nicelisttexts.site +nicely.info +nicemail.cc +nicemail.online +nicemail.pro +nicemebel.pl +nicement.com +niceminute.com +nicemonewer.store +nicemotorcyclepart.com +nicenewfile.site +nicenewfiles.site +nicenewstuff.site +niceroom2.eu +nicespotfiles.site +nicespotstuff.site +nicespottext.site +niceteeshop.com +nicewoodenbaskets.com +nicext.com +niceyou06.site +nichaoxing.cc +nichenetwork.net +nichess.cf +nichess.ga +nichess.gq +nichess.ml +nichole.essence.webmailious.top +nick-ao.com +nickbizimisimiz.ml +nickloswebdesign.com +nickmxh.com +nicknassar.com +nickolis.com +nickrizos.com +nickrosario.com +nicloo.com +nicnadya.com +nicoimg.com +nicolabs.info +nicolaseo.fr +nicoleturner.xyz +nicolhampel.com +niconiconii.xyz +nicoric.com +nicton.ru +nidalwsedd.tech +nidama.ga +nideno.ga +nidokela.biz.st +nie-podam.pl +niede.de +niegolewo.info +nieise.com +niekie.com +niemail.com +niemozesz.pl +niepodam.pl +nieworld.website +nifect.com +nifone.ru +nigdynieodpuszczaj.pl +nigeria-nedv.ru +nigge.rs +nigget.gq +niggetemail.tk +night.monster +nightfood.studio +nightfunmore.online.ctu.edu.gr +nightmedia.cf +nightorb.com +nihennaisi188.cc +nihongames.pl +niibb.com +niickel.us +nijakvpsx.com +nijmail.com +nikart.pl +nikata.fun +nike-air-rift-shoes.com +nike-airmax-chaussures.com +nike-airmaxformen.com +nike-nfljerseys.org +nike.coms.hk +nikeairjordansfrance.com +nikeairjp.com +nikeairmax1zt.co.uk +nikeairmax90sales.co.uk +nikeairmax90ukzt.co.uk +nikeairmax90usa.com +nikeairmax90zr.co.uk +nikeairmax90zt.co.uk +nikeairmax90zu.co.uk +nikeairmaxonline.net +nikeairmaxskyline.co.uk +nikeairmaxvipus.com +nikeairmaxzt.co.uk +nikefreerunshoesuk.com +nikehhhh.com +nikehigh-heels.info +nikejashoes.com +nikejordansppascher.com +nikenanjani.art +nikepopjp.com +nikerunningjp.com +nikesalejp.com +nikesalejpjapan.com +nikeshoejapan.com +nikeshoejp.org +nikeshoesoutletforsale.com +nikeshoesphilippines.com +nikeshox4sale.com +nikeskosalg.com +niketexanshome.com +niketrainersukzt.co.uk +nikihiklios.gr +nikiliosiufe.de +nikkifenton.com +nikkikailey.chicagoimap.top +niko313.com +nikoiios.gr +nikojii.com +nikola-tver.ru +nikon-coolpixl810.info +nikoncamerabag.info +nikora.biz.st +nikora.fr.nf +nikosiasio.gr +nikossf.gr +nikz.spymail.one +nilazan.space +nilechic.store +nilocaserool.tk +nilyazilim.com +nimadir.com +nimfa.info +nimiety.xyz +nimilite.online +nimilite.shop +nimrxd.com +ninaanwar.art +ninafashion.shop +ninakozok.art +nincsmail.com +nincsmail.hu +nine.emailfake.ml +nine.fackme.gq +ninepacman.com +ninewestbootsca.com +ningame.com +ninja-mail.com +ninja0p0v3spa.ga +ninjabinger.com +ninjachibi.finance +ninjadoll.international +ninjadoll.org +ninjagg.com +nio.spymail.one +niohotel.ir +nionic.com +niotours.ir +nipef.com +nipponian.com +niprack.com +niq.laste.ml +niqn.freeml.net +nirapatta24.com +nisantasiclinic.com +nisc.me +niseko.be +niskaratka.eu +niskopodwozia.pl +nissa.com.mx +nissan370zparts.com +nissanleaf.club +nitricoxidesupplementshq.com +nitricpowerreview.org +nitroshine.xyz +nitynote.com +nitza.ga +niubiba.lol +nivalust.com +nivy.com +niwalireview.net +niwghx.com +niwghx.online +niwise.life +niwl.net +niwod.com +nixad.com +nixemail.net +nixonbox.com +niydomen897.ga +niydomen897.gq +niydomen897.ml +niydomen897.tk +njamf.org +njaw.laste.ml +njc65c15z.com +nje.laste.ml +njelarubangilan.cf +njelarucity.cf +njetzisz.ga +njeu.mimimail.me +njhdes.xyz +njjhjz.com +njksc.spymail.one +njnh.emlhub.com +njpsepynnv.pl +njr.freeml.net +njrtu37y872334y82234234.unaux.com +njsco.anonbox.net +njtec.com +njuk.emlhub.com +njxsquiltz.com +njzksdsgsc.ga +nk.emltmp.com +nkads.com +nkcompany.ru +nkcs.ru +nkdx.freeml.net +nkebiu.xyz +nkgursr.com +nkhfmnt.xyz +nkiehjhct76hfa.ga +nkjdgidtri89oye.gq +nkln.com +nkmq7i.xyz +nkmx8h.xyz +nkn.spymail.one +nknq65.pl +nko.kr +nkqgpngvzg.pl +nkshdkjshtri24pp.ml +nktechnical.tech +nktltpoeroe.cf +nkvtkioz.pl +nkwx.laste.ml +nl.edu.pl +nl.szucsati.net +nladsgiare.shop +nlbassociates.com +nlch.mimimail.me +nllessons.com +nlmdatabase.org +nlnr.freeml.net +nlopenworld.com +nlpreal-vn-2299908.yaconnect.com +nls.emltmp.com +nm.beardedcollie.pl +nm123.com +nm5905.com +nm7.cc +nmagazinec.com +nmail.cf +nmailtop.ga +nmailv.com +nmaller.com +nmameraca.com +nmappingqk.com +nmarticles.com +nmbbmnm2.info +nmc.spymail.one +nmcb.cc +nmemacara.com +nmemail.top +nmemail.xyz +nmep.yomail.info +nmfrvry.cf +nmfrvry.ga +nmfrvry.gq +nmfrvry.ml +nmhnveyancing.online +nmhnveyancing.store +nmidas.online +nmo.spymail.one +nmpkkr.cf +nmpkkr.ga +nmpkkr.gq +nmpkkr.ml +nmqyasvra.pl +nmrefere.com +nms3.at +nmske.website +nmsr.com +nmsu.com +nmsy83s5b.pl +nmxjvsbhnli6dyllex.cf +nmxjvsbhnli6dyllex.ga +nmxjvsbhnli6dyllex.gq +nmxjvsbhnli6dyllex.ml +nmxjvsbhnli6dyllex.tk +nmzs.emltmp.com +nn.emlhub.com +nn2.pl +nn46gvcnc84m8f646fdy544.tk +nn5ty85.cf +nn5ty85.ga +nn5ty85.gq +nn5ty85.tk +nnacell.com +nncncntnbb.tk +nnejakrtd.pl +nnggffxdd.com +nngok.site +nnh.com +nnjiiooujh.com +nnnnnn.com +nnot.net +nnoway.ru +nntcesht.com +nnvl.com +nnzzy.com +no-dysfonction.com +no-more-hangover.tk +no-one.cyou +no-spam.hu +no-spam.ws +no-spammers.com +no-trash.ru +no-ux.com +no-vax.cf +no-vax.ga +no-vax.gq +no-vax.ml +no-vax.tk +no.blatnet.com +no.emlpro.com +no.lakemneadows.com +no.marksypark.com +no.oldoutnewin.com +no.ploooop.com +no.tap.tru.io +no.yomail.info +no07.biz +no11.xyz +no1but.icu +no2maximusreview.org +no2paper.net +noahfleisher.com +noar.info +noauu.com +nobilne3oo.website +nobinal.site +nobitcoin.net +noblelord.com +noblemail.bid +nobleperfume.info +noblepioneer.com +nobugmail.com +nobulk.com +nobullpc.com +nobuma.com +noc0szetvvrdmed.cf +noc0szetvvrdmed.ga +noc0szetvvrdmed.gq +noc0szetvvrdmed.ml +noc0szetvvrdmed.tk +noc1tb4bfw.cf +noc1tb4bfw.ga +noc1tb4bfw.gq +noc1tb4bfw.ml +noc1tb4bfw.tk +noclegi0.pl +noclegiwsieci.com.pl +noclickemail.com +nocodewp.dev +nocp.ru +nocp.store +nocturnalresha.io +nocujunas.com.pl +nod03.ru +nod9d7ri.aid.pl +nodejs.uk +nodemon.peacled.xyz +nodeoppmatte.com +nodepositecasinous.com +nodesauce.com +nodezine.com +nodie.cc +nodnor.club +noduha.com +noe.prometheusx.pl +noe2fa.digital +noedgetest.space +noefa.com +noelia.meghan.ezbunko.top +noexpire.top +nofakeipods.info +nofaxpaydayloansin24hrs.com +nofbi.com +nofear.space +nofocodobrasil.tk +nofxmail.com +nogmailspam.info +nogueira2016.com +noicd.com +noidem.com +noidos.com +noifeelings.com +noig.laste.ml +noihse.com +noinfo.info +noisemails.com +noisyence.com +noiuihg2erjkzxhf.cf +noiuihg2erjkzxhf.ga +noiuihg2erjkzxhf.gq +noiuihg2erjkzxhf.ml +noiuihg2erjkzxhf.tk +noiybau.online +nokatmaroc.com +nokdot.com +nokia.redirectme.net +nokiahere.cf +nokiahere.ga +nokiahere.gq +nokiahere.ml +nokiahere.tk +nokiamail.cf +nokiamail.com +nokiamail.ga +nokiamail.gq +nokiamail.ml +noklike.info +nokorweb.com +nolanzip.com +nolemail.ga +nolettersbox.com +nolikeowi2.com +nolimemail.com.ua +nolimitbooks.site +nolimitfiles.site +nolog.email +nolog.network +nolopiok.baby +nolteot.com +nolvadex.website +nomad1.com +nomadhub.xyz +nomail.cf +nomail.ch +nomail.fr +nomail.ga +nomail.net +nomail.nodns.xyz +nomail.pw +nomail.top +nomail.xl.cx +nomail2me.com +nomailthankyou.com +nomame.site +nomes.fr.nf +nomeucu.ga +nominex.space +nomnomca.com +nomoremail.net +nomorespam.kz +nomorespamemails.com +nomotor247.info +nomrista.com +nomtool.info +nomylo.com +nonamecyber.org +nonameex.com +nonapkr.com +nonchalantresmita.biz +nondtenon.ga +none.cyou +noneso.site +nonetary.xyz +nonewanimallab.com +nongi.anonbox.net +nongmak.net +nongnue.com +nongvannguyen.com +nongzaa.cf +nongzaa.gq +nongzaa.ml +nongzaa.tk +nonicamy.com +nonise.com +nonlowor.ga +nonohairremovalonline.com +nonspam.eu +nonspammer.de +nonstop-traffic-formula.com +nontmita.ga +nonze.ro +noobf.com +noobsie.my.id +noobtoobz.com +noopala.club +noopala.online +noopala.store +noopala.xyz +noopept.store +nooploop.store +noopmail.com +noopmail.org +noopmail.org.bsmedia.vn +noorrafet.cloud +noorrafet.website +noorsaifi.website +noorwesam1.website +noosty.com +nootopics.tulane.edu +nootropicstudy.xyz +nop.emlpro.com +nopalzure.me +nopenopenope.com +nopino.com +noq7m.anonbox.net +noquierobasura.ga +noqulewa.com +noquviti.com +nor.spymail.one +norahoguerealestateagentbrokenarrowok.com +norbal.org +norcalenergy.edu +norcos.com +nordexexplosives.com +noref.in +noreply.fr +noreply.pl +norfolkquote.com +nori24.tv +norih.com +norkinaart.net +normandys.com +normcorpltd.com +noroasis.com +norquestassociates.com +norsa.com.br +norseforce.com +northandsouth.pl +northcmu.com +northdallas-plasticsurgeons.com +northdallashomebuyers.com +northeastern-electric.com +northemquest.com +northernbets.co +northernwicks.com +northernwinzhotelcasino.com +northface-down.us +northfaceeccheap.co.uk +northfaceonlineclearance.com +northfacesalejacketscouk.com +northfacesky.com +northfaceuka.com +northfaceusonline.com +northibm.com +northshorelaserclinic.com +northsixty.com +northstardev.me +northstardev.tech +northstardirect.co.uk +northweststeelart.com +northyorkdogwalking.com +norules.zone +norvasconlineatonce.com +norveg-nedv.ru +norwars.site +norwaycup.cf +norwegischlernen.info +norzflhkab.ga +noscabies.org +nose-blackheads.com +nosemail.com +noseycrazysumrfs5.com +nosh.ml +nospace.info +nospam.allensw.com +nospam.barbees.net +nospam.fr.nf +nospam.sparticus.com +nospam.thurstons.us +nospam.today +nospam.wins.com.br +nospam.ze.tc +nospam2me.com +nospam4.us +nospamdb.com +nospamfor.us +nospammail.bz.cm +nospammail.net +nospamme.com +nospammer.ovh +nospamthanks.info +nostockui.com +nostrabirra.com +nostrajewellery.xyz +not.cowsnbullz.com +not.lakemneadows.com +not.legal +not.ploooop.com +not0k.com +notable.de +notamail.xyz +notaproduction.com +notarymarketing.com +notaryp.com +notasitseems.com +notatempmail.info +notbooknotbuk.com +notboxletters.com +notchbox.info +notcuttsgifts.com +notdus.xyz +notebookercenter.info +notebooki.lv +notebookmail.top +notebookmerkezi.com +notebookware.de +notedns.com +notenation.com +notesapps.com +notherone.ca +nothingtoseehere.ca +notice-cellphone.club +notice-iphone.club +notif.me +notification-iphone.club +notion.work +notipr.com +notivsjt0uknexw6lcl.ga +notivsjt0uknexw6lcl.gq +notivsjt0uknexw6lcl.ml +notivsjt0uknexw6lcl.tk +notlettersmail.com +notmail.com +notmail.ga +notmail.gq +notmail.ml +notmailinator.com +notnote.com +notowany.pl +notregmail.com +notrelab.site +notrnailinator.com +notsharingmy.info +notua.com +notvn.com +nouf.emlhub.com +noumirasjahril.art +nountree.com +nourashop.com +nov-vek.ru +nova-entre.ga +novaeliza.art +novaemail.com +novagun.com +novaix.vn +novaopcj.icu +novartismails.com +novatiz.com +novelbowl.xyz +novemberdelta.myverizonmail.top +novembervictor.webmailious.top +novencolor.otsoft.pl +novensys.pl +novgorod-nedv.ru +novidadenobrasil.com +novosib-nedv.ru +novosti-pro-turizm.ru +novosti2019.ru +novostinfo.ru +novostroiki-moscow.ru +novpdlea.cf +novpdlea.ga +novpdlea.ml +novpdlea.tk +novstan.com +novusvision.net +now.im +now.mefound.com +now.oldoutnewin.com +now.ploooop.com +now.poisedtoshrike.com +now4you.biz +noway.emlpro.com +noway.pw +noways.ddns.net +nowbuyway.com +nowbuzzoff.com +nowcare.us +nowdigit.com +nowemail.ga +nowemailbox.com +nowena.site +nowfitpro.com +nowfixweb.com +nowhere.org +nowhex.com +nowhivehub.com +nowlike.com +nowmymail.com +nowmymail.net +nownaw.ml +nowoczesne-samochody.pl +nowoczesnesamochody.pl +nowpodbid.com +nowthatsjive.com +nowtopzen.com +nowwin3.com +nox.llc +noxanne.com +noxius.ltd +noyabrsk.me +noyp.fr.nf +noyten.info +nozamas.com +npaiin.com +npajjgsp.pl +npas.de +npfd.de +npfd.gq +npg.laste.ml +nphcsfz.pl +nphl.laste.ml +npo2.com +npofgo90ro.com +npoopmeee.site +npp.yomail.info +nproxi.com +nps.freeml.net +npsis.net +nputa.spymail.one +npv.kr +npwfnvfdqogrug9oanq.cf +npwfnvfdqogrug9oanq.ga +npwfnvfdqogrug9oanq.gq +npwfnvfdqogrug9oanq.ml +npwfnvfdqogrug9oanq.tk +npyez.anonbox.net +nq.emltmp.com +nqav95zj0p.kro.kr +nqcf.com +nqcialis.com +nqeq3ibwys0t2egfr.cf +nqeq3ibwys0t2egfr.ga +nqeq3ibwys0t2egfr.gq +nqeq3ibwys0t2egfr.ml +nqeq3ibwys0t2egfr.tk +nqhe2.anonbox.net +nql.yomail.info +nqmo.com +nqn.freeml.net +nqpf.laste.ml +nqse.yomail.info +nrb.dropmail.me +nrehi.com +nresponsea.com +nrets.anonbox.net +nrf.spymail.one +nrhskhmb6nwmpu5hii.cf +nrhskhmb6nwmpu5hii.ga +nrhskhmb6nwmpu5hii.gq +nrhskhmb6nwmpu5hii.ml +nrhskhmb6nwmpu5hii.tk +nrlord.com +nroc2mdfziukz3acnf.cf +nroc2mdfziukz3acnf.ga +nroc2mdfziukz3acnf.gq +nroc2mdfziukz3acnf.ml +nroc2mdfziukz3acnf.tk +nroeor.com +nrsje.online +nrsl.emltmp.com +nrsuk.com +nrwproperty.com +ns01.biz +ns2.vipmail.in +nsa.yomail.info +nsabdev.com +nsaking.de +nsamuy.buzz +nsandu.com +nsbwsgctktocba.cf +nsbwsgctktocba.ga +nsbwsgctktocba.gq +nsbwsgctktocba.ml +nsbwsgctktocba.tk +nscream.com +nsddourdneis.gr +nsdjr.online +nses.online +nsholidayv.com +nsja.com +nsk1vbz.cf +nsk1vbz.ga +nsk1vbz.gq +nsk1vbz.ml +nsk1vbz.tk +nsserver.org +nst-customer.com +nsvmx.com +nsvpn.com +nswgovernment.ga +nsxy.emlpro.com +nt-xp.click +nt3lj.anonbox.net +ntadalafil.com +ntalecom.net +ntb9oco3otj3lzskfbm.cf +ntb9oco3otj3lzskfbm.ga +ntb9oco3otj3lzskfbm.gq +ntb9oco3otj3lzskfbm.ml +ntb9oco3otj3lzskfbm.tk +ntdxx.com +ntdy.icu +ntdz.club +ntdz.icu +ntegelan.ga +nterdawebs.ga +nterfree.it +ntflx.store +nthmail.com +nthmessage.com +nthrl.com +nthrw.com +ntilboimbyt.ga +ntilsibi.ga +ntirrirbgf.pl +ntkdev.click +ntkworld.com +ntlhelp.net +ntllma3vn6qz.cf +ntllma3vn6qz.ga +ntllma3vn6qz.gq +ntllma3vn6qz.ml +ntllma3vn6qz.tk +ntlshopus.com +ntlword.com +ntlworkd.com +ntp.homes +ntrg.laste.ml +ntschools.com +ntservices.xyz +ntslink.net +ntspace.shop +ntt.gotdns.ch +ntub.cf +ntudofutluxmeoa.cf +ntudofutluxmeoa.ga +ntudofutluxmeoa.gq +ntudofutluxmeoa.ml +ntudofutluxmeoa.tk +ntutnvootgse.cf +ntutnvootgse.ga +ntutnvootgse.gq +ntutnvootgse.ml +ntutnvootgse.tk +ntuv4sit2ai.cf +ntuv4sit2ai.ga +ntuv4sit2ai.gq +ntuv4sit2ai.ml +ntuv4sit2ai.tk +ntviagrausa.com +ntwr.spymail.one +ntwteknoloji.com +ntx.freeml.net +ntxstream.com +nty5upcqq52u3lk.cf +nty5upcqq52u3lk.ga +nty5upcqq52u3lk.gq +nty5upcqq52u3lk.ml +nty5upcqq52u3lk.tk +nu588.com +nub3zoorzrhomclef.cf +nub3zoorzrhomclef.ga +nub3zoorzrhomclef.gq +nub3zoorzrhomclef.ml +nub3zoorzrhomclef.tk +nubenews.com +nubescontrol.com +nubotel.com +nubri.tw +nubyc.com +nucleant.org +nuclene.com +nucor.ru +nuctrans.org +nuda.pl +nude-vista.ru +nudecamsites.com +nudeluxe.com +nudinar.net +nuesond.com +nuevomail.com +nugaba.com +nugastore.com +nughtclab.com +nuh.emlpro.com +nujayar.com +nukahome.com +nuke.africa +nuliferecords.com +nullbox.info +nulledsoftware.com +nulledsoftware.net +nultxb.us +numanavale.com +number-inf-called.com +number-whoisit.com +numberfamily.us +numbersearch-id.com +numbersgh.com +numbersstationmovie.com +numbic.com +numerobo.com +numitas.ga +numllery.com +numweb.ru +nun.ca +nunudatau.art +nunung.cf +nunungcantik.ga +nunungnakal.ga +nunungsaja.cf +nuo.co.kr +nuo.kr +nuoifb.com +nuoivo.site +nuomnierutnn.store +nuox.eu.org +nuprice.co +nuqhvb1lltlznw.cf +nuqhvb1lltlznw.ga +nuqhvb1lltlznw.gq +nuqhvb1lltlznw.ml +nuqhvb1lltlznw.tk +nuqypepalopy.rawa-maz.pl +nur-fuer-spam.de +nurdea.biz +nurdea.com +nurdea.net +nurdead.biz +nurdeal.biz +nurdeal.com +nurdeas.biz +nurdeas.com +nurdintv.com +nurdsgetbad2015.com +nurfuerspam.de +nurkowania-base.pl +nurotohaliyikama.xyz +nurpharmacy.com +nursalive.com +nurseryschool.ru +nurslist.com +nurularifin.art +nurumassager.com +nusaas.com +nusy.dropmail.me +nut-cc.nut.cc +nut.cc +nut.favbat.com +nutcc.nut.cc +nutpa.net +nutrice.xyz +nutrijoayo.com +nutritiondrill.com +nutritionreporter.com +nutritionzone.net +nutrizin.com +nutrmil.site +nutroastingmachine.net +nutropin.in +nutrv.com +nuts2trade.com +nutsmine.com +nutte.com +nuttyjackstay.ml +nuv.laste.ml +nuvast.com +nuvi.site +nvapplelab.com +nvb467sgs.cf +nvb467sgs.ga +nvb467sgs.gq +nvb467sgs.ml +nvb467sgs.tk +nvbusinesschronicles.com +nvc-e.com +nvcc.org +nvcdv29.tk +nvce.net +nvenuntgeg.ga +nvetvl55.orge.pl +nvfpp47.pl +nvfxcrchef.com +nvgf3r56raaa.cf +nvgf3r56raaa.ga +nvgf3r56raaa.gq +nvgf3r56raaa.ml +nvgf3r56raaa.tk +nvhrw.com +nvi.spymail.one +nvision2011.co.cc +nvmetal.pl +nvn.one +nvnav.com +nvpdq3.site +nvtelecom.info +nvtmail.bid +nvuti.studio +nvuti.wine +nvv1vcfigpobobmxl.cf +nvv1vcfigpobobmxl.gq +nvv1vcfigpobobmxl.ml +nvysiy.xyz +nvyw.emltmp.com +nvzj.com +nw7cxrref2hjukvwcl.cf +nw7cxrref2hjukvwcl.ga +nw7cxrref2hjukvwcl.gq +nw7cxrref2hjukvwcl.ml +nw7cxrref2hjukvwcl.tk +nwak.com +nwb.dropmail.me +nwd6f3d.net.pl +nweal.com +nwesmail.com +nwexercisej.com +nwheart.com +nwhsii.com +nwldx.com +nwldx.net +nwpalace.com +nwpoa.info +nwufewum9kpj.gq +nwyf.dropmail.me +nwyf.mailpwr.com +nwytg.com +nwytg.net +nwyzoctpa.pl +nx-mail.com +nx.yomail.info +nx1.de +nx1.us +nxbrasil.net +nxdgrll3wtohaxqncsm.cf +nxdgrll3wtohaxqncsm.gq +nxdgrll3wtohaxqncsm.ml +nxdgrll3wtohaxqncsm.tk +nxeswavyk6zk.cf +nxeswavyk6zk.ga +nxeswavyk6zk.gq +nxeswavyk6zk.ml +nxeswavyk6zk.tk +nxgwr24fdqwe2.cf +nxgwr24fdqwe2.ga +nxgwr24fdqwe2.gq +nxgwr24fdqwe2.ml +nxgwr24fdqwe2.tk +nxmwzlvux.pl +nxpeakfzp5qud6aslxg.cf +nxpeakfzp5qud6aslxg.ga +nxpeakfzp5qud6aslxg.gq +nxpeakfzp5qud6aslxg.ml +nxpeakfzp5qud6aslxg.tk +nxraarbso.pl +nxtbroker.com +nxtseccld.tk +nxtsports.com +nxyf58.dropmail.me +ny.emltmp.com +ny7.me +nyahraegan.miami-mail.top +nyamail.com +nyanime.gq +nyasan.com +nyatempto.ga +nybella.com +nyc-pets.info +nyc.org +nyc2way.com +nyccaner.ga +nyccommunity.info +nycconstructionaccidentreports.com +nycemore.com +nycexercise.com +nychealthtech.com +nyerem.in +nyeschool.org +nyexercise.com +nyfeel.com +nyfhk.com +nyfinestbarbershop.com +nyflcigarettes.net +nyi.laste.ml +nyk.dropmail.me +nylonbrushes.org +nylworld.com +nymopyda.kalisz.pl +nyms.net +nyobase.com +nyoregan09brex.ml +nypato.com +nyrmusic.com +nytaudience.com +nyumail.com +nyusul.com +nyuuzyou.shop +nyv.emltmp.com +nywcmiftn8hwhj.cf +nywcmiftn8hwhj.ga +nywcmiftn8hwhj.gq +nywcmiftn8hwhj.ml +nywcmiftn8hwhj.tk +nyxstores.id +nz.emlpro.com +nzaif.com +nzan.freeml.net +nzbeez.com +nzdau19.website +nzgoods.net +nzhkmnxlv.pl +nzk.emltmp.com +nzmymg9aazw2.cf +nzmymg9aazw2.ga +nzmymg9aazw2.gq +nzmymg9aazw2.ml +nzmymg9aazw2.tk +nzntdc4dkdp.cf +nzntdc4dkdp.ga +nzntdc4dkdp.gq +nzntdc4dkdp.ml +nzntdc4dkdp.tk +nzpc.emlpro.com +nzrmedia.com +nzttrial.xyz +o-pizda.info +o-taka.ga +o.idigo.org +o.muti.ro +o.oai.asia +o.opendns.ro +o.polosburberry.com +o.spamtrap.ro +o.wp-viralclick.com +o029o.ru +o060bgr3qg.com +o0i.es +o0vcny.spymail.one +o13mbldrwqwhcjik.cf +o13mbldrwqwhcjik.ga +o13mbldrwqwhcjik.gq +o13mbldrwqwhcjik.ml +o13mbldrwqwhcjik.tk +o1mail.veinflower.veinflower.xyz +o2.co.com +o22.com +o22.info +o2mail.co +o2stk.org +o3enzyme.com +o3live.com +o3vgl9prgkptldqoua.cf +o3vgl9prgkptldqoua.ga +o3vgl9prgkptldqoua.gq +o3vgl9prgkptldqoua.ml +o3vgl9prgkptldqoua.tk +o473ufpdtd.ml +o473ufpdtd.tk +o4ht5.anonbox.net +o4tnggdn.mil.pl +o4zkthf48e46bly.cf +o4zkthf48e46bly.ga +o4zkthf48e46bly.gq +o4zkthf48e46bly.ml +o4zkthf48e46bly.tk +o5ikd.anonbox.net +o5o5.ru +o6.com.pl +o6o4h29rbcb.xorg.pl +o7edqb.pl +o7hoy.anonbox.net +o7i.net +o7t2auk8msryc.cf +o7t2auk8msryc.ga +o7t2auk8msryc.gq +o7t2auk8msryc.ml +o7t2auk8msryc.tk +o8t30wd3pin6.cf +o8t30wd3pin6.ga +o8t30wd3pin6.gq +o8t30wd3pin6.ml +o8t30wd3pin6.tk +o90.org +o90opri9e.com +oa.emlpro.com +oa5lqy.com +oabibleh.com +oadx.freeml.net +oafrem3456ails.com +oai.asia +oakfiling.com +oakleglausseskic.com +oakley-solbriller.com +oakleyfancyflea.com +oakleyoutlet.com +oakleysaleonline.net +oakleysaleonline.org +oakleysalezt.co.uk +oakleysonlinestore.net +oakleysonlinestore.org +oakleysoutletonline.com +oakleysoutletstore.net +oakleysoutletstore.org +oakleystorevip.com +oakleysunglasses-online.co.uk +oakleysunglassescheapest.org +oakleysunglassescheapsale.us +oakleysunglassesdiscountusw.com +oakleysunglassesoutletok.com +oakleysunglassesoutletstore.org +oakleysunglassesoutletstore.us +oakleysunglassesoutletzt.co.uk +oakleysunglassessoldes.com +oakleysunglasseszt.co.uk +oakleyusvip.com +oakon.com +oaksw.com +oal.emlhub.com +oalegro.pl +oallenlj.com +oalsp.com +oamail.com +oanbeeg.com +oanghika.com +oanhdaotv.net +oanhtaotv.com +oanhxintv.com +oaouemo.com +oarange.fr +oardkeyb.com +oasiscafedallas.com +oasiscentral.com +oaudienceij.com +oauth-vk.ru +oawk.spymail.one +oaxmail.com +oazv.net +ob.emltmp.com +ob5d31gf3whzcoo.cf +ob5d31gf3whzcoo.ga +ob5d31gf3whzcoo.gq +ob5d31gf3whzcoo.ml +ob5d31gf3whzcoo.tk +ob7eskwerzh.cf +ob7eskwerzh.ga +ob7eskwerzh.gq +ob7eskwerzh.ml +ob7eskwerzh.tk +obamaiscool.com +obatku.tech +obchod-podlahy.cz +obd2forum.org +obelisk4000.cf +obelisk4000.ga +obelisk4000.gq +obeliskenterprises.co +obemail.com +obermail.com +obesekisbianti.biz +obesityhelp.online +obet889.online +obfusko.com +obgsdf.site +obibike.net +obibok.de +obimail.com +obiq.xyz +obirah.com +obitel.com +obitoto.com +object.space +objectmail.com +objectuoso.com +oblivionchecker.com +obm.dropmail.me +obmail.com +obmw.ru +obo.kr +obobbo.com +oborudovanieizturcii.ru +oboymail.ga +obrezinim.ru +observantmarcelina.net +obserwatorbankowy.pl +obtechglobal.com +obtqadqunonkk1kgh.cf +obtqadqunonkk1kgh.ga +obtqadqunonkk1kgh.gq +obtqadqunonkk1kgh.ml +obtqadqunonkk1kgh.tk +obtrid.site +obtruncate.xyz +obuchenie-zarabotku.online +obumail.com +obuv-poisk.info +obverse.com +obviousdistraction.com +obviousidea.com +obvy.us +obxpestcontrol.com +obxstorm.com +obychnaya-zhenshchina.ru +obymbszpul.pl +obzor.link +obzor.wiki +ocassettew.com +occasics.site +occasionalmandiri.co +occumulately.site +occural.site +occurueh.com +oceancares.xyz +oceanicmail.gdn +oceansofwaves.com +ocenka-krym.ru +oceore.com +oceva.site +ocfindlocal.com +ocft.emlhub.com +ochkimoscow.ru +ochupella.ru +ocie.emlpro.com +ocigaht4.pc.pl +ocisd.com +ociun.com +ock.freeml.net +ocketmail.com +ocmail.com +ocnegib.ga +ocotbukanmain.club +ocourts.org +ocsonline.com +octa-sex.com +octavialogantgu.site +octbit.com +octovie.com +octowall.com +ocvc.yomail.info +ocvtv.site +ocxlpjmjug.ga +oczyszczalnie-sciekow24.pl +od21gwnkte.cf +od21gwnkte.ga +od21gwnkte.gq +od21gwnkte.ml +od21gwnkte.tk +od9b0vegxj.cf +od9b0vegxj.ga +od9b0vegxj.gq +od9b0vegxj.ml +od9b0vegxj.tk +odadingmangoleh.fun +odavissza.hu +odaymail.com +odbiormieszkania.waw.pl +odchudzanienit.mil.pl +odchudzedsfanie.pl +odd.bthow.com +oddhat.com +oddiyanadharmasanctuary.org +oddsbucket.com +oddwayinternational.com +ode.emlhub.com +odeask.com +odegda-optom.biz +odem.com +odemail.com +odemodiv.com +odgcrimes.com +odinaklassnepi.net +odinsklinge.com +odishakenduleaves.com +odixer.rzeszow.pl +odja.com +odkm.emlpro.com +odkn.com +odkrywcy.com +odnorazovoe.ru +odocu.site +odod.com +odoiiwo.com +odomail.com +odoo-consultant.com +odoo-demo.com +odoo-gold-partner.com +odoo-implementation.com +odoo-integration.com +odoo-partner-uk.com +odoo-partner-usa.com +odoo-tour.com +odooapplicationdevelopment.com +odoousa.com +odqykmt.pl +odrk.freeml.net +odseo.ru +odsniezanie.kera.pl +odsniezanienieruchomosci.pl +odszkodowanie-w-anglii.eu +odu-tube.ru +odulmail.com +oduyzrp.com +odvh.xyz +odysseybuilders.com +odzyskiwaniedanych.com +odzywkidorzes.eu +oe1f42q.com +oehrj.anonbox.net +oeioswn.com +oekakies.com +oelmjo.com +oem.spymail.one +oemkoreabrand.com +oemkoreafactory.com +oemmeo.com +oemsale.org +oemsoftware.eu +oemzpa.cf +oenek.com +oenii.com +oeoqzf.pl +oepia.com +oepik.anonbox.net +oepo.laste.ml +oeppeo.com +oerfa.org +oerpub.org +oertefae.tk +oes.laste.ml +oeshare.biz +oesw.com +oeu4sdyoe7llqew0bnr.cf +oeu4sdyoe7llqew0bnr.ga +oeu4sdyoe7llqew0bnr.gq +oeu4sdyoe7llqew0bnr.ml +oeu4sdyoe7llqew0bnr.tk +oewob.com +of.blatnet.com +of.cowsnbullz.com +of.emlpro.com +of.marksypark.com +ofacchecking.com +ofacer.com +ofanda.com +ofaw.com +ofce.emltmp.com +ofdow.com +ofdyn.com +ofenbuy.com +oferta.pl +oferty-domiporta.pl +oferty-kredytowe.com.pl +oferty-warszawa.pl +ofey.laste.ml +offensivealiwardhana.net +offerall.biz +offersale.info +offertapremium.com +offficepost.com +office-dateien.de +officebuhgaltera.pp.ua +officecombine.com +officeking.pl +officeliveusers.com +officemalaga.com +officemanagementinfo.com +officepoland.com.pl +offices.freshbreadcrumbs.com +officesupport.fun +officesupportonline.com +officetechno.ru +official-colehaan.com +official-louisvuitton.com +official-saints.com +official-tomsshoes.net +official.republican +official.site +official49erssportshop.com +officialairmaxprostore.com +officialairmaxsproshop.com +officialairmaxuksshop.com +officialfreerun.com +officialltoms-shoes.com +officialltoms-shoes.org +officialmailsites.com +officialmulberry.com +officialmulberryonline.com +officialnflbears.com +officialnflbearsshop.com +officialnflcoltsstore.com +officialnfldenverbroncoshop.com +officialnflfalconshoponline.com +officialnflgiantspromart.com +officialnflpackerspromart.com +officialnflsf49ershop.com +officialnflsteelersprostore.com +officialngentot.cf +officialngentot.ga +officialngentot.gq +officialngentot.ml +officialngentot.tk +officialouisvuittonsmart.com +officialpatriotssportshop.com +officialravenssportshop.com +officialravensuperbowlshop.com +officialredbottomsshop.com +officialreversephonelookupsites.com +officialsf49erssuperbowlshop.com +officialsf49ersteamshop.com +officialtiffanycoproshop.com +officialtolol.ga +officieel-airmaxs.com +officieelairmaxshop.com +officiel-jordans.com +officiel-tnrequin.com +officielairmaxfr.com +officielairmaxfrance.com +officielairmaxpascher.com +officielairmaxsshop.com +officielchaussurestore.com +officiellairmaxsshop.com +officielle-jordans.com +officielleairmax.com +officiellejordan.com +officielmaxshop.com +officielnikeairmas.org +officieltnrequinfr.com +officieltnrequinfrshop.com +offsala.com +offsetmail.com +offshore-company.tk +offshore-proxies.net +offshorepa.com +offthebridge.com +offthechainfishing.com +offtherecordmail.com +ofgmail.com +ofiac.com +oficinasjorgevelasquez.com +ofirit.com +ofisher.net +ofm.emlhub.com +ofmail.com +ofmailer.net +ofmf.co.cc +ofojwzmyg.pl +ofordhouse.org +ofordhouse.site +ofou.emlpro.com +ofowatch.com +oftenerey.com +ofth3crumrhuw.cf +ofth3crumrhuw.ga +ofth3crumrhuw.gq +ofth3crumrhuw.ml +ofth3crumrhuw.tk +ofu.dropmail.me +ofular.com +ofvn.com +og2j06b2y.xorg.pl +ogcl.mimimail.me +ogemail.com +oggology.com +ogirisim.xyz +ogladajonlinezadarmo.pl +oglerau.com +ogloszeniadladzieci.pl +ogmail.com +ogpe.laste.ml +ogplugs.com +ogremail.net +ogrencikredisi.org +ogretio.com +ogrmux.com +ogrodzenia.pl +ogu188.com +ogu7777.net +ogvoice.com +ogx.laste.ml +oh.spymail.one +ohaaa.de +ohaauthority.org +ohamail.com +ohb.dropmail.me +ohcw.com +ohdaddy.co.uk +ohdomain.xyz +ohfz.emlpro.com +ohgitu.me +ohh.freeml.net +ohi-design.pl +ohi.tw +ohio-riverland.info +ohioflyfishinguides.com +ohioticketpayments.xyz +ohkogtsh.ga +ohkogtsh.ml +ohm.edu.pl +ohmail.com +ohmbet.org +ohmm.emltmp.com +ohmyfly.com +ohohdream.com +oholeguyeducation.com +ohrabbi.me +ohrana-biysk.ru +ohtheprice.com +ohxmail.com +ohyesjysuis.fr +oi7wx.anonbox.net +oiche.xyz +oid.emlhub.com +oida.icu +oidhdozens.com +oidzc1zgxrktxdwdkxm.cf +oidzc1zgxrktxdwdkxm.ga +oidzc1zgxrktxdwdkxm.gq +oidzc1zgxrktxdwdkxm.ml +oidzc1zgxrktxdwdkxm.tk +oigmail.com +oihygr.website +oiizz.com +oikaweb.com +oikd4.anonbox.net +oikrach.com +oilcocomasag.live +oilcocomasag.store +oilofolay.in +oilpaintingsale.net +oilpaintingvalue.info +oilrepairs.com +oiltempof.icu +oimail.com +oing.cf +oink8jwx7sgra5dz.cf +oink8jwx7sgra5dz.ga +oink8jwx7sgra5dz.gq +oink8jwx7sgra5dz.ml +oink8jwx7sgra5dz.tk +oinkboinku.com +oinstyle.com +oinvest.joburg +oioinb.com +oioio.club +oiplikai.ml +oipmail.com +oippg.ru +oipplo.com +oiqas.com +oiqfioqwepqow.tk +oiqfnoqwieopqwei.ga +oiqnfiqwepoiqwe.ga +oistax.com +oitlook.co +oiv.laste.ml +oiw.laste.ml +oiwke.com +oiwp.freeml.net +oixr.emlpro.com +oizxwhddxji.cf +oizxwhddxji.ga +oizxwhddxji.gq +oizxwhddxji.ml +oizxwhddxji.tk +oj.laste.ml +oj.spymail.one +ojamail.com +ojdh71ltl0hsbid2.cf +ojdh71ltl0hsbid2.ga +ojdh71ltl0hsbid2.gq +ojdh71ltl0hsbid2.ml +ojdh71ltl0hsbid2.tk +ojemail.com +ojh.freeml.net +ojimail.com +ojm.emltmp.com +ojobmail.com +ojolbet.com +ojosambat.cf +ojosambat.ml +ojpvym3oarf3njddpz2.cf +ojpvym3oarf3njddpz2.ga +ojpvym3oarf3njddpz2.gq +ojpvym3oarf3njddpz2.ml +ojpvym3oarf3njddpz2.tk +ojwf.com +ok-body.pw +ok.sy +ok8883.com +okathens.com +okax.emltmp.com +okaybet777.com +okayion.com +okbeatsdrdre1.com +okbody.pw +okcdeals.com +okclprojects.com +okcomputer.ru +okdiane35.pl +oke.bar +okeoceapasajaoke.com +oker.com +okexbit.com +okezone.bid +okg.emlpro.com +okgmail.com +okhko.com +oki9oeuw.com +okiae.com +okinawa.li +okinotv.ru +okkaydream.com +okkokshop.com +okl.emlpro.com +okledslights.com +oklho.com +oklkfu.com +okmail.com +okmail.p-e.kr +okmilton.com +okna-sochi.ru +okna2005.ru +oknagornica.ru +okndrt2ebpshx5tw.cf +okndrt2ebpshx5tw.ga +okndrt2ebpshx5tw.gq +okndrt2ebpshx5tw.ml +okndrt2ebpshx5tw.tk +oknokurierskie.pl +okocewakaf.com +okolkad.buzz +okrent.us +okrhosting.com +okrockford.com +okryszardkowalski.pl +oks.emltmp.com +oksanantonio.com +okstorytye.com +oksunglassecool.com +oktai.ru +oktempe.com +oktoberfest2012singapore.com +oktv.sbs +oku.ovh +okuito.xyz +okulistykakaszubska.pl +okulsfhjntc77889.ga +okventura.com +oky.ovh +oky.spymail.one +okzk.com +ol.dropmail.me +ol.telz.in +olafmail.com +olafood.com +olahoo.com +olaytacx.top +olbosi.ga +olc.one +olcasevdan.cfd +olchromlei.ga +old-recipes.com +old.blatnet.com +old.cowsnbullz.com +old.makingdomes.com +old.marksypark.com +old.ploooop.com +old.poisedtoshrike.com +oldcelebrities.net +olden.com.pl +oldgoi.emltmp.com +oldgwt.space +oldhatseo.co +oldmadein.com +oldmummail.online +oldnavycouponsbox.com +oldscheme.org +oldschoolnewbodyreviews.org +oldstationcafe8.com +olduser.cf +olechnowicz.com.pl +olegfemale.org +olegmike.org +oleybet249.com +olgis.ru +olgsale.top +olgt6etnrcxh3.cf +olgt6etnrcxh3.ga +olgt6etnrcxh3.gq +olgt6etnrcxh3.ml +olgt6etnrcxh3.tk +oli.spymail.one +olimp-case.ru +olinbzt.ga +olindaonline.site +olinel.ga +olinel.ml +olisadebe.org +olisup.cyou +olittem.site +olivegardencouponshub.com +oliveli.com +oliviadiffuser.store +oljdsjncat80kld.gq +ollablaed.com +ollbiz.com +ollisterpascheremagasinfrance.com +ollness.com +olmail.com +olmalaimi.ga +olo4lol.uni.me +olobmai.ga +oloh.ru +oloh.store +ololomail.in +ololzi.ga +olp.emltmp.com +olpame.com +olplq6kzeeksozx59m.cf +olplq6kzeeksozx59m.ga +olplq6kzeeksozx59m.gq +olplq6kzeeksozx59m.ml +olplq6kzeeksozx59m.tk +olqn.com +olsenmail.men +olsnornec.ml +olvqnr7h1ssrm55q.cf +olvqnr7h1ssrm55q.ga +olvqnr7h1ssrm55q.gq +olvqnr7h1ssrm55q.ml +olvqnr7h1ssrm55q.tk +olwr.com +olyabeling.site +olypmall.ru +olyztnoblq.pl +om.emlpro.com +om.laste.ml +omahsimbah.com +omail.pro +omailo.top +oman.com +omarnasrrr.com +omarrr.online +omarrry.tk +omca.info +omd.emlhub.com +omdiaco.com +omdlism.com +omdo.xyz +omeaaa124.ddns.net +omeea.com +omega-3-foods.com +omega.omicron.spithamail.top +omegacoin.org +omegafive.net +omegasale.org +omegaxray.thefreemail.top +omegazetacryptopool.online +omeie.com +omenwi.ga +omeprazolex.com +omeraydinoglu.com +omerfaruksahin.com +omerindassagi.ga +omesped7.net +omessage.gq +omfg.run +omg-greatfood.com +omg6.com +omgdelights.com +omgdodedo.com +omggreatfoods.com +omheightsy.com +omi4.net +omibrown.com +omicron.omega.myverizonmail.top +omicron.token.ro +omicron4.ml +omicrongamma.coayako.top +omicronlambda.ezbunko.top +omicronwhiskey.coayako.top +omilk.site +omineralsby.com +omj.dropmail.me +omk24.de +omlkr.anonbox.net +ommail.com +omn.emltmp.com +omne.com +omnievents.org +omnimart.store +omnyo.com +ompokerasia.com +omsk-nedv.ru +omsk-viagra.ru +omsshoesonline4.com +omtamvan.com +omtecha.com +omumail.com +omv.laste.ml +omwe.ru +omxvfuaeg.pl +omzae.com +omzg5sbnulo1lro.cf +omzg5sbnulo1lro.ga +omzg5sbnulo1lro.gq +omzg5sbnulo1lro.ml +omzg5sbnulo1lro.tk +on-review.com +on.cowsnbullz.com +on.emltmp.com +on.marksypark.com +onasabiz.com +onaxgames.com +onbachin.ga +onbap.com +onbf.org +oncebar.com +oncesex.com +oncloud.ws +oncult.ru +ondemandemail.top +ondemandmap.com +ondesign.info +one-college.ru +one-mail.top +one-ml.com +one-sec-mail.site +one-shop.online +one-time.email +one.blatnet.com +one.emailfake.ml +one.fackme.gq +one.marksypark.com +one.oldoutnewin.com +one.pl +one.sch.lv +one2mail.info +oneandoneny.com +onebalu.com +onebiginbox.com +onebucktwobuckthree.com +onebyoneboyzooole.com +onecalltrack.com +onecbm.com +onecitymail.com +onecroc.com +onedaymail.cf +onedaymail.ga +onedayyylove.xyz +onedonation.com +onedrive.web.id +onefineline.com +onegoodchair.com +onehandtyper.com +oneheartusa.com +oneindex.in.net +onekisspresave.com +onelegalplan.com +onelettersmail.com +onelinkpr.net +onemahanet.com +onemail.host +onemail.website +onemail1.com +onemailserv.xyz +onemailx.xyz +onemoremail.net +onemoretimes.info +onenime.ga +oneoffemail.com +oneoffmail.com +oneonfka.org.ua +onepack.systems +onepiece-vostfr.stream +onepiecetalkblog.com +onepvp.com +onestepaboveclean.org +onestepgpt.tech +onestepmail.com +onestop21.com +onestopwv.com +onet.com +onetag.org +onetap.site +onetempmail.com +onetimeusemail.com +onetm-ml.com +onetm.jp +onetopclick.online +onetouchedu.com +onetouchtv.com +onety.pl +onewaylinkcep.com +onewaymail.com +onewayticket.online +onextube.com +ongc.ga +onghelped.com +onhealth.tech +onheb.com +onhrrzqsubu.pl +onhz.spymail.one +oniaj.com +onialtd.com +onick.tech +oniecan.com +oninmail.com +onion.ee.eu.org +onionred.com +onit.com +onitopia.com +onkyo1.com +onlatedotcom.info +onlcool.com +onligaddes.site +onlimail.com +online-business-advertising.com +online-casino24.us +online-dartt.pl +online-dating-bible.com +online-dating-service-sg.com +online-geld-verdienen.gq +online-pills.xyz +online-std.com +online-stream.biz +online-support.tech +online-web.site +online.ms +online5.ru +onlineaccutaneworldpills.com +onlineadultwebcam.com +onlineautoloanrates.com +onlineautomatenspiele.host +onlineavtomati.net +onlinebankingcibc.com +onlinebankingpartner.com +onlinecanada.biz +onlinecarinsuranceexpert.com +onlinecasino-x.ru +onlinecasinostop.ru +onlinechristianlouboutinshoesusa.us +onlinecmail.com +onlinecollegemail.com +onlinecomputerhelp.net +onlinedatingsiteshub.com +onlinedeals.pro +onlinedeals.trade +onlinedutyfreeeshop.com +onlinedutyfreeshop.com +onlineee.com +onlinefs.com +onlinefunnynews.com +onlineguccibags.com +onlinegun.com +onlinehackland.com +onlinehealthreports.com +onlinehunter.ml +onlineidea.info +onlineindex.biz +onlineinsurancequotescar.net +onlinejackpots.bid +onlinejerseysnews.com +onlinejordanretro2013.org +onlinelivesexcam.com +onlinemail.press +onlinemail.pw +onlinemailfree.com +onlinemarket365.ru +onlinemedic.biz +onlinemoneyfan.com +onlinemoneymaking.org +onlinenet.info +onlinenewsfootball.com +onlinenewyorksingles.com +onlinepaydayloansvip.com +onlinepharmacy-order.com +onlinepharmacy.name +onlineplayers.ru +onlinepokerid.info +onlinepokiesau.com.au +onlineprofessionalorganizer.com +onlinesexcamchat.com +onlineshoesboots.com +onlineshop24h.pl +onlineshopinfo.com +onlineshoppingcoupons24.com +onlineshopsinformation.com +onlinestodays.info +onlinetantraclasses.com +onlinetantracourses.com +onlinetomshoeoutletsale.com +onlineusa.biz +onlinevideomusic.xyz +onlinewcm.com +onlinexploits.com +only-bag.com +only.blatnet.com +only.marksypark.com +onlyapp.net +onlyapps.info +onlyblood.com +onlyhaededor.com +onlykills.xyz +onlyme.pl +onlys.site +onlysext.com +onlysingleparentsdating.co.uk +onlysolars.com +onlyu.link +onlyways.ru +onlywedding.ru +onmagic.ru +onmail.top +onmail.win +onmail3.com +onmailflare.com +onmailzone.com +onmier.com +onmuscletissue.uk +onnormal.com +onnoyukihiro.site +onofmail.com +onosyaikh.com +onphlegeal.ga +onplayagain.net +onpointpartners.com +onprice.co +onqin.com +onqus.com +onqwfiojqwopeiq.ga +onqwfopqwipoeqwe.ga +onsailcharter.info +onsaleadult.com +onsalemall.top +onshop5.com +onshoreteam.com +onsitecomputing.com +onsitetrainingcourses.com +onstochin.ga +ontalk.biz +ontasa.com +ontelist.ga +onthetok.com +onthewaterlifestyle.com +ontheweblearning.com +ontimeflight.ir +ontyne.biz +onuadon.ga +onumail.com +onvii.com +onw.spymail.one +onwardmail.com +onwmail.com +onymi.com +onzberam.ga +onzmail.com +onzu.mimimail.me +oo-mail.net +oo.emltmp.com +oo.pl +oo2s7.anonbox.net +ooag.com +ooaj.com +ooapmail.com +oob8q2rnk.pl +oochiecoochie.com +ooeawtppmznovo.cf +ooeawtppmznovo.ga +ooeawtppmznovo.gq +ooeawtppmznovo.ml +ooeawtppmznovo.tk +oofbrazil.com +oofmail.tk +oogmail.com +oohioo.com +oohlaleche.com +oohotmail.club +oohotmail.com +oohotmail.online +oohsecrets.com +ooikfjeojg.com +ookfmail.com +ookkkuw.jungleheart.com +oolk.com +oolloo.org +oolmail.com +oolong.ro +oolus.com +oonabrangamsnell.com +oonies-shoprus.ru +ooof.gq +ooomail.ga +oooomo.site +ooooni.site +ooooooo.com +oooooooo.store +oopi.org +oopsify.com +oosln.com +ooter.nl +ootlook.com +oou.emlhub.com +oou.us +ooum.laste.ml +oourmail.xyz +ooutlook.com +oovk.ru +oovk.store +opa.spymail.one +opalroom.com +opamtis.online +opanv.com +opar.emltmp.com +opayq.com +opelkun.online +opelmail.com +opemails.com +open-domains.info +open-sites.info +open.brainonfire.net +openavz.com +opencalls.co +opende.de +opendigitalmail.net +opendns.ro +openfront.com +openingforex.com +openlinemail.com +openmail.lol +openmail.ml +openmail.pro +openmail.tk +openmailbox.tk +openmindedzone.club +opennames.info +opennetgame.org +openskiesgroup.com +openskj.com +opensourceed.app +opentrash.com +opentrashbox.org +openwebmail.contractors +operabrow.com +operacjezeza.pl +operatingtechnology.com +operationpatchwork.com +operativemedia.com +operenetow.com +opetron.com +opettajatmuljankoulu.tk +opexonline.com +ophaltde.com +ophdoghau.ga +opheliia.com +opilon.com +opinionsbte.com +opisce.site +opito.co.uk +opkast.net +oplaskit.ml +opljggr.org +opmail.com +opmmail.com +opmmax.com +opmmedia.ga +opna.me +opno.life +opojare.org +opole-bhp.pl +opoprclea.website +oposite.org +opowlitowe53.tk +opp24.com +oppamail.com +oppax.com +oppein.pl +oppobitty-myphen375.com +opposir.com +oppositivity.xyz +oppostreamingonline.com +oppubg.ru +opqienqwpoe.ga +opqwfopqwiepqwe.ga +oprevolt.com +oproom.com +opsmkyfoa.pl +opss40.net +opten.email +optf.yomail.info +opthix.io +opthix.me +opticdesk.xyz +optidesk.xyz +optikavid.ru +optimalstackreview.net +optimalstackreviews.net +optimaweb.me +optimisticheart.com +optimisticheart.org +optimumnutritionseriousmass.net +optimuslinks.com +optinum.net +optiplex.com +optitum.com +optivex.cfd +optline.com +optmails.xyz +optom-sumki.ru +optonlime.net +optonline.bet +optonlinr.net +optykslepvps.com +optymalizacja.com.pl +opude.com +opulent-fx.com +opus.laste.ml +oputin.ga +oputin.tk +opwebw.com +opzeo.com +oq.mimimail.me +oq.spymail.one +oqao.com +oqgj.emlpro.com +oqiwq.com +oqlylrzixa.ga +oqnwfoqwpeipoqwe.ga +oqtypical.com +oqwnfqwpoiepqw.tk +or.blurelizer.com +or.emlpro.com +or.emltmp.com +or.orgz.in +or.ploooop.com +or.spymail.one +oracruicat.xyz +oralia.freshbreadcrumbs.com +oralreply.com +oramail.net +oranek.com +orangatango.com +orangdalem.org +orange-bonplan.com +orangecountyfare.com +orangegraphic.com +orangeinbox.org +orangesticky.info +orangotango.cf +orangotango.ga +orangotango.gq +orangotango.ml +orangotango.tk +orante.xyz +orbitforce.com +orbitjolly.com +orbitnew.net +orbub.one +ordenadores.online +order-fulfillment.net +order84.gmailmirror.com +orderbagsonline.handbagsluis.net +ordershoes.com +ordinaryamerican.net +ordinarybzi.com +ordinaryyz1.com +ordite.com +oredaorissa.com +oregon-nedv.ru +oreidresume.com +oremal.com +oremou.mailpwr.com +orenge.fr +oreple.com +oresolvedm.com +orfea.pl +orfeaskios.com +org.blatnet.com +org.marksypark.com +org.oldoutnewin.com +organiccoffeeplace.com +organicfarming101.com +organicgardensupply.net +organicgreencoffeereview.com +organicmedinc.com +organics.com.bd +organisasipoetra.io +orgasm.cheap +orgasm.university +orgcity.info +orgiiusisk.gr +orgiosdos.gr +orgmbx.cc +orgogiogiris.gr +orgria.com +oriellyautoparts.com +orientcode.com +oriete.cf +origamilinux.com +original-trilogy.com +originalhooters.co +origrar.com +orikamset.de +orimail.com +oringame.com +orinmail.com +oriogiosi.gr +orion.tr +oriondertest.it +orionpetshop.shop +orionwebs.net +oriwijn.com +orkaled.es +orlandoroofreplacement.com +orleasi.com +orlydns.com +ormtalk.com +ormutual.com +oroki.de +oronny.com +orosbu.com +orotab.com +orperfect.com +orpxp547tsuy6g.cf +orpxp547tsuy6g.ga +orpxp547tsuy6g.gq +orpxp547tsuy6g.ml +orpxp547tsuy6g.tk +orq.dropmail.me +orq1ip6tlq.cf +orq1ip6tlq.ga +orq1ip6tlq.gq +orq1ip6tlq.ml +orq1ip6tlq.tk +orsbap.com +orsltd.co.uk +ortests.com +ortho3.com +orthodrs.com +orthopathy.info +ortimax.com +ortogenmail.com +orumail.com +orvit.net +orvnr2ed.pl +orx.emlhub.com +orxy.tech +oryclgfmdt.ga +orymane.com +oryx.hr +os.dropmail.me +os.freeml.net +osa.pl +osakawiduerr.cf +osakawiduerr.gq +osakawiduerr.ml +osamail.com +oscar.delta.livefreemail.top +oscar20.live +oscarpostlethwaite.com +osdfsew.tk +osendingwr.com +osfujhtwrblkigbsqeo.cf +osfujhtwrblkigbsqeo.ga +osfujhtwrblkigbsqeo.gq +osfujhtwrblkigbsqeo.ml +osfujhtwrblkigbsqeo.tk +oshietechan.link +osidecorate.com +oskadonpancenoye.com +oskarplyt.cf +oskarplyt.ga +oskarplyt.gq +oskarplyt.ml +oskarstalbergcitygenerator.com +oskq.emlhub.com +oslermedical.com +osmom.justdied.com +osmqg.anonbox.net +osmqgmam5ez8iz.cf +osmqgmam5ez8iz.ga +osmqgmam5ez8iz.gq +osmqgmam5ez8iz.ml +osmqgmam5ez8iz.tk +osmye.com +oso.pl +osoftx.software +osormail.com +ospik.online +ospirun.com +osporno-x.info +ospul.com +osrypdxpv.pl +ossas.com +ostah.ru +ostahie.com +osteopath-enfield.co.uk +osterrike.com +ostinmail.com +ostrov.net +ostrozneinwestowanie.pl +ostup.anonbox.net +osuedc.org +osuvpto.com +oswietlenieogrodow.pl +oswo.net +osxofulk.com +oszczednezycie.pl +otanhome.com +otaywater.org +otb.laste.ml +otekyc.xyz +otelecom.net +otemdi.com +otezuot.com +othao.com +othedsordeddy.info +other.marksypark.com +other.ploooop.com +otheranimals.ru +otherdog.net +otheremail.org +othergoods.ru +otherinbox.codupmyspace.com +otherinbox.com +othersch.xyz +othest.site +otixero.com +otkrit-ooo.ru +otlaecc.com +otlook.es +otmail.com +otmail.jp +otnasus.xyz +otodir.com +otoeqis66avqtj.cf +otoeqis66avqtj.ga +otoeqis66avqtj.gq +otoeqis66avqtj.ml +otoeqis66avqtj.tk +otomax-pro.com +otona.uk +otonmail.ga +otozuz.com +otp247.me +otpku.com +otpmail.top +otpmeta.email +otrabajo.com +otratransportation.com +ottappmail.com +ottawaprofilebacks.com +otterroofing.net +ottrme.com +ottvv.com +otu1txngoitczl7fo.cf +otu1txngoitczl7fo.ga +otu1txngoitczl7fo.gq +otu1txngoitczl7fo.ml +otu1txngoitczl7fo.tk +oturizme.net +otvetinavoprosi.com +otzyvy-yk.ru +ou127.space +oua.laste.ml +ouadeb43.xzzy.info +oubn.mimimail.me +ouenkwxrm.shop +ouhihu.cf +ouhihu.ga +ouhihu.gq +ouhihu.ml +ouishare.us +oulook.com +oultlook.com +oultlookii.com +oungsaie.com +ount.ru +oup3kcpiyuhjbxn.cf +oup3kcpiyuhjbxn.ga +oup3kcpiyuhjbxn.gq +oup3kcpiyuhjbxn.ml +oup3kcpiyuhjbxn.tk +our.cowsnbullz.com +our.oldoutnewin.com +ourawesome.life +ourawesome.online +ourbox.info +ourcocktaildress.com +ourcocktaildress.net +ourdietpills.org +ourgraduationdress.com +ourgraduationdress.net +ourhealthministry.com +ourhosting.xyz +ourisp.net +ourjelly.com +ourklips.com +ourl.me +ourlook.de +ourlouisvuittonfr.com +ourmonclerdoudounefr.com +ourmonclerpaschere.com +ourmudce.ga +ouroboros.icu +ouropretoonline.online +ourpreviewdomain.com +oursblog.com +oursecure.com +ourstorereviews.org +ourupad.ga +ousoleil.com +out-email.com +out-mail.com +out-mail.net +out-sourcing.com.pl +out.cowsnbullz.com +out.marksypark.com +out.oldoutnewin.com +outbacksteakhousecouponshub.com +outcom.com +outdoorproductsupplies.com +outdoorslifestyle.com +outernet.nu +outernet.shop +outfu.com +outfurra.ga +outhei.com +outhere.com +outikoumail.com +outlawmma.co.uk +outlawspam.com +outlddook.com +outlen.com +outlet-michaelkorshandbags.com +outletcoachfactorystoreus.com +outletcoachonlinen.com +outletcoachonliner.com +outletgucciitaly.com +outletjacketsstore.com +outletkarenmillener.co.uk +outletlouisvuittonborseiitaly.com +outletlouisvuittonborseitaly.com +outletlouisvuittonborseoutletitaly.com +outletlouisvuittonsbag.co.uk +outletmichaelkorssales.com +outletmonclerpiuminiit.com +outletomszt.com +outletpiuminimoncleritaly.com +outletpiuminimoncleritaly1.com +outletraybans.com +outlets5.com +outletstores.info +outlettcoachstore.com +outlettomsonlinevip.com +outlettomsonlinezt.com +outlettomszt.com +outlettoryburchjpzt.com +outllok.com +outllok.es +outlo.com +outlok.com +outlok.it +outlok.net +outloo.be +outloo.com +outlook-mails.ga +outlook.b.bishop-knot.xyz +outlook.dynamailbox.com +outlook.edu.pl +outlook.sbs +outlook.twitpost.info +outlook2.gq +outlookbox.me +outlookkk.online +outlookonr.com +outlookonr.online +outlookpro.net +outlookqk.site +outloomail.gdn +outloook.be +outlouk.com +outloutlook.com +outluk.co +outluk.com +outluo.com +outluok.com +outlyca.tk +outmail.win +outmail4u.ml +outmix.com +outrageousbus.com +outrageousmail.top +outree.org +outrlook.com +outsidered.xyz +outsidestructures.com +outstandingtrendy.info +outuok.com +ouwoanmz.shop +ouwrmail.com +ouylook.es +ouzadverse.com +ov.freeml.net +ov.yomail.info +ov3u841.com +ovaclockas24.net +ovaqmail.com +ovarienne.ml +ovbe.dropmail.me +ovbest.com +ovea.pl +ovefagofceaw.com +ovenyudhaswara.biz +over-craft.ru +over-you-24.com +over.ploooop.com +over.popautomated.com +overagent.com +overcomebf.com +overcomeoj.com +overdrivemedia.com +overkill4.pl +overkill5.pl +overkill6.pl +overmetre.com +overnted.com +overseasdentist.com +overtechs.com +overwatch.party +overwhelminghafizhul.io +overwholesale.com +ovh9mgj0uif.xorg.pl +ovi.usa.cc +ovimail.cf +ovimail.ga +ovimail.gq +ovimail.ml +ovimail.tk +ovinh.com +ovipmail.com +ovlo.spymail.one +ovlov.cf +ovlov.ga +ovlov.gq +ovlov.ml +ovlov.tk +ovmail.com +ovmail.net +ovobri.com +ovomail.co +ovooovo.com +ovorowo.com +ovout.com +ovpn.to +ovvee.com +ovwfzpwz.pc.pl +ovxe.freeml.net +owa.kr +owageskuo.com +owatch.co +owawkrmnpx876.tk +owbot.com +oweiidfjjif.cf +oweiidfjjif.ga +oweiidfjjif.gq +oweiidfjjif.ml +oweiidfjjif.tk +owemolexi.swiebodzin.pl +owenmcdsa.sbs +owfcbxqhv.pl +owh.ooo +owlag.com +owleyes.ch +owlpic.com +owlymail.com +owmail.net +own-tube.com +ownerbanking.org +ownersimho.info +ownsyou.de +ownyourapps.com +owob.emltmp.com +owohbfhobr.ga +owoso.com +owpb.laste.ml +owrdonjk6quftraqj.cf +owrdonjk6quftraqj.ga +owrdonjk6quftraqj.gq +owrdonjk6quftraqj.ml +owrdonjk6quftraqj.tk +owski.de +owsz.edu.pl +owube.com +ox5bk.us +oxadon.tech +oxavps.me +oxbio.xyz +oxbreaksk.com +oxcel.art +oxddadul.ga +oxfarm1.com +oxfo.edu.pl +oxfor.edu.pl +oxford-edu.cf +oxford-edu.university +oxford.gov +oxfordedu.cf +oxiburn.com +oxjawi.dropmail.me +oxjl.com +oxkrqdecor.com +oxkvj25a11ymcmbj.cf +oxkvj25a11ymcmbj.ga +oxkvj25a11ymcmbj.gq +oxkvj25a11ymcmbj.tk +oxmail.com +oxmail.homes +oxnipaths.com +oxopoha.com +oxsgyd.fun +oxsignal.me +oxtenda.com +oxudvqstjaxc.info +oxva.spymail.one +oxvps.us +oxxdd12.com +oxyelitepro.ru +oxyemail.com +oxzi.com +oy.dropmail.me +oy.emltmp.com +oyalmail.com +oydtab.com +oyekgaring.ml +oygkt.com +oyisam.my +oyl.emltmp.com +oylmm.com +oylstze9ow7vwpq8vt.cf +oylstze9ow7vwpq8vt.ga +oylstze9ow7vwpq8vt.gq +oylstze9ow7vwpq8vt.ml +oylstze9ow7vwpq8vt.tk +oymail.com +oymuloe.com +oyo.emlpro.com +oyo.pl +oysa.life +oyu.kr +oyuhfer.cf +oyuhfer.ga +oyuhfer.gq +oyuhfer.ml +oyul.spymail.one +oyuncudostu.com +oz.emlpro.com +ozark.store +ozatvn.com +ozijmail.com +ozkadem.edu.pl +ozlaq.com +ozm.fr +ozmail.com +oznmtwkng.pl +ozny.freeml.net +ozost.com +ozozwd2p.com +ozqn1it6h5hzzxfht0.cf +ozqn1it6h5hzzxfht0.ga +ozqn1it6h5hzzxfht0.gq +ozqn1it6h5hzzxfht0.ml +ozqn1it6h5hzzxfht0.tk +ozra.com +ozsaip.com +oztasmermer.com +ozumz.com +ozva.emlhub.com +ozyl.de +ozyumail.com +ozzi12.com +ozzq.yomail.info +p-31.ru +p-668.top +p-a-y.biz +p-aac.top +p-banlis.ru +p-cc1.top +p-cctv.top +p-gdl.cf +p-gdl.ga +p-gdl.gq +p-gdl.ml +p-gdl.tk +p-oops.com +p-response.com +p-ttc.top +p-ttz.top +p-value.ga +p-value.tk +p-vva.top +p-y.cc +p.mrrobotemail.com +p.new-mgmt.ga +p.polosburberry.com +p.teemail.in +p0o9iehfg.com +p180.cf +p180.ga +p180.gq +p180.ml +p180.tk +p1c.us +p1nhompdgwn.cf +p1nhompdgwn.ga +p1nhompdgwn.gq +p1nhompdgwn.ml +p1nhompdgwn.tk +p2chb.anonbox.net +p2marketing.co.uk +p2wnow.com +p2zyvhmrf3eyfparxgt.cf +p2zyvhmrf3eyfparxgt.ga +p2zyvhmrf3eyfparxgt.gq +p2zyvhmrf3eyfparxgt.ml +p2zyvhmrf3eyfparxgt.tk +p33.org +p4tnv5u.pl +p58fgvjeidsg12.cf +p58fgvjeidsg12.ga +p58fgvjeidsg12.gq +p58fgvjeidsg12.ml +p58fgvjeidsg12.tk +p5mail.com +p684.com +p6halnnpk.pl +p6s4resx6.xorg.pl +p71ce1m.com +p7wyv.anonbox.net +p8oan2gwrpbpvbh.cf +p8oan2gwrpbpvbh.ga +p8oan2gwrpbpvbh.gq +p8oan2gwrpbpvbh.ml +p8oan2gwrpbpvbh.tk +p8y56fvvbk.cf +p8y56fvvbk.ga +p8y56fvvbk.gq +p8y56fvvbk.ml +p8y56fvvbk.tk +p90x-dvd.us +p90xdvds60days.us +p90xdvdsale.info +p90xlifeshow.com +p90xstrong.com +p9fnveiol8f5r.cf +p9fnveiol8f5r.ga +p9fnveiol8f5r.gq +p9fnveiol8f5r.ml +p9fnveiol8f5r.tk +pa912.com +pa913.com +pa975.com +pa9e.com +paanf.anonbox.net +paapitech.com +pacarmu.link +pacdoitreiunu.com +paceforwarders.com +paceincorp.com +pacfut.com +pachilly.com +pacificraft.com +pacificwestrealty.net +pack-de-mujeres.net +pack.oldoutnewin.com +pack.ploooop.com +pack.poisedtoshrike.com +packersandmovers-pune.in +packersproteamsshop.com +packerssportstore.com +packiu.com +packmein.life +packmein.online +packmein.shop +packsurfwifi.com +pacnoisivoi.com +pacnut.com +pacourts.com +pactdog.com +padanghijau.online +padbest.com +padcasesaling.com +paddgapho.ga +paddgapho.tk +paddlepanel.com +paddockpools.net +padili.com +padlet-alternate.link +padlettings.com +padvn.com +padye.com +padyou.com +paeharmpa.ga +paehc.co.uk +paehc.uk +paeurrtde.com +pafasdigital.com +paffoca.shop +pafnuty.com +pafrem3456ails.com +paftelous.website +pagamenti.tk +pagarrumahkita.xyz +page1ranker.com +pagedangan.me +pagg.yomail.info +paharpurmim.cf +paharpurmim.ga +paharpurmim.gq +paharpurmim.ml +paharpurmim.tk +paharpurtitas.cf +paharpurtitas.ga +paharpurtitas.gq +paharpurtitas.ml +paharpurtitas.tk +pahed.com +paherowalk.org +paherpur.ga +paherpur.gq +paherpur.ml +pahilldob.ga +pahrulirfan.net +pahrumptourism.com +paiconk.site +paidattorney.com +paiindustries.com +paikhuuok.com +painsocks.com +paint.bthow.com +paintballpoints.com +paintedblackhorseranch.com +painting-commission.com +paintyourarboxers.com +pairefan.ga +paiucil.com +paiy.emlhub.com +pak.emlpro.com +pakadebu.ga +pakayathama.ml +paketliburantourwisata.com +paketos.ru +pakkaji.com +pakolokoemail.com.uk +pakrocok.tech +pakservices.info +pakwork.com +palaciosvinodefinca.com +palaena.xyz +palau-nedv.ru +paldept.com +paleomail.com +paleorecipebookreviews.org +palermo-pizza.ru +palingbaru.tech +paliny.com +paliospiti.com +paller.cf +palm-bay.info +palmerass.tk +palmettospecialtytransfer.com +palosdonar.com +palpialula.gq +pals-pay54.cf +palsengineering.com +paltalkurl.com +pamapamo.com +pamaweb.com +pamelakline.com +pamil.fr.nf +pamperedpetsanimalrescue.org +pamposhtrophy.com +pamptingprec.ga +pamuo.site +pamyr.com +panacea.ninja +panaceabiotech.com +panaged.site +panama-nedv.ru +panama-real-estate.cf +panarabanesthesia2021.live +panasonicgf1.net +pancakemail.com +panchitocastellodelaplana.com +panchoalts.com +pancon.site +pancosj.cf +pancosj.ga +pancosj.gq +pancosj.ml +pancreaticprofessionals.com +pandacn8app.com +pandacoin.shop +pandamail.tk +pandarastore.top +pandoradeals.com +pandoradrodmc.com +pandoraonsalestore.com +pandostore.co +panelademinas.com.br +panelesloneczne.pisz.pl +panelfinance.com +panelpros.gq +panels.top +panelssd.com +paneltiktok.com +panen228.net +pangaxie.com +panget.com +pangtiin.com +pangzi.biz +panjalu.digital +panjalupusat.online +pankasyno23.com +pankujvats.com +pankx.cf +pankx.ga +pankx.ml +pankx.tk +panlvzhong.com +panopticsites.com +panpacificbank.com +pantabi.com +panteraclub.com +panterrra.com +pantheonclub.info +pantheonstructures.com +panwithsse.ga +paobv.com +paohetao.com +paoina.com +paoracmoss.com +paosk.com +papa.foxtrot.ezbunko.top +papai.cf +papai.ga +papai.gq +papai.ml +papai.tk +papakiung.com +papaparororo.com +papaplopa.fun +papasha.net +papayamailbox.com +paperblank.com +paperfu.com +paperlesspractice.com +papermakers.ml +paperpapyrus.com +paperyuyu.xyz +papierkorb.me +papillomadelete.info +paplease.com +papogij.digital +papolog.com +papua-nedv.ru +papubar.pl +paqba.com +paqd5.anonbox.net +para2019.ru +parabellum.us +paradigmplumbing.com +paradisedev.tk +paragvai-nedv.ru +paralamb.ml +paralet.info +paramail.cf +parampampam.com +paranaguia.com +parashospital.com +paraska.host +parasluhov.ru +parbehon.ga +parcel4.net +parcival-store.net +parclan.com +pardisyadak.com +parelay.org +parentsxke.com +parer.net +pareton.info +parezvan.com +parfaitparis.com +parfum-sell.ru +parfum-uray.ru +parfum33.ru +pariag.com +paridisa.cf +paridisa.ga +paridisa.gq +paridisa.ml +paridisa.tk +parimatch-1xbet.site +parimatchstavki9.com +parisannonce.com +parisdentists.com +parisinabridal.net +parispatisserie.com +parisvipescorts.com +parittas.com +parkcc.me +parkcrestlakewood.xyz +parkerglobal.com +parkers4events.com +parkingaffiliateprogram.com +parkll.xyz +parkpulrulfland.xyz +parkwaypolice.com +parlaban.com +parleasalwebp.zyns.com +parlimentpetitioner.tk +parolonboycomerun.com +parqueadero.work +parsinglabs.com +partchild.biz +partcobbsi.ga +partenariat.ru +partimestudent.com +partmed.net +partmonth.us +partnera.site +partnerct.com +partnered.systems +partneriklan.com +partnerlink-stoloto.site +partners-personnel.com +partners.blatnet.com +partners.lakemneadows.com +partners.oldoutnewin.com +partpaotideo.com +partskyline.com +partualso.site +partwork.biz +party4you.me +partybombe.de +partyearrings.com +partyheld.de +partyweddingdress.net +parusie.de +pasarakun.art +pasarakun.me +pasarjohar.biz +pascherairjordanchaussuresafr.com +pascherairjordanssoldes.com +pascoding.com +pasdus.fr.cr +paseacuba.com +pasenraaghous.xyz +pashter.com +passacredicts.xyz +passas7.com +passava.com +passboxer.com +passedil.com +passgrumqui.ga +passionblood.com +passionforbusinessblog.com +passionhd.pro +passionhd18.info +passionwear.us +passive-income.tk +passiveagenda.com +passives-einkommen.ga +passport11.com +passportholder.me +passrountomb.ga +passthecpcexam.com +passtown.com +passued.site +passw0rd.cf +passw0rd.ga +passw0rd.gq +passw0rd.ml +passw0rd.tk +password.colafanta.cf +password.nafko.cf +passwordhacking.net +passwort.schwarzmail.ga +past-line.com +pastcraze.xyz +paste.emlhub.com +pastebinn.com +pastebitch.com +pasterlia.site +pastipass.com +pastmao.com +pastortips.com +pastryofistanbul.com +pastycarse.pl +pasukanganas.tk +patacore.com +patance.com +patandlornaontwitter.com +patchde.icu +patcourtna.ga +pateba.ga +patedi.ga +patheticcat.cf +patho.com +pathtoig.com +patity.com +patmortspac.ga +patmui.com +patonce.com +patorodzina.pl +patrickmeinhardt.de +patriotsjersey-shop.com +patriotsprofanshop.com +patriotsproteamsshop.com +patriotssportshoponline.com +pattyhearts.website +patzwccsmo.pl +pauikolas.tk +paulblogs.com +paulfucksallthebitches.com +paulkippes.com +paulpartington.com +paulsmithgift.com +paulsmithnihonn.com +paulsmithpresent.com +paulwardrip.com +paulzbj.ml +pautriphhea.ga +pavestonebuilders.com +pavilionx2.com +pawfullyfit.com +pawgpt.nl +pawssentials.com +pawtopup.com +paxlys.com +paxnw.com +paxven.com +pay-debtor.com +pay-mon.com +pay-pal48996.ml +pay-pal55424.ml +pay-pal63.tk +pay-pal8585.ml +pay-pal8978746.tk +pay-pals.cf +pay-pals.ga +pay-pals.ml +pay-pals54647.cf +pay-pals5467.ml +pay-pp.top +pay-us.lol +pay.rentals +pay2pay.com +pay4d.space +payadoctoronline.com +paych.com +payday-loans-since-1997.co.uk +paydayadvanceworld.co.uk +paydaycash750.com.co +paydaycic2013.co.uk +paydayinstantly.net +paydayjonny.net +paydaylaons.org +paydayloan.us +paydayloanaffiliate.com +paydayloanmoney.us +paydayloans.com +paydayloans.org +paydayloans.us +paydayloansab123.co.uk +paydayloansangely.co.uk +paydayloansbc123.co.uk +paydayloansonline1min.com +paydayloansonlinebro.com +paydayloansproviders.co.uk +paydayloanyes.biz +paydayoansangely.co.uk +paydaypoll.org +paydayquiduk.co.uk +payeer-ru.site +payforclick.net +payforclick.org +payforpost.net +payforpost.org +payinapp.com +paying-tax.com +paylaar.com +paylessclinic.com +paymentfortoday.com +paymentmaster.gq +payot.club +paypal.comx.cf +payperex2.com +payprinar.ga +payseho.ga +payspun.com +paytesacard.app +paytesacard.com +pazard.com +pazarlamadahisi.com +pazuric.com +pb-shelley.cf +pb-shelley.ga +pb-shelley.gq +pb-shelley.ml +pb-shelley.tk +pb.yomail.info +pb5g.com +pbastaff.org +pbbb.emlpro.com +pbitrading.com +pbloodsgmu.com +pbridal.com +pbs.laste.ml +pbt.freeml.net +pbtower.com +pc-au.lol +pc-service-in-heidelberg.de +pc.emltmp.com +pc1520.com +pc24poselokvoskresenki.ru +pcaa.lol +pcaccessoriesshops.info +pcapsi.com +pcattended.com +pcc.mailboxxx.net +pccareit.com +pccomputergames.info +pcdashu.com +pcfastkomp.com +pcgameans.ru +pcgamemart.com +pchatz.ga +pcijztufv1s4lqs.cf +pcijztufv1s4lqs.ga +pcijztufv1s4lqs.gq +pcijztufv1s4lqs.ml +pcijztufv1s4lqs.tk +pcixemftp.pl +pckage.com +pcknowhow.de +pclaptopsandnetbooks.info +pcmo.de +pcmo.laste.ml +pcmylife.com +pco.emltmp.com +pcpccompik91.ru +pcq.yomail.info +pcqasought.com +pcrc.de +pcusers.otherinbox.com +pcz.emltmp.com +pd6badzx7q8y0.cf +pd6badzx7q8y0.ga +pd6badzx7q8y0.gq +pd6badzx7q8y0.ml +pd6badzx7q8y0.tk +pd7a42u46.pl +pdam.com +pdaoffice.com +pdaworld.online +pdaworld.store +pdazllto0nc8.cf +pdazllto0nc8.ga +pdazllto0nc8.gq +pdazllto0nc8.ml +pdazllto0nc8.tk +pdc.emlpro.com +pdcqvirgifc3brkm.cf +pdcqvirgifc3brkm.ga +pdcqvirgifc3brkm.gq +pdcqvirgifc3brkm.ml +pdcqvirgifc3brkm.tk +pddauto.ru +pdf-cutter.com +pdf24-ch.org +pdfa.site +pdfa.space +pdfb.site +pdfc.site +pdfd.site +pdfd.space +pdff.site +pdfh.site +pdfi.press +pdfia.site +pdfib.site +pdfie.site +pdfif.site +pdfig.site +pdfih.site +pdfii.site +pdfij.site +pdfik.site +pdfim.site +pdfin.site +pdfio.site +pdfip.site +pdfiq.site +pdfir.site +pdfis.site +pdfit.site +pdfiu.site +pdfiv.site +pdfiw.site +pdfix.site +pdfiy.site +pdfiz.site +pdfj.site +pdfk.site +pdfl.press +pdfl.site +pdfly.in +pdfm.site +pdfp.site +pdfpool.com +pdfq.site +pdfr.site +pdfra.site +pdfrb.site +pdfrc.site +pdfrd.site +pdfre.site +pdfrf.site +pdfrg.site +pdfrh.site +pdfri.site +pdfrj.site +pdfrk.site +pdfrl.site +pdfrm.site +pdfrn.site +pdfro.site +pdfrp.site +pdfs.icu +pdfs.press +pdfsa.site +pdfsb.site +pdfsc.site +pdfsd.site +pdfse.site +pdfsg.site +pdfsh.site +pdfsi.site +pdfsj.site +pdfsk.site +pdfsl.site +pdfsm.site +pdfsn.site +pdfso.site +pdfsp.site +pdfsq.site +pdfsr.site +pdfss.site +pdfst.site +pdfsv.site +pdfsw.site +pdfsx.site +pdfsy.site +pdfsz.site +pdft.site +pdfu.site +pdfw.site +pdfy.site +pdfz.icu +pdfz.site +pdfzi.biz +pdjkyczlq.pl +pdmmedical.org +pdoax.com +pdold.com +pdood.com +pdtdevelopment.com +pe.hu +pe.yomail.info +pe19et59mqcm39z.cf +pe19et59mqcm39z.ga +pe19et59mqcm39z.gq +pe19et59mqcm39z.ml +pe19et59mqcm39z.tk +peace.mielno.pl +peacebuyeriacta10pills.com +peachcalories.net +peachsleep.com +peacoats.co +peak.oueue.com +peakance.com +peakbitlab.com +peakfixkey.com +peakfizz.com +peakinbox.net +peakkutsutenpojp.com +peakpoppro.com +peaksneakerjapan.com +peaksun.com +peakwavepro.com +peakwaveway.com +peapz.com +pear.email +pearless.com +pearly-papules.com +pearlypenilepapulesremovalreview.com +peatresources.com +pebih.com +pebkit.ga +pebti.us +pecbo.org +pecdo.com +peci.emlpro.com +pecinan.com +pecinan.net +pecinan.org +pecintapoker.com +pecmail.gq +pecmail.tk +pectcandtive.gettrials.com +pedalpatchcommunity.org +pedangcompany.com +pedes.spicysallads.com +pedias.org +pediatrictherapyandconsult.com +pedigon.com +pedimed-szczecin.pl +pedpulm.com +peemanlamp.info +peepeepopoda.com +peepto.me +peer10.tk +peerbonding.com +peevr.com +peewee-sweden.com +pegasse.biz +pegasus.metro.twitpost.info +pegasusaccounting.com +pegellinux.ga +pegoku.com +pegweuwffz.cf +pegweuwffz.ga +pegweuwffz.gq +pegweuwffz.ml +pegweuwffz.tk +peidmont.org +peio.com +peix.xyz +pejovideomaker.tk +pekanrabu.biz +pekimail.com +pekin.org +pekl.ml +pekoi.com +pekow.org +pekow.us +pekow.xyz +peksmcsx.com +pel.com +pelagius.net +pelanpelanmas.my.id +pelecandesign.com +peler.tech +peliscloud.com +pelor.ga +pelor.tk +pelrofis.gq +peluang-vip.com +pelung.com +pemail.com +pemberontakjaya88.com +pembola.com +pemess.com +pemwe.com +pen960.ml +penakturu.email +penampilannieken.io +penandpaper.site +pencalc.xyz +pencap.info +pencemaran.com +pendapatmini.net +pendivil.site +pendokngana.cf +pendokngana.ga +pendokngana.gq +pendokngana.ml +pendokngana.tk +penelopegemini.co.uk +penelopegemini.com +penelopegemini.uk +penemails.com +penest.bid +pengangguran.me +pengelan123.com +penghasilan.online +penguincreationdate.pw +penienet.ru +penimed.at +penis.computer +penisenlargementbiblereview.org +penisenlargementshop.info +penisgoes.in +penisuzvetseni.com +penmangroup.com +pennwoods.net +pennyauctionsonlinereview.com +peno-blok1.ru +penoto.tk +penraker.com +pens4t.pl +pensjonatyprojekty.pl +penspam.com +pentagonltd.co.uk +pentest-abc.net +penuyul.online +penyewaanmobiljakarta.com +peogi.com +peopledrivecompanies.com +peoplehavethepower.cf +peoplehavethepower.ga +peoplehavethepower.gq +peoplehavethepower.ml +peoplehavethepower.tk +peopleloi.club +peopleloi.online +peopleloi.site +peopleloi.website +peopleloi.xyz +peoplemr.biz +peoplepc.fr +peoplepoint.ru +peoplepoliticallyright.com +pep.emlpro.com +pepamail.com +pepbot.com +pepenews.club +peppe.usa.cc +pepperlink.net +pepperload.com +pepsi.coms.hk +pepsisanc.com +peptide-conference.com +peptize29nq.online +peq.emlhub.com +pequenosnegocioslucrativos.com +peramatozoa.info +perance.com +perasut.us +peratron.com +perceptium.com +perchsb.com +percikanilmu.com +percyfx.com +perdeciertac.com +perdoklassniki.net +perdredupoids24.fr +pereezd-deshevo.ru +pereirafitzgerald.com +perelinkovka.ipiurl.net +peresvetov.ru +perevozim78spb.ru +perevozov.com +perfect-teen.com +perfect-u.pw +perfectcreamshop.com +perfectfirstimpressions.com +perfectnetworksbd.com +perfectskinclub.com +perfectth.com +perfectu.pw +perfomjobs.com +perfromance.net +perfumephoenix.com +perg.laste.ml +pergi.id +perillorollsroyce.com +periperoraro.com +perirh.com +peristical.xyz +peritusauto.pl +perjalanandinas.cf +perjalanandinas.ga +perjalanandinas.gq +perjalanandinas.ml +perjalanandinas.tk +perkdaily.com +perkinsit.com +perkypoll.com +perkypoll.net +perkypoll.org +perl.mil +perm-master.ru +permanentans.ru +permcourier.com +permkurort.ru +perpetualsecurities.com +perplexisme.io +perrybear.com +pers.craigslist.org +persatuanburuh.us +persebaya1981.cf +persebaya1999.cf +pershart.com +persimmongrove.org +person.blatnet.com +person.cowsnbullz.com +person.lakemneadows.com +person.marksypark.com +person.martinandgang.com +personal-email.ml +personal-fitness.tk +personal-health-information.com +personalassistant.live +personalcok.cf +personalcok.ga +personalcok.gq +personalcok.ml +personalcok.tk +personalenvelop.cf +personalinjuryclaimsadvice.com +personalizedmygift.com +personalizedussbsales.info +personalmailer.cf +personaltrainerinsurancequote.com +perspectivescs.org +pertera.com +perthusedcars.co.uk +pertinem.ml +pertinenthersavira.net +pertoys.shop +peru-nedv.ru +perutmules.buzz +perverl.co.cc +pervova.net +pesachmeals.com +pesico.com +pesnibeez.ru +pesowuwzdyapml.cf +pesowuwzdyapml.ga +pesowuwzdyapml.gq +pesowuwzdyapml.ml +pesowuwzdyapml.tk +pestabet.com +pet-care.com +pet.emlhub.com +petalmail.tk +petalmail.xyz +petebrigham.net +peterdethier.com +petergunter.com +peterhoffmanlaw.com +peterschoice.info +petertijj.com +petervwells.com +petesauto.com +petiscoprojects.site +petitemademoiselle.it +petiteyusefha.co +petitlien.fr +petloca.com +petphotographer.photography +petrhofman.shop +petrolgames.com +petromap.com +petronas.cf +petronas.gq +petrzilka.net +petscares.life +petscares.live +petscares.online +petscares.shop +petscares.world +petsday.org +petshomestore.com +petssiac.com +pett41.freshbreadcrumbs.com +peugeot-citroen-fiat.ru +peugeot-club.org +peugeot206.cf +peugeot206.ga +peugeot206.gq +peugeot206.ml +pewnealarmy.pl +pewpewpewpew.pw +pexda.co.uk +peyekkolipi.buzz +peyeng.site +peykesabz.com +peyonic.site +peyzag.ru +pezda.com +pezhub.org +pezi.emlhub.com +pezmail.biz +pfgvreg.com +pflege-schoene-haut.de +pflznqwi.xyz +pfmretire.com +pfortunezk.com +pft.spymail.one +pfui.ru +pg.yomail.info +pg59tvomq.pl +pgazhyawd.pl +pgbs.de +pgby.dropmail.me +pgbyx.anonbox.net +pgd.spymail.one +pgdln.cf +pgdln.ga +pgdln.gq +pgdln.ml +pgfweb.com +pgioa4ta46.ga +pgjgzjpc.shop +pgne.spymail.one +pgobo.com +pgqudxz5tr4a9r.cf +pgqudxz5tr4a9r.ga +pgqudxz5tr4a9r.gq +pgqudxz5tr4a9r.ml +pgqudxz5tr4a9r.tk +pgri22sma.me +pgslotwallets.com +pgtr.laste.ml +pguar-t.com +pgwj.emlpro.com +ph7cb.anonbox.net +phaantm.de +phamay.com +phamtuki.com +phanmembanhang24h.com +phanmemfacebook.com +phanmemmaxcare.com +phantommail.cf +phantomsign.com +pharm-france.com +pharma-pillen.in +pharmacy-city.com +pharmacy-generic.org +pharmacy-online.bid +pharmacycenter.online +pharmacyshop.top +pharmafactsforum.com +pharmasiana.com +pharmatiq.com +pharmshop-online.com +pharmwalmart.com +pharusa.biz +pharveta.ga +phatculol.click +phatmail.net +phatrukhabaenglish.education +phbikemart.com +phclaim.ml +phcornerdns.com +phctool.com +phd-com.ml +phd-com.tk +phdriw.com +phdsearchandselection.com +phea.ml +phearak.ml +pheasantridgeestates.com +phecrex.cf +phecrex.ga +phecrex.gq +phecrex.ml +phecrex.tk +phefinsi.ga +phen375-help1.com +phen375.tv +phenomers.xyz +phentermine-mortgages-texas-holdem.biz +pheolutdi.ga +phh6k4ob9.pl +phickly.site +philadelphiaflyerjerseyshop.com +philadelphiaquote.com +philatelierevolutionfrancaise.com +philihp.org +philipdowney.com +philipposflavors.com +philipsmails.pw +phillipsandtemro.com +philosophyquotes.org +phim.best +phim47.com +phim68vn.com +phimg.org +phimib.com +phimteen.net +phitheon.com +phj.freeml.net +phkp446e.orge.pl +phmail.us +phmb5.anonbox.net +phn.dropmail.me +phobicpatiung.biz +phoe.com +phoenixdate.com +phoenixexteriorsllc.com +phoenixstyle.com +phonam4u.tk +phone-elkey.ru +phone-top-new-speed.club +phone-zip.com +phoneaccessoriestips.info +phonearea.us +phonecalltracking.info +phonecasesforiphone.com +phonecasesforiphonestore.com +phonestlebuka.com +phongchongvirus.com +phonghoithao.net +phongpon.click +phopocy.com +phosk.site +photo-impact.eu +photoaim.com +photobrex.com +photocircuits.com +photoconception.com +photodezine.com +photoimaginganddesign.com +photomark.net +photonmail.com +photonspower.com +phpbb.uu.gl +phpieso.com +phpmail.pro +phpto.us +phqobvrsyh.pl +phrase-we-had-to-coin.com +phrastime.site +phreaker.net +phsacca.com +phse.com +phtunneler.cf +phtunneler.com +phtunneler.ml +phtunnelerph.com +phtunnelerr.com +phubt.com +phucdpi3112.com +phucmmo.com +phugruphy.com +phuked.net +phukiend2p.store +phukk.anonbox.net +phuongblue1507.xyz +phuongfb.com +phuongphamfb.site +phuongpt9.tk +phuongsimonlazy.ga +phus8kajuspa.cu.cc +phymail.info +phymix.de +phyones.com +physcroenmail.com +physiall.site +physicaladithama.io +physicalcloud.co +physicaltherapydegree.info +physicaltherapysalary.info +phz.dropmail.me +pi.vu +piaa.me +piabellacasino.com +piaggio.cf +piaggio.ga +piaggio.gq +piaggio.ml +piaggioaero.cf +piaggioaero.ga +piaggioaero.gq +piaggioaero.ml +piaggioaero.tk +piala188.com +pialaeropa180.com +piamendi.ga +pianomusicinfo.com +pianounlimited.com +pianoxltd.com +piappp.se +piaskowanie24.pl +piba.info +pibgmible.ga +pibubear.ga +pibwifi.com +picandcomment.com +picanto.pl +picbop.com +picdirect.net +picdv.com +picfame.com +picfibum.ga +pichosti.info +pickadulttoys.com +pickawash.com +pickettproperties.org +picklez.org +pickmail.org +pickmemail.com +picknameme.fun +picktu.pics +pickupizrg.com +pickuplanet.com +pickybuys.com +pickyourmail.info +picomail.biz +picous.com +picsart.site +picsedate.com +picsviral.net +picture-movies.com +pictureattic.com +pictureframe1.com +picvw.com +pid.mx +pidcockmarketing.com +pidhoes.com +pidmail.com +pidouno.com +pidox.org +pie.favbat.com +piecza.ml +pieknanaplazylezy.eu +pieknewidokilasem.eu +pieknybiust.com.pl +pient.com +piepeka.ga +pietergroup.com +pietershop.com +pieu.site +piewish.com +piftir.com +pig.pp.ua +pigeon-mail.bid +pigeonmail.bid +pigeonprotocol.com +piggybankcrypto.com +piggywiggy22.info +pigicorn.com +pigmanis.site +pigsin.shop +pigybankcoin.com +pihey.com +pii.at +pijan.my +pijanify.my +pikabu.press +pikagen.cf +pikespeakcardiology.com +piki.si +pikirkumu.cf +pikirkumu.ga +pikirkumu.gq +pikirkumu.ml +pikolanitto.cf +pikos.online +pilazzo.ru +piletaparvaz.com +piletaparvaz.ir +pilios.com +pillen-fun-shop.com +pillole-blu.com +pillole-it.com +pillowfightlosangeles.com +pillsbreast.info +pillsellr.com +pillsshop.info +pillsvigra.info +pilomaterial57.ru +piloq.com +pilosella.club +pilottime.com +pilpres2018.ga +pilpres2018.ml +pilpres2018.tk +pilv.com +pimalu.com +pimeariver.com +pimmel.top +pimmt.com +pimpedupmyspace.com +pimples.com +pimpmystic.com +pimpstyle.com +pimr.spymail.one +pin-fitness.com +pinaclecare.com +pinafh.ml +pinamail.com +pinbahis237.com +pinbhs4.com +pinbookmark.com +pinchevisados.tk +pinchevisauno.cf +pincoffee.com +pinecuisine.com +pinehill-seattle.org +pinehollowquilts.com +pinemaile.com +pinetreesports.com +pinf.emlhub.com +pingbloggereidan.com +pingddns.com +pingddns.net +pingddns.org +pingextreme.com +pingir.com +pingxtreme.com +pinkfrosting.com.au +pinkgifts.ru +pinkgreengenerator.me +pinkiezze.com +pinkinbox.org +pinklovers.net +pinknbo.cf +pinknbo.ga +pinknbo.gq +pinknbo.ml +pinkribbonmail.com +pinksalt.org +pinoy.monster +pinoyflex.tv +pinsmigiterdisp.xyz +pinstripesecretarial.com +pintermail.com +pinupmail.space +pio21.pl +piocvxasd321.info +piogroup.software +pioj.online +piolk.online +pioneer.pro +pioneeri.com +pipaipo.org +pipecutting.com +pipemail.space +pipi.net +pipinbos.host +pipiska6879.ga +pipiska6879.ml +pipiska6879.tk +pippoc.com +pippop.cf +pippopmig33.cf +pippopmigme.cf +pippuzzo.gq +piqamail.top +piquate.com +piralsos.com +pirataz.com +piratedgiveaway.ml +pirategy.com +piribet100.com +pirogovaov.website +pirolsnet.com +piromail.com +piry.site +pisakii.pl +pisanie-tekstow.pl +pisceans.co.uk +piscium.minemail.in +piscosf.com +pisdapoolamoe.com +piseliger.xyz +pisem.net +pisls.com +pisqopli.com +pistolcrockett.com +pitamail.info +pitaniezdorovie.ru +piter-nedv.ru +pithu.org +pitiful.pp.ua +pitimail.xxl.st +pitkern-nedv.ru +pitonresources.org +pittatech.com +pittpenn.com +pittsborochiro.com +pitvn.ga +piuminimoncler2013italia.com +piuminimoncler2013spaccio.com +piusmbleee49hs.cf +piusmbleee49hs.ga +piusmbleee49hs.gq +piusmbleee49hs.ml +piusmbleee49hs.tk +pivo-bar.ru +piwopiwo.com.pl +piwu.laste.ml +pix.freeml.net +pixatate.com +pixdd.com +pixdoudounemoncler.com +pixego.com +pixelgagnant.net +pixelrate.info +pixelsshop.xyz +pixeltips.xyz +pixerz.com +pixieapp.com +pixiegirlshop.com +pixiil.com +pixoledge.net +piz.freeml.net +pizu.ru +pizu.store +pizza25.ga +pizzaface.com +pizzajunk.com +pizzamagic.com +pizzament.com +pizzanadiapro.website +pizzanewcas.eu +pj.laste.ml +pj12l3paornl.cf +pj12l3paornl.ga +pj12l3paornl.gq +pj12l3paornl.ml +pj12l3paornl.tk +pja.laste.ml +pja.yomail.info +pjbals.co.pl +pjbpro.com +pji40o094c2abrdx.cf +pji40o094c2abrdx.ga +pji40o094c2abrdx.gq +pji40o094c2abrdx.ml +pji40o094c2abrdx.tk +pjjkp.com +pjm.laste.ml +pjmanufacturing.com +pjw.yomail.info +pk.laste.ml +pk2s.com +pk4.org +pk7lz.anonbox.net +pkcabyr.cf +pkcabyr.ml +pkdnht.us +pkj.emltmp.com +pkrzh.storeyee.com +pkwccarbnd.pl +pkwreifen.org +pkykcqrruw.pl +pl-praca.com +pl.emlhub.com +pl85s5iyhxltk.cf +pl85s5iyhxltk.ga +pl85s5iyhxltk.gq +pl85s5iyhxltk.ml +pl85s5iyhxltk.tk +placathic.ga +placdescre.ga +placebod.com +placebomail10.com +placebrony.link +placemail.online +placeright.ru +placrospho.ga +pladprodandartistmgt.com +plainst.site +plancetose.com +planchas-ghd.org +planchasghdy.com +plancul2013.com +planet-travel.club +planetario.online +planetvirtworld.ru +planeze.com +plangeeks.com +planiwpreap.ga +plano-mail.net +planowaniewakacji.pl +plansulcutt.ga +plant-stand.com +plant.vegas +plant1plant.com +plantbasedbacon.com +plantcarbs.com +plantfeels.com +plantiary.com +planto.net +plants61.instambox.com +plantsvszombies.ru +planyourwed.com +plaspayti.ga +plasticandclothing.com +plasticwebsites.com +plastikmed.com +plateapanama.com +plates4skates2.info +platini.com +platinum-plus.com +platinum.blatnet.com +platinum.cowsnbullz.com +platinum.emailies.com +platinum.poisedtoshrike.com +platinumalerts.com +platinumr.com +platrax-tg.ga +plavixprime.com +play1x.icu +play555.best +play588.com +playcard-semi.com +playcell.fun +playcoin.online +player-midi.info +players501.info +playforfun.ru +playforpc.icu +playfortunaonline.ru +playfunplus.com +playfuny.com +plaync.top +playonlinerealcasino.com +playsbox.ru +playsportsji.com +playtell.us +playtheopenroad.com +playtoou.com +playtubes.net +playwithkol.com +playxo.com +plc.laste.ml +plclip.com +plcschool.org +plcshools.org +pleasanthillapartments.com +pleasedontsendmespam.de +pleasegoheretofinish.com +pleasenoham.org +pleasherrnan.ga +pleasherrnan.ml +pleb.lol +pleca.com +plecmail.ml +plee.nyc +plemedci.ga +plemrapen.ga +plerexors.com +plesniaks.com.pl +plethurir.ga +plexamab.ga +plexfirm.com +plexolan.de +plexvenet.com +plez.org +plfdisai.ml +plfdisai.tk +plgbgus.ga +plgbgus.ml +plhk.ru +plhosting.pl +plht.mailpwr.com +pliego.dev +pliqya.xyz +plitkagranit.com +pliz.fr.nf +pljqj.anonbox.net +ploae.com +plodexe.com +ploki.fr +plokpgmeo2.com +plollpy.edu +ploncy.com +ploneix.com +ploraqob.ga +plorhosva.ga +plotterart.com +plotwin.xyz +ployapp.com +ployerem.com +plrdn.com +plsh.xyz +plt.com.pl +pluggedinsocial.net +plughk.com +plumber-thatcham.co.uk +plumberdelray.com +plumberjerseycity.info +plumberplainfieldnj.info +plumbingpackages.com +plumblandconsulting.co.uk +plumdrop.xyz +plumfox.com +plumrelrei.ga +plumrelrei.ml +plumripe.com +plumrite.com +plus-size-promdresses.com +plusance.com +plusfieldzone.com +plusfitgate.com +plusfitpoint.com +plusgmail.ru +plusiptv.xyz +plusmail.cf +plusonefactory.com +plussizecorsets4sale.com +plussized.xyz +plussmail.com +plussparknet.com +plussparkzen.com +plustrak.ga +plutocow.com +plutofox.com +plw.me +plxa.com +plymouthrotarynh.org +plyty-betonowe.com.pl +pm.emlpro.com +pm8m8g.spymail.one +pmail.site +pmarketst.com +pmbk.spymail.one +pmcindia.com +pmcj.laste.ml +pmdlt.win +pmeq.laste.ml +pmeshki.ru +pmlep.de +pmpmail.org +pmq.spymail.one +pmriverside.com +pmsvs.com +pmtmails.com +pmtr.emlhub.com +pmw.emlhub.com +pn.emlpro.com +pnc.laste.ml +pndan.com +pnew-purse.com +pngrise.com +pngykhgrhz.ga +pngzero.com +pnizgotten.com +pnmproduction.com +pno.emlpro.com +pnpbiz.com +pnrep.com +pnvp7zmuexbqvv.cf +pnvp7zmuexbqvv.ga +pnvp7zmuexbqvv.gq +pnvp7zmuexbqvv.ml +pnvp7zmuexbqvv.tk +po-telefonu.net +po.bot.nu +po.com +po.laste.ml +poainec.com +poalmail.ga +poanunal.ga +poanunal.tk +pob9.pl +poblx.com +pobpx.com +pochatkivkarmane.ga +pochatkivkarmane.gq +pochatkivkarmane.ml +pochatkivkarmane.tk +pochta.pw +pochta2.xrumersoft.ru +pochta2018.ru +pochta3.xrumersoft.ru +pochtac.ru +pochtadom.com +pochtamt.ru +pochtar.men +pochtar.top +pochwilowke.com.pl +pocketino.digital +pocketslotz.co +poclickcassx.com +poco.redirectme.net +pocupki.ru +poczta.bid +poczta.pl +pocztaaonet.pl +pocztex.ovh +poczxneolinka.info +poczxneolinkc.info +podam.pl +podarbuke.ru +podatnik.info +poderosa.com +podgladaczgoogle.pl +podhub.email +podkarczowka.pl +podlogi.net +podmozon.ru +podpiski24.online +poegal.ru +poehali-otdihat.ru +poenir.com +poers.com +poesd.com +poesie-de-nuit.com +poeticise.ml +poetred.com +poetrysms.in +poetrysms.org +poey4.anonbox.net +pofmagic.com +pogotowiepozyczkowe.com.pl +poh.ong +poh.pp.ua +pohotmi.ga +pointandquote.com +pointcreator.com +pointsom.com +pointssurvey.com +poioijnkjb.cf +poioijnkjb.ml +poiopuoi568.info +poisontech.net +poiuweqw2.info +pojdveri.ru +pojok.ml +pojx.laste.ml +pokeett.site +pokegofast.com +pokeline.com +pokemail.net +pokemonbattles.science +pokemons1.fr.nf +poker-texas.com.pl +pokerasean.com +pokerbonuswithoutdeposit.com +pokercash.org +pokerduo.com +pokerface11.info +pokeronlinecc.site +pokersdating.info +pokersgg.com +pokertexas1001.com +pokertexas77.com +pokertexasidn.com +pokesmail.xyz +poketani.nl +poketi-simmern.de +pokeymoms.org +poki.us +pokiemobile.com +pokjey.com +poko.my +pokr-str.ru +pokr.com +pokrowcede.pl +pokupai-mili.ru +poky.ro +polacy-dungannon.tk +polameaangurata.com +poland-nedv.ru +polaniel.xyz +polaris-280.com +polarkingxx.ml +polasela.com +polatalam.network +polatalemdar.com +polatcas.cfd +polatfafsca.shop +polatrix.com +polatyaninecmila.shop +polccat.site +polemarh.ru +polen-ostsee-ferienhaus.de +polesk.com +polezno2012.com +policare.com +policity.ml +poliden.me +polikasret.ml +polimatsportsp.com +polimi.ml +polina777.ru +polinom.ga +polioneis-reborb.com +polishbs.pl +polishmasters.ml +polishusa.com +polishxwyb.com +polit-tekhnologiya.ru +politesuharnita.io +politicalcowboy.com +politikerclub.de +polits.info +poliusraas.tk +polizisten-duzer.de +polkaauth.com +polkadot.tk +polkaidot.ml +polkaroad.net +polkarsenal.com +pollgirl.org +polljonny.org +pollrokr.net +pollux.mineweb.in +pollys.me +polmaru.ga +polnaserdew.ga +polobacolono.com +polohommefemmee2.com +polol.com +polopasdcheres.com +polopashcheres.com +polopasqchere7.com +poloralphlaurenjacket.org +poloralphlaurenpascheresfrancefr.com +poloralphlaurenpascherfr1.com +polosburberry.com +polosiekatowice.pl +polostar.me +polpo93w.com +polpuzzcrab.ga +polres-aeknabara.cf +polsekan.club +polskikatalogfirm.pl +poltawa.ru +polvexar.space +poly-swarm.com +polyace.ru +polycond.eu +polyfaust.com +polyfaust.net +polyformat.media +polyfox.xyz +polygami.pl +polymnestore.co +polymorph.icu +polysolextcoin.cloud +polyswarms.com +polytrame.com +pomka997.online +pomorscyprzedsiebiorcy.pl +pompanette.maroonsea.com +pomyslnaatrakcjedladzieci.pl +pomysloneo.net +pomyslynabiznes.net +ponahakizaki.xyz +ponenes.info +pongpong.org +ponibo.com +ponibox.com +ponili.cf +ponk.com +ponotaxi.com +ponp.be +pontualcontabilidade.org +poo.email +pooae.com +pooasdod.com +pooev.com +poofy.org +pooj.de +pookmail.com +poolameafrate.com +poolemail.men +poolfared.ml +poolitalia.com +poolkantibit.site +poolph.com +poolseidon.com +pooltoys.com +poolx.site +pooo.dropmail.me +pooo.ooguy.com +poopiebutt.club +pop-game.top +pop-newpurse.com +pop-s.xyz +pop.com +pop2011email.co.tv +pop3.xyz +pop3boston.top +pop3email.cz.cc +pop3mail.cz.cc +popa-mopa.ru +popak.work.gd +popbum.com +popcanadagooseoutlet.com +popconn.party +popcornfarm7.com +popcornfly.com +popecompany.com +popemailwe.com +popeorigin.pw +popesodomy.com +popgx.com +popherveleger.com +poplk.com +popmail.io +popmail3.veinflower.veinflower.xyz +popmaildf.com +popmailserv.org +popmailset.com +popmailset.org +popmile45.com +popofish.com +popol.fr.nf +popolo.waw.pl +poppell.eu +poppellsimsdsaon.eu +poppunk.pl +poppuzzle.com +popso.cf +popso.ga +popso.gq +popso.ml +popso.tk +popsok.cf +popsok.ga +popsok.gq +popsok.ml +popsok.tk +popteen4u.com +popularbagblog.com +popularclub.com +popularedstore.com +popularjackets.info +popularmotorcycle.info +popularswimwear.info +populiser.com +popuptvs.net +popuza.net +poqjwfpoqwfpoqwjeq.ga +poqnwfpoqwiepoqwnep.ga +poqwnfpoqwopqweiqwe.ga +porarriba.com +porch-pride-slight-feathers.xyz +porchauhodi.org +porco.cf +porco.ga +porco.gq +porco.ml +pordiosw.com +pordpopogame.com +poreglot.ru +porevoorevo.co.cc +porhantek.shop +poribikers.tk +porilo.com +porjoton.com +porkinjector.info +porn-movies.club +pornfreefiles.com +pornizletr.com +porno-man.com +porno-prosto.ru +porno-sex-video.net +pornobilder-mal-gratis.com +pornoclipskostenlos.net +pornomors.info +pornopopki.com +pornoseti.com +pornosexe.biz +pornosiske.com +porororebus.top +porry.store +porsh.net +porsilapongo.cl +port-to-port.com +porta.loyalherceghalom.ml +portableblender.club +portablespeaker.club +portablespins.co +portadosfundos.ml +portal-finansowy.com.pl +portal-internetowo-marketingowy.pl +portal-marketingowy.pl +portal-ogloszeniowy-24.pl +portal.academic.edu.rs +portalcutter.com +portalduniajudi.com +portalix.network +portalliveai.com +portalnetworkai.com +portalplantas.com +portalsehat.com +portaltrendsarena.com +portalvideo.info +portalweb.icu +portalworldai.com +portatiles.online +porterbraces.com +portigalconsulting.com +portocalamecanicalor.com +portocalelele.com +portsaid.cc +portsefor.ga +portu-nedv.ru +posatlanta.net +posdz.com +posicionamientowebmadrid.com.es +posiedon.me +posiedon.site +posiklan.com +posmotretonline.ru +possystemsguide.com +post-box.in +post-box.xyz +post-mail-server.com +post-shift.ru +post.melkfl.es +post.mydc.in.ua +post0.profimedia.net +post123.site +posta.store +posta2015.ml +postacin.com +postafree.com +postalmail.biz +postbox.cyou +postbx.ru +postbx.store +postcardsfromukraine.crowdpress.it +postcm.com +postelectro.com +postemail.net +postermanderson.com +posteronwall.com +postfach.cc +postfach2go.de +posthava.ga +posthectomie.info +postheo.de +postim.de +postimel.com +postinbox.pw +postlee.eu +postnasaldripbadbreath.com +postonline.me +postroimkotedg.ru +postshift.ru +postupstand.com +posurl.ga +potaance.com +potarveris.xyz +potatoheaded.ga +potawaomi.org +potencialexstore.ru +potenss.academy +potobx.com +potrawka.eu +pottattemail.xyz +poubelle-automatique.org +poubelle-du.net +poubelle.fr.nf +pouet.xyz +pourforme.com +pourri.fr +poutineyourface.com +povaup.com +poverts.com +povorotov.ru +pow-pows.com +powcoin.net +powdergeek.com +power-leveling-service.com +power.ruimz.com +powerbike.de +powerdast.ru +powered.name +powerencry.com +powerexsys.com +powerlink.com.np +powerml.racing +poweronrepair.com +powerpressed.com +powers-balances.ru +powerscrews.com +powerssmo.com +powertoolsarea.com +powertradecopier.com +powerup.katasumber.com +powerxvista.com +powerz.org +powested.site +powiekszaniepenisaxxl.pl +powlearn.com +powmatic.com +poww.me +pox2.com +poy.e-paws.net +poy.kr +poyrtsrxve.pl +pozitifff.com +pozitiv.ru +pozycja-w-google.com +pozycjanusz.pl +pozycjonowanie-2015.pl +pozycjonowanie-jest-ok.pl +pozycjonowanie-stron-szczecin.top +pozycjonowanie.com +pozycjonowanie.com.pl +pozycjonowanie56.pl +pozycjonowaniekielce.pl +pozycjonowanieopole.net +pozycjonowanietop.pl +pozyczka-chwilowka-opinie.eu +pozyczka-chwilowki.pl +pozyczka-provident.info +pozyczkabezbik24.com.pl +pozyczkabezbikikrd.com +pozyczkasms24.com.pl +pozyczki-dowod.pl +pozyczki48.pl +pozyczkigotowkowewuk.com.pl +pozyczkiinternetowechwilowki.com.pl +pozyczkilokalne.pl +pozyczkiprywatne24.net +pozyczkiwuk.com.pl +pozyczkodawcy.com +pozyczkoserwis.pl +pozyjo.eu +pp-a1.lol +pp-a7.lol +pp-ahbaab-al-ikhlash.com +pp-ai.lol +pp-gua.top +pp-mc.lol +pp-n1.lol +pp-n6.top +pp-nn.lol +pp-no.lol +pp-tw.cc +pp-vc.lol +pp.ua +pp6.lol +pp7rvv.com +pp916.com +pp98.cf +pp98.ga +pp98.gq +pp98.ml +pp98.tk +ppaa.help +ppabldwzsrdfr.cf +ppabldwzsrdfr.ga +ppabldwzsrdfr.gq +ppabldwzsrdfr.ml +ppabldwzsrdfr.tk +ppat.lol +ppbanr.com +ppbk.ru +ppbomail.com +ppc-e.com +ppcc.lol +ppcmedia.co +ppdf.cc +pperspe.com +ppetw.com +ppgu8mqxrmjebc.ga +ppgu8mqxrmjebc.gq +ppgu8mqxrmjebc.ml +ppgu8mqxrmjebc.tk +pple.com +ppme.pro +ppmoazqnoip2s.cf +ppmoazqnoip2s.ga +ppmoazqnoip2s.gq +ppmoazqnoip2s.ml +ppnet.ru +ppoet.com +ppp998.com +pppppp.com +pppwqlewq.pw +ppqifei.top +ppri.com +pprizesmnb.com +ppshua.icu +ppst4.com +pptrvv.com +pptv.lol +ppugc.anonbox.net +ppx219.com +ppx225.com +ppx237.com +ppy.spymail.one +ppymail.win +ppz.pl +pq6fbq3r0bapdaq.cf +pq6fbq3r0bapdaq.ga +pq6fbq3r0bapdaq.gq +pq6fbq3r0bapdaq.ml +pq6fbq3r0bapdaq.tk +pqbg.emlpro.com +pqemail.top +pqi.spymail.one +pqnwfowpqiepq.ga +pqoia.com +pqoss.com +pqtoxevetjoh6tk.cf +pqtoxevetjoh6tk.ga +pqtoxevetjoh6tk.gq +pqtoxevetjoh6tk.ml +pqtoxevetjoh6tk.tk +pr1ngsil4nmu.ga +pr2xs.anonbox.net +pr4y.web.id +pr7979.com +prac6m.xyz +practicalsight.com +practicys.com +practitionergrowthinstitute.com +prada-bags-outlet.org +prada-messenge-bag.us +prada-shoes.info +pradabagsalejp.com +pradabagshopjp.com +pradabagstorejp.com +pradabagstorejp.org +pradabakery.com +pradabuyjp.com +pradahandbagsrjp.com +pradahotonsale.com +pradajapan.com +pradajapan.org +pradajapan.orgpradajapan.orgpradajapan.orgpradajapan.orgpradajapan.orgpradajapan.orgpradajapan.orgpradajapan.orgpradajapan.org +pradanewjp.com +pradanewjp.org +pradanewstyle.com +pradaoutletonline.us +pradaoutletpop.com +pradaoutletshopjp.com +pradaoutletus.us +pradapursejp.com +pradapursejp.org +pragmatic.website +pramolcroonmant.xyz +pranceville.com +pranto.me +prasannasafetynets.com +prass.me +prastganteng.online +pratik-ik.com +pratikmail.com +pratikmail.net +pratikmail.org +pravorobotov.ru +pray.agencja-csk.pl +prayersa3.com +prayshopee.cf +prazdnik-37.ru +prc.cx +prca.site +prcaa.site +prcab.site +prcac.site +prcad.site +prcae.site +prcaf.site +prcag.site +prcah.site +prcai.site +prcaj.site +prcak.site +prcal.site +prcam.site +prcan.site +prcao.site +prcap.site +prcar.site +prcas.site +prcau.site +prcav.site +prcax.site +prcay.site +prcaz.site +prcb.site +prcc.site +prcd.site +prce.site +prcea.site +prceb.site +prcec.site +prcee.site +prcef.site +prceg.site +prceh.site +prcei.site +prcej.site +prcek.site +prcel.site +prcem.site +prcen.site +prceo.site +prcep.site +prceq.site +prcer.site +prces.site +prcf.site +prcg.site +prch.site +prci.site +prcj.site +prck.site +prcl.site +prcn.site +prco.site +prcp.site +prcq.site +prcs.site +prct.site +prcu.site +prcv.site +prcx.site +prcy.site +prcz.site +prdalu.com +prebuilding.com +precisionmetalsmiths.com +precisionpestcontrol.com +predatorrat.cf +predatorrat.ga +predatorrat.gq +predatorrat.ml +predatorrat.tk +predictoraviator.xyz +prediksibola88.com +prednestr-nedv.ru +prednisone-20mg-pills.com +preferentialwer.store +prefood.ru +pregnan.ru +pregnancymiraclereviewnow.org +pregnancymiraclereviews.info +prehers.com +prekab.net +preklady-polstina.cz +prekuldown47mmi.ml +prellaner.online +premiapp.com +premierpainandwellness.com +premierr.site +premiertrafficservices.com +premigu.co +premilo.cloud +premiora.id +premipay.io +premirum.shop +premium-emailos.com +premium-mail.fr +premium4pets.info +premiumail.ml +premiumcannabis.online +premiumgreencoffeereview.com +premiumlabels.de +premiumonebd.store +premiumperson.website +premiumseoservices.net +premiumvns.com +premku.my.id +premoto.com +preorderdiablo3.com +preownedluxurycars.com +preparee.top +prepw.com +presaper.ga +prescription-swimming-goggles.info +prescriptionbyphone.com +presences.me +preseven.com +presidentoto.com +presinnil.ga +preskot.info +prespa.mochkamieniarz.pl +presporary.site +pressbypresser.info +pressreleasedispatcher.com +pressuredell.com +prestamospersonales.nom.es +prestamospersonalesfzrz.com +prestig-okno.com +prestigeii.com +prestore.co +presunad.cf +pret-a-renover-rona.com +pret-a-renover.com +pretans.com +prethlah907huir.cf +pretreer.com +prettyishlady.com +prettyishlady.net +prettylashes.co +prettysoonlips.com +prettyyards.com +preup.xyz +prevary.site +preventativeaction.com +preventth.com +previos.com +prewx.com +prfl-fb4.xyz +price.blatnet.com +price.cowsnbullz.com +price.lakemneadows.com +price.lease +price.marksypark.com +pricebit.co +priceblog.co +pricegh.com +pricegh.fun +priceio.co +pricekin.shop +pricenew.co +pricenow.co +priceonline.co +pricep.com +pricepage.co +priceplunges.com +pricetag.ru +priceworld.co +pricraball.tk +pride-worldwi.de +pride.nafko.cf +pridemail.co +prignant.com +priligyonlineatonce.com +priligyonlinesure.com +priligyprime.com +prilution-gmbh.org +primabananen.net +primails.me +primalburnkenburge.com +primaperkasa.me +primaryale.com +primate.de +prime-gaming.ru +prime-zone.ru +prime.gold.edu.pl +primeblog.us +primecialisonline.com +primejetnet.com +primelocationlets.co.uk +primerisegrid.com +primerka.co.cc +primex.club +primonet.pl +primotor.com +prin.be +prince-api.tk +prince-khan.tk +prince.id +princeance.com +princeroyal.net +princesscutengagementringsinfo.info +princessge.com +princeton-edu.com +princeton.edu.pl +princeton2008.com +princetowncable.com +principlez.com +pring.org +pringlang.cf +pringlang.ga +pringlang.gq +pringlang.ml +prinicad.ga +printala.ga +printecone.com +printemailtext.com +printersni.co.uk +printf.cf +printf.ga +printf.ml +printofart.ru +printphotos.ru +printz.site +priokfl.gr +priong.com +prioritypaydayloans.com +priorityxn5.com +priscimarabrasil.com +prisessifor.xyz +prismgp.com +prismlasers.tk +prisonity.com +priv.beastemail.com +privacy-mail.top +privacy.elumail.com +privacy.net +privacyharbour.com +privacylock.net +privacymailshh.com +privacys.tech +privacyshield.cc +privacywi.com +privatdemail.net +private-investigator-fortlauderdale.com +private-year.com +private.kubuntu.myhomenetwork.info +private33.com +privatebag.ml +privateclosets.com +privatehost.xyz +privateinvest.me +privateinvestigationschool.com +privatemail.in +privatemail1.jasaseo.me +privatemail1.katasumber.com +privatemail1.kategoriblog.com +privatemailinator.nl +privateme.site +privatemitel.cf +privatemitel.ml +privatemusicteacher.com +privatesent.tk +privboz.email +privmag.com +privmail.edu.pl +privy-mail.com +privy-mail.de +privyinternet.com +privyinternet.net +privymail.de +privyonline.com +privyonline.net +prixfixeny.com +priyo-mail.com +priyo.ovh +priyo.site +priyoemail.site +priyomail.in +priyomail.net +priyomail.top +priyomail.uk +priyomail.us +priyor.com +priyp.com +prkdi.anonbox.net +prlinkjuicer.info +prmail.top +prn.dropmail.me +pro-baby-dom.ru +pro-expert.online +pro-files.ru +pro-imports.com +pro-tag.org +pro.cloudns.asia +pro.iskba.com +pro.marksypark.com +pro.poisedtoshrike.com +pro100girl.ru +pro100sp.ru +pro2mail.net +pro5g.com +proadech.com +probabilitical.xyz +probaseballfans.net +probbox.com +probdd.com +probenext.com +probizemail.com +problemcompany.us +problemstory.us +probowlvoting.info +probowlvoting2011.info +procarautogroup.com +proceedwky.com +processzhq.com +procowork.com +procrackers.com +prodaza-avto.kiev.ua +prodelval.org +prodence.com +prodercei.ga +prodigysolutionsgroup.net +prodleskea.ga +prodojiz.ga +producativel.site +produciden.site +productdealsonline.info +productemails.info +producti-online-pro.com +production4you.ru +productpacking.com +productsproz.com +productzf.com +produgy.net +produktu.ru +produsivity.biz +proeasyweb.com +proefhhnwtw.pl +proeful.com +proemail.ml +proemeil.pl +proexbol.com +proexpertonline.ru +profast.top +profcsn.eu +profeocn.pl +profeocnn.pl +profesjonalne-pozycjonowanie.com +professional-go.com +professionalgo.live +professionalgo.site +professionalgo.store +professionalgo.website +professionalgo.xyz +professionalseast.com +professionalseoservicesuk.com +professionegommista.com +professionneldumail.com +profi-bot.ru +profihent.ru +profile3786.info +profileguard.club +profilelinkservices.com +profilepictureguard.club +profilepictureguard.net +profilific.com +profimails.pw +profinin.ga +profit-kopiarki.com +profit-pozycjonowanie.pl +profit.idea-profit.pl +profitcheetah.com +profitindex.ru +profitmate.company +profitxtreme.com +profmistde.ga +profonmail.com +profrasound.ga +progefel.ga +progem.pl +progetti.rs +progiftstore.org +progigy.net +progonrumarket.ru +progps.rs +programacomoemagrecer.org +programfact.us +programmaperspiarecellulari.info +programmeimmobilier-neuf.org +programmerov.net +programmingant.com +programmiperspiarecellulari.info +programmispiapercellulari.info +programmr.us +programpit2013rok.pl +programtv.edu.pl +programwoman.us +progrespolska.net +progressi8ve.com +prohade.com +prohisi.store +prohost24.ru +proigy.net +project-xhabbo.com +projectaus.com +projectbasho.org +projectcl.com +projectcrankwalk.com +projectgold.ru +projectku.me +projectmike.pl +projector-replacement-lamp.info +projectred.ru +projectsam.net +projectsolutionsllc.com +projekty.com +projektysamochodowe.pl +projmenkows.ga +proklain.com +prolagu.pro +prolifepowerup.com +prolug.com +promail.net +promail.site +promail1.net +promail9.net +promaild.com +promaill.com +promails.xyz +promailt.com +promdresses-short.com +promedtur.com +promenadahotel.pl +promist-sa.com +promkat.info +promo-msk.com +promobetgratis.com +promobetgratis.net +promocjawnecie.pl +promogsi.ga +promonate.site +promosbc.com +promoteion.com +promotime.com +promotion-seo.net +promotionalcoder.com +promotor.website +promotzy.com +promptly700.com +promroy.ru +promtmt.ru +promyscandlines.pl +pronkede.ga +prontobet.com +prontonmail.com +pronutech.com +proofcamping.com +propcleaners.com +propecia.ru.com +propeciabuyonlinenm.com +propeciaonlinesure.com +propeciaonlinesureone.com +properevod.ru +properties.com +propertyhotspot.co.uk +propertytalking.com +propgenie.com +propoker.vn +proporud.com +propradayo.com +proprice.co +proprietativalcea.ro +propscore.com +prorefit.eu +proscaronlinesure.com +proscarprime.com +prosek.xyz +proseriesm.info +prosfor.com +proshopnflfalcons.com +proshopnflravens.com +proshopsf49ers.com +prosingly.best +proslowo.home.pl +prosmail.info +prosolutiongelreview.net +prosolutionpillsreviews.org +prosophys.site +prospartos.co.uk +prosperformula.com +prosperidademail.com +prosperre.com +prosquashtour.net +proste-przetargi.pl +prostitutki-s-p-b.ru +prostodin.space +protechskillsinstitute.com +protection-0ffice365.com +protectionmanagers.com +protectrep.com +protectsmail.net +protectsrilanka.com +protectthechildsman.com +protectyourhealthandwealth.com +protein-krasnodar.ru +protempmail.com +protestly.com +protestore.co +protestosteronereviews.com +protipsters.net +protivirus.ru +protnonmail.com +proto2mail.com +proton-team.com +protonamail.com +protonemach.waw.pl +protongras.ga +protonic.org +protonmail55.lady-and-lunch.lady-and-lunch.xyz +protonza.com +protrendcolorshop.com +prout.be +provamail.com +proveity.com +provident-pl.info +providentwniosek.info +providentwnioski.pl +providesoft.software +providier.com +provko.com +provlst.com +provmail.net +provokedc47.tk +provsoftprov.ga +prow.cf +prow.ga +prow.gq +prow.ml +prowerl.com +prowessed.com +prowickbaskk.com +proxiesblog.com +proxito.de +proxivino.com +proxsei.com +proxy-gateway.net +proxy.dreamhost.com +proxy1.pro +proxy4gs.com +proxyduy.site +proxymail.eu +proxyparking.com +prozdeal.com +prplunder.com +prs7.xyz +prsnly.com +prtc.com +prtnews.com +prtnx.com +prtshr.com +prtxw.com +prtz.eu +pruchcongpo.ga +prudentialltm.com +pruettwaldrup.com +prumrstef.pl +prurls.com +prwmqbfoxdnlh8p4z.cf +prwmqbfoxdnlh8p4z.ga +prwmqbfoxdnlh8p4z.gq +prwmqbfoxdnlh8p4z.ml +prwmqbfoxdnlh8p4z.tk +prxnzb4zpztlv.cf +prxnzb4zpztlv.ga +prxnzb4zpztlv.gq +prxnzb4zpztlv.ml +prxnzb4zpztlv.tk +pryamieruki.ru +prydirect.info +pryeqfqsf.pl +prywatnebiuro.pl +pryworld.info +przeciski.ovh +przepis-na-pizze.pl +przeprowadzam.eu +przezsms.waw.pl +przyklad-domeny.pl +ps-nuoriso.com +ps.emlhub.com +ps126mat.com +ps160.mpm-motors.cf +ps21cn.com +ps2emulatorforpc.co.cc +ps4info.com +ps5-store.ru +psacake.me +psasey.site +psccodefree.com +pscylelondon.com +pse.laste.ml +psettinge5.com +pseudoname.io +pseyusv.com +psh.me +psicanalisi.org +psiek.com +psikus.pl +psiolog.com +psirens.icu +psk3n.com +psles.com +psmscientific.com +psnator.com +psncl.com +psncodegeneratorsn.com +psnworld.com +pso2rmt.com +psoriasisfreeforlifediscount.org +psoxs.com +pspinup.com +pspvitagames.info +psv.dropmail.me +psw.kg +psy-hd-astro.ru +psyans.ru +psychedelicwarrior.xyz +psychiatragabinet.pl +psycho.com +psychodeli.co.uk +psychologize694rf.online +psyhicsydney.com +psyiszkolenie.com +psymedic.ru +psymejsc.pl +psz.spymail.one +pt-cc.lol +pt-games.com +pt.emlpro.com +ptc.vuforia.us +ptcassino.com +ptcji.com +ptcks1ribhvupd3ixg.cf +ptcks1ribhvupd3ixg.ga +ptcks1ribhvupd3ixg.gq +ptcks1ribhvupd3ixg.ml +ptcks1ribhvupd3ixg.tk +ptcsites.in +ptct.net +ptdt.emlpro.com +pteddyxo.com +pterodactyl.email +ptgtar7lslnpomx.ga +ptgtar7lslnpomx.ml +ptgtar7lslnpomx.tk +ptgurindam.com +ptimesmail.com +ptimtailis.ga +ptiong.com +ptjdthlu.pl +ptjp.com +ptkd.com +ptll5r.us +ptmail.top +ptmm.com +ptncereio.com +ptpigeaz0uorsrygsz.cf +ptpigeaz0uorsrygsz.ga +ptpigeaz0uorsrygsz.gq +ptpigeaz0uorsrygsz.ml +ptpigeaz0uorsrygsz.tk +ptpomorze.com.pl +ptrike.com +ptsculure.com +ptsejahtercok.online +pttj.de +ptyuch.ru +pu-c.lol +puabook.com +puan.tech +puanghli.com +puapickuptricksfanboy.com +puaqbqpru.pl +pub-mail.com +pub.emltmp.com +puba.site +puba.space +pubb.site +pubc.site +pubd.site +pube.site +puberties.com +puberties.net +pubf.site +pubfb.com +pubg-pro.xyz +pubgeresnrpxsab.cf +pubgeresnrpxsab.ga +pubgeresnrpxsab.gq +pubgeresnrpxsab.ml +pubgeresnrpxsab.tk +pubgm.website +pubh.site +pubi.site +pubia.site +pubid.site +pubie.site +pubif.site +pubig.site +pubih.site +pubii.site +pubij.site +pubik.site +pubil.site +pubim.site +pubin.site +pubip.site +pubiq.site +pubir.site +pubis.site +pubit.site +pubiu.site +pubiv.site +pubiw.site +pubix.site +pubiy.site +pubiz.site +pubj.site +pubk.site +publa.site +publb.site +publc.site +publd.site +puble.site +publg.site +publh.site +publi.innovatio.es +public-files.de +publicadjusterinfo.com +publichobby.com +publictracker.com +publj.site +publl.site +publm.site +publn.site +publo.site +publp.site +publq.site +publr.site +publs.site +publt.site +publu.site +publv.site +publx.site +publz.site +pubm.site +pubmail886.com +pubn.site +puboa.site +pubp.site +pubpng.com +pubr.site +pubs.ga +pubt.site +pubv.site +pubw.site +pubwarez.com +pubwifi.myddns.me +pubx.site +puby.site +puchmlt0mt.ga +puchmlt0mt.gq +puchmlt0mt.tk +puclyapost.ga +pucp.de +pud.org +pudel.in +puds5k7lca9zq.cf +puds5k7lca9zq.ga +puds5k7lca9zq.gq +puds5k7lca9zq.ml +puds5k7lca9zq.tk +pudxe.com +pudy6.anonbox.net +puebloareaihn.org +pueblowireless.com +puegauj.pl +puelladulcis.com +puerto-nedv.ru +puffbarvapestore.com +puglieisi.com +puh4iigs4w.cf +puh4iigs4w.ga +puh4iigs4w.gq +puh4iigs4w.ml +puh4iigs4w.tk +puhuleather.com +puibagajunportbagaj.com +puikusmases.info +pujanpujari.com +pujb.emlpro.com +puje.com +puji.pro +puk.us.to +pukimay.cf +pukimay.ga +pukimay.gq +pukimay.ml +pukimay.tk +puks.de +pularl.site +pulating.site +pullcombine.com +pullmail.info +pullnks.com +pulmining.com +pulpa.pl +pulpmail.us +pulsakita.biz +pulsatiletinnitus.com +pulsedlife.com +pulseofthestreets.com +pulwarm.net +pumail.com +pumamaning.cf +pumamaning.ml +pumapumayes.cf +pumapumayes.ml +pumasale-uk.com +pumashopkutujp.com +pump-ltd.ru +pumps-fashion.com +pumpwearil.com +puncakyuk.com +punchthat.com +punchyandspike.com +punggur.tk +pungkiparamitasari.com +punishly.com +punkass.com +punkexpo.com +punkmail.com +punkproof.com +punto24.com.pl +punyabcl.com +punyaprast.nl +puouadtq.pl +puppetmail.de +puppyproduct.com +purajewel.com +purati.ga +purcell.email +purearenas.com +purecollagenreviews.net +puregreencleaning.com.au +puregreencoffeefacts.com +purelogistics.org +puremuscleproblogs.com +puressancereview.com +puretoc.com +purewhitekidneyx.org +purificadorasmex1.com.mx +purinanestle.com +puritronicde.com.mx +puritronicdemexico.com.mx +puritronicmexicano.com.mx +puritronicmexico.com.mx +puritronicmx.com.mx +puritronicmx2.com.mx +puritronicmxococo2.com.mx +puritunic.com +purixmx2000.com +purixmx2012.com +purkz.com +purmuttad.com +purnomostore.online +purokabig.com +purple.flu.cc +purple.igg.biz +purple.nut.cc +purple.usa.cc +purplemail.ga +purplemail.gq +purplemail.ml +purplemail.tk +purplepromo.com +purpleroyaltycollections.com +purselongchamp.net +purseorganizer.me +pursesoutletsale.com +pursesoutletstores.info +purseva11ey.co +pursip.com +pursuil.site +purtunic.com +pusat.biz.id +pusatinfokita.com +pusclekra.ga +push.uerly.com +push19.ru +push50.com +pushcom.store +pushmojo.com +pusmail.com +pussport.com +pustmati.ga +put2.net +puta.com +putameda.com +putdomainhere.com +putfs6fbkicck.cf +putfs6fbkicck.ga +putfs6fbkicck.gq +putfs6fbkicck.ml +putfs6fbkicck.tk +putlockerfree.info +putlook.com +putrimarino.art +putrimeilani.my.id +putsbox.com +puttana.cf +puttana.ga +puttana.gq +puttana.ml +puttana.tk +puttanamaiala.tk +putthidkr.ga +putthisinyourspamdatabase.com +puttingpv.com +putzmail.pw +puw.emlhub.com +puw.emlpro.com +puxa.top +puyenkgel50ccb.ml +puzzlepro.es +puzzspychmusc.ga +puzzspychmusc.tk +pv3xur29.xzzy.info +pv447.anonbox.net +pvcb.lol +pvcc.lol +pvccephe.com +pvcstreifen-vorhang.de +pvdprohunter.info +pver.com +pvmail.pw +pvmr.dropmail.me +pvtnetflix.com +pvuw.mimimail.me +pw-mail.cf +pw-mail.ga +pw-mail.gq +pw-mail.ml +pw-mail.tk +pw.epac.to +pw.flu.cc +pw.fm.cloudns.nz +pw.igg.biz +pw.islam.igg.biz +pw.loyalherceghalom.ml +pw.mymy.cf +pw.mysafe.ml +pw.nut.cc +pwbs.de +pweoij90.com +pwf.emltmp.com +pwfwtgoxs.pl +pwjsdgofya4rwc.cf +pwjsdgofya4rwc.ga +pwjsdgofya4rwc.gq +pwjsdgofya4rwc.ml +pwjsdgofya4rwc.tk +pwkosz.pl +pwn9.cf +pwodskdf.com +pwodskdf.net +pwp.lv +pwpwa.com +pwrby.com +pwt9azutcao7mi6.ga +pwt9azutcao7mi6.ml +pwt9azutcao7mi6.tk +pwtw.laste.ml +pwvoyhajg.pl +pwy.pl +pwyemail.com +px.freeml.net +px0dqqkyiii9g4fwb.cf +px0dqqkyiii9g4fwb.ga +px0dqqkyiii9g4fwb.gq +px0dqqkyiii9g4fwb.ml +px0dqqkyiii9g4fwb.tk +px1.pl +px4jk.anonbox.net +px9ixql4c.pl +pxddcpf59hkr6mwb.cf +pxddcpf59hkr6mwb.ga +pxddcpf59hkr6mwb.gq +pxddcpf59hkr6mwb.ml +pxddcpf59hkr6mwb.tk +pxeneu.xyz +pxih.emltmp.com +pxje.freeml.net +pxjtw.com +pxlys.com +pxq.emltmp.com +pxqpma.ga +pxtv56c76c80b948b92a.xyz +pxv.laste.ml +pxvu3.anonbox.net +py.emlpro.com +pyadu.com +pyatigorskhot.info +pyf.spymail.one +pyffqzkqe.pl +pygmypuff.com +pyhaihyrt.com +pyhtml.com +pyiauje42dysm.cf +pyiauje42dysm.ga +pyiauje42dysm.gq +pyiauje42dysm.ml +pyiauje42dysm.tk +pyjgoingtd.com +pyk.spymail.one +pyl.yomail.info +pylehome.com +pylojufodi.com +pylondata.com +pymehosting.es +pyp72.anonbox.net +pypdtrosa.cf +pypdtrosa.ga +pypdtrosa.ml +pypdtrosa.tk +pyqp.freeml.net +pyrelle.com +pyrokiwi.xyz +pyroleech.com +pyromail.info +pyskillsgame.com +pystyportfel.pl +pytb.yomail.info +pythonups.mom +pyxe.com +pyz.emltmp.com +pzikteam.tk +pzqs.emlhub.com +pzu.bz +pzuilop.de +pzwdb.anonbox.net +q-q.me +q.jetos.com +q.new-mgmt.ga +q.polosburberry.com +q.xtc.yt +q0.us.to +q0bcg1druy.ga +q0bcg1druy.ml +q0bcg1druy.tk +q0rpqy9lx.xorg.pl +q2b.ru +q2gfiqsi4szzf54xe.cf +q2gfiqsi4szzf54xe.ga +q2gfiqsi4szzf54xe.gq +q2gfiqsi4szzf54xe.ml +q2gfiqsi4szzf54xe.tk +q2lofok6s06n6fqm.cf +q2lofok6s06n6fqm.ga +q2lofok6s06n6fqm.gq +q2lofok6s06n6fqm.ml +q2lofok6s06n6fqm.tk +q314.net +q3ddo.anonbox.net +q4heo7ooauboanqh3xm.cf +q4heo7ooauboanqh3xm.ga +q4heo7ooauboanqh3xm.gq +q4heo7ooauboanqh3xm.ml +q4heo7ooauboanqh3xm.tk +q5prxncteag.cf +q5prxncteag.ga +q5prxncteag.gq +q5prxncteag.ml +q5prxncteag.tk +q5vm7pi9.com +q5zui.anonbox.net +q65pk6ii.targi.pl +q6suiq1aob.cf +q6suiq1aob.ga +q6suiq1aob.gq +q6suiq1aob.ml +q6suiq1aob.tk +q7t43q92.com +q7t43q92.com.com +q8cbwendy.com +q8ec97sr791.cf +q8ec97sr791.ga +q8ec97sr791.gq +q8ec97sr791.ml +q8ec97sr791.tk +q8fqrwlxehnu.cf +q8fqrwlxehnu.ga +q8fqrwlxehnu.gq +q8fqrwlxehnu.ml +q8fqrwlxehnu.tk +q8i4v1dvlsg.ga +q8i4v1dvlsg.ml +q8i4v1dvlsg.tk +q8z.ru +qa.freeml.net +qa.team +qaa5d3.dropmail.me +qaaw.ga +qablackops.com +qabq.com +qaclk.com +qacmemphis.com +qacmjeq.com +qacquirep.com +qaetaldkgl64ygdds.gq +qafatwallet.com +qafrem3456ails.com +qaioz.com +qakexpected.com +qascfr.tech +qasd2qgznggjrl.cf +qasd2qgznggjrl.ga +qasd2qgznggjrl.ml +qasd2qgznggjrl.tk +qassemeliwa.online +qasti.com +qatqxsify.pl +qatw.net +qazghjsoho.ga +qazmail.ga +qazmail.ml +qazulbaauct.cf +qazulbaauct.ga +qazulbaauct.gq +qazulbaauct.ml +qazulbaauct.tk +qb.hazziz.biz.st +qb04x4.badcreditcreditcheckpaydayloansloansloanskjc.co.uk +qb23c60behoymdve6xf.cf +qb23c60behoymdve6xf.ga +qb23c60behoymdve6xf.gq +qb23c60behoymdve6xf.ml +qb23c60behoymdve6xf.tk +qbaydx2cpv8.cf +qbaydx2cpv8.ga +qbaydx2cpv8.gq +qbaydx2cpv8.ml +qbaydx2cpv8.tk +qbefirst.com +qbex.pl +qbfree.us +qbg32bjdk8.xorg.pl +qbgmvwojc.pl +qbi.kr +qbikgcncshkyspoo.cf +qbikgcncshkyspoo.ga +qbikgcncshkyspoo.gq +qbikgcncshkyspoo.ml +qbikgcncshkyspoo.tk +qbj.emlhub.com +qbknowsfq.com +qbkqxrmvrh.ga +qblockingd.com +qbmail.bid +qbnifofx.shop +qbqbtf4trnycocdg4c.cf +qbqbtf4trnycocdg4c.ga +qbqbtf4trnycocdg4c.gq +qbqbtf4trnycocdg4c.ml +qbsgdf.xyz +qbt.dropmail.me +qbtemail.com +qbuog1cbktcy.cf +qbuog1cbktcy.ga +qbuog1cbktcy.gq +qbuog1cbktcy.ml +qbuog1cbktcy.tk +qbxy.dropmail.me +qc.to +qc0lipw1ux.cf +qc0lipw1ux.ga +qc0lipw1ux.ml +qc0lipw1ux.tk +qcd.dropmail.me +qceh.dropmail.me +qcf.emltmp.com +qcmail.qc.to +qcpj.freeml.net +qcu.dropmail.me +qcvsziiymzp.edu.pl +qcy.emltmp.com +qd.spymail.one +qdeathse.com +qdeliverssx.com +qdhm.emltmp.com +qdiian.com +qdl.dropmail.me +qdproceedsp.com +qdr.emltmp.com +qdrj.freeml.net +qdrwriterx.com +qds.dropmail.me +qdu.emltmp.com +qdw.emlhub.com +qe41hqboe4qixqlfe.gq +qe41hqboe4qixqlfe.ml +qe41hqboe4qixqlfe.tk +qeabluqwlfk.agro.pl +qeaxluhpit.pl +qecl.com +qedwardr.com +qefmail.com +qeft.freeml.net +qege.site +qeispacesq.com +qejjyl.com +qelawi.xyz +qeotxmwotu.cf +qeotxmwotu.ga +qeotxmwotu.gq +qeotxmwotu.ml +qeotxmwotu.tk +qepn5bbl5.pl +qeps.de +qeqrtc.ovh +qeu.laste.ml +qeurtor.com +qewaz21.eu +qewzaqw.com +qeyt.emlpro.com +qf.emltmp.com +qf.yomail.info +qf1tqu1x124p4tlxkq.cf +qf1tqu1x124p4tlxkq.ga +qf1tqu1x124p4tlxkq.gq +qf1tqu1x124p4tlxkq.ml +qf1tqu1x124p4tlxkq.tk +qfa.emltmp.com +qfavori.com +qfhh3mmirhvhhdi3b.cf +qfhh3mmirhvhhdi3b.ga +qfhh3mmirhvhhdi3b.gq +qfhh3mmirhvhhdi3b.ml +qfhh3mmirhvhhdi3b.tk +qfhometown.com +qfibiqwueqwe.ga +qfja.xyz +qfjy.laste.ml +qfm.freeml.net +qfoqwnofqweq.ga +qfrsxco1mkgl.ga +qfrsxco1mkgl.gq +qfrsxco1mkgl.ml +qfrwilliam.com +qfso.emlpro.com +qg.laste.ml +qg.yomail.info +qg8zn7nj8prrt4z3.cf +qg8zn7nj8prrt4z3.ga +qg8zn7nj8prrt4z3.gq +qg8zn7nj8prrt4z3.ml +qg8zn7nj8prrt4z3.tk +qgae.com +qgeorgea.com +qgfkslkd1ztf.cf +qgfkslkd1ztf.ga +qgfkslkd1ztf.gq +qgfkslkd1ztf.ml +qgstored.com +qhexkgvyv.pl +qhhub.com +qhid.com +qhqd.emlpro.com +qhqhidden.com +qhrgzdqthrqocrge922.cf +qhrgzdqthrqocrge922.ga +qhrgzdqthrqocrge922.gq +qhrgzdqthrqocrge922.ml +qhrgzdqthrqocrge922.tk +qhrhtlvek.com +qhsmedicaltraining.com +qhstreetr.com +qhtn.yomail.info +qhvg.emlpro.com +qhwclmql.pl +qhwigbbzmi.ga +qi.laste.ml +qianaseres.com +qianhost.com +qiantangylzc.com +qiaua.com +qibl.at +qifnsklfo0w.com +qijn.spymail.one +qinenut.site +qingheluo.com +qinicial.ru +qiofhiqwoeiopqwe.ga +qiott.com +qiowfnqowfopqpowepn.ga +qip-file.tk +qipaomei.com +qipmail.net +qiq.us +qiqmail.ml +qiradio.com +qirzgl53rik0t0hheo.cf +qirzgl53rik0t0hheo.ga +qirzgl53rik0t0hheo.gq +qirzgl53rik0t0hheo.ml +qirzgl53rik0t0hheo.tk +qisdo.com +qisoa.com +qiu.emlhub.com +qiviamd.pl +qiziriq.uz +qj.emlpro.com +qj.freeml.net +qj97r73md7v5.com +qjactives.com +qjn.emlpro.com +qjnnbimvvmsk1s.cf +qjnnbimvvmsk1s.ga +qjnnbimvvmsk1s.gq +qjnnbimvvmsk1s.ml +qjnnbimvvmsk1s.tk +qjp.emltmp.com +qjsd.freeml.net +qjuhpjsrv.pl +qjul.emltmp.com +qkbzptliqpdgeg.cf +qkbzptliqpdgeg.ga +qkbzptliqpdgeg.gq +qkbzptliqpdgeg.ml +qkbzptliqpdgeg.tk +qkerbl.com +qkffkd.com +qkjruledr.com +qkpa.emlhub.com +qkr.emlpro.com +qkrthasid.com +qkw4ck7cs1hktfba.cf +qkw4ck7cs1hktfba.ga +qkw4ck7cs1hktfba.gq +qkw4ck7cs1hktfba.ml +qkw4ck7cs1hktfba.tk +ql2qs7dem.pl +ql9yzen3h.pl +qlclaracm.com +qld.laste.ml +qldatedq.com +qlearer.com +qlenw.com +qlevjh.com +qlhnu526.com +qlijgyvtf.pl +qlillness.com +qlnxfghv.xyz +qlovey.buzz +qlq.emlpro.com +qluiwa5wuctfmsjpju.cf +qluiwa5wuctfmsjpju.ga +qluiwa5wuctfmsjpju.gq +qluiwa5wuctfmsjpju.ml +qlvf.emltmp.com +qm.dropmail.me +qm1717.com +qmail.com +qmail2.net +qmailers.com +qmails.loan +qmails.online +qmails.pw +qmails.services +qmails.website +qmails.world +qmails.xyz +qmailshop.com +qmailtgs.com +qmailv.com +qmi25.anonbox.net +qmoil.com +qmperehpsthiu9j91c.ga +qmperehpsthiu9j91c.ml +qmperehpsthiu9j91c.tk +qmqmqmzx.com +qmr.yomail.info +qmrbe.com +qmtvchannel.co.uk +qmvf.emlpro.com +qmwparouoeq0sc.cf +qmwparouoeq0sc.ga +qmwparouoeq0sc.gq +qmwparouoeq0sc.ml +qmwparouoeq0sc.tk +qn5egoikcwoxfif2g.cf +qn5egoikcwoxfif2g.ga +qn5egoikcwoxfif2g.gq +qn5egoikcwoxfif2g.ml +qn5egoikcwoxfif2g.tk +qnb.io +qncd.mimimail.me +qnd.dropmail.me +qnicloud.life +qninhtour.live +qnk.freeml.net +qnk.yomail.info +qnkznwsrwu3.cf +qnkznwsrwu3.ga +qnkznwsrwu3.gq +qnkznwsrwu3.ml +qnkznwsrwu3.tk +qnlburied.com +qnmails.com +qnnc.freeml.net +qnorfolkx.com +qnuqgrfujukl2e8kh3o.cf +qnuqgrfujukl2e8kh3o.ga +qnuqgrfujukl2e8kh3o.gq +qnuqgrfujukl2e8kh3o.ml +qnuqgrfujukl2e8kh3o.tk +qnxo.com +qnzkugh2dhiq.cf +qnzkugh2dhiq.ga +qnzkugh2dhiq.gq +qnzkugh2dhiq.ml +qnzkugh2dhiq.tk +qo.laste.ml +qo.spymail.one +qo6dp.anonbox.net +qobz.com +qocya.com +qodiq.com +qofocused.com +qofu.mimimail.me +qoika.com +qoiolo.com +qonfident.com +qonmprtxz.pl +qoo-10.id +qopmail.com +qopow.com +qopwfnpoqwieopqwe.ga +qopxlox.com +qorikan.com +qortu.com +qp-tube.ru +qpalong.com +qpapa.ooo +qpaud9wq.com +qpdishwhd.buzz +qpe.emlpro.com +qperformsrx.com +qpfoejkf2.com +qpg.emltmp.com +qphf.spymail.one +qphs.spymail.one +qpi8iqrh8wtfpee3p.ga +qpi8iqrh8wtfpee3p.ml +qpi8iqrh8wtfpee3p.tk +qpowfopqwipoqwe.ga +qpowfqpownqwpoe.ga +qpp.emlpro.com +qpptplypblyp052.cf +qpulsa.com +qq.my +qq152.com +qq163.com +qq164.com +qq234.com +qq323.com +qq568.top +qq8hc1f9g.pl +qqa.spymail.one +qqaa.com +qqaa.zza.biz +qqcs.mimimail.me +qqh.emlhub.com +qqhokipoker.org +qqhow.com +qqipgthtrlm.cf +qqipgthtrlm.ga +qqipgthtrlm.gq +qqipgthtrlm.ml +qqipgthtrlm.tk +qqjpd.anonbox.net +qqkini.asia +qqmimpi.com +qqocod00.store +qqowl.club +qqpstudios.com +qqq.xyz +qqq333asad.shop +qqqo.com +qqqwwwil.men +qqspot.com +qqtb.mailpwr.com +qqwtrnsqdhb.edu.pl +qqzymail.win +qrav.com +qrd6gzhb48.xorg.pl +qreciclas.com +qrl.emlhub.com +qrlv.mailpwr.com +qrmta.works +qrn.emlhub.com +qrno1i.info +qro.dropmail.me +qropspensionadvice.com +qropspensiontransfers.com +qrsm.mimimail.me +qrt.laste.ml +qrudh.win +qrvdkrfpu.pl +qrzemail.com +qs1986.com +qs2k.com +qscreated.com +qsdqsdqd.com +qsdt.com +qseminarb.com +qsfzvamuzk.ga +qsg.dropmail.me +qsjs998.com +qsl.ro +qst.freeml.net +qswg.freeml.net +qsxer.com +qt.dprots.com +qt.dropmail.me +qt.laste.ml +qt1.ddns.net +qtc.org +qtfxtbxudvfvx04.cf +qtfxtbxudvfvx04.ga +qtfxtbxudvfvx04.gq +qtfxtbxudvfvx04.ml +qtfxtbxudvfvx04.tk +qtlbb.anonbox.net +qtlhkpfx3bgdxan.cf +qtlhkpfx3bgdxan.ga +qtlhkpfx3bgdxan.gq +qtlhkpfx3bgdxan.ml +qtlhkpfx3bgdxan.tk +qtmail.net +qtmail.org +qtmtxzl.pl +qtmx.space +qtooth.org +qtpxsvwifkc.cf +qtpxsvwifkc.ga +qtpxsvwifkc.gq +qtpxsvwifkc.ml +qtpxsvwifkc.tk +qtsaver.com +qtum-ico.com +qtwd.mailpwr.com +qtwicgcoz.ga +qtxm.us +qty.laste.ml +quaatiorup.ga +quadrafit.com +quaestore.co +quaipragma.ga +qualifyamerica.com +qualityimpres.com +qualityth.com +qualtric.com +quamox.com +quanaothethao.com +quanaril.ga +quandahui.com +quangcaoso1.net +quangcaouidfb.club +quangvps.com +quanpzo.click +quantentunnel.de +quanthax.com +quanticmedia.co +quantnetwork.city +quantnodes.com +quantobasta.ru +quantsoftware.com +quantumofhappiness.com +quantyti.com +quarida.com +quarl.xyz +quarnipe.ga +quarrycoin.com +quasoro.ga +quatetaline.com +quattrocchi.us +qubecorp.tk +qubismdbhm.ga +que-les-meilleurs-gagnent.com +quebec.alpha.webmailious.top +quebec.victor.webmailious.top +quebecgolf.livemailbox.top +quebecorworld.com +quebecstart.com +quebecupsilon.thefreemail.top +quecompde.ga +quediode.ga +queeejkdfg7790.cf +queeejkdfg7790.ga +queeejkdfg7790.gq +queeejkdfg7790.ml +queeejkdfg7790.tk +queen.com +queen408.ga +queensbags.com +queensmassage.co.uk +queentravel.org +quehuongta.com +quemede.ga +quemillgyl.ga +quequeremos.com +quertzs.com +querydirect.com +quesoran.ga +questhivehub.com +questionabledahuli.io +questionsystem.us +questionwoman.biz +questore.co +questtechsystems.com +questza.com +quetronis.ga +queuem.com +quh.yomail.info +quhw.freeml.net +qui-mail.com +qui2-mail.com +quiba.pl +quichebedext.freetcp.com +quick-emails.com +quick-mail.cc +quick-mail.club +quick-mail.info +quick-mail.online +quick-shopping.online +quickbookstampa.com +quickcash.us +quickemail.info +quickemail.top +quickerpitch.com +quickestloans.co.uk +quickinbox.com +quicklined.xyz +quickloans.com +quickloans.us +quickloans560.co.uk +quicklymail.info +quickmail.best +quickmail.in +quickmail.nl +quickmail.rocks +quickmailgroup.com +quickmailhub.app +quickpaydayloansuk34.co.uk +quickreport.it +quickresponsecanada.info +quicksend.ch +quicktreage.com +quicktv.xyz +quicooti.ga +quid4pro.com +quidoli.ga +quiet.jsafes.com +quikdrop.space +quiline.com +quillet.eu +quilombofashion.shop +quimail.site +quimaress.ga +quimbanda.com +quintalaescondida.com +quintania.top +quinz.me +quipas.com +quirkynyc.com +quirsratio.com +quis.freeml.net +quitsmokinghelpfulguide.net +quitsmokingmanyguides.net +quizitaly.com +quizr.org +qul.dropmail.me +quockhanh8686.top +quodro.com +quossum.com +quote.ruimz.com +quoteko.ga +quotesre.com +ququb.com +qur.emlpro.com +qurist.com +quuradminb.com +quxppnmrn.pl +quxx168.com +quyendo.com +quyinvis.net +qv.com +qv.emlhub.com +qv7.info +qvap.ru +qvaq.ru +qvarqip.ru +qvestmail.com +qvharrisu.com +qvkc.freeml.net +qvmao.com +qvozt.anonbox.net +qvs.yomail.info +qvwd.emltmp.com +qvwthrows.com +qvy.me +qw.emlpro.com +qwarmingu.com +qwbqwcx.com +qwccd.com +qwe.com +qweasdzxcva.com +qweazcc.com +qweewqrtr.info +qwefaswee.com +qwefewtata.com +qwekssxt6624.cf +qwekssxt6624.ga +qwekssxt6624.gq +qwekssxt6624.ml +qwekssxt6624.tk +qwer123.com +qwereed.eu +qwerqwerty.ga +qwerqwerty.ml +qwerqwerty.tk +qwertaz.com +qwerty-ggr.xyz +qwertyhandsome.net +qwertymail.cf +qwertymail.ga +qwertymail.gq +qwertymail.ml +qwertymail.ru +qwertymail.tk +qwertyuiop.tk +qwertywar.com +qwerytr978.info +qwexaqwe.com +qwezxsa.co.uk +qwfijqiowfoiqwnf.ga +qwfiohqiofhqwieqwe.ga +qwfioqwiofuqwoe.ga +qwfly.com +qwfox.com +qwfqowfqiowfq.ga +qwfqowhfioqweioqweqw.ga +qwickmail.com +qwik-ayoyo-00.shop +qwiklabs-monthly.me +qwiklabsgames.me +qwiklabsme.me +qwiklabssuane.fun +qwkcmail.com +qwkcmail.net +qwopeioqwnfq.me +qwplaquceo.ga +qwpofqpoweipoqw.tk +qwqrwsf.date +qwqsmm.tk +qwrezasw.com +qwrfssdweq.com +qws.lol +qwsa.ga +qwtof1c6gewti.cf +qwtof1c6gewti.ga +qwtof1c6gewti.gq +qwtof1c6gewti.ml +qwtof1c6gewti.tk +qwvasvxc.com +qwvsacxc.com +qwyg.mailpwr.com +qx95.com +qx98.com +qxf.dropmail.me +qxlvqptiudxbp5.cf +qxlvqptiudxbp5.ga +qxlvqptiudxbp5.gq +qxlvqptiudxbp5.ml +qxlvqptiudxbp5.tk +qxpaperk.com +qyd.spymail.one +qygt.emltmp.com +qygwq.anonbox.net +qyj.emlpro.com +qysyny.site +qywf.spymail.one +qyx.pl +qz.laste.ml +qz.yomail.info +qzbdlapps.shop.pl +qzdnetf.com +qzdynxhzj71khns.cf +qzdynxhzj71khns.gq +qzdynxhzj71khns.ml +qzdynxhzj71khns.tk +qzhqxqxj.mimimail.me +qzick.com +qzlfalleno.com +qzsn.freeml.net +qztc.edu +qzueos.com +qzvbxqe5dx.cf +qzvbxqe5dx.ga +qzvbxqe5dx.gq +qzvbxqe5dx.ml +qzvbxqe5dx.tk +qzw.emltmp.com +r-mail.cf +r-mail.ga +r-mail.gq +r-mail.ml +r.polosburberry.com +r.yasser.ru +r0.igg.biz +r0ywhqmv359i9cawktw.cf +r0ywhqmv359i9cawktw.ga +r0ywhqmv359i9cawktw.gq +r0ywhqmv359i9cawktw.ml +r0ywhqmv359i9cawktw.tk +r115pwhzofguwog.cf +r115pwhzofguwog.ga +r115pwhzofguwog.gq +r115pwhzofguwog.ml +r115pwhzofguwog.tk +r18udogyl.pl +r1qaihnn9wb.cf +r1qaihnn9wb.ga +r1qaihnn9wb.gq +r1qaihnn9wb.ml +r1qaihnn9wb.tk +r2cakes.com +r2vw8nlia9goqce.cf +r2vw8nlia9goqce.ga +r2vw8nlia9goqce.gq +r2vw8nlia9goqce.ml +r2vw8nlia9goqce.tk +r2vxkpb2nrw.cf +r2vxkpb2nrw.ga +r2vxkpb2nrw.gq +r2vxkpb2nrw.ml +r2vxkpb2nrw.tk +r3-r4.tk +r31s4fo.com +r3h.com +r3hyegd84yhf.cf +r3hyegd84yhf.ga +r3hyegd84yhf.gq +r3hyegd84yhf.ml +r3hyegd84yhf.tk +r3r313w2.store +r4-3ds.ca +r4.dns-cloud.net +r43vu.anonbox.net +r4carta.eu +r4carte3ds.com +r4carte3ds.fr +r4ds-ds.com +r4ds.com +r4dscarte.fr +r4gmw5fk5udod2q.cf +r4gmw5fk5udod2q.ga +r4gmw5fk5udod2q.gq +r4gmw5fk5udod2q.ml +r4gmw5fk5udod2q.tk +r4ifr.com +r4iii.anonbox.net +r4nd0m.de +r4ntwsd0fe58xtdp.cf +r4ntwsd0fe58xtdp.ga +r4ntwsd0fe58xtdp.gq +r4ntwsd0fe58xtdp.ml +r4ntwsd0fe58xtdp.tk +r4skz.anonbox.net +r4unxengsekp.cf +r4unxengsekp.ga +r4unxengsekp.gq +r4unxengsekp.ml +r4unxengsekp.tk +r56r564b.cf +r56r564b.ga +r56r564b.gq +r56r564b.ml +r56r564b.tk +r57u.co.cc +r5p.xyz +r67ln.anonbox.net +r6cnjv0uxgdc05lehvs.cf +r6cnjv0uxgdc05lehvs.ga +r6cnjv0uxgdc05lehvs.gq +r6cnjv0uxgdc05lehvs.ml +r6cnjv0uxgdc05lehvs.tk +r6q9vpi.shop.pl +r7m8z7.pl +r8lirhrgxggthhh.cf +r8lirhrgxggthhh.ga +r8lirhrgxggthhh.ml +r8lirhrgxggthhh.tk +r8r4p0cb.com +r9jebqouashturp.cf +r9jebqouashturp.ga +r9jebqouashturp.gq +r9jebqouashturp.ml +r9jebqouashturp.tk +r9ycfn3nou.cf +r9ycfn3nou.ga +r9ycfn3nou.gq +r9ycfn3nou.ml +r9ycfn3nou.tk +ra.emlhub.com +ra3.us +raanank.com +raaninio.ml +rabaz.org +rabbit10.tk +rabdevis.online +rabidsammich.com +rabihtech.xyz +rabin.ca +rabinkov.com +rabiot.reisen +rabitex.com +rabotnikibest.ru +rabr.freeml.net +rabuberkah.cf +rac.spymail.one +racaho.com +racarie.com +race-karts.com +racfq.com +rachelleighny.com +rachelmaryam.art +rachelsreelreviews.com +rachidrachid.space +rackabzar.com +racketity.com +racovor.com +racquetballnut.com +racseho.ga +radade.com +radardetectorhunt.com +radardetectorshuck.site +radarfind.com +radarmail.lavaweb.in +radarscout.com +radbandz.com +radecoratingltd.com +radhixa.app +radiantliving.org +radiku.ye.vc +radio-crazy.pl +radiobruaysis.com +radiodale.com +radiodirectory.ru +radiofurqaan.com +radiologyhelp.info +radiologymadeeasy.com +radiosiriushduser.info +raditya.club +radius.in +radiven.com +radskirip.ga +radskirip.ml +radugateplo.ru +radules.site +rady24.waw.pl +radyourfabarosu.com +rael.cc +rael.us +raetp9.com +raez.emltmp.com +raf-store.com +rafailych.site +rafalrudnik.pl +raffles.gg +rafmail.cf +rafmix.site +rafrem3456ails.com +rafv.spymail.one +rag-tube.com +ragel.me +ragitone.com +ragm.mimimail.me +ragzwtna4ozrbf.cf +ragzwtna4ozrbf.ga +ragzwtna4ozrbf.gq +ragzwtna4ozrbf.ml +ragzwtna4ozrbf.tk +rahabionic.com +rahavpn.men +rahyci.gq +raiasu.cf +raiasu.ga +raiasu.gq +raiasu.ml +raiasu.tk +raiet.com +raihnkhalid.codes +raikas77.eu +rail-news.info +railcash.com +railroad-gifts.com +raimu.cf +raimucok.cf +raimucok.ga +raimucok.gq +raimucok.ml +raimunok.xyz +raimuwedos.cf +raimuwedos.ga +raimuwedos.gq +raimuwedos.ml +rain.laohost.net +rainbowchildrensacademy.com +rainbowflowersaz.com +rainbowforgottenscorpion.info +rainbowgelati.com +rainbowly.ml +rainbowstore.fun +rainbowstored.ml +raindaydress.com +raindaydress.net +rainence.com +rainharvester.com +rainmail.biz +rainmail.top +rainmail.win +rainsofttx.com +rainstormes.com +rainwaterstudios.org +raiplay.cf +raiplay.ga +raiplay.gq +raiplay.ml +raiplay.tk +raisero.com +raisersharpe.com +raisethought.com +raitbox.com +raiway.cf +raiway.ga +raiway.gq +raiway.ml +raiway.tk +raj-stopki.pl +raj.emltmp.com +raja69toto.com +rajabioskop.com +rajaiblis.com +rajapoker99.site +rajapoker99.xyz +rajarajut.co +rajasoal.online +rajawaliindo.co.id +rajdnocny.pl +rajemail.tk +rajeshcon.cf +rajetempmail.com +rajshreetrading.com +rakaan.site +raketenmann.de +rakhasimpanan01.ml +rakietyssniezne.pl +rakinvymart.com +rakippro8.com +rakl.yomail.info +rakmalhatif.com +raknife.com +ralala.com +raleigh-construction.com +raleighquote.com +raleighshoebuddy.com +ralib.com +raligaan.com +ralkstruck.com +ralph-laurensoldes.com +ralphlauren51.com +ralphlaurenfemme3.com +ralphlaurenoutletzt.co.uk +ralphlaurenpascherfr1.com +ralphlaurenpaschersfrance.com +ralphlaurenpolo5.com +ralphlaurenpolozt.co.uk +ralphlaurenshirtszt.co.uk +ralphlaurensoldes1.com +ralphlaurensoldes2.com +ralphlaurensoldes3.com +ralphlaurensoldes4.com +ralphlaurenteejp.com +ralphlaurenukzt.co.uk +ralree.com +ralutnabhod.xyz +ramadanokas.xyz +ramaninio.cf +ramarailfans.ga +rambakcor44bwd.ga +rambara.com +rambgarbe.ga +rambgarbe.tk +ramblermail.com +ramcen.com +ramenjoauuy.com +ramenmail.de +ramgoformacion.com +ramin200.site +ramireschat.com +ramizan.com +ramjane.mooo.com +rampas.ml +rampasboya.ml +ramphy.com +rampmail.com +ramseymail.men +ramsmail.com +ramswares.com +ranas.ml +rancidhome.net +rancility.site +rand1.info +randkiuk.com +rando-nature.com +randol.infos.st +random-mail.tk +randomail.io +randomail.net +randomfever.com +randomgift.com +randomnamespicker.com +randomniydomen897.ga +randomniydomen897.tk +randompickers.com +randomplanet.com +randomseantheblogger.xyz +randomusnet.com +randrai.com +randykalbach.info +rangdongfish.com +rangerjerseysproshop.com +rangermalok.com +rangkutimail.me +ranirani.space +rankgapla.ga +rankingweightgaintablets.info +rankmagix.net +ranktong7.com +ranky.com +ranmoi.net +ranran777.shop +ransvershill.ga +rao-network.com +rao.kr +raonaq.com +raotus.com +rap-master.ru +rapa.ga +rapally.site +rapatbahjatya.net +rape.lol +rapenakyodilakoni.cf +rapid-guaranteed-payday-loans.co.uk +rapidbeos.net +rapidcontentwizardofficial.com +rapidefr.fr.nf +rapidlyws.com +rapidmail.com +rapiicloud.xyz +rapik.online +raposoyasociados.com +rapt.be +rapzip.com +raqid.com +raqueldavalos.com +raraa.store +rarame.club +rarepersona.com +rarerpo.website +rarsato.xyz +rartg.com +rascvetit.ru +rasczsa.com +rasczsa2a.com +rasczsa2a3.com +rasczsa2a34.com +rasczsa2a345.com +rasewaje3ni.online +rash-pro.com +rashchotimah.co +raskhin54swert.ml +rasnick.dynamailbox.com +raspa96.plasticvouchercards.com +raspberrypi123.ddns.net +rastarco.com +rastenivod.ru +rastrofiel.com +ratcher5648.gq +ratcher5648.ml +rate98.shop +ratedane.com +ratel.org +rateliso.com +ratemycollection.com +ratesforrefinance.net +ratesiteonline.com +rathurdigital.com +rating-slimming.info +ratingslimmingpills.info +rationare.site +ratixq.com +ratnariantiarno.art +ratsukellari.info +ratsup.com +ratswap.com +ratta.cf +ratta.ga +ratta.gq +ratta.ml +ratta.tk +rattlearray.com +rattlecore.com +ratu855.com +raubtierbaendiger.de +rauheo.com +rauland-kandel.de +raurua.com +rauxa.seny.cat +rav-4.cf +rav-4.ga +rav-4.gq +rav-4.ml +rav-4.tk +rav4.tk +ravenom.ru +ravensproteamsshop.com +ravenssportshoponline.com +ravenssuperbowlonline.com +ravensuperbowlshop.com +raveqxon.blog +raveqxon.site +raverbaby.co.uk +ravipatel.tk +ravnica.org +rawhidefc.org +rawizywax.com +rawmails.com +rawr.foo +rawrr.ga +rawrr.tk +rawscored.com +rawscores.net +rawscoring.com +rax.la +raxtest.com +raybanpascher2013.com +raybanspascherfr.com +raybanssunglasses.info +raybansunglassesdiscount.us +raybansunglassessalev.net +raybansunglasseswayfarer.us +raybanvietnam.vn +raygunapps.com +rayi.dropmail.me +raylee.ga +raymondjames.co +rayofshadow.xyz +rayong.mobi +rayyanta.com +raz.freeml.net +razemail.com +razeny.com +razernv.com +razin.me +razinrocks.me +razorajas.com +razorbackfans.net +razumkoff.ru +razuz.com +razzam.store +rb.freeml.net +rb.laste.ml +rbb.org +rbbel.anonbox.net +rbeiter.com +rbfxecclw.pl +rbg.dropmail.me +rbh.emlhub.com +rbitz.net +rbiwc.com +rbjkoko.com +rblx.site +rbmail.co.uk +rbnv.org +rbo88.xyz +rbpc6x9gprl.cf +rbpc6x9gprl.ga +rbpc6x9gprl.gq +rbpc6x9gprl.ml +rbpc6x9gprl.tk +rbs1.xyz +rbscoutts.com +rbteratuk.co.uk +rbym.dropmail.me +rc3s.com +rc6bcdak6.pl +rca7f.anonbox.net +rcasd.com +rcbdeposits.com +rcedu.team +rceo.emlhub.com +rchd.de +rcinvn408nrpwax3iyu.cf +rcinvn408nrpwax3iyu.ga +rcinvn408nrpwax3iyu.gq +rcinvn408nrpwax3iyu.ml +rcinvn408nrpwax3iyu.tk +rcm-coach.net +rcmails.com +rcml.emltmp.com +rco.yomail.info +rcode.site +rcpt.at +rcr.yomail.info +rcrmanuals.com +rcs.gaggle.net +rcs7.xyz +rctrue.com +rcvideo.com +rdahb3lrpjquq.cf +rdahb3lrpjquq.ga +rdahb3lrpjquq.gq +rdahb3lrpjquq.ml +rdahb3lrpjquq.tk +rdiffmail.com +rdk.dropmail.me +rdklcrv.xyz +rdlocksmith.com +rdluxe.com +rdresolucoes.com +rdrt.ml +rdset.com +rdupi.org +rdvx.tv +rdw.spymail.one +rdy.emlpro.com +rdycuy.buzz +rdyn171d60tswq0hs8.cf +rdyn171d60tswq0hs8.ga +rdyn171d60tswq0hs8.gq +rdyn171d60tswq0hs8.ml +rdyn171d60tswq0hs8.tk +re-gister.com +re-vo.tech +re.spymail.one +rea.freeml.net +rea.spymail.one +reacc.me +reachbeyondtoday.com +reachout.pw +reachupstate.org +reactbooks.com +reactive-school.ru +read-wordld.website +reada.site +readb.site +readc.press +readc.site +readcricketclub.co.uk +readd.site +readf.site +readg.site +readh.site +readi.site +readissue.com +readk.site +readm.site +readmail.biz.st +readn.site +readoa.site +readob.site +readoc.site +readod.site +readoe.site +readof.site +readog.site +readp.site +readq.site +readr.site +reads.press +readsa.site +readsb.site +readsc.site +readse.site +readsf.site +readsg.site +readsh.site +readsi.site +readsk.site +readsl.site +readsm.site +readsn.site +readsp.site +readsq.site +readss.site +readst.site +readsu.site +readsv.site +readsw.site +readsx.site +readsy.site +readsz.site +readt.site +readtoyou.info +readu.site +readv.site +readx.site +readya.site +readyb.site +readyc.site +readycoast.xyz +readyd.site +readye.site +readyf.site +readyforyou.cf +readyforyou.ga +readyforyou.gq +readyforyou.ml +readyg.site +readyh.site +readyi.site +readyj.site +readyl.site +readym.site +readyn.site +readyo.site +readyp.site +readyq.site +readyr.site +readys.site +readysetgaps.com +readyt.site +readyturtle.com +readyu.site +readyv.site +readyw.site +readyx.site +readyy.site +readyz.site +readz.site +reaic.com +reaktifmuslim.network +real2realmiami.info +realacnetreatments.com +realantispam.com +realchristine.com +realcryptostudio.tech +realdealneil.com +realedoewblog.com +realedoewcenter.com +realedoewnow.com +realestatearticles.us +realestateassetsclub.com +realestatedating.info +realestateinfosource.com +realestateinvestorsassociationoftoledo.com +realestateseopro.com +realfashionusa.com +realhairlossmedicine.com +realhairlossmedicinecenter.com +realhoweremedydesign.com +realhoweremedyshop.com +realieee.com +realinflo.net +realit.co.in +reality-concept.club +realjordansforsale.xyz +really.istrash.com +reallyfast.info +reallymyemail.com +reallymymail.com +realme.redirectme.net +realmka.io +realpharmacytechnician.com +realpix.online +realprol.online +realprol.website +realproseremedy24.com +realquickemail.com +realremedyblog.com +realrisotto.com +realsoul.in +realtreff24.de +realtyalerts.ca +realwebcontent.info +reamtv.com +reaneta.ga +reanult.com +reaoxyrsew.ga +reaoxysew.ga +reatreast.site +reauflabit.ga +rebag.cf +rebag.ga +rebag.gq +rebag.ml +rebag.tk +rebami.ga +rebatedates.com +rebates.stream +rebation.com +rebeca.kelsey.ezbunko.top +rebeccamelissa.miami-mail.top +rebeccasfriends.info +rebeccavena.com +rebekamail.com +rebelrodeoteam.us +reberpzyl.ga +rebhornyocool.com +rebnayriahni.online +rebrebasoer.shop +recargaaextintores.com +recdubmmp.org.ua +receipt.legal +receiptical.xyz +receita-iof.org +receiveee.chickenkiller.com +receiveee.com +receivethe.email +recembrily.site +receptiki.woa.org.ua +rechnicolor.site +rechtsbox.com +rechyt.com +reciaz.com +reciclaje.xyz +recipeforfailure.com +recitationse.store +recklesstech.club +recode.me +recognised.win +recollaitavonforlady.ru +recommendedstampedconcreteinma.com +reconced.site +reconditionari-turbosuflante.com +reconmail.com +record.me +recordar.site +recordboo.org.ua +recorderxfm.com +recordstimes.org.ua +recoverwater.com +recoveryhealth.club +recrea.info +recreationfourcorners.site +recruitaware.com +recruitengineers.com +rectalcancer.ru +recupemail.info +recurrenta.com +recursor.net +recursor.org +recutv.com +recyclemail.dk +red-mail.info +red-mail.top +red-paddle.ru +red-r.org +red-tron.com +red.rosso.ml +redacciones.net +redaksikabar.com +redalbu.ga +redanumchurch.org +redarrow.uni.me +redbottomheels4cheap.com +redbottomshoesdiscounted.com +redbottomsshoesroom.com +redbottomsstores.com +redcarpet-agency.ru +redchan.it +reddcoin2.com +reddit.usa.cc +reddithub.com +rededimensions.tk +redefinedcloud.com +redeo.net +redexecutive.com +redf.site +redfeathercrow.com +redfoxbet68.com +redfoxbet74.com +redfoxbet87.com +redgil.com +redgogork.com +redhattrend.com +redhawkscans.com +redheadnn.com +redi-google-mail-topik.site +redi.fr.nf +rediffmail.net +rediffmail.website +redinggtonlaz.xyz +redirect.plus +redirectr.xyz +rediska.site +reditd.asia +redleuplas.ga +redlineautosport.com +redmail.tech +redmi.redirectme.net +redmn.com +redondobeachwomansclub.org +redovisningsbyra.nu +redpeanut.com +redpen.trade +redproxies.com +redrabbit1.cf +redrabbit1.ga +redrabbit1.tk +redragon.xyz +redring.org +redrivervalleyacademy.com +redrobins.com +redrockdigital.net +redsium.com +redsnow.ga +redsuninternational.com +redtopgames.com +redtube-video.info +reduceness.com +reduxe.jino.ru +redvideo.ga +redviet.com +redwinegoblet.info +redwinelady.com +redwinelady.net +redwoodscientific.co +reebnz.com +reebsd.com +reedbusiness.nl +reeducaremagrece.com +reefohub.place +ref-fuel.com +refaa.site +refab.site +refac.site +refad.site +refae.site +refaf.site +refag.site +refaj.site +refak.site +refal.site +refam.site +refao.site +refap.site +refaq.site +refar.site +refas.site +refau.site +refav.site +refaw.site +refb.site +refbux.com +refea.site +refec.site +refed.site +refee.site +refeele.live +refeh.site +refei.site +refej.site +refek.site +refem.site +refeo.site +refep.site +refeq.site +refer.methode-casino.com +refer.oueue.com +referado.com +referalu.ru +referralroutenight.website +refertific.xyz +refes.site +refet.site +refeu.site +refev.site +refew.site +refex.site +refey.site +refez.site +refide.com +refina.tk +refinedsatryadi.net +refittrainingcentermiami.com +refk.site +refk.space +refl.site +reflexgolf.com +reflexologymarket.com +refm.site +refo.site +refp.site +refpiwork.ga +refr.site +refsb.site +refsc.site +refse.site +refsh.site +refsi.site +refsj.site +refsk.site +refsm.site +refsn.site +refso.site +refsp.site +refsq.site +refsr.site +refss.site +refst.site +refstar.com +refsu.site +refsv.site +refsw.site +refsx.site +refsy.site +reftoken.net +refurhost.com +refv.site +refw.site +refy.site +refz.site +reg.xmlhgyjx.com +reg19.ml +regacc.shop +regadub.ru +regaloregamicudz.org +regalos.store +regalsz.com +regapts.com +regarganteng.store +regation.online +regbypass.com +regbypass.comsafe-mail.net +regcloneone.xyz +regclonethree.xyz +regclonetwo.xyz +regencyop.com +reggaestarz.com +reginaldchan.net +reginekarlsen.me +region13.ga +regional.delivery +regionteks.ru +regiopage-deutschland.de +regiopost.top +regiopost.trade +regishub.com +registand.site +registered.cf +regitord.co.uk +regitord.uk +regmail.kategoriblog.com +regmailproject.info +regpp7arln7bhpwq1.cf +regpp7arln7bhpwq1.ga +regpp7arln7bhpwq1.gq +regpp7arln7bhpwq1.ml +regpp7arln7bhpwq1.tk +regreg.com +regspaces.tk +reguded.ga +regularlydress.net +rehau39.ru +rehezb.com +rehtdita.com +reicono.ga +reiep.com +reifide.com +reignict.com +reilly.erin.paris-gmail.top +reillycars.info +reimondo.com +reinadogeek.com +reinshaw.com +rejectmail.com +rejm.freeml.net +rejo.technology +rejudsue.ml +rejuvenexreviews.com +rekaer.com +rekannaesi.ga +reklama.com +reklambladerbjudande.se +reklambladerbjudanden.se +reklamilanlar005.xyz +reklamowaagencjawarszawa.pl +reklamtr81.website +reksareksy78oy.ml +reksodents.live +reksodents.shop +reksodents.world +rekt.ml +rekthosting.ml +relaterial.xyz +relathetic.parts +relationlabs.codes +relationship-cure.com +relationshiphotline.com +relationshiping.ru +relativegifts.com +relax.ruimz.com +relaxabroad.ru +relaxall.ru +relaxcafes.ru +relaxgamesan.ru +relaxplaces.ru +relaxrussia.ru +relaxself.ru +relaxyplace.ru +relay-bossku3.com +relay-bossku4.com +releaseyourmusic.com +releasingle.xyz +reliable-mail.com +reliablecarrier.com +reliableproxies.com +relianceretail.tech +reliefmail.com +reliefsmokedeter.com +reliefteam.com +religionguru.ru +religioussearch.com +relisticworld.world +relivacca.cfd +relleano.com +relliklondon.com +relmarket.com +relmosophy.site +relopment.site +relrb.com +relscience.us +relumyx.com +relxv.com +remail.cf +remail.ga +remail7.com +remaild.com +remailed.ws +remailer.tk +remailsky.com +remailt.net +remainmail.top +remarkable.rocks +remaster.su +remaxofnanaimopropertymanagement.com +rembaongoc.com +remcold.live +remehan.ga +remehan.ml +remgelind.cf +remight.site +remingtonaustin.com +remisde.cf +remium.my.id +remium4pets.info +remodelingcontractorassociation.com +remonciarz-malarz.pl +remont-92.ru +remont-dvigateley-inomarok.ru +remonty-firma.pl +remonty-malarz.pl +remontyartur.pl +remontyfirma.pl +remontymalarz.pl +remontynestor.pl +remooooa.cloud +remote.li +remotepcrepair.com +removersllc.com +removingmoldtop.com +remprojects.com +remsd.ru +remusi.ga +remyqneen.com +remythompsonphotography.com +renaltechnologies.com +renatabitha.art +renate-date.de +renatika.com +renault-sa.cf +renault-sa.ga +renault-sa.gq +renault-sa.ml +renault-sa.tk +renaulttmail.pw +renaulttrucks.cf +renaulttrucks.ga +renaulttrucks.gq +renaulttrucks.ml +renaulttrucks.tk +rencontre-coquine.work +rencr.com +rendrone.fun +rendrone.online +rendrone.xyz +rendymail.com +renegade-hair-studio.com +rengginangred95btw.cf +rengo.tk +renliner.cf +renomitt.gq +renotravels.com +renouweb.fr +renovasibangun-rumah.com +renovateur.com +renovation-manhattan.com +renraku.in +rent2.xyz +rentacarpool.com +rentaen.com +rentaharleybike.com +rentalmobiljakarta.com +rentandwell.club +rentandwell.online +rentandwell.site +rentandwell.xyz +rentasig.com +rentautomoto.com +rentforsale7.com +rentierklub.pl +rentk.com +rentokil.intial.com +rentonmotorcycles.com +rentonom.net +rentowner.website +rentowner.xyz +rentproxy.xyz +rentshot.pl +rentz.club +rentz.fun +rentz.website +renx.de +renydox.com +reollink.com +reorganize953mr.online +reoub.anonbox.net +rep.biz.id +repaemail.bz.cm +repairnature.com +reparacionbatres.com +repatecus.pl +repdom.info +repeatxdu.com +repex.es +repk.site +replacementr.store +replanding.site +replicalouisvuittonukoutlets.com +replicant.club +replicasunglassesonline.org +replicasunglasseswholesaler.com +replicawatchesusa.net +replyloop.com +repolusi.com +repomega4u.co.uk +reportes.ml +reportfresh.com +reports-here.com +reprecentury.xyz +reprint-rights-marketing.com +reproductivestrategies.com +repshop.net +reptech.org +reptilegenetics.com +reptilemusic.com +republiktycoon.com +republizar.site +repyoutact.com +reqaxv.com +reqdocs.com +requanague.site +requestmeds.com +reqz.spymail.one +rerajut.com +reretuli.cfd +rertimail.org +rerttymail.com +rerunway.com +res.craigslist.org +resadjro.ga +resavacs.com +rescuewildlife.com +research-chemicals.pl +research.gng.edu.pl +researchaeology.xyz +researchobservatories.org.uk +resellermurah.me +resepbersama.art +resepbersamakita.art +resepindonesia.site +resepku.site +reservationforum.com +reservelp.de +reservely.ir +reservematch.ir +reservepart.ir +reset123.com +resetsecure.org +resfe.com +resgedvgfed.tk +residencecure.com +residencemedicine.com +residencerewards.com +residencialgenova.com +resifi.com +resilientrepulsive.site +resindia.com +resistore.co +resmail24.com +resmso.com +resnitsy.com +resolution4print.info +resorings.com +resort-in-asia.com +resortbadge.site +resortmapprinters.com +respectabrew.com +respectabrew.net +respekus.com +responsive.co.il +resself.site +ressources-solidaires.info +resspi.com +rest-lux.ru +restauracjarosa.pl +restaurant-bischoff-winnweiler.de +restauranteosasco.ml +resthomejobs.com +restorationscompanynearme.com +restoringreach.com +restromail.com +restrument.xyz +restudwimukhfian.store +restumail.com +resturaji.com +resultaatmarketing.com +resulter.me +resultevent.ru +resultierten.ml +resume.land +resumeworks4u.com +resumewrite.ru +resunleasing.com +resurgeons.com +reswitched.team +ret35363ddfk.cf +ret35363ddfk.ga +ret35363ddfk.gq +ret35363ddfk.ml +ret35363ddfk.tk +retailtopmail.cz.cc +retep.com.au +rethmail.ga +reticaller.xyz +retinaonlinesure.com +retinaprime.com +retireddatinguk.co.uk +retkesbusz.nut.cc +retqio.com +retractablebannerstands.interstatecontracting.net +retractablebannerstands.us +retragmail.com +retretajoo.shop +retrmailse.com +retroflavor.info +retrogamezone.com +retroholics.es +retrojordansforsale.xyz +retropup.com +retrt.org +retrwhyrw.shop +rettmail.com +return0.ga +return0.gq +return0.ml +reubidium.com +reunionaei.com +reusable.email +rev-amz.xyz +rev-mail.net +rev-zone.net +rev3.cf +revampall.com +revarix.com +revealeal.com +revealeal.net +revelryshindig.com +revengemc.us +revenueads.net +reverbnationpromotions.com +reverse-lookup-phone.com +reversefrachise.com +reversehairloss.net +reverseyourdiabetestodayreview.org +reverze.ru +revi.ltd +reviase.com +review4forex.co.uk +reviewfood.vn +reviewsmr.com +reviewtable.gov +revistasaude.club +revistavanguardia.com +reviveherdriveprogram.com +revivemail.com +revoadastore.shop +revofgod.cf +revolve-fitness.com +revolvingdoorhoax.org +revreseller.com +revtxt.com +revutap.com +revy.com +rewardents.com +rewas-app-lex.com +rewerty.fun +rewet43.store +rewolt.pl +rewqweqweq.info +rewtorsfo.ru +rex-app-lexc.com +rexagod-freeaccount.cf +rexagod-freeaccount.ga +rexagod-freeaccount.gq +rexagod-freeaccount.ml +rexagod-freeaccount.tk +rexagod.cf +rexagod.ga +rexagod.gq +rexagod.ml +rexagod.tk +rexburgonbravo.com +rexburgwebsites.com +rexhuntress.com +rexmail.fun +rey.freeml.net +reymisterio.com +reynox.com +rezato.com +rezgan.com +rezkavideo.ru +rezoth.ml +rezqaalla.fun +rezumenajob.ru +rezunz.com +rf-now.com +rf.emltmp.com +rf.gd +rf.yomail.info +rf7gc7.orge.pl +rfactorf1.pl +rfavy2lxsllh5.cf +rfavy2lxsllh5.ga +rfavy2lxsllh5.gq +rfavy2lxsllh5.ml +rfc822.org +rfcdrive.com +rffff.net +rfirewallj.com +rfreedomj.com +rftt.de +rfz.emlpro.com +rfzaym.ru +rgb9000.net +rgcpb.anonbox.net +rgdoubtdhq.com +rgo.emlhub.com +rgphotos.net +rgriwigcae.ga +rgrocks.com +rgtvtnxvci8dnwy8dfe.cf +rgtvtnxvci8dnwy8dfe.ga +rgtvtnxvci8dnwy8dfe.gq +rgtvtnxvci8dnwy8dfe.ml +rgtvtnxvci8dnwy8dfe.tk +rgwfagbc9ufthnkmvu.cf +rgwfagbc9ufthnkmvu.ml +rgwfagbc9ufthnkmvu.tk +rh.laste.ml +rh3qqqmfamt3ccdgfa.cf +rh3qqqmfamt3ccdgfa.ga +rh3qqqmfamt3ccdgfa.gq +rh3qqqmfamt3ccdgfa.ml +rh3qqqmfamt3ccdgfa.tk +rhadryeir.com +rhafhamed.online +rhause.com +rhav.laste.ml +rheank.com +rheiop.com +rhexis.xyz +rhizoma.com +rhombushorizons.com +rhondaperky.com +rhondawilcoxfitness.com +rhpzrwl4znync9f4f.cf +rhpzrwl4znync9f4f.ga +rhpzrwl4znync9f4f.gq +rhpzrwl4znync9f4f.ml +rhpzrwl4znync9f4f.tk +rhsknfw2.com +rhv.freeml.net +rhv.laste.ml +rhyta.com +rhzla.com +ri-1.software +ri.laste.ml +ri688.com +riador.online +riamof.club +riaucyberart.ga +riavisoop.ga +riazra.bond +riazra.net +ribentu.com +ribizlata.com +ribo.com +riboflavin.com +rice.cowsnbullz.com +ricewaterhous.store +rich-mail.net +rich-money.pw +rich.blatnet.com +rich.ploooop.com +richardon.biz.id +richardpauline.com +richardscomputer.com +richcreations.com +richdi.ru +richdn.com +richezamor.com +richfinances.pw +richfunds.pw +richinssuresh.ga +richloomfabric.com +richlyscentedcandle.in +richmondhairsalons.com +richmondpride.org +richmoney.pw +richoandika.online +richocobrown.online +richonedai.pw +richsmart.pw +ricimail.com +ricis.net +rickifoodpatrocina.tk +rickux.com +ricoda.store +ricorit.com +ricret.com +ricrk.com +rid.freeml.net +riddermark.de +riddle.media +ride-tube.ru +ridebali.com +rider.email +ridesharedriver.org +ridgecrestretirement.com +ridingonthemoon.info +ridisposal.com +ridteam.com +riedc.com +riepupu.myddns.me +rifasuog.tech +riff-store.com +riffcat.eu +rifkian.cf +rifkian.ga +rifkian.gq +rifkian.ml +rifkian.tk +rifo.ru +rigation.site +rightchild.us +rightclaims.org +rightexch.com +rightmili.club +rightmili.online +rightmili.site +rightpricecaravans.com +rightweek.us +rightwringlisk.co.uk +rightwringlisk.uk +rigionse.site +rigtmail.com +rijahg.spymail.one +rijschoolcosma-nijmegen.nl +rika0525.com +rikpol.site +rillamail.info +rim7lth8moct0o8edoe.cf +rim7lth8moct0o8edoe.ga +rim7lth8moct0o8edoe.gq +rim7lth8moct0o8edoe.ml +rim7lth8moct0o8edoe.tk +rimier.com +rimka.eu +rimmerworld.xyz +rimshacooking.site +rinadiana.art +rinaldistore.site +rincewind4.pl +rincewind5.pl +rincewind6.pl +rincianjuliadi.net +ring.favbat.com +ring123.com +ringment.com +ringobaby344.ga +ringobaby344.gq +ringobaby344.tk +ringofyourpower.info +ringomail.info +ringtoneculture.com +ringwormadvice.info +riniiya.com +rinomg.com +rinseart.com +rio2000.tk +riobeli.ga +riomaglo.ga +riotap.com +riotph.ml +ripiste.cf +rippb.com +rippedabs.info +riptorway.live +riptorway.store +ririe.club +ririsbeautystore.com +rirre.com +risanmedia.id +risantekno.com +risaumami.art +rise.de +riseist.com +risel.site +risencraft.ru +risingbengal.com +risingsuntouch.com +riski.cf +risma.mom +ristoranteernesto.com +ristorantelafattoria.info +ristoranteparodi.com +risu.be +ritade.com +ritadecrypt.net +ritannoke.top +riteros.top +ritumusic.com +ritzw.com +riuire.com +riujnivuvbxe94zsp4.ga +riujnivuvbxe94zsp4.ml +riujnivuvbxe94zsp4.tk +riv3r.net +rivalbox.com +rivaz24.ru +river-branch.com +riveramail.men +rivercityauto.net +riverdale.club +rivermarine.org +riverparkhospital.com +riversidebuildingsupply.com +riversidecapm.com +riversidecfm.com +riversidequote.com +riverviewcontractors.com +rivimeo.com +riw1twkw.pl +riwayeh.com +rizamail.com +rizberk.com +rizet.in +riztatschools.com +rizzalsprem.xyz +rj-11.cf +rj-11.ga +rj-11.gq +rj-11.ml +rj-11.tk +rj.emlhub.com +rj.laste.ml +rj11.cf +rj11.ga +rj11.gq +rj11.ml +rj11.tk +rjacks.com +rjbemestarfit.site +rjbtech.com +rjki.dropmail.me +rjnbox.com +rjolympics.com +rjostre.com +rjtjfunny.com +rjtrainingsolutions.com +rjvelements.com +rjwm.com +rjxewz2hqmdshqtrs6n.cf +rjxewz2hqmdshqtrs6n.ga +rjxewz2hqmdshqtrs6n.gq +rjxewz2hqmdshqtrs6n.ml +rjxewz2hqmdshqtrs6n.tk +rjxmt.website +rk03.xyz +rk4vgbhzidd0sf7hth.cf +rk4vgbhzidd0sf7hth.ga +rk4vgbhzidd0sf7hth.gq +rk4vgbhzidd0sf7hth.ml +rk4vgbhzidd0sf7hth.tk +rk9.chickenkiller.com +rkay.live +rkbds4lc.xorg.pl +rklips.com +rko.kr +rkofgttrb0.cf +rkofgttrb0.ga +rkofgttrb0.gq +rkofgttrb0.ml +rkofgttrb0.tk +rkomo.com +rktmadpjsf.com +rkytuhoney.com +rlcraig.org +rlggydcj.xyz +rlh.laste.ml +rlhz.emlhub.com +rljewellery.com +rlmw.emltmp.com +rlooa.com +rlr.pl +rlrcm.com +rls-log.net +rltj.mailpwr.com +rlva.com +rlxpoocevw.ga +rm.emlhub.com +rm2rf.com +rm88.edu.bz +rma.ec +rmail.cf +rmailcloud.com +rmailgroup.in +rmaortho.com +rmazau.buzz +rmbarqmail.com +rmcp.cf +rmcp.ga +rmcp.gq +rmcp.ml +rmcp.tk +rme.yomail.info +rmea.com +rmfjsakfkdx.com +rmibeooxtu.ga +rmindia.com +rml.laste.ml +rmnt.net +rmomail.com +rmpc.de +rmqkr.net +rms-sotex.pp.ua +rmtmarket.ru +rmtvip.jp +rmtvipbladesoul.jp +rmtvipredstone.jp +rmune.com +rmutl.com +rmv.spymail.one +rmxsys.com +rn.spymail.one +rna.emltmp.com +rnailinator.com +rnakmail.com +rnan.dropmail.me +rnated.site +rnaxasp.com +rnc69szk1i0u.cf +rnc69szk1i0u.ga +rnc69szk1i0u.gq +rnc69szk1i0u.ml +rnc69szk1i0u.tk +rnd-nedv.ru +rndnjfld.com +rne.dropmail.me +rne.emltmp.com +rng.lakemneadows.com +rng.ploooop.com +rng.poisedtoshrike.com +rnjc8wc2uxixjylcfl.cf +rnjc8wc2uxixjylcfl.ga +rnjc8wc2uxixjylcfl.gq +rnjc8wc2uxixjylcfl.ml +rnjc8wc2uxixjylcfl.tk +rnm-aude.com +rno.emlhub.com +rnor.laste.ml +rnorou.buzz +rnt.spymail.one +rnuj.com +rnwknis.com +rnx.emltmp.com +rnza.com +rnzcomesth.com +ro-na.com +ro.dropmail.me +ro.lt +roadbike.ga +roadrundr.com +roadrunneer.com +roafrunner.com +roalemd00.online +roalx.com +roargame.com +roaringteam.com +roarr.app +roastedtastyfood.com +roastic.com +roastscreen.com +rob4sib.org +robbinsv.ml +robedesoiree-longue.com +robertmowlavi.com +robertspcrepair.com +robhung.com +robinhardcore.com +robinkikuchi.info +robinkikuchi.us +robinsnestfurnitureandmore.com +robla.com +robo.epool.pl +robo.poker +robo3.club +robo3.co +robo3.me +robo3.site +robodan.com +robohobo.com +roboku.com +robomart.net +roborena.com +robot-alice.ru +robot-mail.com +robot2.club +robot2.me +robotbobot.ru +robothorcrux.com +robox.agency +roccard.com +roccoshmokko.com +rocjetmail.com +rockdian.com +rockemail.com +rocket201.com +rocketestate724.com +rocketgmail.com +rockethosting.xyz +rocketmaik.com +rocketmail.cf +rocketmail.ga +rocketmail.gq +rocketmaill.com +rocketslotsnow.co +rocketspinz.co +rockeymail.com +rockhotel.ga +rockingchair.com +rockinrio.ml +rockinrio.tk +rockislandapartments.com +rockjia.com +rockkes.us +rocklandneurological.com +rocklive.online +rockmail.top +rockmailapp.com +rockmailgroup.com +rockport.se +rockrtmail.com +rocksmail.cfd +rockstmail.com +rockwithyouallnight23.com +rockyboots.ru +rockyoujit.icu +roclok.com +rocoiran.com +rodan.com +rodapoker.xyz +rodhazlitt.com +rodigy.net +rodiquez.eu +rodiquezmcelderry.eu +rodneystudios.com +rodroderedri.com +rodsupersale.com +rodtookjing.com +roducts.us +rodzinnie.org +roewe.cf +roewe.ga +roewe.gq +roewe.ml +rogacomp.tk +roger-leads.com +rogerin.space +roghv.anonbox.net +rogjf.com +rogowiec.com.pl +rograc.com +rogres.com +rogtat.com +roguemaster.dev +roguesec.net +rohingga.xyz +rohkalby.com +rohoza.com +roidirt.com +roids.top +roissyintimates.com +rojay.fr +rojotego.site +rokamera.site +rokanisren.online +rokerakan.shop +roketus.com +rokiiya.site +rokko-rzeszow.com +roko-koko.com +rokuro88.investmentweb.xyz +rolenot.com +roleptors.xyz +rolex19bet.com +rolex31bet.com +rolexdaily.com +rolexok.com +rolexreplicainc.com +rolexreplicawatchs.com +roliet.com +rollagodno.ru +rollercover.us +rollerlaedle.de +rollindo.agency +rolling-stones.net +rollingboxjapan.com +rollsroyce-plc.cf +rollsroyce-plc.ga +rollsroyce-plc.gq +rollsroyce-plc.ml +rollsroyce-plc.tk +rolmis.com +rolndedip.cf +rolndedip.ga +rolndedip.gq +rolndedip.ml +rolndedip.tk +rolne.seo-host.pl +romadoma.com +romagnabeach.com +romail.site +romail9.com +romails.net +romana.site +romancelane.lat +romania-nedv.ru +romaniansalsafestival.com +romanstatues.net +romantiskt.se +romantyczka.pl +romatso.com +rombomail.com +romebook.com +romehousing.com +romog.com +romz.tech +ronabuildingcentre.com +ronadecoration.com +ronadvantage.com +ronahomecenter.com +ronahomegarden.com +ronalansing.com +ronaldo77.shop +ronaldw.freeml.net +ronalerenovateur.com +rondecuir.us +ronete.com +roni.rojermail.ml +ronipidp.gq +ronnierage.net +ronter.com +rontgateprop.com +ronthebusnut.com +roofing4.expresshomecash.com +rooftest.net +room369.red +room369.work +roomfact.us +roommother.biz +roomserve.ir +roomsystem.us +rooseveltmail.com +root-server.xyz +root.hammerhandz.com +rootbrand.com +rootfest.net +rootprompt.org +ropack.be +rophievisioncare.com +ropolo.com +roptaoti.com +ropu.com +roratu.com +rorma.site +rosalinetaurus.co.uk +rosalinetaurus.com +rosalinetaurus.uk +roseau.me +rosebearmylove.ru +rosebird.org +rosechina.com +roselarose.com +roselug.org +roshaveno.com +rosmillo.com +rossa-art.pl +rossional.site +rossmail.ru +rosswins.com +rostlantik.tk +rosymac.com +rot3k.com +rotandilas.store +rotaniliam.com +rotaparts.com +rotate.pw +rotecproperty.xyz +rotermail.com +roth-group.com +rotiyu1-privdkrt.press +rotmanventurelab.com +rotomails.co.uk +rotomails.com +rotulosonline.site +roud.emlhub.com +roudar.com +rouflav.com +roughpeaks.com +roujpjbxeem.agro.pl +roulettecash.org +roundclap.fun +roundlayout.com +roundtrips.com +routerboardvietnam.com +routine4me.ru +rover.info +rover100.cf +rover100.ga +rover100.gq +rover100.ml +rover100.tk +rover400.cf +rover400.ga +rover400.gq +rover400.ml +rover400.tk +rover75.cf +rover75.ga +rover75.gq +rover75.ml +rover75.tk +rovesurf.com +rovianconspiracy.com +rovolowo.com +rovw.laste.ml +row-keeper.com +row.kr +rowantreepublishing.com +rowdydow.com +rowe-solutions.com +roweryo.com +rowmin.com +rowmoja6a6d9z4ou.cf +rowmoja6a6d9z4ou.ga +rowmoja6a6d9z4ou.gq +rowmoja6a6d9z4ou.ml +rowmoja6a6d9z4ou.tk +rowplant.com +roxannenyc.com +roxling.com +roxmail.co.cc +roxmail.tk +roxoas.com +royal-soft.net +royal.net +royalcoachbuses.com +royaldoodles.org +royalepizzaandburgers.com +royalgardenchinesetakeaway.com +royalgifts.info +royalhost.info +royalhosting.ru +royalka.com +royallogistic.com +royalmail.top +royalmarket.club +royalmarket.life +royalmarket.online +royalnt.net +royalvx.com +royalweb.email +royalwestmail.com +royandk.com +royaumedesjeux.fr +royins.com +roys.ml +rozaoils.site +rozebet.com +rozkamao.in +rozsadneinwestycje.pl +rp.emltmp.com +rpaowpro3l5ha.tk +rpaymentov.com +rpdmarthab.com +rpfundingoklahoma.com +rpgitxp6tkhtasxho.cf +rpgitxp6tkhtasxho.ga +rpgitxp6tkhtasxho.gq +rpgitxp6tkhtasxho.ml +rpgitxp6tkhtasxho.tk +rpgmonk.com +rphinfo.com +rphqakgrba.pl +rpkw2.anonbox.net +rpkxsgenm.pl +rpl-id.com +rplid.com +rpn.spymail.one +rppkn.com +rproductle.com +rps-msk.ru +rpvduuvqh.pl +rq.spymail.one +rq1.in +rq1h27n291puvzd.cf +rq1h27n291puvzd.ga +rq1h27n291puvzd.gq +rq1h27n291puvzd.ml +rq1h27n291puvzd.tk +rq6668f.com +rqbf.spymail.one +rqmail.xyz +rqpl.yomail.info +rqql.freeml.net +rqqv.emlhub.com +rqr.emlhub.com +rqzetvmh77.online +rqzuelby.pl +rr-0.cu.cc +rr-1.cu.cc +rr-2.cu.cc +rr-3.cu.cc +rr-ghost.cf +rr-ghost.ga +rr-ghost.gq +rr-ghost.ml +rr-ghost.tk +rr-group.cf +rr-group.ga +rr-group.gq +rr-group.ml +rr-group.tk +rr.ccs.pl +rr.nu +rranf.anonbox.net +rrasianp.com +rraybanwayfarersaleukyj.co.uk +rremontywarszawa.pl +rrenews.eu +rrilnanan.gq +rrk.laste.ml +rrmail.one +rrqkd9t5fhvo5bgh.cf +rrqkd9t5fhvo5bgh.ga +rrqkd9t5fhvo5bgh.gq +rrqkd9t5fhvo5bgh.ml +rrqkd9t5fhvo5bgh.tk +rrrcat.com +rrtl.dropmail.me +rrunua.xyz +rrw.spymail.one +rrwbltw.xyz +rs-p.club +rs.freeml.net +rs.yomail.info +rs311e8.com +rsbysdmxi9.cf +rsbysdmxi9.ga +rsbysdmxi9.gq +rsbysdmxi9.ml +rsbysdmxi9.tk +rscrental.com +rsfdgtv4664.cf +rsfdgtv4664.ga +rsfdgtv4664.gq +rsfdgtv4664.ml +rsfdgtv4664.tk +rshagor.xyz +rsjp.tk +rsma.de +rsmspca.com +rsnfoopuc0fs.cf +rsnfoopuc0fs.ga +rsnfoopuc0fs.gq +rsnfoopuc0fs.ml +rsnfoopuc0fs.tk +rsoi.dropmail.me +rsp.dropmail.me +rsps.site +rsqqz6xrl.pl +rsr.spymail.one +rssblog.pl +rssfwu9zteqfpwrodq.ga +rssfwu9zteqfpwrodq.gq +rssfwu9zteqfpwrodq.ml +rssfwu9zteqfpwrodq.tk +rsstao.com +rstoremail.ml +rstoremail.tk +rstoresmail.ml +rsultimate.com +rsvhr.com +rswilson.com +rta.yomail.info +rtcut.com +rteet.com +rtert.org +rtfa.site +rtfa.space +rtfaa.site +rtfab.site +rtfac.site +rtfad.site +rtfae.site +rtfaf.site +rtfag.site +rtfah.site +rtfai.site +rtfaj.site +rtfak.site +rtfal.site +rtfam.site +rtfan.site +rtfao.site +rtfap.site +rtfaq.site +rtfas.site +rtfat.site +rtfau.site +rtfav.site +rtfaw.site +rtfax.site +rtfay.site +rtfaz.site +rtfb.site +rtfc.press +rtfc.site +rtfe.site +rtff.site +rtfg.site +rtfh.site +rtfi.site +rtfia.site +rtfib.site +rtfic.site +rtfid.site +rtfie.site +rtfif.site +rtfig.site +rtfj.site +rtfk.site +rtfl.site +rtfn.site +rtfo.site +rtfq.site +rtfsa.site +rtfsb.site +rtfsc.site +rtfsd.site +rtfse.site +rtfsf.site +rtfsg.site +rtfsh.site +rtfsj.site +rtfsk.site +rtfsl.site +rtfsm.site +rtfsn.site +rtfso.site +rtfsp.site +rtfsr.site +rtfss.site +rtfst.site +rtfsu.site +rtfsv.site +rtfsw.site +rtfsx.site +rtfsy.site +rtfsz.site +rtft.dropmail.me +rtft.site +rtfu.site +rtfv.site +rtfw.site +rtfx.site +rtfz.site +rthjr.co.cc +rti.kellergy.com +rtil.laste.ml +rtiv.emltmp.com +rtjg99.com +rtmegypt.com +rtotlmail.com +rtotlmail.net +rtpgacor.de +rtrtr.com +rts6ypzvt8.ga +rts6ypzvt8.gq +rts6ypzvt8.ml +rts6ypzvt8.tk +rtskiya.xyz +rtstyna111.ru +rtstyna112.ru +rtunerfjqq.com +ru.emlpro.com +ru1.site +ru84i.dropmail.me +ruafdulw9otmsknf.cf +ruafdulw9otmsknf.ga +ruafdulw9otmsknf.ml +ruafdulw9otmsknf.tk +ruangbonus.com +ruangkita.online +ruangsmk.info +ruasspornisn4.uni.cc +ruay369.com +ruay776.com +ruay899.com +ruay969.com +rubeg.com +rubeshi.com +rubiro.ru +rubygon.com +rubytk39hd.shop +ruchikoot.org +rucls.com +rudelyawakenme.com +ruderclub-mitte.de +ruditnugnab.xyz +rudymail.ml +rueaxnbkff.ga +ruedeschaus.com +rugbyfixtures.com +rugbypics.club +ruggedinbox.com +rugman.pro +ruguox.com +ruhbox.com +ruhshe5uet547.tk +ruhtan.com +ruihuat168.store +ruincuit.com +ruinnyrurrendmail.com +rujbreath.com +ruk17.space +rulersonline.com +rulk.spymail.one +rumahcloudindonesia.online +rumbu.com +rumednews.site +rumgel.com +rumomokio.site +rumpelhumpel.com +rumpelkammer.com +run.spymail.one +runafter.yomail.info +runalone.uni.me +runball.us +runballrally.us +runchet.com +rundablage.com +runeclient.com +runfons.com +rungel.net +runi.ca +runmail.club +runmail.info +runmail.xyz +running-mushi.com +runningdivas.com +runnox.com +runqx.com +runrunrun.net +ruomvpp.com +ruozhi.cn +rupayamail.com +ruru.be +rus-black-blog.ru +rus-massaggio.com +rus-sale.pro +rush.ovh +rushdrive.com +rushmails.com +rushu.online +rusita.ru +ruskovka.ru +ruslanneck.de +ruslot.site +rusm.online +rusmotor.com +ruspalfinger.ru +rusrock.info +rusru.com +russ2004.ru +russellconstructionca.com +russellmail.men +russeriales.ru +russia-nedv.ru +russia-vk-mi.ru +russiblet.site +rustara.com +rustarticle.com +rustetic.com +rustracker.site +rustright.site +rustroigroup.ru +rustydoor.com +rustyload.com +rusyakikerem.network +rut.emlpro.com +rutale.ru +rutherfordchemicals.com +ruthmarini.art +rutop.net +ruu.kr +ruutukf.com +ruvifood.com +ruye.mailpwr.com +ruzsbpyo1ifdw4hx.cf +ruzsbpyo1ifdw4hx.ga +ruzsbpyo1ifdw4hx.gq +ruzsbpyo1ifdw4hx.ml +ruzsbpyo1ifdw4hx.tk +ruzzinbox.info +rv-br.com +rvb.ro +rvbspending.com +rvcd.emlhub.com +rvdogs.com +rvemold.com +rvjtudarhs.cf +rvjtudarhs.ga +rvjtudarhs.gq +rvjtudarhs.ml +rvjtudarhs.tk +rvmail.xyz +rvneous.com +rvrecruitment.com +rvrsemortage.bid +rvw.emlpro.com +rw24.de +rw9.net +rwanded.xyz +rwbktdmbyly.auto.pl +rwerghjoyr.cloud +rwf.mailpwr.com +rwfn.emlhub.com +rwgfeis.com +rwhhbpwfcrp6.cf +rwhhbpwfcrp6.ga +rwhhbpwfcrp6.gq +rwhhbpwfcrp6.ml +rwhhbpwfcrp6.tk +rwhpr33ki.pl +rwmail.xyz +rwmg.dropmail.me +rwstatus.com +rx.dred.ru +rx.emlhub.com +rx.emltmp.com +rx.laste.ml +rx.qc.to +rxbuy-pills.info +rxby.com +rxcay.com +rxdoc.biz +rxdrugsreview.info +rxdtlfzrlbrle.cf +rxdtlfzrlbrle.ga +rxdtlfzrlbrle.gq +rxdtlfzrlbrle.ml +rxejyohocl.ga +rxhealth.com +rxig.laste.ml +rxit.com +rxking.me +rxlur.net +rxlz.emlhub.com +rxmail.us +rxmail.xyz +rxmaof5wma.cf +rxmaof5wma.ga +rxmaof5wma.gq +rxmaof5wma.ml +rxmaof5wma.tk +rxmedic.biz +rxnts2daplyd0d.cf +rxnts2daplyd0d.ga +rxnts2daplyd0d.gq +rxnts2daplyd0d.tk +rxpharmacymsn.com +rxpil.fr +rxpiller.com +rxr6gydmanpltey.cf +rxr6gydmanpltey.ml +rxr6gydmanpltey.tk +rxtx.us +rxwv.laste.ml +rxy.spymail.one +ry.emlhub.com +ryan-wood.ru +ryanandkellywedding.com +ryanb.com +ryanreynolds.info +ryb.laste.ml +rybalkovedenie.ru +rybprom.biz +ryby.com +rycz2fd2iictop.cf +rycz2fd2iictop.ga +rycz2fd2iictop.gq +rycz2fd2iictop.ml +rycz2fd2iictop.tk +rydh.xyz +rye.emltmp.com +ryen15ypoxe.ga +ryen15ypoxe.ml +ryen15ypoxe.tk +ryev.laste.ml +ryg.dropmail.me +rygel.infos.st +ryhm.emltmp.com +ryj15.tk +ryjewo.com.pl +ryl.emlpro.com +ryldnwp4rgrcqzt.cf +ryldnwp4rgrcqzt.ga +ryldnwp4rgrcqzt.gq +ryldnwp4rgrcqzt.ml +ryldnwp4rgrcqzt.tk +ryoichi26.toptorrents.top +ryoir.biz.id +ryounge.com +ryovpn.com +ryszardkowalski.pl +ryteto.me +ryu.emltmp.com +ryumail.net +ryumail.ooo +ryuzak.site +ryyr.ru +ryyr.store +ryzdgwkhkmsdikmkc.cf +ryzdgwkhkmsdikmkc.ga +ryzdgwkhkmsdikmkc.gq +ryzdgwkhkmsdikmkc.tk +rz.freeml.net +rz.laste.ml +rza.dropmail.me +rzaca.com +rzayev.com +rzdxpnzipvpgdjwo.cf +rzdxpnzipvpgdjwo.ga +rzdxpnzipvpgdjwo.gq +rzdxpnzipvpgdjwo.ml +rzdxpnzipvpgdjwo.tk +rzemien1.iswift.eu +rzesomaniak.pl +rzesyodzywka.pl +rzesyodzywki.pl +rzip.site +rzn.host +rzp.laste.ml +rzrg.emlhub.com +rzru2.anonbox.net +rzuduuuaxbqt.cf +rzuduuuaxbqt.ga +rzuduuuaxbqt.gq +rzuduuuaxbqt.ml +rzuduuuaxbqt.tk +rzyp.laste.ml +s-e-arch.com +s-hope.com +s-ly.me +s-mail.ga +s-mail.gq +s-mdg.top +s-port.pl +s-potencial.ru +s-retail.ru +s-rnow.net +s-s.flu.cc +s-s.igg.biz +s-s.nut.cc +s-s.usa.cc +s-url.top +s-zx.info +s.bloq.ro +s.bungabunga.cf +s.dextm.ro +s.ea.vu +s.polosburberry.com +s.proprietativalcea.ro +s.sa.igg.biz +s.vdig.com +s.wkeller.net +s0.at +s00.orangotango.ga +s0467.com +s0nny.com +s0ny.cf +s0ny.flu.cc +s0ny.ga +s0ny.gq +s0ny.igg.biz +s0ny.ml +s0ny.net +s0ny.nut.cc +s0ny.usa.cc +s0ojarg3uousn.cf +s0ojarg3uousn.ga +s0ojarg3uousn.gq +s0ojarg3uousn.ml +s0ojarg3uousn.tk +s1288poker.art +s1811.com +s188game.com +s1a.de +s1nj8nx8xf5s1z.cf +s1nj8nx8xf5s1z.ga +s1nj8nx8xf5s1z.gq +s1nj8nx8xf5s1z.ml +s1nj8nx8xf5s1z.tk +s1xssanlgkgc.cf +s1xssanlgkgc.ga +s1xssanlgkgc.gq +s1xssanlgkgc.ml +s1xssanlgkgc.tk +s30.pl +s33db0x.com +s37ukqtwy2sfxwpwj.cf +s37ukqtwy2sfxwpwj.ga +s37ukqtwy2sfxwpwj.gq +s37ukqtwy2sfxwpwj.ml +s3k.net +s3rttar9hrvh9e.cf +s3rttar9hrvh9e.ga +s3rttar9hrvh9e.gq +s3rttar9hrvh9e.ml +s3rttar9hrvh9e.tk +s3s4.tk +s3wrtgnn17k.cf +s3wrtgnn17k.ga +s3wrtgnn17k.gq +s3wrtgnn17k.ml +s3wrtgnn17k.tk +s42n6w7pryve3bpnbn.cf +s42n6w7pryve3bpnbn.ga +s42n6w7pryve3bpnbn.gq +s42n6w7pryve3bpnbn.ml +s42n6w7pryve3bpnbn.tk +s45.o-r.kr +s48aaxtoa3afw5edw0.cf +s48aaxtoa3afw5edw0.ga +s48aaxtoa3afw5edw0.gq +s48aaxtoa3afw5edw0.ml +s48aaxtoa3afw5edw0.tk +s4cbj.anonbox.net +s4f.co +s4ngnm.xyz +s4qgkz6tg.freeml.net +s4qpc.anonbox.net +s51zdw001.com +s6.weprof.it +s64hedik2.tk +s6a5ssdgjhg99.cf +s6a5ssdgjhg99.ga +s6a5ssdgjhg99.gq +s6a5ssdgjhg99.ml +s6a5ssdgjhg99.tk +s6dtwuhg.com +s6qjunpz9es.ga +s6qjunpz9es.ml +s6qjunpz9es.tk +s80aaanan86hidoik.cf +s80aaanan86hidoik.ga +s80aaanan86hidoik.gq +s80aaanan86hidoik.ml +s8sigmao.com +s96lkyx8lpnsbuikz4i.cf +s96lkyx8lpnsbuikz4i.ga +s96lkyx8lpnsbuikz4i.ml +s96lkyx8lpnsbuikz4i.tk +s9s.xyz +sa.igg.biz +sa.laste.ml +sa.spymail.one +sa3edfool.space +sa3eed123.store +saab9-3.cf +saab9-3.ga +saab9-3.gq +saab9-3.ml +saab9-3.tk +saab9-4x.cf +saab9-4x.ga +saab9-4x.gq +saab9-4x.ml +saab9-4x.tk +saab9-5.cf +saab9-5.ga +saab9-5.gq +saab9-5.ml +saab9-5.tk +saab9-7x.cf +saab9-7x.ga +saab9-7x.gq +saab9-7x.ml +saab9-7x.tk +saab900.cf +saab900.ga +saab900.gq +saab900.ml +saab900.tk +saabaru.cf +saabaru.ga +saabaru.gq +saabaru.ml +saabaru.tk +saabcars.cf +saabcars.ga +saabcars.gq +saabcars.ml +saabcars.tk +saabgroup.cf +saabgroup.ga +saabgroup.gq +saabgroup.ml +saabgroup.tk +saabohio.com +saabscania.cf +saabscania.ga +saabscania.gq +saabscania.ml +saabscania.tk +saadatkhodro.com +saarcxfp.priv.pl +saaristomeri.info +saasalternatives.net +sabahekonomi.xyz +sabbati.it +sabdestore.xyz +saberastro.space +sabesp.com +sabetex.app +sablecc.com +sabra.pl +sabrestlouis.com +sabrgist.com +sabtu.me +sac-chane1.com +sac-louisvuittonpascher.info +sac-prada.info +sac-zbcg.com +sac2013louisvuittonsoldes.com +sacamain2013louisvuittonpascher.com +sacamainlouisvuitton2013pascher.info +sacamainlouisvuittonsac.com +sacburberrypascher.info +saccatalyst.com +saccaud.com +sacchanelpascherefr.fr +sacchanelsac.com +sacgucc1-magasin.com +sacgucci-fr.info +sach.ir +sachermes.info +sachermespascher6.com +sachermskellyprix.com +sachiepvien.net +sacil.xyz +sackboii.com +sackdicam.cf +saclancelbb.net +saclancelbbpaschers1.com +saclanceldpaschers.com +saclancelpascheresfrance.com +saclavuitonpaschermagasinfrance.com +saclchanppascheresfr.com +saclongchampapascherefrance.com +saclongchampdefrance.com +saclouisvuitton-fr.info +saclouisvuittonapaschere.com +saclouisvuittonboutiquefrance.com +saclouisvuittonenfrance.com +saclouisvuittonnpascher.com +saclouisvuittonpascherenligne.com +saclouisvuittonsoldesfrance.com +saclover.one +saclovutonsfr9u.com +sacnskcn.com +sacolt.com +sacramentoreal-estate.info +sacslancelpascherfrance.com +sacslouisvuittonpascher-fr.com +sacsmagasinffr.com +sacsmagasinffrance.com +sacsmagasinfr9.com +sacsmagasinsfrance.com +sactownsoftball.com +sada-sd.cc +sadaas.com +sadai.com +sadanggiambeo.cyou +sadas.com +sadasdsa.cloud +sadbor.club +sadd.us +sadfopp.gq +sadfsdf.com +sadim.site +sads-ads-awe.top +sadsghghjj.top +sadwertopc.com +saecvr7.store +saeoil.com +saerfiles.ru +saeuferleber.de +safaat.cf +safariseo.com +safe-buy-cialis.com +safe-cart.com +safe-file.ru +safe-mail.ga +safe-mail.gq +safe-mail.net +safe-planet.com +safeemail.xyz +safemail.cf +safemail.icu +safemail.tk +safemaildesk.info +safemailweb.com +safenord.com +safeonlinedata.info +safepaydayloans365.co.uk +safer.gq +safermail.info +safersignup.com +safersignup.de +safeshate.com +safetempmail.com +safetymagic.net +safetymail.com +safetymail.info +safetymasage.club +safetymasage.online +safetymasage.site +safetymasage.store +safetymasage.website +safetymasage.xyz +safetypost.de +safewebmail.net +safezero.co +saffront.xyz +safirahome.com +safirbahis.com +safrem3456ails.com +safrgly.site +sagame.build +sagame.click +sagame.cloud +sagame.lol +sagd33.co.uk +sage.mailinator.com +sage.speedfocus.biz +sagebrushtech.com +saging.tk +saglikisitme.com +saglobe.com +sagmail.ru +sags-per-mail.de +sagun.info +sah-ilk-han.com +sahabatasas.com +saharacancer.co.uk +saharacancer.com +saharacancer.uk +saharanightstempe.com +sahdisus.online +sahikuro.com +sahitya.com +sahrulselow.cf +sahrulselow.ga +sahrulselow.gq +sahrulselow.ml +saidnso.gq +saidwise.com +saidytb.ml +saierw.com +saigonmaigoinhaubangcung.com +saigonmail.us +sailmail.io +sailorment.com +sainfotech.com +saint-philip.com +saintmirren.net +saitama88.club +saivon.com +sajhrge.online +sajutadollars.com +sakam.info +sakamail.net +sakana.host +sakarmain.com +sakaryaozguvenemlak.com +sakaryapimapen.com +sakiori.it +saktiemel.com +saladchef.me +saladsanwer.ru +salahjabder1.cloud +salahkahaku.cf +salahkahaku.ga +salahkahaku.gq +salahkahaku.ml +salamanderbaseball.com +salamandraux.com +salamfilm.xyz +salamonis.online +salankoha.website +salaopm.ml +salarypapa.club +salarypapa.online +salarypapa.xyz +salasadd.fun +salata.city +salazza.com +sald.de +saldov.club +saldov.xyz +saldowidyaningsih.biz +sale-nike-jordans.org +sale.craigslist.org +salebots.ru +salecheaphot.com +salechristianlouboutinukshoess.co.uk +salecse.tk +salehippo.com +salehubs.store +salehww.cloud +saleiphone.ru +saleis.live +salemail.com +salemen.com +salemmohmed.cloud +salemnewschannel.com +salemtwincities.com +saleprocth.com +sales.lol +salesbeachhats.info +salescheapsepilators.info +salescoupleshirts.info +salesfashionnecklaces.info +salesfotce.com +saleshtcphoness.info +saleskf.com +salesmanagementconference.org +salesoperationsconference.org +salesscushion.info +salessmenbelt.info +salessuccessconsulting.com +salesunglassesonline.net +saleswallclock.info +saleuggsbootsclearance.com +salewebmail.com +salihhhhhsss.cloud +salla.dev +salle-poker-en-ligne.com +salmeow.tk +salon-chaumont.com +salon3377.com +salonareas.online +salonean.online +salonean.shop +salonean.site +salonean.store +salonean.xyz +salonesmila.es +salonkarma.club +salonkarma.online +salonkarma.site +salonkarma.xyz +salonme.ru +salonvn.hair +salonyfryzjerskie.info +salopanare.fun +salsasmexican.com +salsoowi.site +salst.ninja +salt.jsafes.com +saltamontes.bar +saltel.net +saltrage.xyz +saltyrimo.club +saltyrimo.store +saltysushi.com +saluanthrop.site +salvador-nedv.ru +salvationauto.com +salvatore1818.site +salventrex.com +salvo84.freshbreadcrumbs.com +sam-dizainer.ru +sam1.eu.org +samaki.com +samalekan.club +samalekan.online +samalekan.space +samalekan.xyz +samaltour.club +samaltour.online +samaltour.site +samaltour.xyz +samanh.site +samantha17.com +samaoyfxy.pl +samara-nedv.ru +samasdecor.com +samatante.ml +samauil.com +sambalenak.com +sambalrica.xyz +sambeltrasi.site +samblad.ga +samblad.ml +sambuzh.com +samcloudq.com +same-taste.com +sameaccountmanage765.com +samedayloans118.co.uk +samega.com +sameleik.club +sameleik.online +sameleik.site +sameleik.website +samerooteigelonline.co +samharnack.dev +samideal.com +samilor.store +saminiran.com +samisdaem.ru +samjaxcoolguy.com +sammail.ws +samoe-samoe.info +samokat-msk.ru +samolocik.com.pl +samowarvps24.pl +samp-it.de +samp-shop.ru +samprem.site +samproject.tech +sampsonteam.com +samscashloans.co.uk +samsclass.info +samsinstantcashloans.co.uk +samsquickloans.co.uk +samsshorttermloans.co.uk +samstelevsionbeds.co.uk +samsungacs.com +samsunggalaxys9.cf +samsunggalaxys9.ga +samsunggalaxys9.gq +samsunggalaxys9.ml +samsunggalaxys9.tk +samsungmails.pw +samsunk.pl +san-marino-nedv.ru +sana-all.com +sanahalls.com +sanalankara.xyz +sanalcell.network +sanaldans.network +sanalgos.club +sanalgos.online +sanalgos.site +sanalgos.xyz +sancamap.com +sanchof1.info +sanchom1.info +sanchom2.info +sanchom3.info +sanchom4.info +sanchom5.info +sanchom6.info +sanchom7.info +sanchom8.info +sand.emlhub.com +sandalsresortssale.com +sandar.almostmy.com +sandcars.net +sandegg.com +sandelf.de +sandiegochargersjerseys.us +sandiegocontractors.org +sandiegoreal-estate.info +sandiegospectrum.com +sandmary.club +sandmary.online +sandmary.shop +sandmary.site +sandmary.space +sandmary.store +sandmary.website +sandmary.xyz +sandmassage.club +sandmassage.online +sandmassage.site +sandmassage.xyz +sandoronyn.com +sandra2024.site +sandra2024.store +sandra2034.beauty +sandra2034.boats +sandra2034.cfd +sandra2034.click +sandra2034.homes +sandra2034.lol +sandrapcc.com +sandre.cf +sandre.ga +sandre.gq +sandre.ml +sandre.tk +sandtamer.online +sandvpn.com +sandwhichvideo.com +sandwish.club +sandwish.space +sandwish.website +sandypil767676.store +sandyteo.com +sanering-stockholm.nu +sanfinder.com +sanfnge.run +sanfnges.cc +sanfrancisco49ersproteamjerseys.com +sanfranflowersinhair.com +sangamcentre.org.uk +sangaritink09gkgk.tk +sangiangphim.com +sangitasinha.click +sangos.xyz +sangqiao.net +sangvt.com +sanibact-errecom.com +sanibelwaterfrontproperty.com +saniki.pl +sanim.net +sanitzr.com +sanizr.com +sanjamzr.site +sanjaricacrohr.com +sanjati.com +sanjeewa.com +sanjoseareahomes.net +sankakucomplex.com +sankosolar.com +sanmc.tk +sannyfeina.art +sanporeta.ddns.name +sans.su +sansarincel.com +sanshengonline.com +sanstr.com +santa.waw.pl +santaks.com +santamonica.com +santhia.cf +santhia.ga +santhia.gq +santhia.ml +santhia.tk +santikadyandra.cf +santikadyandra.ga +santikadyandra.gq +santikadyandra.ml +santikadyandra.tk +santingiamgia.com +santonicrotone.it +santoriniflyingdress.com +santuy.email +santuyy.tech +sanvekhuyenmai.com +sanvetetre.com +sanzv.com +saoluudulieu.beauty +saoulere.ml +sapbox.bid +sapcom.org +sapi2.com +sapientsoftware.net +sapphikn.xyz +sappisi.com +saprolplur.xyz +sapsut.biz.id +sapu.me +sapya.com +saqioz.freeml.net +saracentrade.com +sarahdavisonsblog.com +sarahglenn.net +sarahstashuk.com +sarahtaurus.co.uk +sarahtaurus.com +sarahtaurus.uk +saraland.com +sarapanakun.com +sarasa.ga +saraut.my.id +sarawakreport.com +saraycasinogiris.net +saraycasinoyeniadresi.com +sarcgtfrju.site +sarcoidosisdiseasetreatment.com +sargrip.asia +sarinaaduhay.com +sarkisozudeposu.com +sartess.com +sarvier.com +sasa22.usa.cc +sasamelon.com +sasha.compress.to +sashschool.tk +sasi-inc.org +saskia.com +sasmil.org +sassy.com +sast.ro +sat.net +satabmail.com +satamqx.com +satan.gq +satana.cf +satana.ga +satana.gq +satcom.cf +satcom.ga +satcom.gq +satcom.ml +satedly.com +satellitefirms.com +satering.com +satey.club +satey.online +satey.site +satey.website +satey.xyz +satigan.com +satisfecho.me +satisfyme.club +satka.net +satorix.id +satoshi1982.biz +satre-immobilier.com +satservizi.net +satubandar.com +satukosong.com +satum.online +saturdata.com +saturnco.shop +saturniusz.info +satusatu.online +satzv.com +sau.emltmp.com +saucent.online +saudagarsantri.com +saudcloud.art +saude-fitness.com +saudealternativa.org +saudenatural.xyz +saudeseniors.com +saudiwifi.com +sauhasc.com +saukute.me +saungadaid.biz.id +sausen.com +sauto.me +savageattitude.com +savagemods.com +save-on-energy.org +save4now.com +saveboxmail.ga +savebrain.com +savelife.ml +savemydinar.com +savesausd.com +savests.com +savetimeerr.fun +savevid.ga +savidtech.com +saving.digital +savingship.com +sawas.ru +sawexo.me +sawoe.com +saxfun.party +saxlift.us +saxophonexltd.com +saxsawigg.biz +say.blatnet.com +say.buzzcluby.com +say.cowsnbullz.com +say.lakemneadows.com +say.ploooop.com +say0.com +sayago.online +sayagon.shop +sayasiapa.xyz +sayawaka-dea.info +sayfie.com +sayitsme.com +saymeow.de +saynotospams.com +sayonara.gq +sayonara.ml +saytren.tk +sayweee.tech +sayyesyes.com +sayyyesss.com +saza.ga +sazco.net +sazhimail.ooo +sbash.ru +sbbcglobal.net +sbcbglobal.net +sbcblobal.net +sbcblogal.net +sbccglobal.net +sbcgblobal.net +sbcgllbal.net +sbcglo0bal.net +sbcgloabal.com +sbcglobai.net +sbcglobal.bet +sbcglobasl.net +sbcglobat.net +sbcglobil.net +sbcglobql.net +sbcglogal.net +sbcglol.net +sbcglopbal.net +sbcgobla.net +sbcgpobal.net +sbclgobal.net +sbclobal.net +sbcobal.net +sbcpro.com +sbeglobal.net +sbglobal.com +sbj.laste.ml +sbnsale.top +sbobet99id.com +sborra.tk +sbscity.us +sbsglobal.net +sbsgroup.ru +sburningk.com +sbuttone.com +sbxglobal.net +sc-court.org +sc-racing.pl +sc2hub.com +sc91pbmljtunkthdt.cf +sc91pbmljtunkthdt.ga +sc91pbmljtunkthdt.gq +sc91pbmljtunkthdt.ml +sc91pbmljtunkthdt.tk +scabiesguide.info +scaffoldinglab.com +scafs.com +scalixmail.lady-and-lunch.xyz +scalpnet.ru +scalpongs.com +scamerahot.info +scams.website +scandicdeals25.com +scandiskmails.gdn +scanf.ga +scanf.gq +scania.gq +scania.tk +scanitxtr.com +scanmail.us +scannerchip.com +scanor69.xyz +scanupdates.info +scaptiean.com +scarden.com +scaredment.com +scarlet.com +scassars.com +scatinc.com +scatmail.com +scatterteam.com +scay.net +scbox.one.pl +sccglobal.net +scdhn.com +scdsb.com +sceath.com +sceenic.com +scenero.com +scenicmail.com +schabernack.ru +schachrol.com +schack.com +schackmail.com +schaden.net +schafmail.de +schaufell.pl +schdpst.com +scheduleer.com +schiborschi.ru +schift.com +schilderkunst.de +schiz.info +schlankefigur24.de +schluesseldienst-stflorian.at +schlump.com +schmeissweg.tk +schmid.cf +schmid.ga +schmid53.freshbreadcrumbs.com +schmuckfiguren.de +schmusemail.de +schnell-geld-verdienen.cf +schnell-geld-verdienen.ga +schnell-geld-verdienen.gq +schnippschnappschnupp.com +scholar.blatnet.com +scholar.cowsnbullz.com +scholar.emailies.com +scholar.inblazingluck.com +scholar.lakemneadows.com +scholar.makingdomes.com +scholarsed.com +scholarshipcn.com +scholarshippro.com +scholarshipsusa.net +scholarshipzon3.com +schollnet.com +scholocal.xyz +school-essay.org +school-good.ru +schoolmother.us +schools.nyc.org +schreib-doch-mal-wieder.de +schreinermeister24.de +schrott-email.de +schtep.ru +schticky.tv +schufafreier-kredit.at +schule-breklum.de +schulweis.com +schulzanallem.de +schwanz.biz +schwarzmail.ga +schwenke.xyz +schwerlastspedition.de +schwoer.de +scianypoznan.pl +science-full.ru +sciencejrq.com +sciencelive.ru +scilerap.gq +scim.emlhub.com +scire.sbs +scizee.com +scj.edu +scm.yomail.info +scmail.cf +scmail.net +scmbnpoem.pl +scook.cfd +scootmail.info +scope.favbat.com +scopelimit.com +scoperter.site +scor-pion.email +scorebog.com +scoreek.com +scotlandswar.info +scottdesmet.com +scottrenshaw.com +scottsdale-resorts.com +scottsseafood.net +scpulse.com +scrambleground.com +scrap-cars-4-cash-coventry.com +scrapebox.in +scrapeemails.com +scrapgram.com +scrapper.site +scrapper.us +scratchy.tk +screalian.site +screamfused.com +screebie.com +screechcontrol.com +screenvel.com +screw.dropmail.me +screwdriver.site +screwyou.com +scriamdicdir.com +scribble.uno +scribo.pl +script.click +scriptspef.com +scrmnto.cf +scrmnto.ga +scrmnto.gq +scrmnto.ml +scroomail.info +scrotum.com +scrsot.com +scrumexperts.com +scscwjfsn.com +scshool.com +scsmalls.com +scsvw.com +sctbmkxmh0xwt3.cf +sctbmkxmh0xwt3.ga +sctbmkxmh0xwt3.gq +sctbmkxmh0xwt3.ml +sctbmkxmh0xwt3.tk +sctcwe1qet6rktdd.cf +sctcwe1qet6rktdd.ga +sctcwe1qet6rktdd.gq +sctcwe1qet6rktdd.ml +sctcwe1qet6rktdd.tk +sctransportnmore.com +scubalm.com +scunoy.buzz +scurmail.com +scussymail.info +scxt1wis2wekv7b8b.cf +scxt1wis2wekv7b8b.ga +scxt1wis2wekv7b8b.gq +scxt1wis2wekv7b8b.ml +scxt1wis2wekv7b8b.tk +sd-exports.org +sd.emlpro.com +sd3.in +sdagds.com +sdasdasdasd.com +sdasds.com +sdbbs.cc +sdbcglobal.net +sdbfsdkjf.online +sdcrefr.online +sdd2q.com +sddfpop.com +sddfregroup.xyz +sddkjfiejsf.com +sddrs-cdfs.shop +sddsfds.help +sde.yomail.info +sdelkanaraz.com +sder.ytr.emltmp.com +sder.ytr.kery.emltmp.com +sdf.freeml.net +sdf.org +sdf44.com +sdfbd.com +sdfbvcrrddd.com +sdfdf.com +sdfdsf.com +sdferwwe.com +sdff.de +sdffg3ds.xyz +sdfgd.in +sdfgdfg.com +sdfgf.com +sdfggf.co.cc +sdfghyj.tk +sdfgsdrfgf.org +sdfgukl.com +sdfgwsfgs.org +sdfiresquad.info +sdfklsadkflsdkl.com +sdfq.com +sdfqwetfv.com +sdfr.de +sdfsb.com +sdfsdf.co +sdfsdf.nl +sdfsdfsd.com +sdfsdfvcfgd.com +sdfsdgd.dropmail.me +sdfsdhef.com +sdfuggs.com +sdg.dropmail.me +sdg34563yer.ga +sdg4643ty34.ga +sdgdsg.dropmail.me +sdgewrt43terdsgt.ga +sdgf.vocalmajoritynow.com +sdgsdfgsfgsdg.pl +sdgsdg.com +sdirfemail.com +sdiussc.com +sdj.fr.nf +sdjhjhtydst11417.tk +sdjksdfjklsdf.freeml.net +sdkajsn.best +sdkfkrorkg.com +sdlat.com +sdmc.emltmp.com +sdnr.it +sdo6k.info +sds-a-gff.xyz +sdsas.xyz +sdsd88.cc +sdsda.com +sdsdaas231.org +sdsdd.com +sdsdf.com +sdsdsds.com +sdsdwab.com +sdsfre.blog +sdsfwerfgroup.link +sdsgzh.xyz +sdsigns.com +sdsuedu.com +sdsus.com +sdui.freeml.net +sdvft.com +sdvgeft.com +sdvrecft.com +sdwoyzypih.ga +sdy21.com +sdysofa.com +se-cure.com +se.dropmail.me +se.emlpro.com +se.xt-size.info +se.yomail.info +se919.com +seacob.com +seafish.club +seafish.online +seafish.site +seafoodcharters.info +seafoodpn.com +seahawksportsshop.com +seahawksproteamsshop.com +seajaymfg.com +seal-concepts.com +seallyfool.site +seangroup.org +seansun.ru +seaoning.click +seaponsension.xyz +searates.info +search-usa.ws +search4gpt.com +searchiehub.com +searchrocketgroup.com +searchs.tech +searience.site +searmail.com +searpen.com +searsgaragedoor.org +searzh.com +seascoutbeta.org +seasiapoker.info +seasideorient.com +seasonhd.ru +seattguru.com +seattledec.com +seattleovariancancerresearch.org +seattlerealestate4you.com +seatto.com +seawgame99.com +sebaball.com +sebbcn.net +seberkd.com +seblog.cz.cc +sec.blatnet.com +sec.cowsnbullz.com +sec.lakemneadows.com +sec.marksypark.com +secandocomsaude.com +secantsquare.com +secbadger.info +secbuf.com +secencode.xyz +secfiz99.com +secglobal.net +secknow.info +secmail.ga +secmail.gq +secmail.ml +secmail.pro +secmail.pw +secmeeting.com +second-chancechecking.com +secondmic.com +secondset1.com +secraths.site +secret-area.tk +secretdev.co.uk +secretdiet.com +secretemail.de +secretfashionstore.com +secretluxurystore.com +secretmail.net +secretmystic.ru +secretreview.net +secretsaiyan.xyz +secretsurveyreviews.info +secti.ga +sector2.org +secur.page +secure-box.info +secure-box.online +secure-fb.com +secure-mail.biz +secure-mail.cc +secure-mail.cn +secure-mail.ml +secure.cowsnbullz.com +secure.lakemneadows.com +secure.okay.email.safeds.tk +secure.oldoutnewin.com +secureapay.com +securebitcoin.agency +secured-link.net +securedcontent.biz +securehost.com.es +secureinvox.com +securemail.cf +securemail.flu.cc +securemail.gq +securemail.igg.biz +securemail.nut.cc +securemail.solutions +securemail.usa.cc +securemaill.ga +securemailserver.cf +securemailserver.ga +securemailserver.gq +securemailserver.ml +securemailserver.tk +secureschoolalliance.com +secureserver.rogers.ca +secureserver.usa.cc +securesmtp.bid +securesmtp.download +securesmtp.stream +securesmtp.trade +securesmtp.website +securesmtp.win +securesys.cf +securesys.ga +securesys.gq +securesys.ml +securesys.tk +securesystems-corp.cf +securesystems-corp.ga +securesystems-corp.gq +securesystems-corp.ml +securesystems-corp.tk +securethering.com +securityfirstbook.com +securox.com +sedakana.online +sedapetnya.guru +sedasagreen01try.tk +sedateviana.io +sedexo.com +sedfafwf.website +sedir.net +sedric.ru +seduck.com +sedv4ph.com +see.blatnet.com +see.lakemneadows.com +see.makingdomes.com +see.marksypark.com +seed.ml +seedaz.com +seedingfb.click +seedscommerce.com +seedspeed.site +seegars.com +seek.bthow.com +seek4wap.com +seekapps.com +seekfindask.com +seekincentives.com +seeking-arrangements.review +seekintertech.info +seekjobs4u.com +seekmore.club +seekmore.fun +seekmore.online +seekmore.site +seekmore.website +seekmore.xyz +seeknear.com +seeksupply.com +seekusjobs.com +seemail.info +seemsence.com +seenontvclub.com +seeout.us +seepacs.com +seesaw.cf +seetarycr.com +seetrx.ga +seevideoemail.com +seeyuan.com +seg8t4s.xorg.pl +segabandemcross74new.ml +seggost.com +segichen.com +segrees.xyz +segundamanozi.net +seguros-brasil.com +sehatalami.click +sehier.fr +seierra.com +seikki.com +seikopoker.com +seinfaq.com +seintergroup.com +seishel-nedv.ru +seismail.com +seitenfirmen.de +sejaa.lv +sejf.com +sejkt.com +sekcjajudo.pl +sekiyadohaku.xyz +sekoeuropa.pl +sekris.com +seksfotki.pl +seksiaki.pl +sektorpoker.com +selaciptama.com +selasa.me +selecsa.es +selectam.ru +selectedovr.com +selectfriends.com +selectivestars.com +selectlaundry.com +selectraindustries.com +selectyourinfo.com +selehom.shop +selenezero.com +selenmoaszs.store +seleramakngah.com +selfarticle.com +selfbalancingscooterspro.com +selfdestructingmail.com +selfdestructingmail.org +selfemployedwriter.com +selfexute.website +selfhelptoolbox.com +selfiecard.com +selfmadesuccesstoday.com +selfreferral.org +selfrestaurant.com +selfretro.net +selfstoragefind.net +selftrak.fit +selivashko.online +sellamiitaly.cf +sellamiitaly.ga +sellamiitaly.gq +sellamiitaly.tk +sellamivpn.cf +sellamivpn.ga +sellamivpn.tk +sellamivpn007.ml +sellamivpn007.tk +sellamivpnvit.tk +sellcow.net +seller-millionaire.store +sellim.site +sellinganti-virussoftwares.info +sellingshop.online +sellodeconfianza.online +sellrent.club +sellrent.online +sellrent.xyz +sells.com +selowcoffee.cf +selowcoffee.ga +selowcoffee.gq +selowcoffee.ml +selowhellboy.cf +selowhellboy.ga +selowhellboy.gq +selowhellboy.ml +seluang.com +selved.site +sem.spymail.one +sem9.com +semail.us +semangat99.cf +semar.edu.pl +semarcomputama.tk +semarhouse.ga +semarhouse.ml +semarhouse.tk +semei6.fun +semenaxreviews.net +semestatogel.com +semi-mile.com +semidesigns.com +semihbulgur.com +seminary-777.ru +semisol.com +semitrailersnearme.com +semleter.ml +semogaderes.com +semonir.com +sempakk.com +semprulz.net +sempuranadi.cf +sempuranadi.ga +sempuranadi.ml +sempuranadi.tk +semsei.co.uk +semusimbersama.online +semut-kecil.com +semutireng.com +semutkecil.com +sen.yomail.info +senang.uu.me +senangpoker.site +senas.xyz +send-email.org +send-money.ru +send22u.info +send4.uk +send624.com +sendapp.uk +sendbananas.website +sendbulkmails.com +senderelasem.tk +sendermail.info +sendfree.org +sendify.email +sendingspecialflyers.com +sendisk.com +sendmesomemails.biz +sendnow.win +sendos.fr.nf +sendos.infos.st +sendrule.com +sendspamhere.com +sendthe.email +sendthemails.com +sendto.cf +senduvu.com +senegal-nedv.ru +seneme.com +senet.com +senfgad.com +sengi.top +sengokunaimo.life +senin.me +seniorom.sk +sennbox.cf +sennbox.ga +sennbox.gq +sennbox.ml +sennbox.tk +sennic.com +senode.ga +senseless-entertainment.com +sensibvwjt.space +sensualerotics.date +sentezeticaret.com +sentimancho.com +sentimentdate.com +sentirerbb.com +sentraduta.com +sentrau.com +senttmail.ga +sentumyi.com +senukexcrreview.in +seo-bux.ru +seo-for-pussies.pl +seo-google.site +seo-mailer.com +seo-turn.ru +seo.beefirst.pl +seo.bytom.pl +seo.viplink.eu +seo1-miguel75.xyz +seo11.mygbiz.com +seo21.pl +seo3.pl +seo39.pl +seo4u.site +seo8.co.uk +seoartguruman.com +seoarticlepowa.com +seoasshole.com +seobacklinks.edu +seobest.website +seoblasters.com +seoblog.com +seobot.com +seobrizz.com +seobungbinh.com +seobuzzvine.com +seocdvig.ru +seocompany.edu +seocu.gen.tr +seodating.info +seoenterprises.com.au +seoestore.us +seoforum.com +seogawd.com +seohesapmarket.com +seohoan.com +seoimpressions.com +seojuice.info +seokings.biz +seoknock.com +seolite.net.pl +seolmi.cf +seolondon.co.uk +seolondon24.co.uk +seolove.fr +seolovin.art +seolovin.site +seomail.net +seomail.top +seomaomao.net +seomarketingservices.nl +seomarketleaders.com +seomoz.org +seondes.com +seonuke-x.com +seonuke.info +seoo-czestochowa.pl +seoofindia.com +seopackagesprice.com +seopapese.club +seoph.website +seopot.biz +seopowa.com +seopress.me +seoprorankings.com +seoquorankings.com +seoranker.pro +seoray.site +seordp.org +seorj.cn +seosavants.com +seosc.pl +seosecretservice.top +seoseoseo.mygbiz.com +seoservicespk.com +seoserwer.com +seosie.com +seoskyline.com +seosnaps.com +seosnob.com +seostatic.pl +seostudio.co +seoteen.com +seoturbina.com +seoverr.com +seovps.com +seowy.eu +seoyo.com +sepatusupeng.gq +sepeda.ga +sepican.club +sepican.online +sepican.site +sepican.store +sepican.website +sepican.xyz +sepoisk.ru +sepole.com +septeberuare.ru +septicvernon.com +sepuranex.me +seputarbet.live +seputarti.com +seqerc.com +sequipment.ru +serarf.site +serbaada.me +serbian-nedv.ru +serenalaila.com +serendora.net +serenitysjournal.com +sergeymavrodi.org +sergeypetrov.nanolv.com +sergw.com +serial-hd.online +serialfilmhd.ru +serialhd1080.ru +serialhd720.ru +serialkillers.us +serialkinogoru.ru +serialkinopoisk.ru +serialreview.com +serials-only.ru +seriaonline.ru +series-online.club +series-online.info +seriousalts.de +seriouslydan.com +seriyaserial.ru +serohiv.com +seron.top +serosin.com +serpshooter.top +serre1.ru +serv.craigslist.org +servciehealth.site +servegame.com +server.blatnet.com +server.ms +server.ploooop.com +server.poisedtoshrike.com +server.popautomated.com +serverboosts.net +servergem.com +serverjavascript.com +servermaps.net +servermuoihaikhongbon.com +serverpro.cf +serversiap.com +serverwarningalert.com +servetecenaz.network +servetselcuk.cfd +service-911.ru +service4.ml +serviced.site +servicee.es +servicegulino.com +servicemercedes.biz +services-gta.tk +services.blatnet.com +services.pancingqueen.com +services.poisedtoshrike.com +services391.com +services4you.de +servicesllc.live +servicetr.me +servicewhirlpool.ru +servicing-ca.info +serviety.site +serving.catchallhost.com +servisetcs.info +servmail.ru +servogamer.ga +servpro10094.com +serwer84626.lh.pl +serwervps232x.com +serwervps24.pl +serwis-agd-warszawa.pl +serwisapple.pl +serwpcneel99.com +ses4services.net +sesforyou.com +seslikalbimsin.com +sessionintel.com +sesxe.com +setafon.biz +setefi.tk +setiabudihitz.com +settags.com +settied.site +settingsizable.info +setxko.com +setyamail.me +seungjjin.com +seven.emailfake.ml +seven.fackme.gq +seven.kozow.com +seven6s.com +sevenmentor.com +sevensjsa.org.ua +sevensmail.org.ua +seventol.fun +seventol.online +seventol.store +seventol.world +seventol.xyz +sevid.me +sevid.tech +seviqt.ga +sewafotocopy-xerox.com +sewamobilharian.com +sewce.com +sewpack.com +sex-chicken.com +sex-guru.net +sex-mobile-blog.ru +sex-ru.net +sex-vox.info +sex.dns-cloud.net +sex.net +sex.si +sexactive18.info +sexakt.org +sexboxx.cf +sexboxx.ga +sexboxx.gq +sexboxx.ml +sexboxx.tk +sexcamcom.com +sexcameralive.com +sexcamonlinefree.com +sexcamsex.org +sexchatcamera.com +sexe-pad.com +sexe-pas-cher.net +sexemamie.com +sexforswingers.com +sexfotka.com +sexical.com +sexini.com +sexinlive.xyz +sexioisoriog.gr +sexo.com +sexsaker.com +sexsation.ru +sexshop.com +sexsmi.org +sextoyth.com +sexwebcamshow.com +sexxfun69.site +sexy.camdvr.org +sexyalwasmi.top +sexyalwax.online +sexycamlive.com +sexychatwebcam.com +sexyfashionswimwear.info +sexyjobs.net +sexylingeriegarte.com +sexymail.gq +sexymail.ooo +sexypleasuregirl.com +sexysleepwear.info +sexytoys24.de +sexywebcamchat.com +sexywebcamfree.com +sexyworld.com +seyf.kim +seylifegr.gr +seyretbi.com +sezet.com +sf.emltmp.com +sf.laste.ml +sf16.space +sf49ersshoponline.com +sf49erssuperbowlonline.com +sf49ersteamsshop.com +sfa59e1.mil.pl +sfai.com +sfamo.com +sfbj.spymail.one +sfcsd.com +sfdadfas.fun +sfdgdmail.com +sfdi.site +sfdjg.in +sfdsd.com +sfell.com +sfer.com +sferamk.ru +sfes.de +sfgov.net +sfgpros.com +sfj.emlpro.com +sflexi.net +sflike.org +sfmail.top +sfolkar.com +sforamseadif.xyz +sfp.dropmail.me +sfpc.de +sfpixel.com +sfreviewapp.gq +sfromni.cyou +sfrty.ru +sfsa.de +sfsloan.com +sftrilogy.com +sfxmailbox.com +sfy.com +sfzcc.com +sg.emlhub.com +sg.freeml.net +sgag.de +sgate.net +sgatra.com +sgb-itu-anjeng.cf +sgb-itu-anjeng.ga +sgb-itu-anjeng.gq +sgb-itu-anjeng.ml +sgb-itu-anjeng.tk +sgb-itu-bangsat.cf +sgb-itu-bangsat.ga +sgb-itu-bangsat.gq +sgb-itu-bangsat.ml +sgb-itu-bangsat.tk +sgbteam.hostingarif.me +sgbteam.nl +sgbtukangsuntik.club +sge-edutec.com +sgep0o70lh.cf +sgep0o70lh.ga +sgep0o70lh.gq +sgep0o70lh.ml +sgep0o70lh.tk +sgesvcdasd.com +sgetrhg6.shop +sgilder.com +sgiochi.it +sgisfg.com +sgizdkbck4n8deph59.cf +sgizdkbck4n8deph59.gq +sgl.emlpro.com +sgm.ovh +sgqki.anonbox.net +sgrege.space +sgsda.com +sgti.com +sgtt.ovh +sgw186.com +sgxboe1ctru.cf +sgxboe1ctru.ga +sgxboe1ctru.gq +sgxboe1ctru.ml +sgxboe1ctru.tk +sh.emlhub.com +sh.emlpro.com +sh.ezua.com +sh.soim.com +shaafshah.com +shackvine.com +shadap.org +shadedgreen.com +shadezbyj.com +shadow-net.ml +shadowgames.cf +shadowlab.co +shadowlinepos.com +shadowmaxstore.com +shadowpowered.com +shadys.biz +shaflyn.com +shahidrazi.online +shahimul.tk +shahobt.info +shahzad.org +shakemain.com +shakemaker.com +shaken.baby +shakked.com +shalar.net +shall.favbat.com +shalvynne.art +shamanimports.com +shamanowners.com +shamanufactual.xyz +shandilas.host +shandongji232.info +shandysalon.live +shandysalon.store +shanemalakas.com +shanghongs.com +shanhaijuli.sbs +shanidarden.us +shankaraay.com +shanky.cf +shannonyaindgkil.com +shanreto.com +shantiom.gq +shaonianpaideqihuanpiaoliu.com +shapedcv.com +shapoo.ch +shapps.online +shapsugskaya.ru +shaqir-hussyin.com +sharcares.life +sharcares.online +sharcares.shop +sharcares.world +shareacarol.com +sharebot.net +shared-files.de +sharedmailbox.org +shareeffo44.shop +shareefshareef.website +shareflix.xyz +shareithub.com +sharemens.online +sharepfizer.finance +sharing-storage.com +sharkfaces.com +sharklasers.com +sharkliveroil.in +sharkmail.xyz +sharkslasers.com +sharksteammop.in +sharli.xyz +sharpmail.com +sharyndoll.com +shasto.com +shats.com +shattersense.com +shaw.pl +shayfeen.us +shaylarenx.com +shayzam.net +shbg.info +shbiso.com +shchiba.uk +shcn.yomail.info +shdxkr.com +she.marksypark.com +she.oldoutnewin.com +she.poisedtoshrike.com +shedik2.tk +shedplan.info +sheehansauction.com +sheenfalls.com +sheep.blatnet.com +sheep.marksypark.com +sheep.oldoutnewin.com +sheep.poisedtoshrike.com +sheephead.website +sheetbooks.com +sheey.com +shehermail.com +sheilamarcia.art +sheilatohir.art +sheileh.net +sheinup.com +shejumps.org +shelby-shop.com +shelbymattingly.com +sheless.xyz +shelvem.com +shemy.site +shenhgts.net +shenji.info +shenshahfood.com +shensufu.com +shepherds-house.com +sheronhouse.co +sherrie.com +sheryli.com +sheytg56.ga +shh.spymail.one +shhedd12.shop +shhmail.com +shhongshuhan.com +shhsfqijnw.ga +shhuut.org +shicoast.com +shid.de +shieldedmail.com +shieldemail.com +shievent.site +shiftmail.com +shiita12.com +shikimori.xyz +shimano-catan.ru +shine.favbat.com +shine49mediahouse.com +shinecoffee.com +shinedyoureyes.com +shingingbow.com +shinglestreatmentx.com +shining.one +shiningblogpro.com +shininglight.us +shinnemo.com +shinsplintsguide.info +shintabachir.art +shiny-star.net +shio365.com +ship-from-to.com +ship79.com +shipboard.ru +shipeinc.com +shipfromto.com +shiphang.club +shiphangmy.club +shiphazmat.org +shipkom.shop +shipping-regulations.com +shippingterms.org +shiprocket.tech +shiprol.com +shiptudo.com +shiqiyx.vip +shirleyanggraini.art +shirleybowman.com +shirleylogan.com +shiro.pw +shiroinime.ga +shironime.ga +shironime.ml +shironime.tk +shirtmakers.de +shirulo.com +shishire8.xyz +shishish.cf +shishish.ga +shishish.gq +shishish.ml +shishuai0511.com +shit.bthow.com +shit.dns-cloud.net +shit.dnsabr.com +shit.net +shitaway.cf +shitaway.flu.cc +shitaway.ga +shitaway.gq +shitaway.igg.biz +shitaway.ml +shitaway.nut.cc +shitaway.tk +shitaway.usa.cc +shitface.com +shitmail.cf +shitmail.de +shitmail.ga +shitmail.gq +shitmail.me +shitmail.ml +shitmail.org +shitmail.tk +shitposting.agency +shittymail.cf +shittymail.ga +shittymail.gq +shittymail.ml +shittymail.tk +shiva-spirit.com +shiyakila.cf +shiyakila.ga +shiyakila.gq +shiyakila.ml +shjto.us +shkele.cyou +shkk.yomail.info +shlon.com +shmeriously.com +sho94.xpath.site +shockinmytown.cu.cc +shockmail.win +shoeir.shop +shoeonlineblog.com +shoes-market.cf +shoes.com +shoes.net +shoesbrandsdesigner.info +shoesclouboupascher.com +shoeskicks.com +shoeslouboutinoutlet.com +shoesonline2014.com +shoesonline4sale.com +shoesshoponline.info +shoesusale.com +shogunraceparts.com +shoha.cc +shoklin.cf +shoklin.ga +shoklin.gq +shoklin.ml +shonecool.club +shonecool.online +shonecool.site +shonecool.xyz +shonky.info +shootence.com +shop-cart.xyz +shop-konditer.ru +shop.lalaboutique.com +shop.winestains.org +shop4mail.net +shopaccmmo.com +shopaccvip.pro +shopbaby.me +shopbabygirlz.com +shopbagsjp.org +shopbantkclone.com +shopbaohan.site +shopburberryjp.com +shopcaunho.com +shopcelinejapan.com +shopcloneus.com +shopcobe.com +shopcreative.cc +shopdigital.info +shopdoker.ru +shopdonna.com +shopduylogic.vn +shopeeboost.com +shopfalconsteamjerseys.com +shopflix.ml +shophall.net +shopifypurs.shop +shopiil.store +shopjpguide.com +shoplebs.club +shoplebs.online +shoplebs.site +shoplebs.space +shoplebs.xyz +shoplouisvuittonoutlets.com +shopmajik.com +shopmizi.com +shopmmovn.com +shopmoza.com +shopmp3.org +shopmulberryonline.com +shopmystore.org +shopnflnewyorkjetsjersey.com +shopnflravenjerseys.com +shoponlinemallus.com +shoponlinewithoutcvv.ru +shoppingcabinets.com +shoppingcow.com +shoppinglove.org +shoppingtrends24.de +shoppinguggboots.com +shoppiny.com +shopppy.shop +shoppradabagsjp.com +shoppung.com +shoppyhunt.com +shopravensteamjerseys.com +shoproyal.net +shopseahawksteamjerseys.com +shopsgrup.us +shopshoes.co.cc +shopshowlv.com +shopsuperbowl49ers.com +shopsuperbowlravens.com +shopteek.store +shoptheway.xyz +shoptrun.online +shopussy.com +shopvia2fa.net +shopviet73.com +shopwee.com +shopxda.com +shopy.club +shopyse.com +shoqc.com +short-haircuts.co +shortddodo.com +shorten.tempm.ml +shorterurl.biz +shorthus.site +shortmail.net +shorttermloans90.co.uk +shoshaa.in +shotarou.com +shotmail.ru +shotsdwwgrcil.com +shotshe.com +shoturl.top +shoulderiu.com +shoulderlengthhairstyles.biz +shouldpjr.com +shouu.cf +showartcenter.com +showbaz.com +showboxmovies.site +showbusians.ru +showcamsex.com +showcasebrand.com +showcoachfactory.com +showlogin.com +showme.social +showmethelights.com +shownabis.ru +showslow.de +showstorm.com +showup.today +showup.us +showupse.live +showupse.online +showupse.site +showupse.xyz +showyoursteeze.com +shp7.cn +shredded.website +shrib.com +shroudofturin2011.info +shrtner.com +shseedawish.site +shshsh.com +shtime2.com +shubowtv.com +shuelder.com +shuffle.email +shufuni.cn +shuoshuotao.com +shurkou.com +shurs.xyz +shut.name +shut.ws +shutaisha.ga +shutenk-shop.com +shuxevka.website +shvedian-nedv.ru +shwaws11.shop +shwetaungcement.org +shwg.de +shyhzsc.com +shzsedu.com +siai.com +siamhd.com +siap-sepuh.com +siapabucol.com +siapaitu.online +siasat.pl +siatkiogrodzeniowenet.pl +sibelor.pw +siberask.com +siberpay.com +sibigkostbil.xyz +sibirskiereki.ru +siboneycubancuisine.com +sicamail.ga +sickseo.catchallhost.com +sickseo.clicksendingserver.com +sickseo.co.uk +sicmag.com +sicmg.com +sicstocks.com +sidamail.ga +siddhacademy.com +siddillion.com +sidedeaths.co.cc +sidelka-mytischi.ru +sidement.com +sidemirror53.com +sidersteel.com +sidhutravel.com +sidkaemail.cf +sidler.us +sidmail.com +sidwell.spicysallads.com +sieczki.com.pl +siemans.com +siemems.com +sienna12bourne.ga +siennamail.com +sieprovev.gq +sieuthiclone.com +sieuthimekong.online +siftportal.ru +sifumail.com +sify.com +sign-in.social +sign-up.website +signalance.com +signalxp.com +signaturefencecompany.com +signaturehomegroup.net +signings.ru +signsoflosangeles.com +signstallers.info +sihanoma.store +sihirfm.net +sihr.laste.ml +sika3.com +sikatan.co +sikdar.site +sikharchives.com +sikis18.org +sikomo.cf +sikomo.ga +sikomo.gq +sikomo.ml +sikomo.tk +sikuder.me +sikumedical.com +sikux.com +silaaccounting.com +silacon.com +silaleg.tk +silangmata.com +silbarts.com +silda8vv1p6qem.cf +silda8vv1p6qem.ga +silda8vv1p6qem.gq +silda8vv1p6qem.ml +silda8vv1p6qem.tk +sildalis.website +silencei.org.ua +silenceofthespam.com +silent-art.ru +silentfood.world +silico.llc +siliconboost.com +siliwangi.ga +silkbrushes.com +silkroadproxy.com +sillver.us +sillylf.com +silnmy.com +silosta.co.cc +silsilah.life +silver.cowsnbullz.com +silver.qwertylock.com +silvercoin.life +silverfox.dev +silverimpressions.ca +silverlinecap.com +silvertrophy.info +silveth.com +silvy.email +silxioskj.com +sim-simka.ru +simaenaga.com +simails.info +simcity.hirsemeier.de +simdpi.com +simemia.co +simeonov.xyz +simerm.com +similarians.xyz +simillegious.site +siminfoscent.cfd +simmanllc.com +simoaudio.live +simoka73.vv.cc +simple-mail-server.bid +simplebox.email +simplebrackets.com +simpleemail.in +simpleemail.info +simpleitsecurity.info +simplemail.in +simplemail.top +simplemailserver.bid +simplemerchantcapital.com +simpleseniorliving.com +simplesocialmedia.solutions +simplesolutionsinc.com +simplesport.ru +simpleverification.com +simplisse.co +simply-email.bid +simplyaremailer.info +simplyemail.bid +simplyemail.men +simplyemail.racing +simplyemail.trade +simplyemail.website +simplyemail.win +simplyfurnished.co.uk +simplyshop24.com +simplysweeps.org +simporate.site +simpsonfurniture.com +simr.emlpro.com +simranaitech.space +simscity.cf +simsdsaon.eu +simsdsaonflucas.eu +simsmail.ga +simsosieure.com +simulink.cf +simulink.ga +simulink.gq +simulink.ml +simulturient.site +sin-mailing.com +sin.cl +sina.toh.info +sinagalore.com +sinaite.net +sinasina.com +sinasinaqq123.info +sinbox.asia +sincerereviews.com +sinclairservices.com +sind-hier.com +sind-wir.com +sinda.club +sindhier.com +sindu.org +sindwir.com +sineli.com +sinema.ml +sinemail.info +sinemailing.com +sinessumma.site +sinfiltro.cl +singapore-nedv.ru +singaporetravel.network +singermarketing.com +single-lady-looking-for-man.club +singlecoffeecupmaker.com +singlesearch12.info +singlespride.com +singletravel.ru +singmails.com +singonline.net +singssungg.faith +singtelmails.com +singuyt.com +sinhq.com +sinime.xyz +sink.fblay.com +sinkorswimcg.com +sinmailing.com +sinnai.com +sinnlos-mail.de +sino.tw +sinorto.com +sinportrhin.online +sins.com +sinsa12.com +sinsize.org +sintec.pl +sinyago.com +sinyomail.gq +siolence.com +siolysiol.com +sipbone.com +sipstrore.com +siptrunkvoipbusinessphone.shop +sir1ddnkurzmg4.cf +sir1ddnkurzmg4.ga +sir1ddnkurzmg4.gq +sir1ddnkurzmg4.ml +sir1ddnkurzmg4.tk +sirafee.com +sirgoo.com +siria.cc +sirkelmail.com +sirkelvip.com +sirneo.info +siroja.top +sirprase.com +sirr.de +sirttest.us.to +sirver.ru +sisari.ru +sisemazamkov.com +sismolo.ga +sistewep.online +sistm.in +sitavu.eu +sitdown.com +sitdown.info +sitdown.us +site-games.ru +site.blatnet.com +site.emailies.com +site.ploooop.com +site24.site +siteher.info +sitehost.shop +siteinfox.com +sitelikere.com +sitemap.uk +sitenet.site +siteposter.net +sites.cowsnbullz.com +sitesglobal.com +sitestyt.ru +siteuvelirki.info +sitezeo.com +sitik.site +sitished.site +sitnicely.com +sitolowcost.com +sitoon.cf +situsbebas.com +situsoke.online +siuk.com +siux3aph7ght7.cf +siux3aph7ght7.ga +siux3aph7ght7.gq +siux3aph7ght7.ml +siux3aph7ght7.tk +sivaaprilia.art +sivtmwumqz6fqtieicx.ga +sivtmwumqz6fqtieicx.gq +sivtmwumqz6fqtieicx.ml +sivtmwumqz6fqtieicx.tk +siwiyjlc.xyz +siwonmail.com +six-six-six.cf +six-six-six.ga +six-six-six.gq +six-six-six.ml +six-six-six.tk +six.emailfake.ml +six.fackme.gq +six25.biz +six55.com +sixdrops.org +sixtptsw6f.cf +sixtptsw6f.ga +sixtptsw6f.gq +sixtptsw6f.ml +sixtptsw6f.tk +sixtymina.com +sixxx.ga +sixze.com +siyonastudio.com +sizableonline.info +sizemin.com +sizemon.com +sizzlemctwizzle.com +sj.mimimail.me +sj969uyqr.laste.ml +sjadhasdhj3423.info +sjandse.website +sjanqhrhq.com +sjasd.net +sjdh.xyz +sjfdksdmfejf.com +sjgk.yomail.info +sjhsbridge.org +sjhvns.cloud +sjindia.com +sjmcfaculty.org +sjmp.emlpro.com +sjokantenfiskogdelikatesse.me +sjpvvp.org +sjrajufhwlb.cf +sjrajufhwlb.ga +sjrajufhwlb.gq +sjrajufhwlb.ml +sjrajufhwlb.tk +sjsfztvbvk.pl +sjsjpany.com +sjsjsj.com +sjuaq.com +sjusngde.info +skabir.website +skabot.com +skachat-1c.org +skachat-888poker.ru +skachatfilm.com +skafi.xyz +skafunderz.com +skakuntv.com +skalive.com +skarwin.com +skateboardingcourses.com +skateru.com +skdjfmail.com +skdl.de +skedware.com +skeefmail.com +skeefmail.net +skeet.software +skerme.com +skg3qvpntq.cf +skg3qvpntq.ga +skg3qvpntq.ml +skg3qvpntq.tk +skhnlm.cf +skhnlm.ga +skhnlm.gq +skhnlm.ml +skibidipa.com +skidka-top.club +skifrance.website +skilaphab.ml +skiller.website +skillfare.com +skillion.org +skilltool.com +skimcss.com +skin-care-tips.com +skin2envy.com +skinacneremedytreatmentproduct.com +skinaestheticlinic.com +skincareonlinereviews.net +skincareproductoffers.com +skinid.info +skinkaito.fun +skinnersum.com +skintagfix.com +skinwhiteningforeverreview.org +skipadoo.org +skipopiasc.info +skipspot.eu +skishop24.de +skite.com +skizohosting.xyz +skkk.edu.my +sklad.progonrumarket.ru +skladchina.pro +skldfsldkfklsd.com +sklep-motocyklowy.xyz +sklep-nestor.pl +sklepsante.com +skluo.com +skmorvdd.xyz +skodaauto.cf +skoozipasta.com +skorao.xyz +skorbola.club +skoshkami.ru +skra.de +skrak.com +skrank.com +skrx.tk +skrzynka.waw.pl +sksdkwlrgoeksf.com +sksfullskin.ga +sksjs.com +sksks.com +skssh.anonbox.net +sktzmobile.com +sku.laste.ml +skue.com +skummi-service.ru +skuno.click +skuur.com +skuxyo.buzz +skuzos.biz +skxemail.com +sky-inbox.com +sky-isite.com +sky-mail.ga +sky-movie.com +sky-ts.de +sky.cowsnbullz.com +sky.dnsabr.com +sky.emailies.com +sky.lakemneadows.com +sky.marksypark.com +sky2x.com +skybarlex.xyz +skycrossmail.com +skycustomhomes.com +skydragon112.cf +skydragon112.ga +skydragon112.gq +skydragon112.ml +skydragon112.tk +skydrive.tk +skye.com +skyfieldhub.com +skygazerhub.com +skyjetnet.com +skylai.cfd +skylarchic.shop +skylinescity.com +skymail.ga +skymail.gq +skymailapp.com +skymailgroup.com +skymemy.com +skymovieshd.space +skymovieshd.store +skyne.be +skynet.infos.st +skynetengine.xyz +skynetfinancial.com +skynettool.xyz +skynt.be +skyometric.com +skypaluten.de +skype.com.se +skypewebui.eu +skyrt.de +skysip.com +skysmail.gdn +skytopway.com +skyvia.info +skyvoid.xyz +skyzerotiger.com +skz.us +skzokgmueb3gfvu.cf +skzokgmueb3gfvu.ga +skzokgmueb3gfvu.gq +skzokgmueb3gfvu.ml +skzokgmueb3gfvu.tk +sladko-ebet-rakom.ru +sladko-milo.ru +slakthuset.com +slamroll.com +slapcoffee.com +slapmail.top +slapsfromlastnight.com +slarmail.com +slashpills.com +slaskpost.rymdprojekt.se +slaskpost.se +slave-auctions.net +slavens.eu +slavenspoppell.eu +slawbud.eu +slchemtech.com +slcr.xyz +sledgeeishockey.eu +sledzikor.az.pl +sleekdirectory.com +sleepary.com +sleepfjfas.org.ua +sleepingtrick.tk +sleepyinternetfun.xyz +slekepeth78njir.ga +slendex.co +slepikas.com +slexports.com +slfence.com +slicediceandspiceny.com +slickdeal.net +sliew.com +slikkness.com +slimail.info +slimearomatic.ru +slimimport.com +slimlet.com +slimming-fast.info +slimming-premium.info +slimmingtabletsranking.info +slimurl.pw +slingomother.ru +sliped.com +slipkin.online +slippery.email +slipry.net +slissi.site +slivmag.ru +slix.dev +slkdjf.com +slkfewkkfgt.pl +slmshf.cf +slobruspop.co.cc +slomail.info +slomke.com +slonmail.com +sloppyworst.co +slopsbox.com +slot-onlinex.com +slot889.net +slotes.ru +slothmail.net +slotidns.com +slotoking.city +sloum.com +slovabegomua.ru +slovac-nedv.ru +sloven-nedv.ru +sloveniakm.com +slowcooker-reviews.com +slowdeer.com +slowfoodfoothills.xyz +slowimo.com +slowm.it +slowmotn.club +slowslow.de +slp.laste.ml +slq.freeml.net +sls.us +slson.com +slsrs.ru +sltanaslert.space +sltmail.com +sltrust.com +slu21svky.com +sluden.work +slugmail.ga +slushmail.com +slushpools.cloud +slushyhut.com +slut-o-meter.com +sluteen.com +slutty.horse +slvlog.com +slwedding.ru +sly.io +sm.emlpro.com +sma.center +smack.email +smahtin.ru +smailpost.info +smailpostin.net +smailpro.com +smajok.ru +smakit.rest +smakit.vn +small.blatnet.com +small.lakemneadows.com +small.makingdomes.com +small.ploooop.com +small.poisedtoshrike.com +smallalpaca.com +smallanawanginbeach.com +smallbusinesshowto.com +smallfrank.com +smallker.tk +smalltown.website +sman14kabtangerang.site +sman1penukal.my.id +smanik2.xyz +smanual.shop +smap4.me +smapfree24.com +smapfree24.de +smapfree24.eu +smapfree24.info +smapfree24.org +smaretboy.pw +smart-27-shop.online +smart-email.me +smart-host.org +smart-mail.info +smart-mail.top +smart-medic.ru +smart.lakemneadows.com +smart.oldoutnewin.com +smartbusiness.me +smartcharts.pro +smartdreamzzz.com +smartemail.fun +smartemailbox.co +smartertactics.com +smartfuture.space +smartgrasspools.tech +smartgrid.com +smartify.homes +smartinbox.online +smartkeeda.net +smartnator.com +smartok.app +smartpaydayonline.com +smartplaygame.com +smartplumbernyc.com +smartpro.tips +smartrepairs.com.au +smartretireway.com +smartsass.com +smartsignout.com +smarttalent.pw +smarttickethub.com +smartvanlines.com +smartvineyards.net +smartvps.xyz +smartvs.xyz +smartx.sytes.net +smarty123.info +smashmail.com +smashmail.de +smashmywii.com +smasnug.tech +smbjrrtk.xyz +smbookobsessions.com +smcelder.com +smcia.anonbox.net +smcleaningbydesign.com +smconstruction.com +smcrossover.com +smeegoapp.com +smellfear.com +smellrear.com +smellypotato.tk +smesthai.com +smetin.com +smeux.com +smfsgoeksf.com +smi.ooo +smileair.org +smilebalance.com +smilefastcashloans.co.uk +smileqeqe.com +smilequickcashloans.co.uk +smilestudioaz.com +smiletransport.com +smilevxer.com +smileyet.tk +smime.ninja +smirnoffprices.info +smirusn6t7.cf +smirusn6t7.ga +smirusn6t7.gq +smirusn6t7.ml +smirusn6t7.tk +smith-jones.com +smith.com +smithgroupinternet.com +smithwright.edu +smk.emlhub.com +smk.freeml.net +smkt.spymail.one +sml2020.xyz +smlmail.com +smlmail.net +smmok-700nm.ru +smmwalebaba.com +smncloud.com +smokefreesheffield.co.uk +smoken.com +smoking.com +smokingcessationandpregnancy.org +smokingpipescheap.info +smoothtakers.net +smoothunit.us +smotret-video.ru +smotretonline2015.ru +smotretonlinehdru.ru +smoug.net +smrn420.com +sms.at +smsazart.ru +smsbaka.ml +smsblue.com +smsbuds.in +smsdash.com +smsenmasse.eu +smsforum.ro +smsjokes.org +smsmint.com +smsraag.com +smsturkey.com +smswan.com +smtd.emlpro.com +smtp.cadx.edu.pl +smtp.docs.edu.vn +smtp.ntservices.xyz +smtp.szela.org +smtp3.cz.cc +smtp33.com +smtp99.com +smtponestop.info +smub.com +smuggroup.com +smulevip.com +smuse.me +smuvaj.com +smwg.info +smybinn.com +smykwb.com +smypatico.ca +smz.yomail.info +sn3bochroifalv.cf +sn3bochroifalv.ga +sn3bochroifalv.gq +sn3bochroifalv.ml +sn3bochroifalv.tk +sn55nys5.cf +sn55nys5.ga +sn55nys5.gq +sn55nys5.ml +sn55nys5.tk +snack-bar.name +snackshop73.com +snacktime.games +snad1faxohwm.cf +snad1faxohwm.ga +snad1faxohwm.gq +snad1faxohwm.ml +snad1faxohwm.tk +snaena.com +snag.org +snail-mail.bid +snail-mail.net +snailmail.bid +snailmail.download +snailmail.men +snailmail.website +snaimail.top +snakebutt.com +snakemail.com +snakement.com +snaknoc.cf +snaknoc.ga +snaknoc.gq +snaknoc.ml +snam.cf +snam.ga +snam.gq +snam.tk +snapbackbay.com +snapbackcapcustom.com +snapbackcustom.com +snapbackdiscount.com +snapbackgaga.com +snapbackhatscustom.com +snapbackhatuk.com +snapboosting.com +snapbrentwood.org +snapbx.com +snapinbox.top +snapmail.cc +snapmail.site +snapunit.com +snapwet.com +snasu.info +sncu.laste.ml +sneakemail.com +sneaker-friends.com +sneaker-mag.com +sneaker-shops.com +sneakerbunko.cf +sneakerbunko.ga +sneakerbunko.gq +sneakerbunko.ml +sneakerbunko.tk +sneakerhub.ru +sneakers-blog.com +sneakersisabel-marant.com +sneakmail.de +sneakyreviews.com +snece.com +snehadas.rocks +snehadas.site +snehadas.tech +snellingpersonnel.com +snine.online +snipe-mail.bid +snipemail4u.bid +snipemail4u.men +snipemail4u.website +snkmail.com +snkml.com +snl9lhtzuvotv.cf +snl9lhtzuvotv.ga +snl9lhtzuvotv.gq +snl9lhtzuvotv.ml +snl9lhtzuvotv.tk +snlw.com +snoodi.com +snowbirdmail.com +snowboardingblog.com +snowboots4usa.com +snowlash.com +snowmail.xyz +snowsweepusa.com +snowthrowers-reviews.com +snpsex.ga +sns.dropmail.me +snsanfcjfja.com +snugmail.net +snuzh.com +snvpro.online +snwxz.com +snx7rm3ba.web.id +so-com.tk +so-net.cf +so-net.ga +so-net.gq +so-net.ml +so.dropmail.me +so4ever.codes +soaap.co +sobakanazaice.gq +sobc.com +sobeatsdrdreheadphones1.com +sobecoupon.com +sobeessentialenergy.com +soblaznvip.ru +soc123.net +socailmarketing.ir +socalgamers5.info +socalnflfans.info +socalu2fans.info +socam.me +soccerfit.com +soccerinstyle.com +soccerjh.com +soccerrrr12.com +socgazeta.com +sochi.shn-host.ru +sochihosting.info +social-inbox.com +social-mailer.tk +socialcampaigns.org +socialcloud99.live +socialeum.com +socialfurry.org +socialhubmail.info +sociallymediocre.com +socialmailbox.info +socialmediamonitoring.nl +socialpreppers99.com +socialsergsasearchengineranker.com +socialstudy.biz +socialtheme.ru +socialviplata.club +socialxbounty.info +sociloy.net +socjaliscidopiekla.pl +socket1212.com +sockfoj.pl +sockhotkey.shop +socksbest.com +socmail.net +soco7.com +socoolglasses.com +socoori.com +socrazy.club +socrazy.online +socsety.com +socte12.com +socusa.ru +socvideo.ru +soczewek-b.pl +soczewki.com +sodap4.org +sodapoppinecoolbro.com +sodaz252.com +sodergacxzren.eu +sodergacxzrenslavens.eu +soeasytop.ru +soebing.com +soeermewh.com +soelegantlyput.com +soeo4am81j.cf +soeo4am81j.ga +soeo4am81j.gq +soeo4am81j.ml +soeo4am81j.tk +sofaion.com +sofaoceco.pl +sofarb.com +sofia.re +sofia123.club +sofiarae.com +sofimail.com +sofme.com +sofort-mail.de +sofortmail.de +sofrge.com +soft-cheap-oem.net +softanswer.ru +softautotool.com +softballball.com +softbank.tk +softdesk.net +softkey-office.ru +softmails.info +softpaws.ru +softpls.asia +softportald.tk +softprimehub.store +softswiss.today +softtoiletpaper.com +softwant.net +softwaredeals.site +softwarepol.club +softwarepol.fun +softwarepol.website +softwarepol.xyz +softwarespiacellulari.info +softwarespiapercellulari.info +softwarezgroup.com +softwiretechnology.com +softxcloud.tech +sogetthis.com +soggybottomrunning.com +soglasie.info +sogolfoz.com +sogopo.cf +sogopo.ga +sogopo.ml +sohai.ml +sohbet10.com +sohbetac.com +sohbetamk.xyz +sohosale.com +sohu.net +sohu.ro +sohufre.cf +sohufre.ga +sohufre.gq +sohufre.ml +sohus.cn +soikeongon.net +soillost.us +soiloptimizer.com +soioa.com +soisz.com +soitanve.cf +soitanve.ml +soitanve.tk +soju.buzz +sokahplanet.com +sokaklambasi.cf +sokaklambasi.ga +sokaklambasi.ml +sokap.eu +sokmany.com +sokosquare.com +sokratit.ru +sokudevvvstudents.xyz +sokuyo.xyz +solanamcu.com +solar-apricus.com +solar-impact.pro +solar.emailind.com +solar.pizza +solaraction.network +solaraction.org +solaractivist.network +solaravenue.org +solarbet99.site +solarclassroom.net +solarcoopc.site +solareclipsemud.com +solaredgelights.com +solarflarecorp.com +solarflight.org +solarfor99dollars.com +solarforninetyninedollars.com +solarino.pl +solarinverter.club +solarlamps.store +solarnyx.com +solarpowered.online +solarquick.africa +solarunited.net +solarunited.org +solarwinds-msp.biz +solatint.com +solddit.com +soldesburbery.com +soldierofthecross.us +soldierreport.com +soleli.com +solemates.me +solerbe.net +soliaflatirons.in +soliahairstyling.in +solidbots.net +solidequity.com +solidframework.com +solidframeworks.com +solidmail.org +solidpokerplayer.com +solidseovps.com +solihulllandscapes.com +solikun.ga +solikun.gq +solikun.tk +solirallc.com +solitaireminerals.com +solkill.store +sollie-legal.online +solliz.online +sollutiongpt.live +solnrg.com +soloadvanced.com +solobizstart.com +solomasks.com +soloner.ru +soloou.xyz +solowkol.site +solowtech.com +solpatu.space +solpowcont.info +soltur.bogatynia.net.pl +solu.gq +solu7ions.com +solusisukses.digital +soluteconsulting.com +soluteconsulting.us +solution-finders.com +solution-space.biz +solutionsmagazine.org +solutionsmanual.us +solutionsnetwork10.com +solve-anxiety.com +solvedbycitizens.com +solvemail.info +solventtrap.wiki +solviagens.store +somacolorado.com +somaderm.health +somalipress.com +somanav.space +somardiz.com +somaroh.com +somdhosting.com +some.cowsnbullz.com +some.oldoutnewin.com +some.ploooop.com +some.us +someadulttoys.com +somebodyswrong.com +somechoice.ga +somecringe.ml +somedd.com +someeh.org +someeh.us +someion.com +somelora.com +somepornsite.com +somera.org +somerandomdomains.com +someredirectpartnerify.info +someredirectpartneroid.info +somersetoil.com +somesite.com +sometainia.site +somethingsirious.com +somniasound.com +somoj.com +somonsuka.com +somosfarol.com.br +somsupport.xyz +son.zone +son16.com +sonaa.online +sonaluma.com +sonamyr.shop +sonasoft.net +sondemar.com +sonderagency.org +sondorshelp.com +sondosmine.fun +sondwantbas.cf +sondwantbas.ga +songart.ru +songbomb.com +songgallery.info +songhana.shop +songjiancai.com +songlists.info +songlyricser.com +songosng.com +songpaste.com +songpong.net +songsan.business +songsblog.info +songshnagu.com +songshnagual.com +songsign.com +songtaitan.com +songtaith.com +songtaitu.com +songtaiu.com +soniaalyssa.art +sonicaz.space +soniconsultants.com +sonicv.com +sonjj.edu.pl +sonmoi356.com +sonnenkinder.org +sonny.tk +sonophon.ru +sonphuongthinh.com +sonpu.xyz +sonrusu.com +sonseals.com +sonshi.cf +sonshi.pl +sontol.pw +sonu.com +sony.redirectme.net +sonyedu.com +sonymails.gdn +sonysun.live +sonyymail.com +soodmail.com +soodomail.com +soodonims.com +soombo.com +soon.it +soopltryler.com +soopr.info +sooq.live +soowz.com +soozoop.com +sopatrack.com +sophiecostumes.com +soplsqtyur.cf +soplsqtyur.ga +soplsqtyur.gq +soplsqtyur.ml +soplsqtyur.tk +sopotstyle.xyz +sopt.com +soptlequick.tech +sopulit.com +sorcios.com +soremap.com +sorir.info +sorteeemail.com +sortsml.com +soscandia.org +sosd.cf +sosejvpn.xyz +soslouisville.com +sosmanga.com +sosod.com +sosohagege.com +sotahmailz.ga +sotayonline.com +sothich.com +sotosegerr.xyz +souc.emlhub.com +souillat.com +soul-association.com +soulfinderhub.lat +soulfire.pl +soulinluv.com +soulsuns.com +soulvow.com +soumail.info +soundclouddownloader.info +soundels.com +sounditems.com +soundmovie.biz +soupans.ru +souqdeal.site +souqegweave.shop +sourcl.club +sourcreammail.info +sousousousou.com +southafrica-nedv.ru +southamericacruises.net +southeastasiaheritage.world +southernmarinesrvcs.com +southernup.org +southgators.com +southlakeapartments.com +southlaketahoeapartments.com +southmiamiroofing.com +southpasadenaapartments.com +southpasadenahistoricdistrict.com +sovan.com +sovixa.com +sowad.tk +sowhatilovedabici.com +soxrazstex.com +soyamsk.com +soyboy.observer +soycasero.com +soyou.net +sozdaem-diety.ru +sozenit.com +sozfilmi.com +sp-market.ru +sp.emlhub.com +sp.woot.at +spa.com +space-company.ru +space.cowsnbullz.com +space.favbat.com +spacebazzar.ru +spacecas.ru +spacecolonyearth.com +spacehotline.com +spaceinvadas.com +spaceitdesign.com +spacemail.info +spacemail.my +spacemail.xyz +spaceonyx.ru +spacepush.org +spacewalker.cf +spacewalker.ga +spacewalker.gq +spacewalker.ml +spacibbacmo.lflink.com +spacted.site +spaereplease.com +spahealth.club +spahealth.online +spahealth.site +spahealth.xyz +spain-nedv.ru +spainholidays2012.info +spajek.com +spam-be-gone.com +spam-en.de +spam-nicht.de +spam.aleh.de +spam.care +spam.ceo +spam.coroiu.com +spam.deluser.net +spam.dhsf.net +spam.dnsx.xyz +spam.fassagforpresident.ga +spam.flu.cc +spam.hortuk.ovh +spam.igg.biz +spam.janlugt.nl +spam.jasonpearce.com +spam.la +spam.loldongs.org +spam.lucatnt.com +spam.lyceum-life.com.ru +spam.mccrew.com +spam.netpirates.net +spam.no-ip.net +spam.nut.cc +spam.org.es +spam.ozh.org +spam.pls.com +spam.pyphus.org +spam.quillet.eu +spam.rogers.us.com +spam.shep.pw +spam.su +spam.tla.ro +spam.trajano.net +spam.usa.cc +spam.visuao.net +spam.wtf.at +spam.wulczer.org +spam4.me +spamail.de +spamama.uk.to +spamarrest.com +spamavert.com +spamblog.biz +spambob.com +spambob.net +spambob.org +spambog.co +spambog.com +spambog.de +spambog.net +spambog.ru +spambooger.com +spambox.info +spambox.irishspringrealty.com +spambox.me +spambox.org +spambox.us +spambox.win +spambox.xyz +spamcannon.com +spamcannon.net +spamcanwait.com +spamcero.com +spamcon.org +spamcorptastic.com +spamcowboy.com +spamcowboy.net +spamcowboy.org +spamday.com +spamdecoy.net +spameater.com +spameater.org +spamelka.com +spamex.com +spamfellas.com +spamfighter.cf +spamfighter.ga +spamfighter.gq +spamfighter.ml +spamfighter.tk +spamfree.eu +spamfree24.com +spamfree24.de +spamfree24.eu +spamfree24.info +spamfree24.net +spamfree24.org +spamgoes.in +spamgourmet.com +spamgourmet.net +spamgourmet.org +spamgrube.net +spamherelots.com +spamhereplease.com +spamhole.com +spamify.com +spaminator.de +spamkill.info +spaml.com +spaml.de +spamlot.net +spammail.me +spammedic.com +spammehere.com +spammehere.net +spammer.fail +spammingemail.com +spammote.com +spammotel.com +spammuffel.de +spammy.host +spamobox.com +spamoff.de +spamok.com +spamok.com.ua +spamok.de +spamok.es +spamok.fr +spamreturn.com +spamsalad.in +spamsandwich.com +spamserver.cf +spamserver.ga +spamserver.gq +spamserver.ml +spamserver.tk +spamserver2.cf +spamserver2.ga +spamserver2.gq +spamserver2.ml +spamserver2.tk +spamslicer.com +spamspameverywhere.org +spamsphere.com +spamspot.com +spamstack.net +spamthis.co.uk +spamthis.network +spamthisplease.com +spamtrail.com +spamtrap.co +spamtrap.ro +spamtroll.net +spamwc.cf +spamwc.de +spamwc.ga +spamwc.gq +spamwc.ml +spamzero.net +spandamail.info +spararam.ru +sparkfilter.online +sparkfilter.xyz +sparkletoc.com +sparkling.vn +sparklogics.com +sparkmail.top +sparkmate.lat +sparkpool.info +sparkpoolprohub.online +sparkroi.com +sparkypremium.com +sparramail.info +sparrowcrew.org +spartan-fitness-blog.info +spartanburgkc.org +sparts.com +sparxbox.info +spasalonsan.ru +spaso.it +spbc.com +spbdyet.ru +spbladiestrophy.ru +spblt.ru +spdplumbing-heating.co.uk +spduszniki.pl +spe24.de +speak2all.com +speakfreely.email +speakfreely.legal +spearsmail.men +spec-energo.ru +spec7rum.me +specialinoevideo.ru +specialistblog.com +specialkien.club +specialkien.website +specialkien.xyz +specialmail.com +specialmailmonster.online +specialmassage.club +specialmassage.fun +specialmassage.online +specialmassage.website +specialmassage.xyz +specialshares.com +specialsshorts.info +specialuxe.com +specism.site +specjalistyczneoserwisy.pl +spectexremont.ru +spectro.icu +speed-mail.co.uk +speed.hexhost.pl +speeddataanalytics.com +speeddategirls.com +speedfocus.biz +speedgaus.net +speedgrowth.me +speedgrowth.tech +speedkill.pl +speedlab.com +speedmail.cf +speedmediablog.com +speedsogolink.com +speedupmail.us +speedyhostpost.net +speemail.info +spektr.info +spektrsteel.ru +spellware.ru +spelovo.ru +spemail.xyz +spent.life +spentlife.life +spentlife.online +spentlife.shop +sperke.net +sperma.cf +sperma.gq +spetsinger.ru +spfence.net +spga.de +spgmail.tk +sph.spymail.one +spharell.com +sphay.com +spheretelecom.com +sphile.site +sphosp.com +sphrio.com +spicethainj.com +spickety.com +spicy.photo +spicycartoons.com +spicysoda.com +spidalar.tk +spider.co.uk +spidersales.com +spidite.com +spierdalaj.xyz +spikebase.com +spikemargin.com +spikeworth.com +spikeysix.site +spikio.com +spin.net +spin1428.top +spinacz99.ru +spindl-e.com +spinefruit.com +spingame.ru +spinly.net +spinmail.info +spinningclubmadrid.com +spinofis.ml +spinwheelnow.com +spinwinds.com +spiritcareers.com +spiritedmusepress.com +spiriti.tk +spiritjerseysattracting.com +spiritosschool.com +spiritsingles.com +spiritsite.net +spiritualfriendship.site +spiritwarlord.com +spirt.com +spkvariant.ru +spkvaruant.ru +splashsecurilty.com +splendacoupons.org +splendyrwrinkles.com +splishsplash.ru +split.bthow.com +splitparents.com +spm.laohost.net +spmu.com +spmy.netpage.dk +spo777.com +spokedcity.com +spoksy.info +spolujizda.info +sponscloud.tech +sponsored-by-xeovo-vpn.ink +sponsored-by-xeovo-vpn.site +sponsored-by-xeovo.site +sponsstore.com +spont.ml +spoofer.cc +spoofmail.de +spoofmail.es +spoofmail.fr +sporexbet.com +sport-gesundheit.de +sport-live-com.ru +sport-partners.ru +sport-polit.com +sport-portalos.com.uk +sport234.click +sport4me.info +sport9.win +sportanswers.ru +sportifyku.me +sportiva.site +sportizi.com +sportkakaotivi.com +sportmiet.ru +sportprediction.com +sportrid.com +sports.myvnc.com +sportsa.ovh +sportsallnews.com +sportsbettingblogio.com +sportscape.tv +sportscentertltc.com +sportsdeer.com +sportsextreme.ru +sportsfoo.com +sportsfunnyjerseys.com +sportsgames2watch.com +sportsinjapan.com +sportsjapanesehome.com +sportskil.online +sportsnews.xyz +sportsnewsforfun.com +sportsnflnews.com +sportsshopsnews.com +sportsstores.co +sportwatch.website +sportylife.us +spot.cowsnbullz.com +spot.lakemneadows.com +spot.marksypark.com +spot.oldoutnewin.com +spot.poisedtoshrike.com +spot.popautomated.com +spotale.com +spotify.best +spotifyindo.com +spotitidku.me +spotlightgossip.com +spotoid.com +spoty.email +spotyprot.live +spotyprot.online +spoxtify.com +spoyascil.my.id +sppwgegt.mailpwr.com +sppy.site +spqb.dropmail.me +spr.io +sprawdzlokatybankowe.com.pl +spraylysol.com +spreaddashboard.com +sprfifijcn.ga +sprin.tf +springboard.co.in +springcitychronicle.com +springfactoring.com +springrive.com +sprintpal.com +spritzzone.de +sproces.shop +sprtmxmfpqmf.com +spruzme.com +sprzet.med.com +sps-visualisierung.de +spsassociates.com +spse.fun +sptgaming.com +spudiuzdsm.cf +spudiuzdsm.ga +spudiuzdsm.gq +spudiuzdsm.ml +spudiuzdsm.tk +spunesh.com +spura2.com.mz +spuramexico2.mx +spuramexico20.com.mx +spuramexico20.mx +spuramexico20012.com +spuramexico20012.com.mx +spuramexico2012.com +spuramexico2012.info +spuramexico2012.net +spuramexico2012.org +spwe.mailpwr.com +spwebsite.com +spwmrk.xyz +spybox.de +spycellphonesoftware.info +spychelin.ml +spyderskiwearjp.com +spylive.ru +spymail.com +spymail.one +spymobilephone.info +spymobilesoftware.info +spyphonemobile.info +spysoftwareformobile.info +sq212ok.com +sq9999.com +sqi.emlhub.com +sqiiwzfk.mil.pl +sqkpihpzzo.ga +sqmail.xyz +sqoai.com +sqsv.dropmail.me +squadhax.ml +squadmetrix.com +squaresilk.com +squashship.com +squeezeproductions.com +squirt.school +squirtsnap.com +squizzy.de +squizzy.eu +squizzy.net +sqwert.com +sqwtmail.com +sqwy.emlhub.com +sqxx.net +sqyieldvd.com +sr.dropmail.me +sr.emlpro.com +sr.ro.lt +sraka.xyz +srancypancy.net +srb.spymail.one +srcitation.com +srenon.com +srestod.net +srfe.com +srgb.de +srhfdhs.com +sriaus.com +sribalaji.ga +sriexpress.com +srizer.com +srjax.tk +srku7ktpd4kfa5m.cf +srku7ktpd4kfa5m.ga +srku7ktpd4kfa5m.gq +srku7ktpd4kfa5m.ml +srku7ktpd4kfa5m.tk +srna.emlpro.com +sroff.com +srrowuvqlcbfrls4ej9.cf +srrowuvqlcbfrls4ej9.ga +srrowuvqlcbfrls4ej9.gq +srrowuvqlcbfrls4ej9.ml +srrvy25q.atm.pl +srscapital.com +srsconsulting.com +srtchaplaincyofcanada.com +srugiel.eu +sruputkopine.co +srv-aple-scr.xyz +srv1.mail-tester.com +srv31585.seohost.com.pl +srv4.rejecthost.com +srvq.com +srwq.emlpro.com +srxua.anonbox.net +sry.li +ss-deai.info +ss-hitler.cf +ss-hitler.ga +ss-hitler.gq +ss-hitler.ml +ss-hitler.tk +ss.undo.it +ss00.cf +ss00.ga +ss00.gq +ss00.ml +ss01.ga +ss01.gq +ss02.cf +ss02.ga +ss02.gq +ss02.ml +ss02.tk +ssaa.emlhub.com +ssacslancelbbfrance2.com +ssahgfemrl.com +ssangyong.cf +ssangyong.ga +ssangyong.gq +ssangyong.ml +ssanphone.me +ssanphones.com +ssaofurr.com +ssaouzima.com +ssatyo.buzz +sschmid.ml +ssd24.de +ssdcgk.com +ssddfxcj.net +ssdfxcc.com +ssdhfh7bexp0xiqhy.cf +ssdhfh7bexp0xiqhy.ga +ssdhfh7bexp0xiqhy.gq +ssdhfh7bexp0xiqhy.ml +ssdhfh7bexp0xiqhy.tk +ssdijcii.com +ssds.com +ssef.com +ssemarketing.net +ssfaa.com +ssfccxew.com +ssfehtjoiv7wj.cf +ssfehtjoiv7wj.ga +ssfehtjoiv7wj.gq +ssfehtjoiv7wj.ml +ssfehtjoiv7wj.tk +ssg24.de +ssgjylc1013.com +sshid.com +ssi-bsn.infos.st +ssij.pl +ssju.mimimail.me +ssjzg.anonbox.net +sskmail.top +ssl-aktualisierung-des-server-2019.icu +ssl.tls.cloudns.asia +sslclaims.com +sslglobalnetwork.com +sslporno.ru +sslsecurecert.com +sslsmtp.bid +sslsmtp.download +sslsmtp.racing +sslsmtp.trade +sslsmtp.website +sslsmtp.win +ssmg.laste.ml +ssmiadion.com +ssnp5bjcawdoby.cf +ssnp5bjcawdoby.ga +ssnp5bjcawdoby.gq +ssnp5bjcawdoby.ml +ssnp5bjcawdoby.tk +sso-demo-azure.com +sso-demo-okta.com +ssoia.com +ssongs34f.com +ssopany.com +sspecialscomputerparts.info +ssrrbta.com +sssdccxc.com +ssseunghyun.com +sssig.one +sssppua.cf +sssppua.ga +sssppua.gq +sssppua.ml +sssppua.tk +ssteermail.com +ssuet-edu.tk +ssunz.cricket +ssvm.xyz +sswinalarm.com +ssww.ml +ssxueyhnef01.pl +sszzzz99.com +st-m.cf +st-m.ga +st-m.gq +st-m.ml +st-m.tk +st.spymail.one +st1.vvsmail.com +stablemail.igg.biz +stablic.site +staceymail.ga +stacjonarnyinternet.pl +stackedlayers.com +stackinglayers.com +stacklance.com +stacktix.xyz +stacys.mom +stadiumclubathemax.com +stafabandk.site +staffburada.com +staffchat.tk +stafflazarus.com +staffprime.com +stagedandconfused.com +stainlessevil.com +staircraft5.com +stalbud2.com.pl +stalbudd22.pl +stalingradd.ru +stalloy.com +stalnoj.ru +stalos.pl +stamberg.nl +stampsprint.com +stanastroo.ml +stanbondsa.com.au +standbildes.ml +standrewswide.co.uk +standupright.com +stanford-edu.cf +stanford-edu.tk +stanford-university.education +stanfordujjain.com +stanleykitchens-zp.in +stannhighschooledu.ml +stanovanjskeprevare.com +stansmail.com +stantonwhite.com +star.emailies.com +star.marksypark.com +star.ploooop.com +star.poisedtoshrike.com +starasta.xyz +starasta1.com +starbichone.com +starcira.com +stardiesel.biz +stardiesel.info +stardiesel.org +stareybary.club +stareybary.online +stareybary.site +stareybary.store +stareybary.website +stareybary.xyz +stargate1.com +starherz.ru +starikmail.in +starkfoundries.com +starkrecords.com +starlex.team +starlight-breaker.net +starlit-seas.net +starlygirls.xyz +starlymusic.com +starmail.net +starmaker.email +starnlink.com +starnow.tk +staronescooter-original.ru +starpl.com +starpolyrubber.com +starpower.space +stars-and-glory.top +starslots.bid +starsofchaos.xyz +start-serial.xyz +startafreeblog.com +startation.net +startcode.tech +startemail.tk +starterplansmo.info +startext.net +startfu.com +startimetable.com +startkeys.com +startoon5.com +startsgreat.ga +startup-jobs.co +startupers.tech +startupschwag.com +startupsjournal.com +startuup.co +startwithone.ga +startymedia.com +starux.de +starvalley.homes +starvocity.com +starwalker.biz +starx.pw +staryzones.com +starzip.link +stashemail.info +stashsheet.com +stat.org.pl +statdvr.com +state.bthow.com +stateblin.space +statecollegeplumbers.com +statemother.us +statepro.store +statepro.xyz +staterecordings.com +staterial.site +stateven.com +stathost.net +staticintime.de +statiix.com +stationatprominence.com +stationdance.com +statisho.com +stativill.site +statloan.info +stats-on-time.com +statsbyte.com +stattech.info +statusers.com +statuspage.ga +statusqm.com +statx.ga +stayfitforever.org +stayhome.li +stayinyang.com +staypei.com +stbwo.anonbox.net +stealbest.com +stealthapps.org +stealthypost.net +stealthypost.org +steam-area.ru +steam.oldoutnewin.com +steam.poisedtoshrike.com +steam.pushpophop.com +steambot.net +steamkomails.club +steamlite.in +steammail.top +steammap.com +steamoh.com +steampot.xyz +steamprank.com +steamreal.ru +steams.redirectme.net +steamth.com +steauaeomizerie.com +steauaosuge.com +stecuste.cyou +steel-pipes.com +steelhorse.site +steelvaporlv.com +steemail.ga +steeplion.info +stefansplace.com +steffikelly.com +stefhf.nl +stefparket.ru +stefraconsultancyagencies.software +stehkragenhemd.de +steifftier.de +steinheart.com.au +steklosila.ru +stelian.net +stelkendh00.ga +stellacornelia.art +stelligenomine.xyz +stelliteop.info +stempmail.com +stensonelectric.com +steorn.cf +steorn.ga +steorn.gq +steorn.ml +steorn.tk +stepbystepwoodworkingplans.com +steplingdor.com +steplingor.com +stepoffstepbro.com +stepx100.ru +sterlingfinancecompany.com +sterlingheightsquote.com +sterlinginvestigations.com +sterlingsilverandscapeing.com +sterlingsilverflatwareset.net +stermail.flu.cc +sterndeutung.li +steroidi-anaboli.org +stetna.site +steueetxznd.media.pl +stevaninepa.art +stevefotos.com +steveix.com +stevenbaker.com +stevyal.tech +stewartsimmonsvfd.org +stexsy.com +stg.malibucoding.com +stgeorgefire.com +sthaniyasarkar.com +stick-tube.com +sticksjh.com +stiedemann.com +stiffbook.xyz +stiffgirls.com +stikezz.com +stillfusnc.com +stillgoodshop.com +stimulanti.com +stinkefinger.net +stinkypoopoo.com +stiqx.in +stivendigital.club +stixinbox.info +stl-serbs.org +stlfasola.com +stloasia.com +stlouisquote.com +stmmedia.com +stmpatico.ca +stockmount.info +stockpair.com +stockpickcentral.com +stocksaa318.xyz +stocktonnailsalons.com +stocosur.cf +stoffreich.de +stoicism.website +stokoski.ml +stokportal.ru +stokyards.info +stomach4m.com +stomatolog.pl +stonehousegrp1.com +stonesmails.cz.cc +stoneurope.com +stonvpshostelx.com +stop-my-spam.cf +stop-my-spam.com +stop-my-spam.ga +stop-my-spam.ml +stop-my-spam.pp.ua +stop-my-spam.tk +stop-nail-biting.tk +stopbitingyournailsnow.info +stopblackmoldnow.com +stopcheater.com +stopforumforum.com +stopforumspam.info +stopforumspamcom.ru +stopgrowreview.org +stophabbos.tk +stopnds.com +stoporoers.com +stopshooting.com +stopspam.app +stopspamservis.eu +stopthawar.ml +storagehouse.net +storageplacesusa.info +storal.co +storant.co +store-perfume.ru +store.cowsnbullz.com +store.hellomotow.net +store.lakemneadows.com +store.oldoutnewin.com +store.poisedtoshrike.com +store4files.com +storeamnos.co +storebanme.com +storebas.fun +storebas.online +storebas.site +storebas.space +storebas.store +storebas.website +storebas.xyz +storechaneljp.org +storeclsrn.xyz +storectic.co +storective.co +storeferragamo.com +storegmail.com +storegmail.net +storegptone.email +storeillet.co +storelivez.com +storellin.co +storemail.cf +storemail.ga +storemail.gq +storemail.ml +storemail.tk +storendite.co +storenia.co +storent.co +storeodon.co +storeodont.co +storeodoxa.co +storeortyx.co +storeotragus.co +storepath.xyz +storeperuvip.com +storepradabagsjp.com +storepradajp.com +storepro.site +storereplica.net +storero.co +storeshop.work +storesr.com +storestean.co +storesteia.co +storesup.fun +storesup.shop +storesup.site +storesup.store +storesup.xyz +storetaikhoan.com +storeutics.co +storeweed.co +storewood.co +storeyee.com +storeyoga.mobi +storiqax.com +storiqax.top +storist.co +storj99.com +storj99.top +storm-gain.net +storm.cloudwatch.net +stormynights.org +storrent.net +story.favbat.com +storyburn.com +storycompany.us +storyhand.biz +storyhive-company.online +storypo.com +storyyear.us +stovepartes1.com +stowawaygingerbeer.com +stpc.de +stpetebungalows.com +stpetersandstpauls.xyz +stqffouerchjwho0.cf +stqffouerchjwho0.ga +stqffouerchjwho0.gq +stqffouerchjwho0.ml +stqffouerchjwho0.tk +str1.doramm.com.pl +stradegycorps.com +stragedycd.com +straightenersaustralia.info +straightenerstylesaustralia.info +straightenerstylescheapuk.info +straightenerstylessaustralia.info +straightenhaircare.com +straightflightgolf.com +straightturtle.com +strakkebuikbijbel.net +strandhunt.com +strangeworldmanagement.com +strapmail.top +strapworkout.com +strapyrial.site +strasbergorganic.com +strategicalbrand.com +strategicprospecting.com +strategysuperb.com +strawhat.design +stread.shop +stream.gg +streamboost.xyz +streamezzo.com +streamfly.biz +streamfly.link +streaming.cash +streamingku.live +streamtv2pc.com +streamup.ru +streber24.de +streerd.com +street.aquadivingaccessories.com +street.oldoutnewin.com +streetcar.shop +streetsinus.com +streetturtle.com +streetwisemail.com +strelizia.site +strengs.space +strenmail.tk +strep.ml +stresser.tk +stresspc.com +strictlysailing.com +strider92.plasticvouchercards.com +strideritecouponsbox.com +strikefive.com +strikermed.online +stripbrushes.us +stripehitter.site +stripers.live +stripouts.melbourne +strivingman.com +stroemka.ru +stroiitelstvo.ru +stroitel-ru.com +stromectoldc.com +stromox.com +strona1.pl +stronawww.eu +strongan.com +strongnutricion.es +strongpeptides.com +strongpesny.ru +strongviagra.net +stronnaa.pl +stronnica.pila.pl +strontmail.men +stronyinternetowekrakow.pl +stronywww-na-plus.com.pl +strorekeep.club +strorekeep.fun +strorekeep.online +strorekeep.site +strorekeep.website +strorekeep.xyz +stroremania.club +stroremania.online +stroremania.site +stroremania.xyz +stroutell.ru +stroydom54.ru +stroymetals.ru +stroytehn.com +strtv.tk +struckmail.com +strumail.com +strx.us +sts.ansaldo.cf +sts.ansaldo.ga +sts.ansaldo.gq +sts.ansaldo.ml +sts.hitachirail.cf +sts.hitachirail.ga +sts.hitachirail.gq +sts.hitachirail.ml +sts.hitachirail.tk +sts9d93ie3b.cf +sts9d93ie3b.ga +sts9d93ie3b.gq +sts9d93ie3b.ml +sts9d93ie3b.tk +stsfsdf.se +stsgraphics.com +ststwmedia.com +sttf.com +stu.lmstd.com +stubbornakdani.io +stuckhere.ml +stuckmail.com +student.gold.edu.pl +student.himky.com +student.neonet.ac.nz +student.semar.edu.pl +studentdonor.org +studentlendingworks.com +studentlettingspoint.com +studentline.tech +studentloaninterestdeduction.info +studentmail.me +students-class1.ml +students.academic.edu.rs +students.fresno.edul.com +students.rcedu.team +students.taiwanccedu.studio +studentscafe.com +studi24.de +studiakrakow.eu +studio-mojito.ru +studio-three.org +studiodesain.me +studiokadr.pl +studiokadrr.pl +studionine09.com +studiopolka.tokyo +studioro.review +studioworkflow.com +study-good.ru +studycase.us +studyhub.edu.pl +studytantra.com +studyyear.us +stuelpna.ml +stuff.munrohk.com +stuffagent.ru +stuffmail.de +stufmail.com +stuhome.me +stumblemanage.com +stumpfwerk.com +stunde.shop +sturaman.com +sturgeonpointchurch.com +stuttgarter.org +stvbz.com +stvmanbetx.com +stvnlza.xyz +stvnzla.xyz +stwirt.com +stx.dropmail.me +stxrr.com +styledesigner.co.uk +stylemail.cz.cc +stylepositive.com +stylerate.online +stylesmail.org.ua +stylesshets.com +stylishcombatboots.com +stylishdesignerhandbags.info +stylishmichaelkorshandbags.info +stylist-volos.ru +styliste.pro +stypedia.com +su.freeml.net +suamiistribahagia.com +suavietly.com +subaru-brz.cf +subaru-brz.ga +subaru-brz.gq +subaru-brz.ml +subaru-brz.tk +subaru-wrx.cf +subaru-wrx.ga +subaru-wrx.gq +subaru-wrx.ml +subaru-wrx.tk +subaru-xv.cf +subaru-xv.ga +subaru-xv.gq +subaru-xv.ml +subaru-xv.tk +subaruofplano.com +subcaro.com +subdito.com +subema.cf +sublimelimo.com +sublingualvitamins.info +submic.com +submissive.com +submoti.tk +subparal.ml +subpastore.co +subrevn.net +subrolive.com +subsequestriver.xyz +substanceabusetreatmentrehab.site +substate.info +suburbanthug.com +subwaysubversive.com +subwaysurfers.info +subzone.space +succeedabw.com +succeedx.com +success.ooo +successforu.org +successforu.pw +successfulnewspedia.com +successfulvideo.ru +successlocation.work +succesvermogen.nl +succesvermogen.online +sucess16.com +suchance.com +suckmyass.com +suckmyd.com +sucknfuck.date +sucknfuck.site +suckoverhappeningnow.dropmail.me +sucrets.ga +suda2.pw +sudan-nedv.ru +sudaneseoverline.com +sudern.de +sudloisirs-nc.com +sudolife.me +sudolife.net +sudomail.biz +sudomail.com +sudomail.net +sudoverse.com +sudoverse.net +sudoweb.net +sudoworld.com +sudoworld.net +sudurr.mobi +suedcore.com +suepbejo.xyz +suepbergoyang.xyz +suepjoki.xyz +sueplali.xyz +sueplaliku.fun +sueshaw.com +suexamplesb.com +sufficient.store +suffocationlive.info +suffolkscenery.info +sufipreneur.org +sufit.waw.pl +sufmail.xyz +suftwari.com +sugar-daddy-meet.review +sugarcane.de +sugarloafstudios.net +suggerin.com +suggermarx.site +suggets.com +sugglens.site +suh.emlhub.com +suhuempek.cf +suhuempek.ga +suhuempek.gq +suhuempek.ml +suhuempek.tk +suhugatel.cf +suhugatel.ga +suhugatel.gq +suhugatel.ml +suhugatel.tk +suhusangar.ml +suioe.com +suitcasesjapan.com +suitezi.com +suits2u.com +suittrends.com +suiyoutalkblog.com +suizafoods.com +sujjn9qz.pc.pl +sujx.mailpwr.com +sukaalkuma.com +sukabokep.tech +sukaloli.n-e.kr +sukasukasuka.me +sukatobrud.cloud +sukenjp.com +suksesboss.com +suksesnet.com +suksukagua.com +sukurozumcantoker.shop +sul.bar +sulari.gq +sulat.horiba.cf +suleymanxsormaz.xyz +sull.ga +sullivanins.com +sullivanscafe.com +sulphonies.xyz +sum.freeml.net +suma-group.com +sumakang.com +sumakay.com +sumarymary.xyz +sumatraalam.biz.id +sumberakun.com +sumberkadalnya.com +sumikang.com +sumitra.ga +sumitra.tk +summerlinmedia.net +summersair.com +summerswimwear.info +summis.com +summitgg.com +sump3min.ru +sumpscufna.gq +sumwan.com +sun.emlhub.com +sun.favbat.com +sun.iki.kr +sunbuh.asia +sunburning.ru +suncareersolution.com +suncityshop.com +sunclubcasino.com +suncoast.net +sundaymovo.com +sundaysuspense.space +sundriesday.com +sunerb.pw +sunetoa.com +sunfuesty.com +sungerbob.net +sungkian.com +sunglassescheapoutlets.com +sunglassespa.com +sunglassesreplica.org +sunglassestory.com +sunhukim.com +suningsuguo123.info +sunmail.ga +sunmail.gq +sunmail.ml +sunmaxsolar.net +sunmulti.com +sunnyblogexpress.com +sunnybloginsider.com +sunnysamedayloans.co.uk +sunriver4you.com +sunsamail.info +sunsetclub.pl +sunsetsigns.org +sunsggcvj7hx0ocm.cf +sunsggcvj7hx0ocm.ga +sunsggcvj7hx0ocm.gq +sunsggcvj7hx0ocm.ml +sunsggcvj7hx0ocm.tk +sunshine94.in +sunshineautocenter.com +sunshineskilled.info +sunsol300.com +sunster.com +suntory.ga +suntory.gq +suntroncorp.com +suntuy.com +sunyds.com +sunyggless.com +sunzmail.online +suoox.com +supappl.me +suparoo.site +supascan.com +supb.site +supc.site +supd.site +supenc.com +super-auswahl.de +super-fast-email.bid +super-lodka.ru +super-szkola.pl +super.lgbt +superacknowledg.ml +superalts.gq +superbags.de +superbemediamail.com +superblohey.com +superbmedicines.com +superbowl500.info +superbowlnflravens.com +superbowlravenshop.com +superbowlstarttime.org +superbwebhost.de +supercardirect.com +supercheapwow.com +supercoinmail.com +supercoolrecipe.com +supercuteitem.shop +superdieta.ddns.net +superdm.xyz +superdom.site +supere.ml +supereme.com +superfanta.net +superfastemail.bid +superfinancejobs.org +superforumb.ga +supergadgets.xyz +supergreatmail.com +supergreen.com +superhappyfunnyseal.com +superhostformula.com +superhouse.vn +superintendent.store +superintendente.store +superintim.com +superior-seo.com +superiormarketers.com +superiorwholesaleblinds.com +supermail.cf +supermail.tk +supermailer.jp +supermails.pl +supermantutivie.com +supermediamail.com +supernews245.de +superoxide.ml +superpene.com +superplatyna.com +superpsychics.org +superraise.com +superrito.com +superrmail.biz +supersave.net +supersentai.space +superserver.site +supersolarpanelsuk.co.uk +superstachel.de +superstarsevens.com +superstarvideo.ru +superth.in +supertopup.my.id +supervk.net +superxmr.org +superyp.com +superzabawka.pl +superzaym.ru +superzesy.pl +supg.site +suph.site +supj.site +supk.site +suplemento.club +suples.pl +supn.site +supo.site +supoa.site +supob.site +supoc.site +supod.site +supoe.site +supof.site +supog.site +supoh.site +supoi.site +supoj.site +supok.site +supoo.site +supop.site +supoq.site +suport.com +suportt.com +supos.site +supou.site +supov.site +supow.site +supox.site +supoy.site +supoz.site +supp-jack.de +suppb.site +suppd.site +suppdiwaren.ddns.me.uk +suppelity.site +supperdryface-fr.com +supperion.com +suppf.site +suppg.site +supph.site +suppi.site +suppj.site +suppk.site +suppl.site +supplements.gov +supplementsdiary.com +supplementwiki.org +supplybluelineproducts.com +supplywebmail.net +suppm.site +suppn.site +suppo.site +suppoint.ru +support.com +support22services.com +support5.mineweb.in +supportain.site +supportbox.xyz +supporthpprinters.com +supporticult.site +supportlike.online +supporttc.com +supportusdad.org +suppotrz.com +suppp.site +suppq.site +supps.site +suppu.site +suppv.site +suppw.site +suppx.site +suppy.site +suppz.site +supq.site +supra-hot-sale.com +supracleanse.com +supraoutlet256.com +supras.xyz +suprasalestore.com +suprashoesite.com +suprasmail.gdn +suprb.site +suprc.site +suprd.site +supre.site +suprememarketingcompany.info +suprf.site +suprg.site +suprh.site +suprhost.net +suprisez.com +suprj.site +suprk.site +suprl.site +suprm.site +suprultradelivery.com +supt.site +supu.site +supw.site +supx.site +supxmail.info +supz.site +suratku.dynu.net +surburbanpropane.com +surdaqwv.priv.pl +sure2cargo.com +suremail.info +suremail.ml +suren.love +surewaters.com +surfact.eu +surfdayz.com +surfeu.se +surfice.com +surfmail.tk +surfomania.pl +surfsideroc.com +surga.ga +surgerylaser.net +suria.club +surigaodigitalservices.net +surinam-nedv.ru +surpa1995.info +surrogate-mothers.info +surrogatemothercost.com +surucukursukadikoy.com +suruitv.com +suruykusu.com +surveyrnonkey.net +survivalgears.net +survivan.com +suryaelectricals.com +suryapasti.com +suscm.co.uk +sushiojibarcelona.com +sushisalmon.online +sushiseeker.com +susi.ml +suskapark.com +sussin99gold.co.uk +sustainable.style +sustainable.trade +susumart.com +sususegarcoy.tk +susybakaa.ml +sutann.us +sute.jp +sutener.academy +sutenerlubvi.fav.cc +sutiami.cf +sutiami.ga +sutiami.gq +sutiami.ml +sutmail.com +sutno.com +suttal.com +sutterhealth.org +sutterstreetgrill.info +suttonsales.com +suubuapp.com +suuyydadb.com +suwarnisolikun.cf +suxt3eifou1eo5plgv.cf +suxt3eifou1eo5plgv.ga +suxt3eifou1eo5plgv.gq +suxt3eifou1eo5plgv.ml +suxt3eifou1eo5plgv.tk +suz6u.anonbox.net +suz99i.it +suzanahouse.co +suzroot.com +suzukilab.net +suzy.email +suzykim.me +suzykim.tech +svadba-talk.com +svapofit.com +svarovskiol.site +svcache.com +svda.com +svdq.emltmp.com +svds.de +sverta.ru +svet-web.ru +svetims.com +svgcube.com +svigrxpills.us +svil.net +sviodd.com +svip520.cn +svipzh.com +svitup.com +svk.jp +svlpackersandmovers.com +svmail.xyz +svoi-format.ru +svpmail.com +svqxv.anonbox.net +svs-samara.ru +svvdfeghdb.help +svvv.ml +svxnie.ga +svxr.org +svy.laste.ml +svywkabolml.pc.pl +sw.spymail.one +sw2244.com +swadleysemergencyreliefteam.com +swagflavor.com +swagmami.com +swagpapa.com +swaidaindustry.org +swankyfood.us +swanticket.com +swap-crypto.site +swapfinancebroker.org +swapinsta.com +swaps.ml +swatre.com +swatteammusic.com +swc.yomail.info +sweatmail.com +sweatpopi.com +swedesflyshop.com +sweemri.com +sweepstakesforme.com +sweet-space.ru +sweetagsfer.gq +sweetannies.com +sweetb.it +sweetheartdress.net +sweetmessage.ga +sweetnessrice.com +sweetnessrice.net +sweetpotato.ml +sweetsfood.ru +sweetsilence.org.ua +sweetspotaudio.com +sweetvibes-bakery.com +sweetville.net +sweetxxx.de +sweetyfoods.ru +swflrealestateinvestmentfirm.com +swfwbqfqa.pl +swiat-atrakcji.pl +swiatdejwa.pl +swiatimprezek.pl +swiatlemmalowane.pl +swides.com +swieszewo.pl +swift-mail.net +swift10minutemail.com +swiftbrowse.biz.id +swifte.space +swiftmail.xyz +swiftselect.com +swimail.info +swimmerion.com +swimminggkm.com +swimmingpoolbuildersleecounty.com +swinbox.info +swingery.com +swinginggame.com +swismailbox.com +swissglobalonline.com +switchisp.com +swizeland-nedv.ru +swk.dropmail.me +swm.emltmp.com +swmail.xyz +swmhw.com +swmsm.anonbox.net +swooflia.cc +sworda.com +swq213567mm.cf +swq213567mm.ga +swq213567mm.gq +swq213567mm.ml +swq213567mm.tk +swqqfktgl.pl +swsdz.com +swsewsesqedc.com +swsguide.com +swskrgg4m9tt.cf +swskrgg4m9tt.ga +swskrgg4m9tt.gq +swskrgg4m9tt.ml +swskrgg4m9tt.tk +swtorbots.net +swuc.emlpro.com +swudutchyy.com +swwatch.com +swype.dev +sx.dropmail.me +sxb.laste.ml +sxbta.com +sxccwwswedrt.space +sxe.laste.ml +sxen.laste.ml +sxp.dropmail.me +sxp.spymail.one +sxqg.spymail.one +sxr.emltmp.com +sxrop.com +sxv.dropmail.me +sxxs.site +sxxx.ga +sxxx.gq +sxxx.ml +sxylc113.com +sxzevvhpmitlc64k9.cf +sxzevvhpmitlc64k9.ga +sxzevvhpmitlc64k9.gq +sxzevvhpmitlc64k9.ml +sxzevvhpmitlc64k9.tk +syadouchebag.com +syahmiqjoss.host +syckcenzvpn.cf +syd.com +sydprems.ml +syerqrx14.pl +syfilis.ru +syh.emlhub.com +syinxun.com +syjxwlkj.com +sykvjdvjko.pl +sylkskinreview.net +sylvannet.com +sylwester.podhale.pl +symapatico.ca +symatoys.ru +symbolisees.ml +symet.net +sympayico.ca +symphonyresume.com +sympleks.pl +symplysliphair.com +sympstico.ca +symptoms-diabetes.info +synami.com +synapse.foundation +synarca.com +syncax.com +synchtradlibac.xyz +synclane.com +syndicatemortgages.com +syndonation.site +synecious17mc.online +synergie.tk +synevde.com +synmeals.com +synonem.com +synonyme.email +syntaxcdn.website +syntaxnews.xyz +syon.freeml.net +syonacosmetics.com +syorb.com +syosetu.gq +syq.spymail.one +syracusequote.com +sysdoctor.win +sysee.com +sysgalaica.es +syslinknet.com +systechmail.com +system-2123.com +system-2125.com +system-32.info +system-765.com +system-765.info +system-962.com +system-962.org +system32.me +systemcart.systems +systemcase.us +systemchange.me +systeminfo.club +systemlow.ga +systemnet.club +systempete.site +systemsflash.net +systemslender.com +systemthing.us +systemwarsmagazine.com +systemy-call-contact-center.pl +systemyear.us +systemyregalowe.pl +systemyrezerwacji.pl +syswars.com +syswift.com +sytes.net +sytet.com +syujob.accountants +syukrieseo.com +sywjgl.com +syzuu.anonbox.net +sz.dropmail.me +sz13l7k9ic5v9wsg.cf +sz13l7k9ic5v9wsg.ga +sz13l7k9ic5v9wsg.gq +sz13l7k9ic5v9wsg.ml +sz13l7k9ic5v9wsg.tk +szcs.spymail.one +szczecin-termoosy.pl +szczepanik14581.co.pl +szdv.dropmail.me +sze.emltmp.com +szef.cn +szeptem.pl +szerz.com +szesc.wiadomosc.pisz.pl +szi4edl0wnab3w6inc.cf +szi4edl0wnab3w6inc.ga +szi4edl0wnab3w6inc.gq +szi4edl0wnab3w6inc.ml +szi4edl0wnab3w6inc.tk +szkolapolicealna.com +szledxh.com +szn.us +szok.xcl.pl +szotv.com +szponki.pl +szsb.de +sztucznapochwa.org.pl +sztyweta46.ga +szucsati.net +szukaj-pracy.info +szvw.emltmp.com +szxo.yomail.info +szxshopping.com +szybka-pozyczka.com +szybki-bilet.site +szybki-remoncik.pl +szz.spymail.one +szzlcx.com +t-email.org +t-kredyt.com +t-mail.org +t-online.co +t-shirtcasual.com +t-student.cf +t-student.ga +t-student.gq +t-student.ml +t-student.tk +t.polosburberry.com +t.psh.me +t.woeishyang.com +t.zibet.net +t099.tk +t0fp3r49b.pl +t16nmspsizvh.cf +t16nmspsizvh.ga +t16nmspsizvh.gq +t16nmspsizvh.ml +t16nmspsizvh.tk +t1bkooepcd.cf +t1bkooepcd.ga +t1bkooepcd.gq +t1bkooepcd.ml +t1bkooepcd.tk +t24e4p7.com +t2jhh.anonbox.net +t30.cn +t3lam.com +t3mtxgg11nt.cf +t3mtxgg11nt.ga +t3mtxgg11nt.gq +t3mtxgg11nt.ml +t3mtxgg11nt.tk +t3rbo.com +t3t97d1d.com +t4a6t.anonbox.net +t4tmb2ph6.pl +t4zla.anonbox.net +t4zpap5.xorg.pl +t5h65t54etttr.cf +t5h65t54etttr.ga +t5h65t54etttr.gq +t5h65t54etttr.ml +t5h65t54etttr.tk +t5sxp5p.pl +t5vbxkpdsckyrdrp.cf +t5vbxkpdsckyrdrp.ga +t5vbxkpdsckyrdrp.gq +t5vbxkpdsckyrdrp.ml +t5vbxkpdsckyrdrp.tk +t60555.com +t63uz.anonbox.net +t6khsozjnhqr.cf +t6khsozjnhqr.ga +t6khsozjnhqr.gq +t6khsozjnhqr.ml +t6khsozjnhqr.tk +t6qdua.bee.pl +t6team.online +t6xeiavxss1fetmawb.ga +t6xeiavxss1fetmawb.ml +t6xeiavxss1fetmawb.tk +t76o11m.mil.pl +t77eim.mil.pl +t7qriqe0vjfmqb.ga +t7qriqe0vjfmqb.ml +t7qriqe0vjfmqb.tk +t8kco4lsmbeeb.cf +t8kco4lsmbeeb.ga +t8kco4lsmbeeb.gq +t8kco4lsmbeeb.ml +t8kco4lsmbeeb.tk +ta-6.com +ta-sg.top +ta.dropmail.me +ta.laste.ml +ta29.app +ta88.app +taa1.com +taaec.com +taagllc.com +taatfrih.com +taax.com +tab-24.pl +tab.poisedtoshrike.com +tabelon.com +tabgs-sg.xyz +tabih.anonbox.net +tabithaanaya.livefreemail.top +tabletas.top +tabletdiscountdeals.com +tabletki-lysienie.pl +tabletki-odchudzajace.eu +tabletki.org +tabletkinaodchudzanie.biz.pl +tabletkinapamiec.xyz +tabletrafflez.info +tabletship.com +tabletstoextendthepenis.info +tablighat24.com +tabtop.site +tac.yomail.info +tac0hlfp0pqqawn.cf +tac0hlfp0pqqawn.ga +tac0hlfp0pqqawn.ml +tac0hlfp0pqqawn.tk +tachnuqia.com +tacocasa.net +tacomacardiology.com +tacomail.de +tacq.com +tactar.com +tacz.pl +tad.emlpro.com +tad.emltmp.com +tadacipprime.com +tadalafilz.com +tadao85.funnetwork.xyz +tadipexs.com +tae.simplexion.pm +taeq.emlpro.com +taeseazddaa.com +tafmail.com +tafmail.wfsb.rr.nu +tafoi.gr +tafrem3456ails.com +tafrlzg.pl +tagara.infos.st +tagbert.com +tagcams.com +tagcchandda.gq +tagesmail.eu +taglead.com +tagmymedia.com +tagt.club +tagt.live +tagt.online +tagt.uk +tagt.us +tagt.xyz +tagyourself.com +taher.pw +tahmin.info +tahnaforbie.xyz +taho21.ru +tahopwnz.website +tahseenenterprises.com +tahugejrot.buzz +tahutex.online +tahyu.com +tai-asu.cf +tai-asu.ga +tai-asu.gq +tai-asu.ml +tai-chi.tech +tai-nedv.ru +tai789.fun +taichungpools.com +taidar.ru +taigomail.ml +taikhoanao.tk +taikhoanfb.xyz +taikz.com +tailfinsports.com +tailoredhemp.com +taimb.com +taimeha.cf +taimurfun.fun +tainguyenfbchat.com +taitz.gq +taiv8.win +taiwan.com +taiwanball.ml +taiwanccedu.studio +taiwea.com +tajba.com +tajcatering.com +tajikishu.site +tajwork.com +takashishimizu.com +takatato.pl +take.blatnet.com +take.marksypark.com +takeafancy.ru +takeawaythis.org.ua +takedowns.org +takeitme.site +takeitsocial.com +takemeint.shop +takenews.com +takeoff.digital +takepeak.xyz +takeshobo.cf +takeshobo.ga +takeshobo.gq +takeshobo.ml +takeshobo.tk +takesonetoknowone.com +takingitoneweekatatime.com +takipcihilesiyap.com +takipcisatinal.shop +takmailing.com +takmemberi.cf +takmemberi.gq +tako.skin +taktalk.net +takuino.app +takumipay.xyz +talahicc.com +talawanda.com +talbotsh.com +taleem.life +talemarketing.com +talk49.com +talkaa.org +talkalk.net +talkdao.net +talkinator.com +talkmises.com +talktal.net +talktoip.com +talkwithme.info +tallerfor.xyz +tallest.com +talmdesign.com +talmetry.com +taltalk.net +taluabushop.com +tamail.com +tamamassage.online +tamanhodopenis.biz +tamanhodopenis.info +tamanta.net +tamaratyasmara.art +tambabatech.site +tambahlagi.online +tambakrejo.cf +tambakrejo.ga +tambakrejo.tk +tamborimtalks.online +tambox.site +tambroker.ru +tamcuong.one +tamdan.com +tamera.eu +tamgaaa12.com +tammega.com +tammyes.my.id +tamngaynua.top +tamoxifen.website +tampa-seo.us +tampabaycoalition.com +tampaflcomputerrepair.com +tampaquote.com +tampasurveys.com +tampatailor.com +tampicobrush.org +tams.codes +tamsholdings.com +tamttts.com +tamuhost.me +tan9595.com +tananachiefs.com +tancients.site +tandartspraktijkscherpenzeel.com +tandberggroup.com +tandbergonline.com +tandcpg.com +tandlplith.se +tandy.co +tang-zxc.xyz +tangarinefun.com +tangeriin.com +tanglewoodstudios.com +tanglike94.win +tango-card.com +tangomining.com +tanhanfo.info +tanihosting.net.pl +taniiepozyczki.pl +tanikredycik.pl +taninsider.com +tanlanav.com +tanning-bed-bulbs.com +tanningcoupon.com +tanningprice.com +tansmail.ga +tantacomm.com +tantang.store +tanteculikakuya.com +tantedewi.ml +tantennajz.com +tantra-for-couples.com +tantraclassesonline.com +tantraforhealth.com +tantralube.com +tantraprostatehealing.com +tantrareview.com +tantraspeeddating.com +tantratv.com +tanukis.org +tao04121995.cloud +tao399.com +taobaigou.club +taobudao.com +taohucom.store +taoisture.xyz +taokhienfacebook.com +taomail.web.id +taosbet.com +taosjw.com +taoxao.online +tapbuybox.com +tapchicuoihoi.com +tape.favbat.com +tapecompany.com +tapecopy.net +tapetoland.pl +tapety-download.pl +taphear.com +taphoaclone.net +tapi.re +tapiitudulu.com +tapmatessoftware.com +tapmiss.com +tappathrun.com +tapsitoaktl353t.ga +taptoplab.com +taptopsmart.com +tapvia.com +tar00ih60tpt2h7.cf +tar00ih60tpt2h7.ga +tar00ih60tpt2h7.gq +tar00ih60tpt2h7.ml +tar00ih60tpt2h7.tk +taraeria.ru +tarciano.com +tarcuttgige.eu +taresz.ga +targetcom.com +targetdb.com +targoo3.site +tariffenet.it +tarikosmanli.shop +tariqa-burhaniya.com +tarisekolis.co.uk +tarisekolis.uk +tarlancapital.com +tarma.cf +tarma.ga +tarma.ml +tarma.tk +tarotllc.com +tartempion.engineer +tartinemoi.com +tartoor.club +tartoor.com +tartoor.space +tarzanmail.cf +tarzanmail.ml +tascon.com +tashjw.com +taskforcetech.com +taskscbo.com +tasmakarta.pl +tastaravalli.tk +tasteofriver.com +tastewhatyouremissing.com +tastiethc.com +tastmemail.com +tastrg.com +tasty-drop.org +tastyarabicacoffee.com +tastyemail.xyz +tastypizza.com +tastyrush.ovh +tastyrush.shop +tatadidi.com +tatalbet.com +tatapeta.pl +tatbuffremfastgo.com +tatebayashi-zeirishi.biz +tatersik.eu +tatotzracing.com +tatsu.uk +tattoopeace.com +tattooradio.ru +tattoos.name +tau.ceti.mineweb.in +tau.emltmp.com +taucoindo.site +taufik.sytes.net +taufikrt.ddns.net +taugr.com +taukah.com +taungmin.ml +tauque.com +taus.emltmp.com +taus.ml +tauttjar3r46.cf +tavares.com +tavazan.xyz +taviu.com +tawagnadirect.us +tawny.roastedtastyfood.com +tawnygrammar.org +tawsal.com +tawtar.com +taxfreeemail.com +taxi-evpatoriya.ru +taxi-france.com +taxi-vovrema.info +taxiaugsburg.de +taxibmt.com +taxibmt.net +taxnon.com +taxon.com +taxy.com +taylerdeborah.london-mail.top +taylorventuresllc.com +taymonera.de +taynguyen24h.net +tayo.ooo +tayohei-official.com +tayoo.com +taytkombinim.xyz +tazpkrzkq.pl +tb-on-line.net +tb.yomail.info +tbbo.de +tbbyt.net +tbchr.com +tbeebk.com +tbeeoejytm.ga +tbez.com +tbgroupconsultants.com +tbhd.dropmail.me +tbko.com +tbm.dropmail.me +tbrfky.com +tbsq.dropmail.me +tbuildersw.com +tbwzidal06zba1gb.cf +tbwzidal06zba1gb.ga +tbwzidal06zba1gb.gq +tbwzidal06zba1gb.ml +tbwzidal06zba1gb.tk +tbxmakazxsoyltu.cf +tbxmakazxsoyltu.ga +tbxmakazxsoyltu.gq +tbxmakazxsoyltu.ml +tbxmakazxsoyltu.tk +tbxqzbm9omc.cf +tbxqzbm9omc.ga +tbxqzbm9omc.gq +tbxqzbm9omc.ml +tbxqzbm9omc.tk +tc-coop.com +tc-solutions.com +tc4q7muwemzq9ls.ml +tc4q7muwemzq9ls.tk +tcases.com +tcbi.com +tcfr2ulcl9cs.cf +tcfr2ulcl9cs.ga +tcfr2ulcl9cs.gq +tcfr2ulcl9cs.ml +tcfr2ulcl9cs.tk +tcg.emlhub.com +tchatrencontrenc.com +tchatroulette.eu +tchatsenegal.com +tchoeo.com +tchvn.tk +tcn.emlhub.com +tcnmistakes.com +tcoaee.com +tcsnews.tv +tcsqzc04ipp9u.cf +tcsqzc04ipp9u.ga +tcsqzc04ipp9u.gq +tcsqzc04ipp9u.ml +tcsqzc04ipp9u.tk +tcua9bnaq30uk.cf +tcua9bnaq30uk.ga +tcua9bnaq30uk.gq +tcua9bnaq30uk.ml +tcua9bnaq30uk.tk +tcwholesale.com +tcwlm.com +tcwlx.com +tdbusinessfinancing.com +tdcryo.com +tdekeg.online +tdf-illustration.com +tdfwld7e7z.cf +tdfwld7e7z.ga +tdfwld7e7z.gq +tdfwld7e7z.ml +tdfwld7e7z.tk +tdir.online +tdlttrmt.com +tdnew.com +tdovk626l.pl +tdp.emlhub.com +tdpizsfmup.ga +tdpz.freeml.net +tdska.org +tdsmproject.com +tdspedia.com +tdtda.com +tdtemp.ga +te.caseedu.tk +te.laste.ml +te.spymail.one +te2jrvxlmn8wetfs.gq +te2jrvxlmn8wetfs.ml +te2jrvxlmn8wetfs.tk +te5s5t56ts.ga +tea-tins.com +teacher.semar.edu.pl +teachersblueprint.com +teaching.kategoriblog.com +teachingdwt.com +teachingjobshelp.com +teacostudy.site +teal.dev +tealeafdevelopers.com +tealeafexperts.com +teamails.net +teamandclub.ga +teambogor.online +teamgdi.com +teamkg.tk +teamlitty.de +teamlonewolf.co +teamobi.net +teamrnd.win +teamspeak3.ga +teamspeakradioguy.com +teamster.com +teamtelko.shop +teamtitan.co +teamtrac.org +teamviewerindirsene.com +teamviral.space +teamvortex.com +teamworker.club +teamworker.online +teamworker.site +teamworker.website +teaparty-news.com +tearflakes.com +tearrecords.com +teasya.com +tebetabies.tech +tebwinsoi.ooo +tebyy.com +tecemail.top +tech-mail.net +tech-repair-centre.co.uk +tech.edu +tech5group.com +tech69.com +techale.tk +techbike.ru +techbird.fun +techblast.ch +techbook.com +techcenter.biz +techcz.com +techdf.com +techdudes.com +techemail.com +techeno.com +techfevo.info +techgroup.me +techgroup.top +techholic.in +techhubup.com +techiewall.com +techindo.web.id +techix.tech +techknowlogy.com +techlabreviews.com +techloveer.com +techmail.info +techmailer.host +techmoe.asia +technicloud.tech +technicolor.cf +technicolor.ga +technicolor.gq +technicolor.ml +technicsan.ru +technidem.fr +technikue.men +techno5.club +technobouyz.com +technocape.com +technoinsights.info +technopark.site +technoproxy.ru +techoth.com +techplanet.com +techproductinfo.com +techromo.com +techspirehub.com +techstat.net +techstore2019.com +techtary.com +techtonic.engineer +techuppy.com +techusa.org +techwebfact.com +techxs.dx.am +tecninja.xyz +tecnosmail.com +tecnotutos.com +tecperote.com +tectronica.com +tedace.com +tedale.com +tedesafia.com +tedguissan.gq +tednbe.com +tedswoodworking.science +teearsw.com +teebate.com +teecheap.store +teeenye.com +teemia.com +teemoloveulongtime.com +teenanaltubes.com +teencaptures.com +teenovgue.com +teensuccessprograms.com +teentravelnyc.com +teeoli.com +teepotrn.com +teeprint.online +teerest.com +teerko.fun +teerko.online +teesdiscount.com +teeshirtsprint.com +teewars.org +teewhole.com +tefer.gov +tefinopremiumteas.com +tefl.ro +tefonica.net +tegifehurez.glogow.pl +tegnabrapal.me +tehdini.cf +tehdini.ga +tehdini.gq +tehdini.ml +tehoopcut.info +tehs8ce9f9ibpskvg.cf +tehs8ce9f9ibpskvg.ga +tehs8ce9f9ibpskvg.gq +tehs8ce9f9ibpskvg.ml +tehs8ce9f9ibpskvg.tk +tehsisri.email +tehsisri.live +tehsusu.cf +tehsusu.ga +tehsusu.gq +tehsusu.ml +tehtrip.com +teicarried.com +teie.laste.ml +teihu.com +teimur.com +tejassec.com +tejmail.pl +tekelbayisi.xyz +tekgk.anonbox.net +tekisto.com +tekkoree.gq +teknografi.site +teknolcom.com +teknologimax.engineer +teknopena.com +teknowa.com +tektok.me +telecama.com +telecharger-films-megaupload.com +telechargerfacile.com +telechargerpiratertricher.info +telechargervideosyoutube.fr +telecineon.co +telecomix.pl +telecomuplinks.com +telefonico.com +telefony-opinie.pl +teleg.eu +telegmail.com +telego446.com +telekbird.com.cn +telekgaring.cf +telekgaring.ga +telekgaring.gq +telekgaring.ml +telekom-mail.com +telekteles.cf +telekteles.ga +telekteles.gq +telekteles.ml +telekucing.cf +telekucing.ga +telekucing.gq +telekucing.ml +telemetricop.com +telemol.club +telemol.fun +telemol.online +telemol.xyz +teleosaurs.xyz +telephoneportableoccasion.eu +telephonesystemsforbusiness.com +teleponadzkiya.co +teleport-pskov.ru +teleuoso.com +teleworm.com +teleworm.us +teligmail.site +telimail.online +telkompro.com +telkoms.net +telkomsel.ml +telkomuniversity.duckdns.org +tellmepass.ml +tellos.xyz +tellsow.fun +tellsow.live +tellsow.online +tellsow.xyz +tellynet.giize.com +telmail.top +telplexus.com +telugump3hits.com +telukmeong1.ga +telukmeong2.cf +telukmeong3.ml +telvetto.com +tem.yomail.info +temail.com +temailz.com +teman-bangsa.com +temasekmail.com +temasparawordpress.es +temengaming.com +temhuv.com +teml.net +temmail.xyz +temp-cloud.net +temp-e.ml +temp-email.info +temp-email.ru +temp-emails.com +temp-inbox.com +temp-inbox.me +temp-link.net +temp-mail.best +temp-mail.cfd +temp-mail.com +temp-mail.de +temp-mail.gg +temp-mail.info +temp-mail.io +temp-mail.life +temp-mail.live +temp-mail.lol +temp-mail.ml +temp-mail.monster +temp-mail.net +temp-mail.now +temp-mail.org +temp-mail.pp.ua +temp-mail.ru +temp-mails.co +temp-mails.com +temp.aogoen.com +temp.bartdevos.be +temp.cab +temp.cloudns.asia +temp.emeraldwebmail.com +temp.headstrong.de +temp.kasidate.me +temp.ly +temp.meshari.dev +temp.qwertz.me +temp.skymeshdynamics.com +temp.wheezer.net +temp1.club +temp15qm.com +temp2.club +temp69.email +tempail.com +tempalias.com +tempamailbox.info +tempatspa.com +tempblockchain.com +tempcloud.in +tempcloud.info +tempe-mail.com +tempekmuta.cf +tempekmuta.ga +tempekmuta.gq +tempekmuta.ml +tempemail.biz +tempemail.co +tempemail.co.za +tempemail.com +tempemail.daniel-james.me +tempemail.info +tempemail.net +tempemail.org +tempemail.pro +tempemail.ru +tempemailaddress.com +tempemailco.com +tempemailgen.com +tempemaill.com +tempemailo.org +tempemails.io +temperatebellinda.biz +tempgmail.ga +tempikpenyu.xyz +tempimbox.com +tempinbox.co.uk +tempinbox.com +tempinbox.xyz +templategeek.net +templerehab.com +tempm.cf +tempm.com +tempm.ga +tempm.gq +tempm.ml +tempmail-1.net +tempmail-2.net +tempmail-3.net +tempmail-4.net +tempmail-5.net +tempmail.al +tempmail.altmails.com +tempmail.best +tempmail.cc +tempmail.cn +tempmail.co +tempmail.com.tr +tempmail.de +tempmail.dev +tempmail.digital +tempmail.edu.pl +tempmail.email +tempmail.eu +tempmail.giize.com +tempmail.id.vn +tempmail.ing +tempmail.io +tempmail.io.vn +tempmail.it +tempmail.net +tempmail.ninja +tempmail.plus +tempmail.pp.ua +tempmail.pro +tempmail.red +tempmail.run +tempmail.space +tempmail.sytes.net +tempmail.tel +tempmail.top +tempmail.us +tempmail.vip +tempmail.website +tempmail.win +tempmail.wizardmail.tech +tempmail.world +tempmail.ws +tempmail101.com +tempmail2.com +tempmail247.top +tempmailapp.com +tempmailco.com +tempmaildemo.com +tempmailed.com +tempmailer.com +tempmailer.de +tempmailer.net +tempmailfree.com +tempmailfree.net +tempmailid.com +tempmailid.net +tempmailid.org +tempmailin.com +tempmailo.com +tempmailo.org +tempmailr.com +tempmails.cf +tempmails.gq +tempmails.net +tempmails.org +tempmailto.org +tempmailyo.org +tempo-email.com +tempo-mail.info +tempo-mail.xyz +tempoconsult.info +tempomail.fr +tempomail.org +tempomailo.site +temporalemail.org +temporam.com +temporam.online +temporam.xin +temporam.xyz +temporamail.com +temporaremail.com +temporarily.de +temporarioemail.com.br +temporarly.com +temporary-email-address.com +temporary-email.com +temporary-email.world +temporary-mail.net +temporary-mailbox.com +temporary.gg +temporaryemail.dpdns.org +temporaryemail.net +temporaryemail.us +temporaryforwarding.com +temporaryinbox.com +temporarymail.com +temporarymail.ga +temporarymail.org +temporarymailaddress.com +temporeal.site +tempos.email +tempos21.es +tempp-mails.com +temppppo.store +temppy.com +tempr-mail.line.pm +tempr.email +tempremail.cf +tempremail.tk +temprmail.com +tempsky.com +tempsky.top +temptami.com +tempthe.net +tempwolf.xyz +tempxmail.info +tempymail.com +tempzo.info +temr0520cr4kqcsxw.cf +temr0520cr4kqcsxw.ga +temr0520cr4kqcsxw.gq +temr0520cr4kqcsxw.ml +temr0520cr4kqcsxw.tk +temxp.net +tenaze.com +tend.favbat.com +tendance.xyz +tenesu.tk +tenhub.uk +tenjb.com +tenmail.org +tennesseeinssaver.com +tennisan.ru +tenniselbowguide.info +tennisnews4ever.info +tenormin.website +tensi.org +tensony.com +tenull.com +tenvia.com +tenvil.com +tenzoves.ru +teonanakatl.info +tepos12.eu +tepzo.com +ter.com +terahack.com +terasd.com +teraz.artykulostrada.pl +terb.laste.ml +terbias.com +terecidebulurum.ltd +teriguyaqin.biz +terika.net +teripanoske.com +terkoer.com +termail.com +termakan.com +termgame.net +terminalerror.com +terminaltheme.cf +terminate.tech +terminateee12.com +terminverpennt.de +termuxtech.tk +ternaklele.ga +terpistick.com +terra-incognita.co +terra7.com +terracheats.com +terrascope.online +terre.infos.st +terrenix.com +terrificbusinesses.com +terrorisiertest.ml +terrorism.tk +terrorqb.com +terryjohnson.online +terrykelley.com +terryputri.art +tert353ayre6tw.ml +teryf.anonbox.net +tes.laste.ml +teselada.ml +tesgurus.net +tesiov.info +teslasteel.com +teslax.me +tesmail.site +tesqwiklabsss.shop +tesqwiklosfn.shop +tessen.info +test-acs.com +test-infos.fr.nf +test.actess.fr +test.com +test.crowdpress.it +test.inclick.net +test.unergie.com +test121.com +test130.com +test32.com +test55.com +test8869.ddns.net +testando.com +testbnk.com +testbooking.com +teste445k.ga +tester-games.ru +tester2341.great-site.net +testerino.tk +testforcextremereviews.com +testhats.com +testi.com +testicles.com +testingtest.com +testlord.com +testmansion.com +testname.com +testoboosts.com +testoforcereview.net +testoh.cf +testoh.ga +testoh.gq +testoh.ml +testoh.tk +testore.co +testosterone-tablets.com +testosteroneforman.com +testoweprv.pl +testpah.ml +testshiv.com +testsmails.tk +testudine.com +testviews.com +tet.emltmp.com +tetaessien.shop +tetekdini.tk +tethjdt.com +tetrads.ru +tetses.web.id +teufelsweb.com +tevhiddersleri.com +tevstart.com +texac0.cf +texac0.ga +texac0.gq +texac0.ml +texac0.tk +texansportsshop.com +texansproteamsshop.com +texas-investigations.com +texas-nedv.ru +texasaol.com +texasps.com +texasretirementservice.info +texify.online +text-me.xyz +textad.us +textbooksandtickets.com +texters.ru +textildesign24.de +textilelife.ru +textmarken.de +textmedude.cf +textmedude.ga +textmedude.gq +textmedude.ml +textmedude.tk +textprayer.com +textpro.site +textsave.net +textwebs.info +textyourexbackreviewed.org +texv.com +texy123.com +tezdbz8aovezbbcg3.cf +tezdbz8aovezbbcg3.ga +tezdbz8aovezbbcg3.gq +tezdbz8aovezbbcg3.ml +tezdbz8aovezbbcg3.tk +tezo.emltmp.com +tezy.site +tezzmail.host +tf.spymail.one +tf5bh7wqi0zcus.cf +tf5bh7wqi0zcus.ga +tf5bh7wqi0zcus.gq +tf5bh7wqi0zcus.ml +tf5bh7wqi0zcus.tk +tf7nzhw.com +tfa.spymail.one +tfcreations.com +tfe.emlpro.com +tfe.spymail.one +tfftv.shop +tfg1.com +tfgphjqzkc.pl +tfiadvocate.com +tfinest.com +tfkc.laste.ml +tfqr.dropmail.me +tfstaiwan.cloudns.asia +tfwno.gf +tfwt.emlpro.com +tfxx.store +tfzav6iptxcbqviv.cf +tfzav6iptxcbqviv.ga +tfzav6iptxcbqviv.gq +tfzav6iptxcbqviv.ml +tfzav6iptxcbqviv.tk +tg7.net +tgaa.emltmp.com +tgb.yomail.info +tgbkun.site +tgduck.com +tgfb.cc +tgggirl.art +tggmalls.com +tghrial.com +tgiq9zwj6ttmq.cf +tgiq9zwj6ttmq.ga +tgiq9zwj6ttmq.gq +tgiq9zwj6ttmq.ml +tgiq9zwj6ttmq.tk +tglservices.com +tgntcexya.pl +tgo.yomail.info +tgpix.net +tgrafx.com +tgres24.com +tgstation.org +tgszgot72lu.cf +tgszgot72lu.ga +tgszgot72lu.gq +tgszgot72lu.ml +tgszgot72lu.tk +tgtshop.com +tgvis.com +tgws.laste.ml +tgxvhp5fp9.cf +tgxvhp5fp9.ga +tgxvhp5fp9.gq +tgxvhp5fp9.ml +tgxvhp5fp9.tk +th3ts2zurnr.cf +th3ts2zurnr.ga +th3ts2zurnr.gq +th3ts2zurnr.ml +th3ts2zurnr.tk +thaiedvisa.com +thaiger-tec.com +thaihealingcenter.org +thaihp.net +thailaaa.org.ua +thailand-mega.com +thailandresort.asia +thailandstayeasy.com +thailongstayjapanese.com +thaiphone.online +thaithai3.com +thaitudang.xyz +thaivisa.cc +thaivisa.es +thaki8ksz.info +thaliaesmivida.com +than.blatnet.com +than.blurelizer.com +than.lakemneadows.com +than.poisedtoshrike.com +than.popautomated.com +thaneh.xyz +thangberus.net +thangmay.biz +thangmay.com +thangmay.com.vn +thangmay.net +thangmay.org +thangmay.vn +thangmaydaiphong.com +thangmaygiadinh.com +thangmayhaiduong.com +thangmaythoitrang.vn +thanhnhien.net +thanksgiving.digital +thanksme.online +thanksme.store +thanksme.xyz +thanksnospam.info +thankyou2010.com +thankyou2014.com +thanosskali209.online +thaotri.com +that.gives +that.lakemneadows.com +that.marksypark.com +thatbloggergirl.com +thatim.info +thavornpalmbeachphuket.ru +thc.st +thclips.com +thclub.com +thdesign.pl +thdv.ru +the-blockchainnews.xyz +the-boots-ugg.com +the-classifiedads-online.info +the-dating-jerk.com +the-first.email +the-johnsons.family +the-louis-vuitton-outlet.com +the-perfect.com +the-popa.ru +the-skyeverton.com +the-source.co.il +the.celebrities-duels.com +the.cowsnbullz.com +the.poisedtoshrike.com +the2012riots.info +the23app.com +the2jacks.com +theacneblog.com +theaffiliatepeople.com +theairfilters.com +thealderagency.com +theallgaiermogensen.com +thealohagroup.international +thealphacompany.com +theanatoly.com +theangelhack.ru +theangelwings.com +theanimalcarecenter.com +theanseladams.com +theaperturelabs.com +theaperturescience.com +theartypeople.com +thearunsounds.org +theaviors.com +theavyk.com +thebat.client.blognet.in +thebearshark.com +thebeatlesbogota.com +thebest4ever.com +thebestarticles.org +thebestmedicinecomedyclub.com +thebestmoneymakingtips.info +thebestremont.ru +thebestrolexreplicawatches.com +thebestwebtrafficservices.info +thebibleen.com +thebigbang.tk +theblackduck.com +theblogster.pw +thebluffersguidetoit.com +thebrand.pro +thebudhound.com +thebusinessdevelopers.com +thebuyinghub.net +thebytehouse.info +thecarinformation.com +thechemwiki.org +thechildrensfocus.com +thecinemanet.ru +thecirchotelhollywood.com +thecity.biz +theclinicshield.com +thecloudindex.com +thecoalblog.com +thecollapsingtower.com +thecongruentmystic.com +theconsumerclub.org +thecontainergroup.com.au +thecontemparywardrobe.com +thecyberpunk.space +thedaring.org +thedarkmaster097.sytes.net +thedatingstylist.com +thedaymail.com +thedealsvillage.com +thedentalshop.xyz +thedepression.com +thediamants.org +thedietsolutionprogramreview.com +thedigitalphotoframe.com +thedimcafe.com +thedirhq.info +thediscountmart.net +thedishrag.com +thedocerosa.com +thedowntowndiva.com +thedowntowndivas.com +thedowntowndivas.net +theeasymail.com +theedoewcenter.com +theemailaccount.com +theemailaddy.com +theemailadress.com +theexitgroup.com +theeyeoftruth.com +thef95zone.com +thefactsproject.org +thefairyprincessshop.com +thefalconsshop.com +thefamilyforest.info +thefamousdiet.com +thefatloss4idiotsreview.org +thefatlossfactorreview.info +thefatlossfactorreviews.co +thefatlossfactorreviews.com +thefirstticket.com +thefitnessgeek.com +thefitnessguru.org +thefitnesstrail.com +theflatness.com +theflexbelt.info +thefluent.org +theflytrip.com +thefmailcom.com +theforgotten-soldiers.com +thefreefamily.xyz +thefreemanual.asia +thefunnyanimals.com +thefuturebit.com +thefxpro.com +thega.ga +thegamesandbeyond.com +thegarbers.com +thegatefirm.com +thegbook.com +theghdstraighteners.com +theglockner.com +theglockneronline.com +thegrampians.net +thegrandcon.com +thehagiasophia.com +thehamkercat.cf +thehatedestroyer.com +thehavyrtda.com +thehealingstartshere.com +thehermans.store +thehillscoffee.com +thehjhvj.ink +thehoanglantuvi.com +thehosh.com +thehypothyroidismrevolutionreview.com +theidgroup.com +theindiaphile.com +theinfomarketing.info +theinquisitor.xyz +theinsuranceinfo.org +theinternetpower.info +theiof.com +their.blatnet.com +their.lakemneadows.com +their.oldoutnewin.com +theirer.com +theittechblog.com +thejamescompany.com +thejoaocarlosblog.tk +thejoker5.com +thejupiterblues.com +thekamasutrabooks.com +thekangsua.com +theking.id +thekitchenfairypc.com +thekittensmurf.com +thekoots.com +thekurangngopi.club +thelavalamp.info +thelifeguardonline.com +thelightningmail.net +thelimestones.com +thelittlechicboutique.com +thelmages.site +theloveapp.lat +thelovedays.com +thelsatprofessor.com +thelubot.site +them.lakemneadows.com +them.poisedtoshrike.com +themadhipster.com +themagicofmakingupreview.info +themail.krd.ag +themail3.net +themailemail.com +themailmall.com +themailpro.net +themailredirector.info +themailservice.ink +themailworld.info +themanicuredgardener.com +themarijuanalogues.com +themarketingsolutions.info +thematicworld.pl +thembones.com.au +themecolours.com +themedicinehat.net +themeg.co +themegreview.com +themesmix.com +themesw.com +themindfullearningpath.com +themindshiftins.org +themodish.org +themogensen.com +themoneysinthelist.com +themoon.co.uk +themostemail.com +themulberrybags.us +themulberrybagsuksale.com +themule.net +then.cowsnbullz.com +then.marksypark.com +then.oldoutnewin.com +then.ploooop.com +thenativeangeleno.com +thenewsdhhayy.com +thenewtinsomerset.news +thenflpatriotshop.com +thenflravenshop.com +thenoftime.org.ua +thenorth-face-shop.com +thenorthfaceoutletb.com +thenorthfaceoutletk.com +thenumberonemattress.com +thenutritionatrix.com +theodore1818.site +theone-blue.com +theone2017.us +theonedinline.com +theonlinemattressstore.com +theopposition.club +theorlandoblog.com +theorlandoguide.net +theothermail.com +theoverlandtandberg.com +thepaleoburnsystem.com +thepaperbackshop.com +theparadisepalmstravelagency.com +theparryscope.com +thepartyzone.org +thepascher.com +thephillycalendar.com +thepieter.com +thepieteronline.com +thepillsforcellulite.info +thepinkbee.com +thepiratebay.cloud +thepiratefilmeshd.org +thepit.ml +thepitujk79mgh.tk +theplug.org +thepolingfamily.com +theporndude.com +thepromenadebolingbrook.com +thepryam.info +thepsoft.org +thequickreview.com +thequickstuff.info +thequicktake.org +theravensshop.com +there.blurelizer.com +there.cowsnbullz.com +there.makingdomes.com +there.poisedtoshrike.com +therealcolonel.press +therealdealblogs.com +therealfoto.com +thereareemails.xyz +therecoverycompanion.com +thereddoors.online +thereptilewrangler.com +theresorts.ru +thereviewof.org +thermoconsulting.pl +thermoplasticelastomer.net +thermostatreviews.org +theroyalstores.com +theroyalweb.club +thesavoys.com +thescrappermovie.com +thesdsfwerf.online +these.ploooop.com +these.poisedtoshrike.com +theseodude.co.uk +thesiance.site +thesickest.co +theskymail.com +theslatch.com +thesmurfssociety.link +thesophiaonline.com +thesourcefilm.org +thespamfather.com +thespawningpool.com +thesqueezemagazine.com +thestats.top +thestopplus.com +thesunshinecrew.com +thesweetshop.me +theta.pl +theta.whiskey.webmailious.top +thetantraoils.com +thetaoofbadassreviews.info +thetayankee.webmailious.top +theteastory.info +thetechnext.net +thetechpeople.net +thetechteamuk.com +thetempmail.com +thetempmailo.ml +thetimeplease.com +thetivilebonza.com +thetrash.email +thetrommler.com +thetruthaboutfatburningfoodsreview.org +thetylerbarton.com +theugg-outletshop.com +thevacayclub.com +thevalentines.faith +thevaporhut.com +thevibram-fivefingers.com +thewaterenhancer.com +theweatherplease.com +thewebbusinessresearch.com +theweifamily.icu +thewhitebunkbed.co.uk +thewickerbasket.net +thewolfcartoon.net +thewoodenstoragebeds.co.uk +theworldart.club +theworldisyours.ru +thex.ro +thexgenmarketing.info +thextracool.info +thexxx.site +they.cowsnbullz.com +they.lakemneadows.com +they.oldoutnewin.com +they.ploooop.com +they.warboardplace.com +thhdfws.us +thhs.spymail.one +thichanthit.com +thichkiemtien.com +thichmmo.com +thidthid.cf +thidthid.ga +thidthid.gq +thidthid.ml +thief.mom +thiefness.com +thienminhtv.net +thiensita.com +thiensita.net +thiet-ke-web.org +thietbivanphong.asia +thietkeweb.org +thingexpress.com +thingfamily.biz +thingkvb.com +thingstory.biz +thinhmin.com +think.blatnet.com +think.lakemneadows.com +think.marksypark.com +thinkbigholdings.com +thinkhive.com +thinkimpact.com +thinkingus24.com +thinksea.info +thinkvidi.com +thiolax.club +thiolax.website +thip-like.com +thirdwrist.com +thirifara.com +this-is-a-free-domain.usa.cc +this.lakemneadows.com +this.marksypark.com +this.oldoutnewin.com +this.ploooop.com +thisdont.work +thisgarry.xyz +thisisatrick.com +thisisfashion.net +thisishowyouplay.org +thisismyemail.xyz +thisisnotmyrealemail.com +thismail.net +thistime.uni.me +thistimedd.tk +thistimenow.org.ua +thistrokes.site +thisurl.website +thiswildsong.com +thiwankaslt.gq +thk2d.anonbox.net +thlink.net +thltrqiexn.ga +thnen.com +thnikka.com +thnk.de +tho.yomail.info +thoas.ru +thodetading.xyz +thodianamdu.com +thoen59.universallightkeys.com +thoinen.tech +thoitrang.vn +thoitrangcongso.vn +thoitrangnucatinh.xyz +thoitrangquyco.vn +thoitrangthudong.vn +thol.emlhub.com +thomasedisonlightbulb.net +thomasla.tk +thomsonmail.us.pn +thongfpuwy.com +thongtinchung.com +thornpubbmadh.info +thotwerx.com +thoughtcouture.com +thoughtcrate.com +thoughtsofanangel.com +thousandoakscarpetcleaning.net +thousandoaksdoctors.com +thqdiviaddnef.com +thqdivinef.com +thr.laste.ml +thraml.com +threadedwsw.com +threadgenius.co +threadneedlepress.com +threatstreams.com +three.emailfake.ml +three.fackme.gq +threecreditscoresreview.com +threekitteen.site +threepp.com +thrma.com +throam.com +thronemd.com +throopllc.com +thrott.com +throwam.com +throwaway.io +throwawayemail.com +throwawayemailaddress.com +throwawaymail.com +throwawaymail.pp.ua +throwawaymail.uu.gl +throya.com +thrrndgkqjf.com +thrttl.com +thrubay.com +thshyo.org +thsideskisbrown.com +thtt.us +thu.thumoi.com +thud.site +thuehost.net +thuelike.net +thueotp.net +thuexedulichhanoi.com +thug.pw +thuguimomo.ga +thuha99.com +thulinh.net +thumbpaste.com +thumbsupparty.com +thumbthingshiny.net +thund.cf +thund.ga +thund.gq +thund.ml +thund.tk +thunderballs.net +thunderbolt.science +thunkinator.org +thurstoncounty.biz +thusincret.site +thuthuatlamseo.com +thuughts.com +thuybich.com +thuyetminh.xyz +thvid.net +thxmate.com +thyagarajan.ml +thyfre.cf +thyfre.ga +thyfre.gq +thyfre.ml +thyroidtips.info +thzhhe5l.ml +ti.igg.biz +ti.laste.ml +tianatrend.shop +tianmi.me +tiapz.com +tiascali.it +tiberjogja.com +tibui.com +tic.ec +ticaipm.com +ticket-please.ga +ticketb.com +ticketkick.com +ticketwipe.com +ticklecontrol.com +ticktell.online +ticoteco.ml +tidaksuka.cfd +tidaktahu.xyz +tideloans.com +tidissajiiu.com +tieboppda.ga +tienao.org +tienthanhevent.vn +tiepp.com +tierde.com +tiervio.com +tieungoc.life +tiffany-silverjewelry.com +tiffanyelite.com +tiffanypower.com +tiffincrane.com +tigasu.com +tignovate.com +tigo.tk +tigoco.tk +tigong.space +tigpe.com +tiguanreview.com +tih.emlpro.com +tij45u835348y228.freewebhosting.com.bd +tijdelijke-email.nl +tijdelijke.email +tijdelijkmailadres.nl +tijfdknoe0.com +tijuanatexmexsevilla.com +tijux.com +tikabravani.art +tikanony.com +tikaputri.art +tikarmeiriana.biz +tikmail.org +tikpal.site +tiksofi.uk +tiktakgrab.com +tiktakgrab.shop +tiktakgrabber.com +tiktokitop.com +tiktokngon.com +tildsroiro.com +tilien.com +tillamook-cheese.name +tillamook.name +tillamookcheese.name +tillerrakes.com +tillid.ru +tillion.com +timail.ml +timberlandboot4sale.com +timberlandf4you.com +timberlandfordonline.com +timberwolfpress.com +timcooper.org +timdavidson.info +time.blatnet.com +time.cowsnbullz.com +time.lakemneadows.com +time.oldoutnewin.com +time.ploooop.com +time4areview.com +timeavenue.fr +timecomp.pl +timecritics.com +timegv.com +timekr.xyz +timeroom.biz +timesetgo.com +timesports.com.ua +timestudent.us +timetmail.com +timevod.com +timewasterarcade.com +timewillshow.com +timgiarevn.com +timgmail.com +timhoreads.com +timkassouf.com +timkiems.com +timlive.charity +timothyjsilverman.com +timspeak.ru +tinaksu.com +tinakuki.lol +tinakuki.monster +tinana.online +tinatoon.art +tinging.xyz +tingn.com +tingxing.store +tinh.com +tinhay.info +tinhdeptrai.xyz +tinilalo.com +tiniliveicloud.lol +tiniliveicloud.pics +tinimama.club +tinimama.online +tinimama.website +tinkeringpans.com +tinkmail.net +tinmail.tk +tinnitusmiraclereviews.org +tinnitusremediesforyou.com +tinorecords.com +tinoza.org +tinpho.com +tinternet.com +tintorama.ru +tintremovals.com +tinxi.us +tiny.cowsnbullz.com +tiny.itemxyz.com +tiny.marksypark.com +tinydef.com +tinyios.com +tinymill.org +tinytimer.org +tinyurl24.com +tinyworld.com +tiofin.com +tioforsellhotch.xyz +tionaboutheverif.com +tip4today.com +tipent.com +tipheaven.com +tipidfranchise.com +tipo24.com +tippabble.com +tippy.net +tiprealm.com +tiprv.com +tipsb.com +tipsehat.click +tipsgrid.com +tipsonhowtogetridofacne.com +tipsshortsleeve.com +tipsygirlnyc.com +tipuni.com +tirasya.pro +tiredecalz.com +tirexos.me +tirillo.com +tirixix.pl +tirreno.cf +tirreno.ga +tirreno.gq +tirreno.ml +tirreno.tk +tirsmail.info +tirtalayana.com +tirupatitemple.net +tisacli.co.uk +tiscal.co.uk +tiscalionline.com +tiscoli.co.uk +tissernet.com +titafeminina.com +titan-host.cf +titan-host.ga +titan-host.gq +titan-host.ml +titan-host.tk +titan4d.com +titanemail.info +titanfzr.store +titanit.de +titas.cf +titaskotom.cf +titaskotom.ga +titaskotom.gq +titaskotom.ml +titaskotom.tk +titaspaharpur.cf +titaspaharpur.ga +titaspaharpur.gq +titaspaharpur.ml +titaspaharpur.tk +titaspaharpur1.cf +titaspaharpur1.ga +titaspaharpur1.gq +titaspaharpur1.ml +titaspaharpur1.tk +titaspaharpur2.cf +titaspaharpur2.ga +titaspaharpur2.gq +titaspaharpur2.ml +titaspaharpur2.tk +titaspaharpur3.cf +titaspaharpur3.ga +titaspaharpur3.gq +titaspaharpur3.ml +titaspaharpur3.tk +titaspaharpur4.cf +titaspaharpur4.ga +titaspaharpur4.gq +titaspaharpur4.ml +titaspaharpur4.tk +titaspaharpur5.cf +titaspaharpur5.ga +titaspaharpur5.gq +titaspaharpur5.ml +titaspaharpur5.tk +titenpa.com +titkiprokla.tk +title1program.com +titmail.com +tittbit.in +titz.com +tiuas.com +tiv.cc +tivilebonza.com +tivilebonzagroup.com +tivo.camdvr.org +tivowxl7nohtdkoza.cf +tivowxl7nohtdkoza.ga +tivowxl7nohtdkoza.gq +tivowxl7nohtdkoza.ml +tivowxl7nohtdkoza.tk +tizi.com +tizter.com +tizxr.xyz +tjdh.xyz +tjjlkctec.pl +tjtkd.com +tjuew56d0xqmt.cf +tjuew56d0xqmt.ga +tjuew56d0xqmt.gq +tjuew56d0xqmt.ml +tjuew56d0xqmt.tk +tjuln.com +tjvb.yomail.info +tjyev.fun +tk-poker.com +tk3od4c3sr1feq.cf +tk3od4c3sr1feq.ga +tk3od4c3sr1feq.gq +tk3od4c3sr1feq.ml +tk3od4c3sr1feq.tk +tk4535z.pl +tk8phblcr2ct0ktmk3.ga +tk8phblcr2ct0ktmk3.gq +tk8phblcr2ct0ktmk3.ml +tk8phblcr2ct0ktmk3.tk +tkaniny.com +tkaninymaxwell.pl +tkcsugik.ovh +tkeiyaku.cf +tkfb24h.com +tkfkdgowj.com +tkfkdwlx.com +tkhaetgsf.pl +tkhplanesw.com +tkitc.de +tkjf.freeml.net +tkjngulik.com +tklgidfkdx.com +tkmailservice.tk +tkmushe.com +tkmy88m.com +tko.co.kr +tko.kr +tkzumbsbottzmnr.cf +tkzumbsbottzmnr.ga +tkzumbsbottzmnr.gq +tkzumbsbottzmnr.ml +tkzumbsbottzmnr.tk +tl.community +tl8dlokbouj8s.cf +tl8dlokbouj8s.gq +tl8dlokbouj8s.ml +tl8dlokbouj8s.tk +tlaunchedjm.com +tlbreaksm.com +tlcemail.in +tlcemail.xyz +tlcfanmail.com +tlcfbmt.online +tlclandscapes.com +tldoe8nil4tbq.cf +tldoe8nil4tbq.ga +tldoe8nil4tbq.gq +tldoe8nil4tbq.ml +tldoe8nil4tbq.tk +tldrmail.de +tlead.me +tlen.com +tlfjdhwtlx.com +tlgpwzmqe.pl +tlhao86.com +tlhconsultingservices.com +tlif.emlhub.com +tlimixs.xyz +tlk.spymail.one +tll.spymail.one +tloj2.anonbox.net +tlpn.org +tlr.emlhub.com +tls.cloudns.asia +tlsacademy.com +tlumaczeniawaw.com.pl +tlus.net +tlvl8l66amwbe6.cf +tlvl8l66amwbe6.ga +tlvl8l66amwbe6.gq +tlvl8l66amwbe6.ml +tlvl8l66amwbe6.tk +tlvsmbdy.cf +tlvsmbdy.ga +tlvsmbdy.gq +tlvsmbdy.ml +tlvsmbdy.tk +tlwmail.xyz +tlwpleasure.com +tm-organicfood.ru +tm.cloud-ip.cc +tm.in-ulm.de +tm.slsrs.ru +tm.tosunkaya.com +tm2mail.com +tm42.gq +tm95xeijmzoxiul.cf +tm95xeijmzoxiul.ga +tm95xeijmzoxiul.gq +tm95xeijmzoxiul.ml +tm95xeijmzoxiul.tk +tmab.xyz +tmail.com +tmail.edu.rs +tmail.gg +tmail.io +tmail.link +tmail.mmomekong.com +tmail.org +tmail.run +tmail.ws +tmail1.com +tmail1.org +tmail1.tk +tmail15.com +tmail2.com +tmail2.org +tmail2.tk +tmail3.com +tmail3.org +tmail3.tk +tmail4.org +tmail4.tk +tmail5.org +tmail5.tk +tmail6.com +tmail7.com +tmail8.website +tmail9.com +tmailavi.ml +tmailbox.ru +tmailcloud.com +tmailcloud.net +tmaildir.com +tmaile.net +tmailer.org +tmailffrt.com +tmailhost.com +tmailinator.com +tmailnesia.com +tmailor.com +tmailor.net +tmailpro.net +tmails.net +tmails.top +tmailservices.com +tmailweb.com +tmajre.com +tmarapten.com +tmatthew.net +tmauv.com +tmavfitness.com +tmcraft.site +tmednews.com +tmet.com +tmfc.dropmail.me +tmgb.yomail.info +tmh.emltmp.com +tmlb.freeml.net +tmljw.info +tmmail.buzz +tmmbt.com +tmmbt.net +tmmcv.com +tmmcv.net +tmmwj.com +tmmwj.net +tmnuuq6.mil.pl +tmo.kr +tmp.bte.edu.vn +tmp.x-lab.net +tmpbox.net +tmpemails.com +tmpeml.com +tmpeml.info +tmpfixzy.app +tmpjr.me +tmpmail.net +tmpmail.org +tmpmails.com +tmpmailtor.com +tmpnator.live +tmpx.sa.com +tms.sale +tms12.com +tmsave.com +tmsk.mailpwr.com +tmst.com.tr +tmtdoeh.com +tmtfdpxpmm12ehv0e.cf +tmtfdpxpmm12ehv0e.ga +tmtfdpxpmm12ehv0e.gq +tmtfdpxpmm12ehv0e.ml +tmtfdpxpmm12ehv0e.tk +tmv2r.anonbox.net +tmvi.com +tmw.freeml.net +tmxttvmail.com +tmyh.yomail.info +tmzh8pcp.agro.pl +tn.emlpro.com +tnatntanx.com +tnbeta.com +tnecnw.com +tnecoy.buzz +tneheut.com +tneiih.com +tnej.dropmail.me +tnf.yomail.info +tnfa.com +tnguns.com +tnhshop.site +tningedi.cf +tnnairmaxpasch.com +tnooldhl.com +tnrequinacheter.com +tnrequinboutinpascheresfrance.com +tnrequinpascherboutiquenlignefr.com +tnrequinpaschertnfr.com +tnrequinpaschertnfrance.com +tnrequinstocker.com +tnsmygqfcz.ga +tntitans.club +tntlogistics.co.uk +tntrealestates.com +tnvrtqjhqvbwcr3u91.cf +tnvrtqjhqvbwcr3u91.ga +tnvrtqjhqvbwcr3u91.gq +tnvrtqjhqvbwcr3u91.ml +tnvrtqjhqvbwcr3u91.tk +tnwvhaiqd.pl +tnyfjljsed.ga +to-boys.com +to.blatnet.com +to.cowsnbullz.com +to.makingdomes.com +to.name.tr +to.ploooop.com +to200.com +to79.xyz +toaia.com +toaik.com +toal.com +toanciamobile.com +toanmobileapps.com +toanmobilemarketing.com +toaraichee.cf +toastmatrix.com +toastsum.com +tobaccodebate.com +tobeluckys.com +tobet360.com +tobinproperties.com +tobjl.info +tobobi.com +tobuhu.org +tobulaters.com +tobuso.com +tobycarveryvouchers.com +tobyye.com +tocadosboda.site +tocheif.com +today-payment.com +todaybestnovadeals.club +todayemail.ga +todaygroup.us +todayinstantpaydayloans.co.uk +todays-web-deal.com +todding12.com +toddsbighug.com +toditokard.pw +todo148.glasslightbulbs.com +todogestorias.es +todongromau.com +todoprestamos.com +todoprestamos.es +todtdeke.xyz +toecye.com +toemail.art +toenailmail.info +toerkmail.com +toerkmail.net +tofeat.com +togame.ru +togelhongkonginfo.net +togelmain.net +togelonline88.org +togelprediksi.com +togeltotojitu.com +togetaloan.co.uk +togetheragain.org.ua +togito.com +tohetheragain.org.ua +tohru.org +tohup.com +tohurt.me +toi.kr +toiea.com +toieuywh98.com +toihocseo.com +toikehos.cf +toilacua.store +toiletkeys.net +toiletroad.com +tokai.tk +tokar.com.pl +tokatgunestv.xyz +tokatta.org +tokeishops.jp +tokem.co +token.ro +tokenguy.com +tokenmail.de +tokeracademy.com +tokerphotos.com +tokerreviews.com +tokito.sbs +tokkabanshop.com +tokki3124.com +tokmail.net +tokobibit.co +tokogpt24jam.online +tokoinduk.com +tokojawa.top +tokokarena.live +tokopremium.co +tokot.ru +tokuriders.club +tokyo-mail1.top +tokyoto.site +tol.net +tol.ooo +toleen.site +tolite.com +tolls.com +tolmedia.com +tolongsaya.me +tolsonmgt.com +tolufan.ru +toluna.cyou +toma-sex.info +tomageek.com +tomahawk.ovh +tomatonn.com +tombapik.com +tomehi.com +tomejl.pl +tomi.emlpro.com +tomlev.me +tommymorris.com +tommyuzzo.com +tomshoesonline.net +tomshoesonlinestore.com +tomshoesoutletonline.net +tomshoesoutletus.com +tomsoutletsalezt.com +tomsoutletw.com +tomsoutletzt.com +tomsshoeoutletzt.com +tomsshoesonline4.com +tomsshoesonsale4.com +tomsshoesonsale7.com +tomsshoesoutlet2u.com +tomthen.org.ua +tomx.de +tomymailpost.com +ton-platform.club +tonaeto.com +tonermix.ru +tongon.online +tonimory.com +tonirovkaclub.ru +tonmails.com +tonne.to +tonneau-covers-4you.com +tonngokhong.vn +tonno.cf +tonno.gq +tonno.ml +tonno.tk +tonolon.cf +tontol.xyz +tonycross.space +tonylandis.com +tonymanso.com +tonyplace.com +tonyrico.com +too.li +too879many.info +tool-9-you.com +tool.pp.ua +toolbox.ovh +toolnator.plus +toolsfly.com +toolsig.team +toolve.com +toolyoareboyy.com +toomail.biz +toomail.net +toomtam.com +toon.ml +toonfirm.com +tooniverser.com +toopitoo.com +tooslowtodoanything.com +tooth.favbat.com +toothandmail.com +toowerl.com +top-mailer.net +top-mails.net +top-shop-tovar.ru +top.blatnet.com +top.droidpic.com +top.lakemneadows.com +top.marksypark.com +top.oldoutnewin.com +top.ploooop.com +top.pushpophop.com +top100mail.com +top101.de +top10bookmaker.com +top10k.com +top10movies.info +top1mail.ru +top1post.ru +top3chwilowki.pl +top4th.in +top5news.fun +top777.site +top9appz.info +topantop.site +toparama.com +topatudo.tk +topazpro.xyz +topbagsforsale.info +topbahissiteleri.com +topbak.ru +topbananamarketing.co.uk +topbuyer.xyz +topbuysteroids.com +topbuysteroids365.com +topchik.xyz +topcialisrxpills.com +topcialisrxstore.com +topclancy.com +topclassemail.online +topclonefb.net +topcoolemail.com +topdatamaster.com +topdentistmumbai.com +topdepcasinos.ru +topdiane35.pl +topdrivers.top +toped303.com +toped888.com +topeducationvn.cf +topeducationvn.ga +topeducationvn.gq +topeducationvn.ml +topeducationvn.tk +topemail24.info +topentertainment.pro +topenworld.com +topenz.com +topepics.com +toperhophy.xyz +topessaywritingbase.com +topfivestars.fun +topfreecamsites.com +topfreeemail.com +topgoogle.info +tophandbagsbrands.info +tophbo.com +tophealthinsuranceproviders.com +topiasolutions.net +topigx.com +topikt.com +topinbox.info +topinrock.cf +topiphone.icu +topjobbuildingservices.com +topjuju.com +toplessbucksbabes.us +toplesslovegirls.com +toplinewindow.com +topljh.pw +topmail-files.de +topmail.bid +topmail.minemail.in +topmail.net +topmail.org +topmail.ws +topmail1.net +topmail2.com +topmail2.net +topmail24.ru +topmail4u.eu +topmailer.info +topmailings.com +topmailmantra.net +topmall.com +topmall.info +topmall.org +topmega.ru +topmodafinilrxstore.com +topmoviesonline.co +topmumbaiproperties.com +topmycdn.com +topnewest.com +topnnov.ru +topofertasdehoy.com +topomnireviews.com +toposterclippers.com +topp10topp.ru +toppartners.cf +toppartners.ga +toppartners.gq +toppartners.ml +toppartners.tk +toppenishhospital.com +toppers.fun +toppieter.com +topplayers.fun +topqualityjewelry.info +topranklist.de +toprumours.com +toprungseo.co +topsale.uno +topsecretvn.cf +topsecretvn.ga +topsecretvn.gq +topsecretvn.ml +topsecretvn.tk +topsellingtools.com +topseos.com +topserwiss.eu +topserwiswww.eu +topshoemall.org +topshoppingmalls.info +topsourcemedia5.info +topstorewearing.com +toptalentsearchexperts.xyz +toptantelefonaksesuar.com +toptenplaces.net +toptextloans.co.uk +toptowners.club +toptowners.online +toptowners.site +toptowners.xyz +toptransfer.cf +toptransfer.ga +toptransfer.gq +toptransfer.ml +toptransfer.tk +toptravelbg.pl +toptrend68.online +topupgg.app +topupgg.space +topusaclassifieds.com +topviagrarxpills.com +topviagrarxstore.com +topvu.net +topwebinfos.info +topwebplacement.com +topwm.org +topyte.com +topzpost.com +tora1.info +toracw.com +torahti.com +torange-fr.com +torbecouples.org +torbenetwork.net +torch.yi.org +tordamyco.xyz +toreandrebalic.com +torgorama.com +torgoviy-dom.com +torgovyicenter.ru +tori.ru +toritorati.com +torm.xyz +tormail.net +tormail.org +tormails.com +tornadopotato.gq +tornbanner.com +torneomail.ga +tornovi.net +torontogooseoutlet.com +torquatoasociados.com +torrent411.fr.nf +torrentclub.online +torrentclub.space +torrentinos.net +torrentpc.org +torrentqq33.com +torrentqq36.com +torrentqq37.com +torrentqq38.com +torrentupload.com +torrimins.com +torrin.shop +tortenboxer.de +tory-burch-brand.com +tory.emlhub.com +toryburch-outletsonline.us +toryburchjanpanzt.com +toryburchjapaneses.com +toryburchjapans.com +toscarugs.co.uk +tosese.com +tosms.ru +tospage.com +toss.pw +tossy.info +tostamail.tk +tosunkaya.com +total-research.com +totalcoach.net +totaldeath.com +totalhealthy.fun +totalhentai.net +totalius.blog +totalkw.com +totallogamsolusi.com +totallyfucked.com +totallynicki.info +totallynotfake.net +totalmail.de +totalnetve.ml +totalpoolservice.com +totalvista.com +totedge.com +totelouisvuittonshops.com +toteshops.com +totesmail.com +totnet.xyz +totoan.info +totobet.club +totococo.fr.nf +totolotoki.pl +tototaman.com +tototogel4d.xyz +totse1voqoqoad.cf +totse1voqoqoad.ga +totse1voqoqoad.gq +totse1voqoqoad.ml +totse1voqoqoad.tk +totuanh.click +totzilla.online +totzilla.ru +touchend.com +touchhcs.com +touchsalabai.org +toudrum.com +toughness.org +touoejiz.pl +touranya.com +tourbalitravel.com +tourcatalyst.com +tourcc.com +tourgogogo.com +touristravel.ru +tourmalinehairdryerz.com +tournament-challenge.com +toursbook.ir +tourschoice.ir +toursfinder.ir +toursline.ir +toursman.ir +toursnetwork.ir +tourspop.ir +tourssee.ir +toursstore.ir +tourtripbali.com +tourvio.ir +toushizemi.com +tousma.com +tovd.com +tovhtjd2lcp41mxs2.cf +tovhtjd2lcp41mxs2.ga +tovhtjd2lcp41mxs2.gq +tovhtjd2lcp41mxs2.ml +tovhtjd2lcp41mxs2.tk +tovip.net +toviqrosadi.beritahajidanumroh.com +toviqrosadi.jasaseo.me +toviqrosadi.tamasia.org +towb.cf +towb.ga +towb.gq +towb.ml +towb.tk +towintztf.top +towndewerap23.eu +townpostmail.com +townshipnjr.com +towsonshowerglass.com +toxtalk.org +toy-coupons.org +toy-guitars.com +toy68n55b5o8neze.cf +toy68n55b5o8neze.ga +toy68n55b5o8neze.gq +toy68n55b5o8neze.ml +toy68n55b5o8neze.tk +toyamail.com +toyhiosl.com +toyiosk.gr +toyota-avalon.club +toyota-clubs.ru +toyota-prius.club +toyota-rav-4.cf +toyota-rav-4.ga +toyota-rav-4.gq +toyota-rav-4.ml +toyota-rav-4.tk +toyota-rav4.cf +toyota-rav4.ga +toyota-rav4.gq +toyota-rav4.ml +toyota-rav4.tk +toyota-sequoia.club +toyota-yaris.tk +toyota.cellica.com +toyotacelica.com +toyotalife22.org +toyotataganka.ru +toyotavlzh.com +toys-r-us-coupon-codes.com +toys.ie +toysfortots2007.com +toysgifts.info +toysikio.gr +toysmansion.com +tozya.com +tp-qa-mail.com +tp.laste.ml +tp54lxfshhwik5xuam.cf +tp54lxfshhwik5xuam.ga +tp54lxfshhwik5xuam.gq +tp54lxfshhwik5xuam.ml +tp54lxfshhwik5xuam.tk +tpaglucerne.dnset.com +tpass.xyz +tpbank.gq +tpbay.site +tpcu.com +tpdjsdk.com +tpfqxbot4qrtiv9h.cf +tpfqxbot4qrtiv9h.ga +tpfqxbot4qrtiv9h.gq +tpfqxbot4qrtiv9h.ml +tpfqxbot4qrtiv9h.tk +tpg24.com +tphu.spymail.one +tplcaehs.com +tpmail.top +tpn3x.anonbox.net +tpobaba.com +tppp.one +tppp.online +tpsdq0kdwnnk5qhsh.ml +tpsdq0kdwnnk5qhsh.tk +tpseaot.com +tpte.org +tpwlb.com +tpws.com +tpy.emlpro.com +tpyy57aq.pl +tq3.pl +tq84vt9teyh.cf +tq84vt9teyh.ga +tq84vt9teyh.gq +tq84vt9teyh.ml +tq84vt9teyh.tk +tqa.emlpro.com +tqc-sheen.com +tql4swk9wqhqg.gq +tqoai.com +tqophzxzixlxf3uq0i.cf +tqophzxzixlxf3uq0i.ga +tqophzxzixlxf3uq0i.gq +tqophzxzixlxf3uq0i.ml +tqophzxzixlxf3uq0i.tk +tqosi.com +tqpx.yomail.info +tqqun.com +tqwagwknnm.pl +tr23.com +tr2k.cf +tr2k.ga +tr2k.gq +tr2k.ml +tr2k.tk +tr32qweq.com +tracciabi.li +traceyrumsey.com +trackden.com +tracker.peacled.xyz +tracklacker.com +tracktoolbox.com +trackworld.fun +trackworld.online +trackworld.site +trackworld.store +trackworld.website +trackworld.xyz +tractorjj.com +trad.com +tradaswacbo.eu +trade-finance-broker.org +tradefinanceagent.org +tradefinancebroker.org +tradefinancedealer.org +tradegrowth.co +tradeinvestmentbroker.org +tradepopclick.com +tradermail.info +tradeseze.com +tradex.gb +tradiated.com +tradiez.com +trading-courses.org +traduongtam.com +trafat.xyz +traffic-ilya.gq +traffic-inc.biz +trafficonlineabcxyz.site +trafficreviews.org +traffictrapper.site +traffictrigger.net +trafficxtractor.com +trafik.co.pl +trafika.ir +tragaver.ga +trail.bthow.com +trailervin.com +trailtoppest.com +trainingcamera.com +trainingegh.com +trainingpedia.online +trainyk.website +traisach.com +traitus.com +trakable.com +traksta.com +tralalajos.ga +tralalajos.gq +tralalajos.ml +tralalajos.tk +trallal.com +tramecpolska.com.pl +tramynguyen.net +tranceversal.com +trandung.site +trangiabao.click +trangmuon.com +trango.co +trangzim.uk +tranlamanh.ml +transcience.org +transfaraga.co.in +transfergoods.com +transformco.co +transformdestiny.com +transgenicorganism.com +transistore.co +transitionsllc.com +translateid.com +translationserviceonline.com +translity.ru +transmentor.com +transmissioncleaner.com +transmute.us +transportationfreightbroker.com +transporteszuniga.cl +trantienclone.fun +trantienclone.top +tranvietmail.click +traodoinick.com +trap-mail.de +trash-amil.com +trash-mail.at +trash-mail.cf +trash-mail.com +trash-mail.de +trash-mail.ga +trash-mail.gq +trash-mail.ml +trash-mail.net +trash-mail.tk +trash-me.com +trash2009.com +trash2010.com +trash2011.com +trash247.com +trash4.me +trashbin.cf +trashbox.eu +trashcanmail.com +trashdevil.com +trashdevil.de +trashemail.de +trashemails.de +trashimail.de +trashinbox.com +trashinbox.net +trashmail.app +trashmail.at +trashmail.com +trashmail.de +trashmail.es +trashmail.fr +trashmail.ga +trashmail.gq +trashmail.hu +trashmail.io +trashmail.live +trashmail.lol +trashmail.me +trashmail.net +trashmail.org +trashmail.pw +trashmail.se +trashmail.tk +trashmail.top +trashmail.win +trashmail.ws +trashmailer.com +trashmailgenerator.de +trashmailr.com +trashmails.com +trashspam.com +trashymail.com +trashymail.net +traslex.com +trassion.site +trasz.com +tratratratomatra.com +trav3lers.com +travala10.com +travel-e-store.com +travel-singapore-with-me.com +travelbenz.com +travelblogplace.com +travelday.ru +traveldesk.com +travelers.co +travelingcome.com +travelistaworld.com +travelitis.site +travelkot.ru +travelovelinka.club +travelparka.pl +travelphuquoc.info +travelsaroundasia.com +travelsdoc.ru +travelso12.com +travelsta.tk +travelstep.ru +traveltagged.com +travelua.ru +travile.com +travissharpe.net +travit12.com +travodoctor.ru +trayna.com +traz.cc +traz.xyz +traze5243.com +trazeco.com +trazimdevojku.in.rs +trazz.com +trbet350.com +trbet477.com +trbet591.com +trbvm.com +trbvn.com +trbvo.com +trcpin.com +trcprebsw.pl +trdhw.info +trdrfyftfgi.fun +treamysell.store +treasuregem.info +treatance.com +treatmentans.ru +treatmented.info +treatmentsforherpes.com +trebusinde.cf +trebusinde.ml +tredinghiahs.com +tree.blatnet.com +tree.emailies.com +tree.heartmantwo.com +tree.ploooop.com +treecon.pl +treeheir.com +treehouseburning.com +treehousetherapy.com +treeremovalmichigan.com +trejni.com +trelatesd.com +tremosd.xyz +trend-maker.ru +trendbettor.com +trendfinance.ru +trendingtopic.cl +trendinx.com +trends-market.site +trendselection.com +trendstomright.com +trendtivia.com +trendzvibe.shopping +trenerfitness.ru +trenkita.com +trenord.cf +trenord.ga +trenord.gq +trenord.ml +trenord.tk +trenssocial00.site +trepsels.online +trerwe.online +tressicolli.com +treterter.shop +tretinoincream-05.com +tretmuhle.com +trezvostrus.ru +trfu.to +trg.pw +trgfu.com +trgovinanaveliko.info +tri-es.ru +triadelta.com +trialforyou.com +trialmail.de +trialseparationtop.com +triangletlc.com +triario.site +tribalks.com +tribesascendhackdownload.com +tribonox79llr.tk +tribora.com +tributeblog.com +tricdistsiher.xyz +trickence.com +trickmail.net +trickminds.com +trickphotographyreviews.net +trickupdaily.com +trickupdaily.net +trickyfucm.com +trickypixie.com +tricoulesmecher.com +tridalinbox.info +triedbook.xyz +trilegal.ml +trillianpro.com +trimar.pl +trimaxglobal.co.uk +trimix.cn +trimsj.com +tringuyen.live +tringuyen.shop +trioariop.site +triogempar.design +trioschool.com +triots.com +trip.bthow.com +tripaco.com +triparish.net +tripolis.com +tripolnet.website +tripoow.tech +trippypsyche.com +trips-shop.ru +tripsterfoodies.net +trisana.net +trishkimbell.com +tristanabestolaf.com +tristarasdfdk1parse.net +tristore.xyz +tritega.com +triteksolution.info +tritunggalmail.com +triumphworldschools.com +trivialnewyork.com +trixtrux1.ru +trizz.pro +trizz.xyz +trmc.net +trobertqs.com +trobudosk.co.uk +trobudosk.org.uk +trobudosk.uk +trochoi.asia +troikos.com +trojangogogo.site +trojanmail.ga +trol.com +trolebrotmail.com +troleskomono.co.uk +troleskomono.org.uk +trollproject.com +trommlergroup.com +trommleronline.com +trommlershop.com +trompetarisca.co +tron.pl +tronghao.site +trongtrung.xyz +tronplatform.org +tronques.ml +tronzillion.com +troofer.com +troops.online +troothshop.com +tropicalbass.info +tropicpvp.ml +tropovenamail.com +troxiu.buzz +trsdfyim.boats +trssdgajw.pl +trtd.info +trubo.wtf +trucdaischool.us +truckaccidentlawyerpennsylvania.org +truckandvanland.com +truckcashoffer.com +truckmetalworks.com +trucmai.cf +trucmai.ml +trucmai.tk +trucrick.com +trucyu.xyz +true-lovehub.lat +true-religion.cc +truebonding.lat +trueedhardy.com +truefile.org +truelove.lat +truemeanji.com +truemr.com +truereligionbrandmart.com +truereligionjeansdublin.eu +truevibe.lat +trufilth.com +truhempire.com +trujillon.xyz +trulli.pl +trulyfreeschool.org +trumanpost.com +trumclone.click +trumclone.com +trumclone.net +trumclone.online +trumclsr.com +trumgamevn.ml +trummmredmail.top +trump.flu.cc +trump.igg.biz +trumpmail.cf +trumpmail.ga +trumpmail.gq +trumpmail.ml +trumpmail.tk +trung.name.vn +trungtampccc.vn +trungtamtoeic.com +trungthu.ga +trushsymptomstreatment.com +trussinteriors.site +trust-deals.ru +trust.games +trustablehosts.com +trustcloud.engineer +trustdong.com +trusted-canadian-online-pharmacy.com +trusted.camera +trusted.parts +trusted.photos +trusted.trading +trusted.wine +trustedcvvshop.ru +trustedproducts.info +trustfarma.online +trustingfunds.ltd +trustingfunds.me +trustinj.trade +trustinthe.cloud +trustmails.info +trustme.host +trustnetsecurity.net +trusttravellive.biz +trusttravellive.info +trusttravellive.net +trusttravellive.travel +truthaboutcellulitereviews.com +truthfinderlogin.com +truthmaker.top +truuhost.com +truvisagereview.com +truwera.com +truxamail.com +trxsuspension.us +trxubcfbyu73vbg.ga +trxubcfbyu73vbg.ml +trxubcfbyu73vbg.tk +try-rx.com +tryalert.com +trydeal.com +tryeverydrop.com +tryhiveclick.com +tryhivekey.com +trymail.tk +trymamail.lol +tryninja.io +trynta.com +trypodgrid.com +tryprice.co +trysubj.com +trythe.net +tryuf5m9hzusis8i.cf +tryuf5m9hzusis8i.ga +tryuf5m9hzusis8i.gq +tryuf5m9hzusis8i.ml +tryuf5m9hzusis8i.tk +trywavegrid.com +tryzoe.com +trzebow.pl +ts-by-tashkent.cf +ts-by-tashkent.ga +ts-by-tashkent.gq +ts-by-tashkent.ml +ts-by-tashkent.tk +ts.emlhub.com +ts5.xyz +ts93crz8fo5lnf.cf +ts93crz8fo5lnf.ga +ts93crz8fo5lnf.gq +ts93crz8fo5lnf.ml +ts93crz8fo5lnf.tk +tsamoncler.info +tsas.tr +tsassoo.shop +tsbeads.com +tsch.com +tscho.org +tsclip.com +tscpartner.com +tsderp.com +tsdn.spymail.one +tshirtformens.com +tshirtsavvy.com +tsih.emltmp.com +tsj.com.pl +tsjb.freeml.net +tsjs.emlpro.com +tsk.tk +tslhgta.com +tsmc.mx +tsmtp.org +tsnmw.com +tspace.net +tspam.de +tspt.online +tspzeoypw35.ml +tssn.com +tst999.com +tstartedpj.com +tsukinft.club +tsukushiakihito.gq +tsvv.emltmp.com +tswd.de +tsyefn.com +tt.emlhub.com +tt.yomail.info +tt2dx90.com +ttbbc.com +ttcgmiami.com +ttd.yomail.info +ttdesro.com +ttdfytdd.ml +tteb.emlhub.com +tthk.com +ttht.us +ttieu.com +ttirv.com +ttirv.net +ttirv.org +ttj.laste.ml +ttlrlie.com +ttm.dropmail.me +ttmail.vip +ttmgss.com +ttmps.com +ttn.dropmail.me +ttoubdzlowecm7i2ua8.cf +ttoubdzlowecm7i2ua8.ga +ttoubdzlowecm7i2ua8.gq +ttoubdzlowecm7i2ua8.ml +ttoubdzlowecm7i2ua8.tk +ttpo89japan.com +ttrzgbpu9t6drgdus.cf +ttrzgbpu9t6drgdus.ga +ttrzgbpu9t6drgdus.gq +ttrzgbpu9t6drgdus.ml +ttrzgbpu9t6drgdus.tk +ttsi.de +ttsport42.ru +ttszuo.xyz +ttt.emlhub.com +ttt72pfc0g.cf +ttt72pfc0g.ga +ttt72pfc0g.gq +ttt72pfc0g.ml +ttt72pfc0g.tk +ttttttttt.com +ttttttttttttttttt.shop +tttttyrewrw.xyz +tturk.com +ttusrgpdfs.pl +ttxcom.info +ttxe.com +ttytgyh56hngh.cf +ttyuhjk.co.uk +tu.emltmp.com +tu2uu.anonbox.net +tu6oiu4mbcj.cf +tu6oiu4mbcj.ga +tu6oiu4mbcj.gq +tu6oiu4mbcj.ml +tu6oiu4mbcj.tk +tualias.com +tuamaeaquelaursa.com +tuana.vip +tuanhungdev.xyz +tuantoto.com +tubanmentol.ml +tube-dns.ru +tube-ff.com +tube-lot.ru +tube-over-hd.ru +tube-rita.ru +tubeadulte.biz +tubebob.ru +tubeemail.com +tubeftw.com +tubegain.com +tubegalore.site +tubehub.net +tubeteen.ru +tubi-tv.best +tubidu.com +tubodamagnifica.com +tubruk.trade +tubzesk.org +tucboxy.com +tucineestiba.com +tuckschool.com +tucumcaritonite.com +tudena.pro +tudxico.icu +tuesdayfi.com +tuf.spymail.one +tufiqon.tech +tug.minecraftrabbithole.com +tugbanurtiftikci.shop +tugboater.com +tugo.ga +tugurywag.life +tuhsuhtzk.pl +tuimail.ml +tujimastr09lioj.ml +tukang.codes +tukieai.com +tuku26012023.xyz +tukucapcut.cfd +tukudawet.tk +tukulyagan.com +tukupedia.co +tular.online +tulnl.xyz +tulsa.gov +tulsapublicschool.org +tumail.com +tumbalproyek.me +tumbleon.com +tumejorfoto.blog +tumjsnceh.pl +tumroc.net +tunacrispy.com +tunasbola.site +tunasbola.website +tunayseyhan.cfd +tuncpersonel.com +tunehriead.pw +tunelux.com +tunestan.com +tunezja-przewodnik.pl +tunghalinh.top +tungsten-carbide.info +tunhide.com +tuni.life +tunis-nedv.ru +tunmanageservers.com +tunnelbeear.com +tunneler01.ml +tunnelermail.shop +tunnelerph.com +tunnell.org +tunningmail.gdn +tunrahn.com +tuofs.com +tuoitre.email +tuongtactot.tk +tuongxanh.net +tupanda.com +tuphmail.com +tupmail.com +tuposti.net +tupuduku.pw +tuqk.com +turbobania.com +turboforex.net +turbomail.ovh +turboparts.info +turboprinz.de +turboprinzessin.de +turbospinz.co +turechartt.com +turf.exchange +turist-tur.ru +turkey-nedv.ru +turkeyth.com +turknet.com +turkuazballooning.com +turnimon.com +turningheads.com +turningleafcrafts.com +turoid.com +turquoiseradio.com +turtlebutfast.com +turtlefutures.com +turtlegrassllc.com +turu.software +turual.com +turuma.com +turuwae.tech +turvichurch.ga +tusitiowebgratis.com.ar +tusitowebserver.com +tusndus.com +tut-zaycev.net +tutavideo.com +tutikembangmentari.art +tutis.me +tutoreve.com +tutsport.ru +tutu.qwertylock.com +tutuapp.bid +tutushop.com +tutusweetshop.com +tutye.com +tuu.laste.ml +tuu854u83249832u35.ezyro.com +tuugo.com +tuuwc.com +tuvanwebsite.com +tuvimoingay.us +tuwg.dropmail.me +tuxreportsnews.com +tuxt.dropmail.me +tuyingan.co +tuyulmokad.ml +tuyulmokad.tk +tuzis.com +tv.emlpro.com +tvchd.com +tvcs.co +tvelef2khzg79i.cf +tvelef2khzg79i.ga +tvelef2khzg79i.gq +tvelef2khzg79i.ml +tvelef2khzg79i.tk +tverya.com +tvetxs.site +tvi72tuyxvd.cf +tvi72tuyxvd.ga +tvi72tuyxvd.gq +tvi72tuyxvd.ml +tvi72tuyxvd.tk +tvinfo.site +tvlg.com +tvmin7.club +tvoe-videohd.ru +tvonlayn2.ru +tvonline.ml +tvoymoy.ru +tvp8.com +tvsch.site +tvshare.space +tvst.de +tvvgroup.com +tvz.spymail.one +twddos.net +tweakacapun.wwwhost.biz +tweakly.net +twearch.com +tweet.fr.nf +twelvee.us +twichzhuce.com +twincreekshosp.com +twinducedz.com +twinklegalaxy.com +twinklyshop.xyz +twinmail.de +twinsbrand.com +twinslabs.com +twinzero.net +twirg.anonbox.net +twirlygirl.info +twistedcircle.com +twit-mail.com +twitch.work +twitchmasters.com +twitlebrity.com +twitt3r.cf +twitt3r.ga +twitt3r.gq +twitt3r.ml +twitt3r.tk +twitteraddersoft.com +twitterchin.top +twitterfree.com +twitterhai.top +twitternam.top +twitterparty.ru +twitterreviewer.tk +twkj.xyz +twkly.ml +twlcd4i6jad6.cf +twlcd4i6jad6.ga +twlcd4i6jad6.gq +twlcd4i6jad6.ml +twlcd4i6jad6.tk +twmail.ga +twmail.tk +twnecc.com +twnker.com +two.emailfake.ml +two.fackme.gq +two.haddo.eu +two.lakemneadows.com +two.marksypark.com +two.popautomated.com +two0aks.com +twobirds-legal.com +twocowmail.net +twodayyylove.club +twodrops.org +twojalawenda.pl +twojapozyczka.online +twoje-nowe-biuro.pl +twojekonto.pl +twojrabat.pl +twood.tk +tworcyatrakcji.pl +tworcyimprez.pl +tworzenieserwisow.com +twosouls.lat +twoweelz.com +twoweirdtricks.com +twsexy66.info +twsh.us +twugg.com +twycloudy.com +twzhhq.com +twzhhq.online +tx.spymail.one +tx4000.com +txantxiku.tk +txbex.com +txcct.com +txdjs.com +txen.de +txfgf.anonbox.net +txgx.emltmp.com +txmovingquotes.com +txn.emlpro.com +txpwg.usa.cc +txrealestateagencies.com +txrsvu8dhhh2znppii.cf +txrsvu8dhhh2znppii.ga +txrsvu8dhhh2znppii.gq +txrsvu8dhhh2znppii.ml +txrsvu8dhhh2znppii.tk +txsignal.com +txt.acmetoy.com +txt.flu.cc +txt.freeml.net +txt10xqa7atssvbrf.cf +txt10xqa7atssvbrf.ga +txt10xqa7atssvbrf.gq +txt10xqa7atssvbrf.ml +txt10xqa7atssvbrf.tk +txt7e99.com +txta.site +txtadvertise.com +txtb.site +txtc.press +txtc.site +txtc.space +txte.site +txtea.site +txteb.site +txtec.site +txted.site +txtee.site +txtef.site +txteg.site +txteh.site +txtf.site +txtfinder.xyz +txtg.site +txth.site +txti.site +txtia.site +txtib.site +txtic.site +txtid.site +txtie.site +txtif.site +txtig.site +txtih.site +txtii.site +txtij.site +txtik.site +txtil.site +txtim.site +txtin.site +txtip.site +txtiq.site +txtir.site +txtis.site +txtit.site +txtiu.site +txtiv.site +txtiw.site +txtix.site +txtiy.site +txtiz.site +txtj.site +txtk.site +txtl.site +txtm.site +txtn.site +txtp.site +txtq.site +txtr.site +txts.press +txts.site +txtsa.site +txtsc.site +txtsd.site +txtse.site +txtsf.site +txtsg.site +txtsh.site +txtsj.site +txtsl.site +txtsn.site +txtso.site +txtsp.site +txtsq.site +txtsr.site +txtss.site +txtsu.site +txtsv.site +txtsw.site +txtsx.site +txtsy.site +txtsz.site +txtt.site +txtu.site +txtv.site +txtw.site +txtx.site +txtx.space +txty.site +txtz.site +txv4lq0i8.pl +txw.emltmp.com +ty.ceed.se +ty.squirtsnap.com +ty12umail.com +ty8800.com +ty9avx.dropmail.me +tyclonecuongsach.site +tycoonsleep.ga +tyduticr.com +tyeo.ga +tyhe.ro +tyhrf.jino.ru +tyincoming.com +tyjw.com +tyldd.com +tylerexpress.com +tylko-dobre-lokaty.com.pl +tymacelectric.com +tymail.top +tymex.tech +tymkvheyo.shop +tympe.net +tynho.com +tynkowanie-cktynki.pl +tyonyihi.com +tyosigma.myvnc.com +typepoker.com +typery.com +typesoforchids.info +typestring.com +typewritercompany.com +typhonsus.tk +typicalfer.com +typlrqbhn.pl +tyskali.org +tytfhcghb.ga +tytyr.pl +tyu.com +tyuha.ga +tyuitu.com +tyurist.ru +tyuty.net +tywmp.com +tz.emltmp.com +tz.tz +tzarmail.info +tzd.dropmail.me +tzd.laste.ml +tzj.yomail.info +tzjx.spymail.one +tzkmp.us +tzqj.laste.ml +tzqmirpz0ifacncarg.cf +tzqmirpz0ifacncarg.gq +tzqmirpz0ifacncarg.tk +tzrtrapzaekdcgxuq.cf +tzrtrapzaekdcgxuq.ga +tzrtrapzaekdcgxuq.gq +tzrtrapzaekdcgxuq.ml +tzrtrapzaekdcgxuq.tk +tzsj.emltmp.com +tztu.emlpro.com +tzymail.com +u-torrent.cf +u-torrent.ga +u-torrent.gq +u-wills-uc.pw +u.civvic.ro +u.coloncleanse.club +u.dmarc.ro +u.labo.ch +u.qvap.ru +u03.gmailmirror.com +u0nuw4hnawyec6t.xyz +u0qbtllqtk.cf +u0qbtllqtk.ga +u0qbtllqtk.gq +u0qbtllqtk.ml +u0qbtllqtk.tk +u1.myftp.name +u14269.gq +u14269.ml +u1gdt8ixy86u.cf +u1gdt8ixy86u.ga +u1gdt8ixy86u.gq +u1gdt8ixy86u.ml +u1gdt8ixy86u.tk +u2.net.pl +u2b.comx.cf +u336.com +u3t9cb3j9zzmfqnea.cf +u3t9cb3j9zzmfqnea.ga +u3t9cb3j9zzmfqnea.gq +u3t9cb3j9zzmfqnea.ml +u3t9cb3j9zzmfqnea.tk +u42tg.anonbox.net +u461.com +u4azel511b2.xorg.pl +u4iiaqinc365grsh.cf +u4iiaqinc365grsh.ga +u4iiaqinc365grsh.gq +u4iiaqinc365grsh.ml +u4iiaqinc365grsh.tk +u4jhrqebfodr.cf +u4jhrqebfodr.ml +u4jhrqebfodr.tk +u4nzbr5q3.com +u5tbrlz3wq.cf +u5tbrlz3wq.ga +u5tbrlz3wq.gq +u5tbrlz3wq.ml +u5tbrlz3wq.tk +u5yks.anonbox.net +u6lvty2.com +u7cjl8.xorg.pl +u7fq0.mimimail.me +u7vt7vt.cf +u7vt7vt.ga +u7vt7vt.gq +u7vt7vt.ml +u7vt7vt.tk +u8mpjsx0xz5whz.cf +u8mpjsx0xz5whz.ga +u8mpjsx0xz5whz.gq +u8mpjsx0xz5whz.ml +u8mpjsx0xz5whz.tk +ua-mail.online +ua.spymail.one +ua3jx7n0w3.com +ua6htwfwqu6wj.cf +ua6htwfwqu6wj.ga +ua6htwfwqu6wj.gq +ua6htwfwqu6wj.ml +ua6htwfwqu6wj.tk +uacro.com +uacrossad.com +uaemail.com +uafebox.com +uafl.dropmail.me +uafusjnwa.pl +uaid.com +uaifai.ml +uaj.spymail.one +uajgqhgug.pl +ualbert.ca +ualberta.ga +ualmail.com +ualusa.com +uam.com +uamail.com +uandresbello.tk +uaob.dropmail.me +uapemail.com +uapproves.com +uaq.spymail.one +uarara5ryura46.ga +uarh.yomail.info +uasalbany.info +uat.laste.ml +uat6m3.pl +uatop.in +uautfgdu35e71m.cf +uautfgdu35e71m.ga +uautfgdu35e71m.gq +uautfgdu35e71m.ml +uautfgdu35e71m.tk +uav3pl.com +uax.freeml.net +uaxj.yomail.info +uaxpress.com +uazo.com +ub.emltmp.com +ubamail.com +ubay.io +ubc.emlhub.com +ubcategories.com +ubdeexu2ozqnoykoqn8.ml +ubdeexu2ozqnoykoqn8.tk +ubdt.spymail.one +uber-mail.com +uberdriver-taxi.ru +ubermail.info +ubermail39.info +ubermember.com +ubfre2956mails.com +ubh.emltmp.com +ubinert.com +ubismail.net +ublastanalytics.com +ublomail.com +ubm.md +ubmail.com +ubnx.emltmp.com +ubpv.spymail.one +ubre.spymail.one +ubumail.com +ubuntu.dns-cloud.net +ubuntu.dnsabr.com +ubuntu.org +ubuspeedi.com +ubwerrr.com +ubwerrrd.com +ubxao.com +uby.emltmp.com +ubz.freeml.net +ubziemail.info +ucandobest.pw +ucansuc.pw +ucavlq9q3ov.cf +ucavlq9q3ov.ga +ucavlq9q3ov.gq +ucavlq9q3ov.ml +ucavlq9q3ov.tk +ucbr.emltmp.com +ucche.us +uccuyosanjuan.com +ucemail.com +ucgbc.org +uch.laste.ml +ucho.top +uchs.com +ucibingslamet.art +ucimail.com +ucir.org +uclinics.com +ucm8.com +ucmamail.com +ucoain.com +ucq.com +ucq9vbhc9mhvp3bmge6.cf +ucq9vbhc9mhvp3bmge6.ga +ucq9vbhc9mhvp3bmge6.gq +ucq9vbhc9mhvp3bmge6.ml +ucsoft.biz +ucupdong.ml +ucw8rp2fnq6raxxm.cf +ucw8rp2fnq6raxxm.ga +ucw8rp2fnq6raxxm.gq +ucw8rp2fnq6raxxm.ml +ucw8rp2fnq6raxxm.tk +ucyeh.com +ucylu.com +ud.spymail.one +udaanexpress.tech +udbaccount.com +udderl.site +udec.edu +udemail.com +udid.com +udingclin.com +udinnews.com +udj.spymail.one +udk.dropmail.me +udlicenses.com +udmail.com +udmissoon.com +udns.cf +udns.gq +udns.tk +udo8.com +udofyzapid.com +udoiswell.pw +udoiwmail.com +udozmail.com +udphub-doge.cf +udruzenjejez.info +udsc.edu +udsm.spymail.one +udsn.emltmp.com +uduomail.com +ue.freeml.net +ue.spymail.one +ue90x.com +ueael.com +uealumni.com +ueb.freeml.net +uebh.laste.ml +uee.edu.pl +ueep.com +uef.emlpro.com +uefia.com +uegumail.com +ueiaco100.info +ueig2phoenix.info +ueimultimeter.info +ueinx.anonbox.net +uemail99.com +uenct2012.info +uengagednp.com +uenglandrn.com +ueno-kojun.com +ueqj99241t0.online +ueqmm.anonbox.net +uesy.emlhub.com +uet.emlpro.com +uetimer.com +uewodia.com +uewryweqiwuea.tk +uey.yomail.info +uf.edu.pl +uf.emlhub.com +uf789.com +ufa-decor.ru +ufa-nedv.ru +ufaamigo.site +ufacturing.com +ufbpq9hinepu9k2fnd.cf +ufbpq9hinepu9k2fnd.ga +ufbpq9hinepu9k2fnd.gq +ufbpq9hinepu9k2fnd.ml +ufbpq9hinepu9k2fnd.tk +ufc.edu.pl +ufcboxingfight.info +ufect.com +uff.laste.ml +ufficialeairmax.com +uffm.de +ufgqgrid.xyz +ufhuheduf.com +ufi9tsftk3a.pl +ufibmail.com +ufk3rtwyb.pl +ufman.site +ufmncvmrz.pl +ufokeuabmail.com +uframeit.com +ufrbox.net +uftf.emltmp.com +ufvjm.com +ufxcnboh4hvtu4.cf +ufxcnboh4hvtu4.ga +ufxcnboh4hvtu4.gq +ufxcnboh4hvtu4.ml +ufxcnboh4hvtu4.tk +ufy.emlhub.com +ug.emlpro.com +ug.wtf +ug.yomail.info +uganbaoamza.shop +ugf1xh8.info.pl +ugg-bootsoutletclearance.info +uggboos-online.com +uggbootoutletonline.com +uggboots-uksale.info +uggboots.com +uggbootscom.com +uggbootsever.com +uggbootsins.com +uggbootsonlinecheap.com +uggbootssale-discount.us +uggbootssale.com +uggbootssales.com +uggbuystorejp.com +uggjimmystores.com +uggpaschermz.com +uggs-canadaonline.info +uggs-outletstores.info +uggs.co.uk +uggsale-uk.info +uggsart.com +uggsguide.org +uggshopsite.org +uggsiteus.com +uggsnowbootsoline.com +uggsoutlet-online.info +uggsrock.com +ughsalecc.com +ugimail.com +ugimail.net +ugipmail.com +uglewmail.pw +ugmail.com +ugny.com +ugogi.com +ugonnamoveit.info +ugps.yomail.info +ugrafix.com +ugreatejob.pw +ugs.emlhub.com +ugsdiesel.com +ugtk.com +uguf.gmail.keitin.site +ugunduzi.com +ugurcanuzundonek.buzz +uguuchantele.com +ugwu.com +ugz.emlpro.com +uha.kr +uhds.tk +uhds.yomail.info +uhe2.com +uhefmail.com +uhek.emltmp.com +uhex.com +uhf.emlpro.com +uhfiefhjubwed.cloud +uhhu.ru +uhi.com +uhjyzglhrs.pl +uhl.emlhub.com +uhmail.com +uhmbrehluh.com +uho1nhelxmk.ga +uho1nhelxmk.gq +uho1nhelxmk.ml +uho1nhelxmk.tk +uhpanel.com +uhrx.site +uhtso.com +uhu7e.anonbox.net +ui-feed.com +ui.spymail.one +uiba-ci.com +uibbahwsx.xyz +uicg.mailpwr.com +uie.spymail.one +uiemail.com +uigfruk8.com +uighugugui.com +uihx.spymail.one +uijbdicrejicnoe.site +uikd.com +uilfemcjsn.pl +uilo.mimimail.me +uimq.com +uini.mailpwr.com +uinkopal.cloud +uinsby.email +uinsby.social +uio.laste.ml +uioct.com +uipvu.site +uiqaourlu.pl +uisd.com +uitblijf.ml +uiu.us +uivvn.net +uiy.laste.ml +uiycgjhb.com +uj.emlpro.com +uj4om.anonbox.net +ujafmail.com +ujames3nh.com +ujapbk1aiau4qwfu.cf +ujapbk1aiau4qwfu.ga +ujapbk1aiau4qwfu.gq +ujapbk1aiau4qwfu.ml +ujapbk1aiau4qwfu.tk +ujg47.anonbox.net +ujijima1129.gq +ujjivanbank.com +ujmail.com +ujoh.mimimail.me +ujpy.emltmp.com +ujrmail.com +ujuzesyz.swiebodzin.pl +ujwo.laste.ml +ujwrappedm.com +ujxspots.com +ujyo.emlhub.com +uk-beauty.co.uk +uk-nedv.ru +uk-tvshow.com +uk-unitedkingdom.cf +uk-unitedkingdom.ga +uk-unitedkingdom.gq +uk-unitedkingdom.ml +uk-unitedkingdom.tk +uk.flu.cc +uk.igg.biz +uk.lakemneadows.com +uk.marksypark.com +uk.nut.cc +uk.oldoutnewin.com +uk.org +uk.ploooop.com +uk.slowdeer.com +uk.to +uk2.net +ukairmax4cheap.com +ukairmaxshoe.com +ukbob.com +ukboer.cc +ukbootsugg.co.uk +ukbuildnet.co.uk +ukcompanies.org +ukddamip.co +ukdiningh.com +ukdressessale.com +ukeg.site +ukelsd.us +ukescortdirectories.com +ukeveningdresses.com +ukexample.com +ukflooringdirect.com +ukfreeisp.co.uk +ukgent.com +ukhollisterer.co.uk +ukhollisteroutlet4s.co.uk +ukhollisteroutlet4u.co.uk +ukhollisteroutletlondon.co.uk +ukhost-uk.co.uk +ukimail.com +ukin3.anonbox.net +ukjton.cf +ukjton.ga +ukjton.gq +ukjton.ml +ukjton.tk +uklc.com +ukld.ru +ukle.com +ukleadingb2b.info +uklouboutinuk.com +uklouboutinuksale.com +uklouisvuittonoutletzt.co.uk +ukm.ovh +ukmail.com +ukmuvkddo.pl +ukniketrainerssale.com +uknowmyname.info +uko.kr +ukolhgfr.mns.uk +ukonline.com +ukoutletkarenmillendresses.org +ukpayday24.com +ukpensionsadvisor.tk +ukpostmail.com +ukpowernetworks.co +ukr-nedv.ru +ukr-po-v.co.cc +ukrainaharnagay.shn-host.ru +ukraynaliopal.network +ukrtovar.ru +uks5.com +uksnapback.com +uksnapbackcap.com +uksnapbackcaps.com +uksnapbackhat.com +uksnapbacks.com +uksurveyors.org +ukt.dropmail.me +uktaxrefund.info +uktrainers4sale.com +uktrainersale.com +uktrainerssale.com +ukv.emltmp.com +ukwebtech.com +ukwt.laste.ml +ukyfemfwc.pl +ukymail.com +ulahadigung.cf +ulahadigung.ga +ulahadigung.gq +ulahadigung.ml +ulahadigung.tk +ulahadigungproject.cf +ulahadigungproject.ga +ulahadigungproject.gq +ulahadigungproject.ml +ulahadigungproject.tk +ulaptopsn.com +ulascimselam.tk +ulemail.com +ulforex.com +ulisaig.com +ulm-dsl.de +ulmich.edu +ulmo.dropmail.me +ulqoirraschifer.cf +ulqoirraschifer.ga +ulqoirraschifer.gq +ulqoirraschifer.ml +ulqoirraschifer.tk +ulr.emlhub.com +ultdesign.ru +ultimatebusinessservices.com +ultimateplumpudding.co.uk +ultra-nyc.com +ultra.fyi +ultrada.ru +ultradrugbuy.com +ultrafitnessguide.com +ultrago.de +ultrahazzam.online +ultrainbox.dev +ultramailinator.com +ultramoviestreams.com +ultraschallanlagen.de +ultraste.ml +ultraxmail.pw +ultrazzam.online +ultrtime.org.ua +ulua.freeml.net +ulumdocab.xyz +ulummky.com +ulzlemwzyx.pl +uma3.be +umaasa.com +umail.net +umail2.com +umail365.com +umail4less.bid +umail4less.men +umail4less.website +umailbox.net +umailz.com +umalypuwa.ru +uman.com +umanit.net +umanit.online +umanit.space +umaw.site +umaxol.com +umbrellascolors.info +umds.com +umehlunua.pl +umej.com +umeoer.web.id +umessage.cf +umestore.click +umf.spymail.one +umfragenliste.de +umgewichtzuverlieren.com +umil.net +ummail.com +ummoh.com +umniy-zavod.ru +umode.net +umoz.us +umpy.com +umrent.com +umrika.com +umrn.ga +umrn.gq +umrn.ml +umrohdulu.com +umscoltd.com +umss.de +umtutuka.com +umumwqrb9.pl +umutyapi.com +umwhcmqutt.ga +umxw.emltmp.com +umy.kr +un-uomo.site +un.laste.ml +unair.nl +unambiguous.net +unarmedover.ml +unaux.com +unbanq.com +unbiex.com +unblockedgamesrun.com +uncensored.rf.gd +uncensoredsurvival.com +unchartedsw.com +unchuy.xyz +uncle.ruimz.com +unclebobscoupons.com +unclebuckspumpkinpatch.com +uncommonsenseunlimited.com +uncond.us +undeadbank.com +undeadforum.com +undentish.site +under500.org +underdosejkt.org +undergmail.com +undermajestic.club +underseagolf.com +undersky.org.ua +undeva.net +undewp.com +undo.it +undoubtedchanelforsale.com +unefty.site +uneppwqi.pl +unevideox.fr +unfairship.com +unfilmx.fr +unfj.mimimail.me +unfortunatesanny.net +ung.spymail.one +ungolfclubs.com +unheatedgems.net +unhjhhng.com +uniaotrafego.com +unicobd.com +unicodeworld.com +unicomti.com +unicornsforsocialism.com +unicorntoday.com +unicredit.tk +unicsite.com +unidoxx.com +unids.com +unif8nthemsmnp.cf +unif8nthemsmnp.ga +unif8nthemsmnp.gq +unif8nthemsmnp.ml +unif8nthemsmnp.tk +uniform.november.aolmail.top +uniformpapa.wollomail.top +unifreshai.com +unigeol.com +unijnedotacje.info.pl +unikle.com +unimail.com +unimark.org +unimbalr.com +unioc.asia +union.powds.com +unioncitymirrortable.com +uniondaleschools.com +unionpkg.com +unip.edu.pl +uniqo.xyz +uniquebedroom-au.com +uniquebrand.pl +uniquesa.shop +uniqueseo.pl +unireaurzicenikaput.com +uniromax.com +uniros.ru +unisexjewelry.org +unisondesign.eu +unit48.online +unit7lahaina.com +unite.cloudns.asia +unite5.com +unitedbullionexchange.com +uniteditcare.com +unitsade.com +unityestates.com +unitymail.me +unitymail.pro +univcloud.tech +universalassetmanagement.com +universalfish.com +universall.me +universallightkeys.com +universalmailing.com +universalprojects.ml +universaltextures.com +universenews.site +universitas.codes +universiteomarbongo.ga +universityecotesbenin.com +universityincanada.info +universityla.edu +universityprof.com +univunid.shop +unjouruncercueil.com +unjunkmail.com +unkn0wn.ws +unknmail.com +unlikeyth.com +unlimit.com +unlimit.email +unlimit.ml +unlimitedfullmoviedownload.tk +unlimitedreviews.com +unlimpokecoins.org +unling.site +unlinkedgames.com +unmail.com +unmail.ru +unmetered.ltd +unmetered.nu +unmetered.se +unmuhbarru.ac.id +unnitv.com +unobitex.com +unomail.com +unomail9.com +unopol-bis.pl +unot.in +unpam.cf +unpastore.co +unplannedthought.com +unprocesseder.store +unprographies.xyz +unratito.com +unraveled.us +unrealsoft.tk +unseen.eu +unsk.emlhub.com +unsy3woc.aid.pl +untedtranzactions.com +unterderbruecke.de +untract.com +untricially.xyz +untuk.us +unuf.com +unurn.com +unve.com +uny.kr +unyx.pw +uo8fylspuwh9c.cf +uo8fylspuwh9c.ga +uo8fylspuwh9c.gq +uo8fylspuwh9c.ml +uo8fylspuwh9c.tk +uo93a1bg7.pl +uoadoausa.pl +uobat.com +uoft.edu.com +uogimail.com +uohj.yomail.info +uojjhyhih.cf +uojjhyhih.ga +uojjhyhih.gq +uojjhyhih.ml +uola.org +uomail.com +uonyc.org +uoo.laste.ml +uooos.com +uor.laste.ml +uorak.com +uoregon.com +uoregon.work +uot.yomail.info +uotluok.com +uotpifjeof0.com +uouweoq132.info +up.agp.edu.pl +up.cowsnbullz.com +up.marksypark.com +up.ploooop.com +up.poisedtoshrike.com +up69.com +upaea.com +upamail.com +upatient.com +upayawalmaina.biz +upc.infos.st +upcmaill.com +update1c.ru +updatehyper.com +updates9z.com +updatesafe.com +updun.freeml.net +upelmail.com +upf7qtcvyeev.cf +upf7qtcvyeev.ga +upf7qtcvyeev.gq +upf7qtcvyeev.tk +upgalumni.com +upgcsjy.com +uphomail.ga +uphomeideas.info +upimage.net +upimagine.com +upimail.com +upived.com +upived.online +uplandscc.com +upliftnow.com +uplinkdesign.com +uplipht.com +uploadimage.info +uploadnolimit.com +upmail.com +upmail.pro +upmedio.com +upmxl.anonbox.net +upnk.com +upoea.com +upol.fun +upozowac.info +upperbox.org +upperemails.com +upperhere.com +upperpit.org +upperviar.com +upphim.net +upppc.com +uppror.se +uproyals.com +uprsoft.ru +upry.com +upsdom.com +upshopt.com +upsidetelemanagementinc.biz +upsilon.lambda.ezbunko.top +upskirtscr.com +upsnab.net +upstate.dev +upsusa.com +uptimebee.com +uptimesystem.io +uptin.net +uptodate.tech +uptours.ir +uptownrp.id +uptuber.info +upumail.com +upurfiles.com +upvotes.me +upw.laste.ml +upwithme.com +upy.kr +uq.laste.ml +uqcgga04i1gfbqf.cf +uqcgga04i1gfbqf.ga +uqcgga04i1gfbqf.gq +uqcgga04i1gfbqf.ml +uqcgga04i1gfbqf.tk +uqdxyoij.auto.pl +uqemail.com +uqghq6tvq1p8c56.cf +uqghq6tvq1p8c56.ga +uqghq6tvq1p8c56.gq +uqghq6tvq1p8c56.ml +uqghq6tvq1p8c56.tk +uqin.com +uqkemail.eu +uqkemail.xyz +uqmail.com +uqopmail.com +uqxcmcjdvvvx32.cf +uqxcmcjdvvvx32.ga +uqxcmcjdvvvx32.gq +uqxcmcjdvvvx32.ml +uqxcmcjdvvvx32.tk +uqxo.us +ur.dropmail.me +uralmaxx.ru +uralplay.ru +uranomail.es +uraplive.com +urbanban.com +urbanblackpix.space +urbanbreaks.com +urbanchannel.live +urbanchickencoop.com +urbanforestryllc.com +urbanized.us +urbanlegendsvideo.com +urbanovalife.com +urbanovapro.com +urbanquarter.co +urbanspacepractice.com +urbanstudios.online +urbansvg.com +urbaza.com +urbsound.com +urcemxrmd.pl +urchatz.ga +urdubbc.us +uredemail.com +ureee.us +uremail.com +urfavtech.biz +urfey.com +urfunktion.se +urgamebox.com +urhbzvkkbl.ga +urhen.com +urid-answer.ru +urirmail.com +url-s.top +url.gen.in +urleur.com +urlme.online +urltc.com +urlwave.org +urnage.com +urnaus1.minemail.in +urodzinydlaadzieci.pl +uroetueptriwe.cz.cc +uroid.com +urologcenter.ru +uronva.com +urruvel.com +ursdursh.shop +urta.cz +uruarurqup5ri9s28ki.cf +uruarurqup5ri9s28ki.ga +uruarurqup5ri9s28ki.gq +uruarurqup5ri9s28ki.ml +uruarurqup5ri9s28ki.tk +urugvai-nedv.ru +urules.ru +urv.laste.ml +urvz.emlpro.com +urx7.com +urxv.com +us-dc.lol +us-p2.top +us-pay.icu +us-pt.top +us-top.net +us-uggboots.com +us-x.top +us.af +us.armymil.com +us.dlink.cf +us.dlink.gq +us.droidpic.com +us.dropmail.me +us.ploooop.com +us.to +us50.top +usa-cc.usa.cc +usa-gov.cf +usa-gov.ga +usa-gov.gq +usa-gov.ml +usa-gov.tk +usa-nedv.ru +usa-tooday.biz +usa-video.net +usa.cc +usa.edu.pl +usa.isgre.at +usa215.gq +usa623.gq +usaagents.com +usabottling.com +usabrains.us +usabs.org +usabuyou.com +usacentrall.com +usach.com +usachan.cf +usachan.gq +usachan.ml +usacityfacts.com +usacy.online +usadaconstructions.studio +usaf.dmtc.press +usagica.com +usagoodloan.com +usahandbagsonlinestorecoach.com +usajacketoutletsale.com +usako.be +usako.net +usalife365.xyz +usaliffebody.online +usalol.ru +usalvmalls.com +usamail.com +usamami.com +usanews.site +usaonline.biz +usapodcasd.com +usapurse.com +usareplicawatch.com +usatlanticexpress.com +usaweb.biz +usawisconsinnewyear.com +usayoman.com +usbc.be +usbcspot.com +usbdirect.ca +usbgadgetsusage.info +usbmicrophone.org.uk +usbuyes.com +usbvap.com +uscalfgu.biz +uscaves.com +usclargo.com +uscoachoutletstoreonlinezt.com +uscosplay.com +usdatapoint.com +usdfjhuerikqweqw.ga +usdtbeta.com +use.blatnet.com +use.lakemneadows.com +use.marksypark.com +use.poisedtoshrike.com +use.qwertylock.com +used-product.fr +used.favbat.com +usedate.online +usedcarsinpl.eu +usedcarsjacksonms.xyz +usedhospitalbeds.com +usedhospitalbeds.net +usefulab.com +usehealth.club +uselesss.org +uselesswebsites.net +usemail.live +usemail.xyz +usenergypro.com +usenetmail.tk +useplace.ru +user.bottesuggds.com +user.peoplesocialspace.com +userbot.p-e.kr +userbot.site +userdrivvers.ru +userloginstatus.email +usermania.online +userpdf.net +users.idbloc.co +users.totaldrama.net +userseo.ga +usettingh.com +usgeek.org +usgov.org +usgpeople.es +usgrowers.com +usgsa.com +usgtl.org +usharer.com +usharingk.com +ushijima1129.cf +ushijima1129.ga +ushijima1129.gq +ushijima1129.ml +ushijima1129.tk +usiaj.com +usintouch.com +usiportal.ru +usitv.ga +usizivuhe.ru +uslouisvuittondamier.com +uslugi-i-tovary.ru +uslugiseo.warszawa.pl +uslyn.com +usm.ovh +usmailbook.com +usmailstar.com +usmajor.us +usn.pw +usodellavoce.net +usoplay.com +uspeakw.com +uspmail.com +ussje.com +ussostunt.com +ussv.club +ustorp.com +ustudentli.com +ustvgo.click +usu.yomail.info +usualism.site +usuus.com +usvetcon.com +usvl.spymail.one +usweek.net +usyv.freeml.net +ut6jlkt9.pl +ut6rtiy1ajr.ga +ut6rtiy1ajr.gq +ut6rtiy1ajr.ml +ut6rtiy1ajr.tk +utahmail.com +utangsss.online +utaro.com +utc7xrlttynuhc.cf +utc7xrlttynuhc.ga +utc7xrlttynuhc.gq +utc7xrlttynuhc.ml +utc7xrlttynuhc.tk +utclubsxu.com +utesmail.com +utf.emltmp.com +utiket.us +utilifield.com +utilities-online.info +utilitservis.ru +utilsans.ru +utliz.com +utmail.com +utoi.cu.uk +utoo.email +utooemail.com +utool.com +utool.us +utor.com +utplexpotrabajos.com +utq.laste.ml +utqa.mimimail.me +utrd.emltmp.com +utrka.com +utsgeo.com +uttoymdkyokix6b3.cf +uttoymdkyokix6b3.ga +uttoymdkyokix6b3.gq +uttoymdkyokix6b3.ml +uttoymdkyokix6b3.tk +uttvgar633r.cf +uttvgar633r.ga +uttvgar633r.gq +uttvgar633r.ml +uttvgar633r.tk +utubemp3.net +utwevq886bwc.cf +utwevq886bwc.ga +utwevq886bwc.gq +utwevq886bwc.ml +utwevq886bwc.tk +utwoko.com +uu.gl +uu1.pl +uu2.ovh +uudimail.com +uue.edu.pl +uuf.me +uugmail.com +uuhd.mailpwr.com +uuhjknbbjv.com +uui5.online +uuii.in +uukx.info +uul.pl +uuluu.net +uuluu.org +uumail.com +uumjdnff.pl +uunifonykrakow.pl +uuo.dropmail.me +uurksjb7guo0.cf +uurksjb7guo0.ga +uurksjb7guo0.gq +uurksjb7guo0.ml +uurksjb7guo0.tk +uuroalaldoadkgk058.cf +uuups.ru +uv.yomail.info +uvamail.com +uvasx.com +uvdi.net +uvedifuciq.host +uvelichit-grud.ru +uvhdl.anonbox.net +uvk4y.anonbox.net +uvmail.com +uvomail.com +uvoofiwy.pl +uvt.emltmp.com +uvv.emlhub.com +uvvc.info +uvy.kr +uvyc.laste.ml +uvyuviyopi.cf +uvyuviyopi.ga +uvyuviyopi.gq +uvyuviyopi.ml +uvyuviyopi.tk +uw.freeml.net +uw.spymail.one +uw5t6ds54.com +uw88.info +uwalumni.co +uwamail.com +uwebmail.live +uwemail.com +uwesport.com +uwillsuc.pw +uwimail.com +uwjw.laste.ml +uwmail.com +uwml.com +uwomail.com +uwoog.emlhub.com +uwork4.us +uwt.emltmp.com +uwucheck.com +uwuefr.com +uwwmog.com +uwwr.mailpwr.com +uwxh.emltmp.com +ux.dob.jp +ux.dropmail.me +ux.freeml.net +ux.uk.to +uxak.com +uxcez1.site +uxdes54.com +uxin.tech +uxkh.com +uxlxpc2df3s.pl +uxo.emlpro.com +uxplurir.com +uxrv.dropmail.me +uxs14gvxcmzu.cf +uxs14gvxcmzu.ga +uxs14gvxcmzu.gq +uxs14gvxcmzu.ml +uxs14gvxcmzu.tk +uxsolar.com +uxt.emltmp.com +uxzicou.pl +uy.emlhub.com +uy.spymail.one +uyc.spymail.one +uydagdmzsc.cf +uydagdmzsc.ga +uydagdmzsc.gq +uydagdmzsc.ml +uydagdmzsc.tk +uyemail.com +uyhip.com +uyp5qbqidg.cf +uyp5qbqidg.ga +uyp5qbqidg.gq +uyp5qbqidg.ml +uyp5qbqidg.tk +uyqwuihd72.com +uyu.kr +uyx.emltmp.com +uyx3rqgaghtlqe.cf +uyx3rqgaghtlqe.ga +uyx3rqgaghtlqe.gq +uyx3rqgaghtlqe.ml +uyx3rqgaghtlqe.tk +uz.dropmail.me +uz6tgwk.com +uz8.net +uzbekbazaar.com +uzbekistan-nedv.ru +uzbo.emltmp.com +uzgrthjrfr4hdyy.gq +uzip.site +uzmail.com +uzmancevap.org +uzrip.com +uzsy.com +uzu6ji.info +uzug.com +uzxia.cf +uzxia.com +uzxia.ga +uzxia.gq +uzxia.ml +uzxia.tk +uzy8wdijuzm.pl +uzyz.spymail.one +v-a-v.de +v-bucks.money +v-dosuge.ru +v-kirove.ru +v-kv.com +v-mail.xyz +v-science.ru +v-soc.ru +v-v.tech +v.jsonp.ro +v.northibm.com +v.polosburberry.com +v00qy9qx4hfmbbqf.cf +v00qy9qx4hfmbbqf.ga +v00qy9qx4hfmbbqf.gq +v00qy9qx4hfmbbqf.ml +v00qy9qx4hfmbbqf.tk +v0domwwkbyzh1vkgz.cf +v0domwwkbyzh1vkgz.ga +v0domwwkbyzh1vkgz.gq +v0domwwkbyzh1vkgz.ml +v0domwwkbyzh1vkgz.tk +v1agraonline.com +v1zw.com +v21.me.uk +v21net.co.uk +v27hb4zrfc.cf +v27hb4zrfc.ga +v27hb4zrfc.gq +v27hb4zrfc.ml +v27hb4zrfc.tk +v2raysts.tk +v2ssr.com +v3bsb9rs4blktoj.cf +v3bsb9rs4blktoj.ga +v3bsb9rs4blktoj.gq +v3bsb9rs4blktoj.ml +v3bsb9rs4blktoj.tk +v3dev.com +v4gdm4ipndpsk.cf +v4gdm4ipndpsk.ga +v4gdm4ipndpsk.gq +v4gdm4ipndpsk.ml +v4gdm4ipndpsk.tk +v4uml.anonbox.net +v58tk1r6kp2ft01.cf +v58tk1r6kp2ft01.ga +v58tk1r6kp2ft01.gq +v58tk1r6kp2ft01.ml +v58tk1r6kp2ft01.tk +v6iexwlhb6n2hf.ga +v6iexwlhb6n2hf.gq +v6iexwlhb6n2hf.ml +v6iexwlhb6n2hf.tk +v7brxqo.pl +v7ecub.com +v7g2w7z76.pl +v7px49yk.pl +v8garagefloor.com +va.dropmail.me +va.emltmp.com +va5vsqerkpmsgibyk.cf +va5vsqerkpmsgibyk.ga +va5vsqerkpmsgibyk.gq +va5vsqerkpmsgibyk.ml +va5vsqerkpmsgibyk.tk +vaasfc4.tk +vaastu.com +vaati.org +vaav.emlpro.com +vaband.com +vac72.anonbox.net +vacancies-job.info +vacationrentalshawaii.info +vacavillerentals.com +vacuus.gq +vacwdlenws604.ml +vadalist.com +vadlag.xyz +vadn.com +vaffanculo.gq +vafleklassniki.ru +vafrem3456ails.com +vafyxh.com +vagina.com +vaginkos.com +vagmag.com +vagqgqj728292.email-temp.com +vagsuerokgxim1inh.cf +vagsuerokgxim1inh.ga +vagsuerokgxim1inh.gq +vagsuerokgxim1inh.ml +vagsuerokgxim1inh.tk +vagus.com +vahjc.anonbox.net +vaievem.ml +vaievem.tk +vaik.cf +vaik.ga +vaik.gq +vaik.ml +vaik.tk +vaimumi.gq +vajq8t6aiul.cf +vajq8t6aiul.ga +vajq8t6aiul.gq +vajq8t6aiul.ml +vajq8t6aiul.tk +valanides.com +valaqua.es +valdezmail.men +valemail.net +valenciabackpackers.com +valentin.best +valerieallenpowell.com +valhalladev.com +valiantgaming.net +valibri.com +valid.digital +valleyinnmistake.info +valleyofcbd.com +valorant.codes +valtresttranach.website +valtrexprime.com +valtrexrxonline.com +valuablegyan.com +value-establish-point-stomach.xyz +value-group.net +valuenu.com +valyousat.net +vamflowers.com +vamosconfe.com +vampresent.ru +van87.com +vanacken.xyz +vananh1.store +vanbil.tk +vancemail.men +vancouvermx.com +vandiemen.co.uk +vandorrenn.com +vaneekelen84.flatoledtvs.com +vaneroln.club +vaneroln.site +vaneroln.space +vaneroln.xyz +vaneshaprescilla.art +vanessa-castro.com +vanessabonafe.com +vanhilleary.com +vanhoangtn1.ga +vanhoangtn1.ooo +vanhoangtn1.us +vanilkin.ru +vankin.de +vanmail.com +vanna-house.ru +vanpoint.net +vansant.it +vansoftcorp.com +vansth.com +vantagepayment.com +vantaxi.pl +vanturtransfer.com +vanuatu-nedv.ru +vanvalu.linuxpl.info +vapaka.com +vapecentral.ru +varadeals.com +varaunited.in +varen8.com +varialomail.biz +varissacamelia.art +varrarestobar.com +varsidesk.com +varslily.com +varyitymilk.online +varyitymilk.xyz +vasculardoctor.com +vaseity.com +vasgyh.space +vasomly.com +vasqa.com +vastemptory.site +vasterbux.site +vasteron.com +vastgoed.video +vastkey.com +vasto.site +vastorestaurante.net +vastuas.com +vasujyzew.shop +vasvast.shop +vaticanakq.com +vatman16rus.ru +vatrel.com +vaudit.ru +vaugne142askum.store +vaultoffer.info +vaultpoint.us +vaultsophia.com +vaultsophiaonline.com +vaupk.org +vavadacazino.com +vavisa.ir +vavk.emltmp.com +vaw.dropmail.me +vawy.laste.ml +vaxdusa.com +vay.kr +vaycongso.vn +vaymail.com +vayme.com +vaynhanh2k7.com +vaytien.asia +vaz.dropmail.me +vazq.emlpro.com +vb.emlpro.com +vba.kr +vba.rzeszow.pl +vbalcer.com +vbbl.spymail.one +vbcn.online +vbdkr.online +vbdwreca.com +vbetstar.com +vbha0moqoig.ga +vbha0moqoig.ml +vbha0moqoig.tk +vbhoa.com +vbi.dropmail.me +vbilet.com +vbmail.top +vbqvacx.com +vbroqa.com +vbv.cards +vbv.dropmail.me +vbvl.com +vbweqva.com +vbz.spymail.one +vc.com +vc.emlhub.com +vc.spymail.one +vc.taluabushop.com +vcamp.co +vcbmail.ga +vcbox.pro +vcd.emltmp.com +vcghv0eyf3fr.cf +vcghv0eyf3fr.ga +vcghv0eyf3fr.gq +vcghv0eyf3fr.ml +vcghv0eyf3fr.tk +vcheaperp.com +vcois.com +vcpen.com +vcrnn.com +vcsid.com +vctel.com +vcticngsh5.ml +vcxvxcvsxdc.cloud +vd.emlpro.com +vd427.anonbox.net +vda.ro +vddaz.com +vdf.laste.ml +vdfg.es +vdg.freeml.net +vdg.laste.ml +vdig.com +vdims.com +vdjsh.anonbox.net +vdmmhozx5kxeh.cf +vdmmhozx5kxeh.ga +vdmmhozx5kxeh.gq +vdmmhozx5kxeh.ml +vdmmhozx5kxeh.tk +vdmz.mimimail.me +vdnd.laste.ml +vdnetmail.gdn +vdp8ehmf.edu.pl +vds.dropmail.me +vdy.itx.mybluehost.me +ve.laste.ml +ve8zum01pfgqvm.cf +ve8zum01pfgqvm.ga +ve8zum01pfgqvm.gq +ve8zum01pfgqvm.ml +ve8zum01pfgqvm.tk +ve9xvwsmhks8wxpqst.cf +ve9xvwsmhks8wxpqst.ga +ve9xvwsmhks8wxpqst.gq +ve9xvwsmhks8wxpqst.ml +ve9xvwsmhks8wxpqst.tk +veanlo.com +veat.ch +veb27.com +veb34.com +veb37.com +veb65.com +vecoss.cloud +vectorbrasil.app +vedastyle.shop +vedats.com +vedettevn.com +vedid.com +vedioo.com +vedmail.com +vedovelli.plasticvouchercards.com +vedula.com +vedv.de +veebee.cf +veebee.ga +veebee.gq +veebee.ml +veebee.tk +veetora.club +veetora.online +veetora.site +veetora.xyz +vef.emlhub.com +vefblogg.com +vefspchlzs2qblgoodf.ga +vefspchlzs2qblgoodf.ml +vefspchlzs2qblgoodf.tk +vegas-x.biz +vegasplus.ru +vegasworlds.com +vegetariansafitri.biz +vegsthetime.org.ua +vehicleowners.tk +vejohy.info +vek.emlhub.com +vekan.com +vektik.com +velatnurtoygar.shop +velavadar.com +veldahouse.co +veldmail.ga +velocity-digital.com +velosegway.ru +velourareview.net +velourclothes.com +velourclothes.net +velouteux.com +velovevexia.art +veloxmail.pw +velozmedia.com +veltexline.com +velvet-mag.lat +vemail.site +vemaybaygiare.com +vemaybaytetgiare.com +vemomail.win +vemrecik.com +vemser.com +venaten.com +vendasml.ml +vendedores-premium.ml +vendmaison.info +vendorbrands.com +veneerdmd.com +venesuela-nedv.ru +venexus.com +vengr-nedv.ru +venkena.online +vennimed.com +venompen.com +ventastx.net +venturacarpetcleaning.net +venturarefinery.com +venturayt.ml +ventureschedule.com +ventureuoso.com +venue-ars.com +venuears.com +venusandmarssextoys.com +venusfactorreviews.co +veo.kr +veo3promts.com +veoos.com +veoultra.online +vepa.info +vepklvbuy.com +ver0.cf +ver0.ga +ver0.gq +ver0.ml +ver0.tk +veralucia.top +verbee.ru +vercelli.cf +vercelli.ga +vercelli.gq +vercelli.ml +vercmail.com +verdejo.com +verfisigca.xyz +vergleche.us +vericon.net +verificationsinc.com +verifymail.cf +verifymail.ga +verifymail.gq +verifymail.ml +verifymail.win +verihotmail.ga +verisign.cf +verisign.ga +verisign.gq +verision.net +verisur.com +veriszon.net +veritybusinesscenter.pl +veriyaz.com +verizondw.com +verkaufsstelle24.de +verlass-mich-nicht.de +vermagerentips24.xyz +vermontlinkedin.com +vermutlich.net +verniprava.com +vernz.cf +vernz.ga +vernz.gq +vernz.ml +vernz.tk +veromodaonlineshop.com +veronicamira.info +veronil.com +verrabahu.xyz +versewears.com +versusbooks.com +verterygiep.com +vertexinbox.com +vertexium.net +verticedecabo.com +vertigosoftware.com +vertilog.com +vertiuoso.com +verumst.com +veruvercomail.com +veruzin.net +verybad.co.uk +verybig.com +veryday.ch +veryday.eu +veryday.info +verydrunk.co.uk +veryfast.biz +verymit.com +veryprice.co +veryrealemail.com +veryrealmail.com +veryrude.co.uk +veryveryeryhq.com +verywise.co.uk +ves.ink +vesa.pw +veska.pl +vestigoroda.info +vetra.cyou +vettery.cf +vettery.gq +vettery.ml +vettery.tk +veueh.com +veve.decisivetalk.com +vevs.de +vewh.laste.ml +vewku.com +vewt.emlhub.com +vex4.top +vexi.my +veyera.tk +vez.dropmail.me +vf.emlpro.com +vfarmemailmkp.click +vfastmails.com +vfazou.xyz +vfdd.com +vfgt.laste.ml +vfienvtua2dlahfi7.cf +vfienvtua2dlahfi7.ga +vfienvtua2dlahfi7.gq +vfienvtua2dlahfi7.ml +vfienvtua2dlahfi7.tk +vfiw.com +vfj9g3vcnj7kadtty.cf +vfj9g3vcnj7kadtty.ga +vfj9g3vcnj7kadtty.gq +vfj9g3vcnj7kadtty.ml +vfj9g3vcnj7kadtty.tk +vfpk.laste.ml +vfrts.online +vft.emlpro.com +vfujey.buzz +vfv.emlhub.com +vfym.emlpro.com +vg.emlhub.com +vgamers.win +vgatodviadapter.com +vgbs.com +vget.freeml.net +vgfjj85.pl +vggboutiqueenlignefr1.com +vgh.spymail.one +vgl.dropmail.me +vgsnake.com +vgsreqqr564.cf +vgsreqqr564.ga +vgsreqqr564.gq +vgsreqqr564.ml +vgsreqqr564.tk +vgv.dropmail.me +vgvgvgv.tk +vgxwhriet.pl +vh.laste.ml +vhan.tech +vhfderf.tech +vhglvi6o.com +vhiz.com +vhjvyvh.com +vhmt7.anonbox.net +vhntp15yadrtz0.cf +vhntp15yadrtz0.ga +vhntp15yadrtz0.gq +vhntp15yadrtz0.ml +vhntp15yadrtz0.tk +vhobbi.ru +vhodin.vip +vhoff.com +vhouse.site +vhoutdoor.com +vhtran.com +vhy.yomail.info +vhzvo.anonbox.net +via-paypal.com +via.tokyo.jp +via17.com +via902.com +viagaraget.com +viagenpwr.com +viagra-cheap.org +viagra-withoutadoctorprescription.com +viagra.com +viagracy.com +viagrageneric-usa.com +viagragenericmy.com +viagraigow.us +viagranowdirect.com +viagraonlineedshop.com +viagrasld.com +viagrasy.com +viagrawithoutadoctorprescription777.bid +viagraya.com +viaip.online +viajando.net +viameta.vn +viano.com +viantakte.ru +viaqara.com +viasldnfl.com +viatokyo.jp +viavuive.net +vibertees.com +vibhavram.com +vibi.cf +vibi4f1pc2xjk.cf +vibi4f1pc2xjk.ga +vibi4f1pc2xjk.gq +vibi4f1pc2xjk.ml +vibi4f1pc2xjk.tk +vibzi.net +vicard.net +vicceo.com +vicentejurado.es +vices.biz +vicimail.com +vicious.life +viciouskhalfia.io +vickaentb.cf +vickaentb.ga +vickaentb.gq +vickaentb.ml +vickaentb.tk +vickeyhouse.com +vickisvideoblog.com +vicsvg.xyz +victeams.net +victime.ninja +victimization206na.online +victor.romeo.wollomail.top +victor.whiskey.coayako.top +victorgold.xyz +victoria-alison.com +victoriaalison.com +victoriacapital.com +victoriantwins.com +victoriazakopane.pl +victorsierra.spithamail.top +victory-mag.ru +victoryforanimals.com +victorysvg.com +vidacriptomoneda.com +vidasole.com +vidchart.com +vide0c4ms.com +video-16porno.fr +video-der.ru +video-insanity.com +video-tube-club.ru +video.blatnet.com +video.cowsnbullz.com +video.ddnsking.com +video.lakemneadows.com +video.oldoutnewin.com +video35.com +videodailytung1.xyz +videodig.tk +videofilling.ru +videoforge.my.id +videogamefeed.info +videographers.global +videography.click +videohandle.com +videohd-clip.ru +videojuegos.icu +videokazdyideni.ru +videoonlinez.com +videophotos.ru +videoproc.com +videoregistrator-rus.space +videos-de-chasse.com +videos.blatnet.com +videos.emailies.com +videos.maildin.com +videos.marksypark.com +videos.mothere.com +videos.poisedtoshrike.com +videos.zonerig.com +videotoptop.com +videotorn.ca +videotubegames.ru +videour.com +videoxx-francais.fr +videoxxl.info +viditag.com +vidloop.biz.id +vidney.com +vidred.gq +vidsourse.com +vidssa.com +vidwobox.com +vieebee.cf +vieebee.ga +vieebee.gq +vieebee.tk +viemery.com +vienna.cf +viennas-finest.com +vienphunxamvidy.com +viergroup.ru +vietanhpaid.com +vietbacsolar.com +vietcap.sbs +vietcode.com +vietfashop.com +vietkevin.com +vietmail.xyz +vietnam-nedv.ru +vietnamnationalpark.org +vietnams.shop +vietuctour.com +vietvoters.org +viewcastmedia.com +viewcastmedia.net +viewcastmedia.org +viewleaders.com +viewmuse.com +viewspotfit.com +viewtechnology.info +vifl.spymail.one +vifo.laste.ml +vifs.laste.ml +vigi.com +vigil4synod.org +vigilantkeep.net +vigoneo.com +vigra-tadacip.info +vigrado.com +vigratadacip.info +vigrxpills.us +vihost.ml +vihost.tk +viicard.com +vijayanchor.com +vikingglass-kr.info +vikingsonly.com +vikinoko.com +vikopeiw21.com +viktminskningsnabbt.net +viktorkedrovskiy.ru +vikyol.com +vilk.com +villabhj.com +villadipuncak.com +villaferri.com +villagepxt.com +villapuncak.org +villarrealmail.men +villastream.xyz +vilnapresa.com +vilocom.vn +vimail24.com +vimailpro.net +vimeck.com +vimemail.com +vinaclicks.com +vinaemail.com +vinakoop.com +vinakop.com +vinamike.com +vinbazar.com +vincentralpark.com +vincenza1818.site +vincitop.com +vinerabazar.com +vinernet.com +vinetack.com +vingood.com +vinhclonefb.top +vinhenglish.site +vinhsu.info +vinincuk.com +vininggunworks.com +vino-veritas.ru +vino.ma +vinogradcentr.com +vinopub.com +vinsmoke.tech +vinsol.us +vintagefashion.de +vintagefashionblog.org +vintange.com +vinthao.com +vintomaper.com +vioe.emltmp.com +viola.gq +viole.cfd +violimakos.com +violympic.online +vionarosalina.art +viophos.store +viovideo.com +viovisa.ir +vip-dress.net +vip-intim-dosug.ru +vip-mail.info +vip-mail.ml +vip-mail.tk +vip-payday-loans.com +vip-replica1.eu +vip-sparkyv2.com +vip-timeclub.ru +vip-watches.ru +vip-watches1.eu +vip.aiot.eu.org +vip.cool +vip.dmtc.press +vip.elumail.com +vip.hstu.eu.org +vip.mailedu.de +vip.sohu.com +vip.sohu.net +vip.stu.office.gy +vip.tom.com +vip4e.com +vipaccfb.cc +vipcherry.com +vipchristianlouboutindiscount.com +vipcodes.info +vipdom-agoy.com +vipepe.com +viperace.com +vipfon.ru +vipg.com +vipgod.ru +viphomeljjljk658.info +viphone.eu.org +vipitv.com +vipivip.vip +viplvoutlet.com +vipmail.id +vipmail.in +vipmail.name +vipmail.net +vipmail.pw +vipnikeairmax.co.uk +vippoker88.info +vippoker88.org +vipracing.icu +vipraybanuk.co.uk +vipremium.xyz +vips.pics +vipsbet.com +vipservers.ga +vipsmail.us +vipsohu.net +vipwxb.com +vipxm.net +vir.waw.pl +viral-science.fun +viralchoose.com +viralclothes.com +viralhits.org +viralizalo.emlhub.com +viralmedianew.me +viralplays.com +viraltoken.co +viralvideosf.com +virarproperty.co.in +vireonidae.com +vireopartners.com +virgiglio.it +virgilian.com +virgilii.it +virgilio.ga +virgilio.gq +virgilio.ml +virgiliomail.cf +virgiliomail.ga +virgiliomail.gq +virgiliomail.ml +virgiliomail.tk +virgin-eg.com +virginiabasketballassociation.com +virginiaintel.com +virginiaturf.com +virginmedua.com +virginmmedia.com +virginsrus.xyz +virglio.com +virgnmedia.com +virgoans.co.uk +viro.live +viroleni.cu.cc +virtize.com +virtual-bank.live +virtual-email.com +virtual-generations.com +virtual-mail.net +virtual-trader.com +virtualdepot.store +virtualemail.info +virtualfelecia.net +virtualjunkie.com +virtualtags.co +virtuf.info +virtznakomstva.ru +virusfreeemail.com +virustoaster.com +viruts2001.top +visa-securepay.cf +visa-securepay.ga +visa-securepay.gq +visa-securepay.ml +visa-securepay.tk +visa.coms.hk +visa.dns-cloud.net +visa.dnsabr.com +visabo.ir +visaflex.ir +visagency.net +visagency.us +visakey.ir +visal007.tk +visal168.cf +visal168.ga +visal168.gq +visal168.ml +visal168.tk +visalaw.ru +visalus.com +visasky.ir +visaua.ru +visavisit.ir +visblackbox.com +viserys.com +visieonl.com +visignal.com +visionarysylvia.biz +visionaut.com +visionbig.com +visioncentury.com +visiondating.info +visionexpressed.com +visionpluseee.fun +visionwithoutglassesscam.org +visit-macedonia.eu +visitany.com +visiteastofengland.org +visithotel.ir +visitinbox.com +visitingcyprus.com +visitingob.com +visitnorwayusa.com +visitorratings.com +visitorweb.net +visitvlore.com +visitxhot.org +visitxx.com +vissering.flatoledtvs.com +vista-express.com +vista-tube.ru +vistaemail.com +vistarto.co.cc +vistomail.com +vistore.co +vistorea.com +visualcluster.com +visualfx.com +visualimpactreviews.com +visualkonzept.de +visualpro.online +visweden.com +vitahicks.com +vitalbeginning.com +vitaldevelopmentsolutions.com +vitalizehairgummy.com +vitalizehairmen.com +vitalizeskinforwomen.com +vitalpetprovisions.com +vitaltools.com +vitaltransporte.shop +vitalyzereview.com +vitamin-water.net +vitamins.com +vitaminsdiscounter.com +vitaspherelife.com +vitinhlonghai.com +vitmol.com +vittamariana.art +vittato.com +viv2.com +vivabem.xyz +vivaenaustralia.com +vivaldi.media +vivarack.com +vivatours.ir +vivech.site +viventel.com +viversemdrama.com +vivianhouse.co +vividbase.xyz +vivie.club +vivista.co.uk +vivo4d.online +vivoci.com +vivodigital.digital +vivopoker.pro +viwsala.com +vix.freeml.net +vixej.com +vixletdev.com +vixmalls.com +vixtricks.com +vizi-forum.com +vizi-soft.com +vizstar.net +vjav.info +vjav.site +vjhl.dropmail.me +vjl.emlpro.com +vjl.spymail.one +vjmail.com +vjoid.ru +vjoid.store +vjto.dropmail.me +vjty.mimimail.me +vjuum.com +vjyrt.anonbox.net +vk-app-online.ru +vk-appication.ru +vk-apps-online.ru +vk-com-application.ru +vk-fb-ok.ru +vk-goog.ru +vk-nejno-sladko.ru +vk-net-app.ru +vk-net-application.ru +vk-russkoe.ru +vk-tvoe.ru +vkbags.in +vkbb.ru +vkbb.store +vkbt.ru +vkbt.store +vkcbt.ru +vkcbt.store +vkcode.ru +vkdmtzzgsx.pl +vkdmtzzgsxa.pl +vkfu.ru +vkfu.store +vkhx.dropmail.me +vkilotakte.ru +vkokfb.ru +vkontakteemail.co.cc +vkoxtakte.ru +vkoztakte.ru +vkpornoprivate.ru +vkpr.store +vkr1.com +vkrr.ru +vkrr.store +vkusno-vse.ru +vkwd7.anonbox.net +vl2ivlyuzopeawoepx.cf +vl2ivlyuzopeawoepx.ga +vl2ivlyuzopeawoepx.gq +vl2ivlyuzopeawoepx.ml +vl2ivlyuzopeawoepx.tk +vlad-webdevelopment.ru +vlemi.com +vlh.emltmp.com +vlhh.laste.ml +vlinitial.com +vlipbttm9p37te.cf +vlipbttm9p37te.ga +vlipbttm9p37te.gq +vlipbttm9p37te.ml +vlipbttm9p37te.tk +vlote.ru +vloux.com +vloyd.com +vlrnt.com +vlrregulatory.com +vlsanxkw.com +vlsca8nrtwpcmp2fe.cf +vlsca8nrtwpcmp2fe.ga +vlsca8nrtwpcmp2fe.gq +vlsca8nrtwpcmp2fe.ml +vlsca8nrtwpcmp2fe.tk +vlstwoclbfqip.cf +vlstwoclbfqip.ga +vlstwoclbfqip.gq +vlstwoclbfqip.ml +vlstwoclbfqip.tk +vlvstech.com +vlwomhm.xyz +vmadhavan.com +vmail.cyou +vmail.me +vmail.site +vmail.tech +vmailcloud.com +vmailing.info +vmailpro.net +vmani.com +vmaryus.iogmail.com.urbanban.com +vmentorgk.com +vmgmails.com +vmh.emlpro.com +vmhdisfgxxqoejwhsu.cf +vmhdisfgxxqoejwhsu.ga +vmhdisfgxxqoejwhsu.gq +vmhdisfgxxqoejwhsu.ml +vmhdisfgxxqoejwhsu.tk +vmilliony.com +vmlfwgjgdw2mqlpc.cf +vmlfwgjgdw2mqlpc.ga +vmlfwgjgdw2mqlpc.ml +vmlfwgjgdw2mqlpc.tk +vmoscowmpp.com +vmpanda.com +vmq.emltmp.com +vmqyxcgfve.ga +vmsf.freeml.net +vmvgoing.com +vmvmv.shop +vmvzzmv.shop +vmx4b.anonbox.net +vn-one.com +vn.freeml.net +vn92wutocpclwugc.cf +vn92wutocpclwugc.ga +vn92wutocpclwugc.gq +vn92wutocpclwugc.ml +vn92wutocpclwugc.tk +vncctv.info +vncctv.net +vncctv.org +vncoders.net +vncwyesfy.pl +vndem.com +vndfgtte.com +vnedu.me +vnhojkhdkla.info +vnkadsgame.com +vnmon.com +vnpd.emlpro.com +vnrrdjhl.shop +vns.laste.ml +vnshare.info +vnsl.com +vnvmail.com +voaina.com +voanioo.com +vobau.net +vocalsintiempo.com +vocating.com +voda-v-tule.ru +vodafone-au.host +vodafoneyusurvivalzombie.com +vodeotron.ca +vodka.in +voemail.com +vofyfuqero.pro +vogons.ru +vogrxtwas.pl +vogue-center.com +vogue.sk +voiax.com +voice13.gq +voiceclasses.com +voicememe.com +void.maride.cc +voidbay.com +voirserie-streaming.com +voiture.cf +vokan.tk +vokofah.ru +volaj.com +volamtuan.pro +volatile.email +voldsgaard.dk +voledia.com +volestream.com +volestream21.com +volestream23.com +volestream24.com +volestream25.com +volgograd-nedv.ru +volkihar.net +volknakone.cf +volknakone.ga +volknakone.gq +volknakone.ml +volkswagen-ag.cf +volkswagen-ag.ga +volkswagen-ag.gq +volkswagen-ag.ml +volkswagen-ag.tk +volkswagenamenageoccasion.fr +volku.org +vollbio.de +volloeko.de +volsingume.ru +volt-telecom.com +voltaer.com +voltalin.site +volumetudo.website +volunteerindustries.com +volvo-ab.cf +volvo-ab.ga +volvo-ab.gq +volvo-ab.ml +volvo-ab.tk +volvo-s60.cf +volvo-s60.ga +volvo-s60.gq +volvo-s60.ml +volvo-s60.tk +volvo-v40.ml +volvo-v40.tk +volvo-xc.ml +volvo-xc.tk +volvogroup.ga +volvogroup.gq +volvogroup.ml +volvogroup.tk +volvopenta.tk +vomerk.com +vomoto.com +vonbe.tk +vonderheide.me +voneger.com +vooltal.shop +voomsec.com +vootin.com +voozadnetwork.com +vops.laste.ml +vorabite.site +vorga.org +vorgilio.it +vorply.com +vors.info +vorscorp.mooo.com +vorsicht-bissig.de +vorsicht-scharf.de +vortexautogroup.com +vortexinternationalco.com +voryxen.com +vosos.xyz +vospitanievovrema.ru +vosts.com +votavk.com +votedb.info +voteforhot.net +votenogeorgia.com +votenonov6.com +votenoonnov6.com +votesoregon2006.info +vothiquynhyen.info +votingportland07.info +votiputox.org +votl.emlpro.com +votnz.com +votooe.com +vouchergeek.com +voucherskuy.com +vouk.cf +vouk.gq +vouk.ml +vouk.tk +vovin.gdn +vovin.life +vovva.ru +voxelcore.com +voxinh.net +voyagebirmanie.net +voyancegratuite10min.com +voyeurseite.info +vozkqkftvo.ga +vozmivtop.ru +vp.com +vp.emlhub.com +vp.emltmp.com +vp.laste.ml +vp.ycare.de +vp113.lavaweb.in +vpanel.ru +vpc608a0.pl +vperdolil.com +vpfbattle.com +vpha.com +vphnfuu2sd85w.cf +vphnfuu2sd85w.ga +vphnfuu2sd85w.gq +vphnfuu2sd85w.ml +vphnfuu2sd85w.tk +vpi.emltmp.com +vpidcvzfhfgxou.cf +vpidcvzfhfgxou.ga +vpidcvzfhfgxou.gq +vpidcvzfhfgxou.ml +vpidcvzfhfgxou.tk +vpmsl.com +vpn.st +vpn33.top +vpns.best +vpnseat.com +vpnsellami.tk +vpnsmail.me +vpod.emltmp.com +vprice.co +vproducta.com +vps-hi.com +vps001.net +vps004.net +vps005.net +vps30.com +vps79.com +vps911.bet +vps911.net +vpsadminn.com +vpsbots.com +vpscloudvntoday.com +vpsfox.com +vpsjqgkkn.pl +vpslists.com +vpsmobilecloudkb.com +vpsorg.pro +vpsorg.top +vpsrec.com +vpstraffic.com +vpstrk.com +vpsuniverse.com +vpvk.emlpro.com +vpw.laste.ml +vpxs.emlpro.com +vq.mimimail.me +vq.yomail.info +vqc.emltmp.com +vqs.laste.ml +vqsprint.com +vqwcaxcs.com +vqwvasca.com +vqx.yomail.info +vqxgsibxne.ga +vr.emltmp.com +vr21.ml +vr5gpowerv.com +vra.spymail.one +vradportal.com +vraskrutke.biz +vrc.emltmp.com +vrc777.com +vreaa.com +vreagles.com +vreeland.agencja-csk.pl +vreemail.com +vregion.ru +vreizon.net +vremonte24-store.ru +vrender.ru +vrgwkwab2kj5.cf +vrgwkwab2kj5.ga +vrgwkwab2kj5.gq +vrgwkwab2kj5.ml +vrgwkwab2kj5.tk +vrify.org +vrloco.com +vrmtr.com +vrou.cf +vrou.ga +vrou.gq +vrou.ml +vrou.tk +vrpitch.com +vrs.freeml.net +vrsim.ir +vru.solutions +vryn.emlpro.com +vryy.com +vs-neustift.de +vs3ir4zvtgm.cf +vs3ir4zvtgm.ga +vs3ir4zvtgm.gq +vs3ir4zvtgm.ml +vs3ir4zvtgm.tk +vs904a6.com +vs9992.net +vsalmonusq.com +vscarymazegame.com +vscon.com +vsdw.laste.ml +vse-smi.ru +vsebrigadi.ru +vsekatal.ru +vselennaya.su +vsembiznes.ru +vsemsoft.ru +vseoforexe.ru +vseokmoz.org.ua +vseosade.ru +vsevnovosti.ru +vsf.emltmp.com +vsf.freeml.net +vsh.laste.ml +vshgl.com +vshisugg.pl +vsimcard.com +vsix.de +vsmailpro.com +vsmethodu.com +vsmini.com +vsooc.com +vspiderf.com +vss6.com +vssms.com +vsszone.com +vstartup4q.com +vstindo.net +vstopsb.com +vstoremisc.com +vt.emlhub.com +vt0bk.us +vt0uhhsb0kh.cf +vt0uhhsb0kh.ga +vt0uhhsb0kh.gq +vt0uhhsb0kh.ml +vt0uhhsb0kh.tk +vt8khiiu9xneq.cf +vt8khiiu9xneq.ga +vt8khiiu9xneq.gq +vt8khiiu9xneq.ml +vt8khiiu9xneq.tk +vt8zilugrvejbs.tk +vteachesb.com +vteensp.com +vtext.net +vthreadeda.com +vtoan.store +vtoasik.ru +vtoe.com +vtop10.site +vtopeklassniki.ru +vtormetresyrs.ru +vtoroum2.co.tv +vtqreplaced.com +vtrue.org +vtt188bet.ga +vtube.digital +vtuberlive.com +vtubernews.com +vtunesjge.com +vtwo.com +vtxmail.us +vu.yomail.info +vu38.com +vu981s5cexvp.cf +vu981s5cexvp.ga +vu981s5cexvp.gq +vu981s5cexvp.ml +vua.freeml.net +vuabai.info +vuatrochoi.nl +vuatrochoi.online +vubby.com +vuganda.com +vugitublo.com +vuhoangtelecom.com +vuihet.ga +vuiy.pw +vuket.org +vulca.sbs +vulcan-platinum24.com +vulcanpioneerjers.org +vulkan333.com +vumurt.org +vupwhich.com +vurq.dropmail.me +vuru.emlpro.com +vusd.net +vusra.com +vutdrenaf56aq9zj68.cf +vutdrenaf56aq9zj68.ga +vutdrenaf56aq9zj68.gq +vutdrenaf56aq9zj68.ml +vutdrenaf56aq9zj68.tk +vuthykh.ga +vuv9hhstrxnjkr.cf +vuv9hhstrxnjkr.ga +vuv9hhstrxnjkr.gq +vuv9hhstrxnjkr.ml +vuv9hhstrxnjkr.tk +vuvuive.xyz +vuy.emlhub.com +vuzimir.cf +vuzxwwptpy.ga +vvaa1.com +vvatxiy.com +vvb3sh5ie0kgujv3u7n.cf +vvb3sh5ie0kgujv3u7n.ga +vvb3sh5ie0kgujv3u7n.gq +vvb3sh5ie0kgujv3u7n.ml +vvb3sh5ie0kgujv3u7n.tk +vvcy.emlpro.com +vvesavedfa.com +vvfdcsvfe.com +vvfgsdfsf.com +vvgmail.com +vvlvmrutenfi1udh.ga +vvlvmrutenfi1udh.ml +vvlvmrutenfi1udh.tk +vvng8xzmv2.cf +vvng8xzmv2.ga +vvng8xzmv2.gq +vvng8xzmv2.ml +vvng8xzmv2.tk +vvoozzyl.site +vvvnagar.org +vvvpondo.info +vvvv.de +vvvvv.uni.me +vvx046q.com +vw-ag.tk +vw-audi.ml +vw-cc.cf +vw-cc.ga +vw-cc.gq +vw-cc.ml +vw-cc.tk +vw-eos.cf +vw-eos.ga +vw-eos.gq +vw-eos.ml +vw-eos.tk +vw-seat.ml +vw-skoda.ml +vw-webmail.de +vwazamarshwildlifereserve.com +vwhins.com +vwnc.dropmail.me +vwolf.site +vworangecounty.com +vwq.freeml.net +vwtedx7d7f.cf +vwtedx7d7f.ga +vwtedx7d7f.gq +vwtedx7d7f.ml +vwtedx7d7f.tk +vwuafdynfg.ga +vwwape.com +vwydus.icu +vwzc.spymail.one +vwzti.anonbox.net +vxc.edgac.com +vxcbe12x.com +vxdsth.xyz +vxeqzvrgg.pl +vxmail.top +vxmail.win +vxmail2.net +vxmlcmyde.pl +vxqt4uv19oiwo7p.cf +vxqt4uv19oiwo7p.ga +vxqt4uv19oiwo7p.gq +vxqt4uv19oiwo7p.ml +vxqt4uv19oiwo7p.tk +vxsolar.com +vxvcvcv.com +vy89.com +vyby.com +vydda.com +vydn.com +vyhade3z.gq +vykup-auto123.ru +vynk.spymail.one +vyrski4nwr5.cf +vyrski4nwr5.ga +vyrski4nwr5.gq +vyrski4nwr5.ml +vyrski4nwr5.tk +vysolar.com +vytevident.com +vywbltgr.xyz +vyxv.emlpro.com +vz.emlhub.com +vz.laste.ml +vzj.laste.ml +vzlom4ik.tk +vzpx.com +vzrxr.ru +vztc.com +vzur.com +vzwpix.com +vzwu.laste.ml +vzzdn.anonbox.net +w-asertun.ru +w-k.lol +w-shoponline.info +w.comeddingwhoesaleusa.com +w.gsasearchengineranker.xyz +w.polosburberry.com +w2203.com +w22fe21.com +w2858.com +w30gw.space +w3boat.com +w3boats.com +w3djp.anonbox.net +w3fax.com +w3fun.com +w3internet.co.uk +w3mailbox.com +w3windsor.com +w45k6k.pl +w4bii.anonbox.net +w4f.com +w4files.xyz +w4fkd.anonbox.net +w4i3em6r.com +w4ms.ga +w4ms.ml +w5gpurn002.cf +w5gpurn002.ga +w5gpurn002.gq +w5gpurn002.ml +w5gpurn002.tk +w5uxx.anonbox.net +w634634.ga +w63507.ga +w656n4564.cf +w656n4564.ga +w656n4564.gq +w656n4564.ml +w656n4564.tk +w6mail.com +w70ptee1vxi40folt.cf +w70ptee1vxi40folt.ga +w70ptee1vxi40folt.gq +w70ptee1vxi40folt.ml +w70ptee1vxi40folt.tk +w777info.ru +w7k.com +w7wdhuw9acdwy.cf +w7wdhuw9acdwy.ga +w7wdhuw9acdwy.gq +w7wdhuw9acdwy.ml +w7wdhuw9acdwy.tk +w7zmjk2g.bij.pl +w918bsq.com +w9f.de +w9y9640c.com +w9zen.com +wa.itsminelove.com +wa.yomail.info +wa010.com +waaluht.com +wab-facebook.tk +wab.com +wac.dropmail.me +wacamole.soynashi.tk +waccord.com +wacold.com +wacopyingy.com +wadiz.blog +wadz.laste.ml +wadzinski59.dynamailbox.com +waelectrician.com +waffed44.shop +wafflebrigadecaptain.net +wafrem3456ails.com +wagfsgsd.yomail.info +wagfused.com +waggadistrict.com +wagon58.website +wah.laste.ml +wahab.com +wahana888.org +wahch-movies.net +wahreliebe.li +wai.emltmp.com +waifu.club +waifu.horse +wailo.cloudns.asia +waitbeqa.com +waitingjwo.com +waitloek.fun +waitloek.online +waitloek.site +waitloek.store +waitweek.site +waitweek.store +waivey.com +wajahglow.com +wajikethanh96ger.gq +wak.emltmp.com +wakacje-e.pl +wakacjeznami.com.pl +wake-up-from-the-lies.com +wakedevils.com +wakescene.com +wakingupesther.com +wakka.com +walala.org +waldemar.ru +waleeed.site +walepy.site +waleskfb.com +walinee.com +walj.laste.ml +walking-holiday-in-spain.com +walkmail.net +walkmail.ru +walkritefootclinic.com +wall-street.uni.me +walletsshopjp.com +wallissonxmodz.tk +wallla.com +wallm.com +wallpaperspic.info +wallsmail.men +walmart-web.com +walmarteshop.com +walmartnet.com +walmartonlines.com +walmartpharm.com +walmartshops.com +walmartsshop.com +walmarttonlines.com +walnuttree.com +walrage.com +walter01.ru +walterandnancy.com +waltoncomp.com +wamerangkul.com +wameta.cloud +wameta.xyz +wampsetupserver.com +wanadoo.com +wanadoux.fr +wanamore.com +wanari.info +wanbeiz.com +wandahadissuara.com +wanderingstarstudio.com +wandsworthplumbers.com +wangdandan-w.cc +wangyangdahai.sbs +wankedy.com +wanko.be +wannie.cf +wanoptimization.info +wanskar.com +want.blatnet.com +want.oldoutnewin.com +want.poisedtoshrike.com +want2lov.us +wantisol.ml +wantplay.site +wants.dicksinhisan.us +wants.dicksinmyan.us +wantwp.com +wanu.homes +wanva.shop +waotao.com +wap-facebook.ml +wapl.ga +wappay.xyz +wappol.com +wapsportsmedicine.net +war-im-urlaub.de +waratishou.us +warau-kadoni.com +warcraft-leveling-guide.info +wardarabando.com +wardauto.com +wardwinnie.com +warepool.com +warezbborg.ru +wargabaru.my.id +wargot.ru +warjungle.com +warlus.asso.st +warman.global +warmence.com +warmion.com +warmnessgirl.com +warmnessgirl.net +warmthday.com +warmthday.net +warmynfh.ru +warna222.com +warnednl2.com +warnetdalnet.com +waroengdo.store +waroengin.com +waroengku.cc +waroengku.cfd +waroengku.digital +waroengku.store +waroengkuy.com +waroengmail.app +waroengpremium.com +waroengpt.com +waroengto.my.id +warpmail.top +warptwo.com +warren.com +warrenforpresident.com +warriorbody.net +warriorpls.com +warteg.space +wartrolreviewssite.info +waruh.com +warungku.me +warunkpedia.com +warunkto.com +waschservice.de +wasd.dropmail.me +wasd.emlhub.com +wasd.emlpro.com +wasd.emltmp.com +wasd.freeml.net +wasd.spymail.one +wasd.yomail.info +wasdfgh.cf +wasdfgh.ga +wasdfgh.gq +wasdfgh.ml +wasdfgh.tk +wasenm33.xyz +washingmachines2012.info +washingtongarricklawyers.com +washingtonttv.com +washoeschool.net +washoeschool.org +wasistforex.net +waskitacorp.cf +waskitacorp.ga +waskitacorp.gq +waskitacorp.ml +waskitacorp.tk +wassermann.freshbreadcrumbs.com +watacukrowaa.pl +wataoke.com +watashiyuo.cf +watashiyuo.ga +watashiyuo.gq +watashiyuo.ml +watashiyuo.tk +watch-harry-potter.com +watch-tv-series.tk +watch.bthow.com +watchclickbuyagency.com +watchclubonline.com +watchcontrabandonline.net +watches-mallhq.com +watchesbuys.com +watcheset.com +watchesforsale.org.uk +watcheshq.net +watchesju.com +watchesnow.info +watchestiny.com +watchever.biz +watchfree.org +watchfull.net +watchheaven.us +watchironman3onlinefreefullmovie.com +watchmanonaledgeonline.net +watchmoviesonline-4-free.com +watchmoviesonlinefree0.com +watchmtv.co +watchnowfree.com +watchnsfw.com +watchreplica.org +watchsdt.tk +watchthedevilinsideonline.net +watchtruebloodseason5episode3online.com +watchunderworldawakeningonline.net +watchwebcamthesex.com +watchzhou.cf +waterburytelephonefcu.com +waterisgone.com +waterlifetmx.com.mx +waterlifetmx2.com.mx +waterloorealestateagents.com +waterso.com +watersportsmegastore.com +watertec1.com +watertinacos.com +waterus2a.com +waterusa.com +wathie.site +watkacukrowa.pl +watkinsmail.bid +watpho.online +watrf.com +wattpad.pl +wau.emltmp.com +wavewon.com +wavleg.com +wawa990.pl +wawadaw.fun +wawan.org +wawi.es +wawinfauzani.com +wawstudent.pl +wawue.com +wawuo.com +way.blatnet.com +way.bthow.com +way.oldoutnewin.com +way.poisedtoshrike.com +wayaengopi.buzz +waylot.us +wayroom.us +ways-to-protect.com +ways2getback.info +ways2lays.info +waysfails.com +wayshop.xyz +waytogobitch.com +waywuygan.xyz +wazabi.club +wazoo.com +wazow.com +waztempe.com +wb-master.ru +wb.emlpro.com +wb.emltmp.com +wb24.de +wbb3.de +wbdev.tech +wbfre2956mails.com +wbkd.freeml.net +wbml.net +wbmmc.com +wbnckidmxh.pl +wbqhurlzxuq.edu.pl +wbrfx.anonbox.net +wbryfeb.mil.pl +wbsv.laste.ml +wc.emlhub.com +wc.pisskegel.de +wca.cn.com +wcblueprints.com +wcct.emlpro.com +wcddvezl974tnfpa7.cf +wcddvezl974tnfpa7.ga +wcddvezl974tnfpa7.gq +wcddvezl974tnfpa7.ml +wcddvezl974tnfpa7.tk +wce.emlpro.com +wchatz.ga +wclr.com +wcpuid.com +wculturey.com +wczasy.com +wczasy.nad.morzem.pl +wczasy.nom.pl +wczh.spymail.one +wd.emlpro.com +wd0payo12t8o1dqp.cf +wd0payo12t8o1dqp.ga +wd0payo12t8o1dqp.gq +wd0payo12t8o1dqp.ml +wd0payo12t8o1dqp.tk +wd5vxqb27.pl +wdd.laste.ml +wdebatel.com +wdge.de +wditu.com +wdkcksd.space +wdmail.ml +wdmail.top +wdmedia.ga +wdmix.com +wdsfbghfg77hj.gq +wdxgc.com +we-b-tv.com +we-dwoje.com.pl +we-ede.top +we-love-life.com +we.lovebitco.in +we.martinandgang.com +we.oldoutnewin.com +we.poisedtoshrike.com +we.qq.my +we9pnv.us +weaksick.com +weakwalk.online +weakwalk.site +weakwalk.store +weakwalk.xyz +wealthbargains.com +wealthymoney.pw +weammo.xyz +wear.favbat.com +weareallcavemen.com +weareconsciousness.com +weareflax.info +weareunity.online +wearewynwood.com +wearinguniforms.info +wearkeymail.site +wearsn.com +weatheford.com +weave.email +web-contact.info +web-design-malta.com +web-design-ni.co.uk +web-email.eu +web-emailbox.eu +web-experts.net +web-ideal.fr +web-inc.net +web-mail.pp.ua +web-mail1.com +web-maill.com +web-mailz.com +web-model.info +web-sift.com +web-site-sale.ru +web-sites-sale.ru +web.discard-email.cf +web.run.place +web20.club +web20r.com +web2mailco.com +web2web.bid +web2web.stream +web2web.top +web3411.de +web3437.de +web3453.de +web3561.de +webail.co.za +webandgraphicdesignbyphil.com +webanx.app +webarnak.fr.eu.org +webaward.online +webaz.xyz +webbamail.com +webbear.ru +webbox.biz +webbusinessanalysts.com +webcamjobslive.com +webcamness.com +webcamnudefree.com +webcamsex.de +webcamsexlivefree.com +webcamshowfree.com +webcamsroom.com +webcamvideoxxx.xyz +webcare.tips +webcity.ca +webclub.infos.st +webcontact-france.eu +webcool.club +webdesign-guide.info +webdesign-romania.net +webdesignspecialist.com.au +webdesigrsbio.gr +webdespro.ru +webdev-pro.ru +webdevex.com +webeditonline.info +webeidea.com +webemail.me +webemailtop.com +webet24.live +webetcoins.com +webfreeai.com +webfu.nl +webgamesclub.com +webgarden.at +webgarden.com +webgarden.ro +webgmail.info +webgoda.com +webhane.com +webhocseo.com +webhomes.net +webhook.site +webhosting-advice.org +webhostingdomain.ga +webhostingjoin.com +webhostingwatch.ru +webhostingwebsite.info +webide.ga +webinarmoa.com +webkatalog1.org +webkiff.info +weblivein.com +weblovein.ru +webm1.xyz +webm4il.in +webm4il.info +webmail.bcm.edu.pl +webmail.flu.cc +webmail.igg.biz +webmail.kolmpuu.net +webmail123.hensailor.hensailor.xyz +webmail2.site +webmail24.to +webmail24.top +webmail360.eu +webmail4.club +webmail4u.eu +webmailaccount.site +webmaild.net +webmaileu.bishop-knot.xyz +webmailforall.info +webmailn7program.tld.cc +webmails.top +webmails24.com +webmailshark.com +webmeetme.com +webmhouse.com +webofip.com +weboka.info +webomoil.com +webonofos.com +webonoid.com +weboors.com +webpersonalshopper.biz +webpiko.top +webpix.ch +webpoets.info +webpro24.ru +webpromailbox.com +webprospekt24.ru +webproton.site +webscash.com +webserverwst.com +webshop.website +websightmedia.com +websinek.com +websitebod.com +websitebody.com +websitebooty.com +websiteconcierge.net +websitedesignjb.com +websitehostingservices.info +websiterank.com +websmail.us +websock.eu +webstarter.xyz +websterinc.com +webstore.fr.nf +websupport.systems +webtasarimi.com +webtechmarketing.we.bs +webtempmail.online +webtimereport.com +webting-net.com +webtoon.club +webtraffico.top +webtrafficstation.net +webtrip.ch +webuser.in +webuuu.shop +webuyahouse.com +webweb.marver-coats.marver-coats.xyz +webwolf.co.za +webxio.pro +webxios.pro +webyzonerz.com +wecareforyou.com +wecell.net +wecemail.com +wecmail.cz.cc +wecp.ru +wecp.store +wedbo.net +weddingcrawler.com +weddingdating.info +weddingdressaccessory.com +weddingdressparty.net +weddinginsurancereviews.info +weddingsontheocean.com +weddingvenuexs.com +wedgesail.com +wedmail.minemail.in +wednesburydirect.info +wedooos.cf +wedooos.ga +wedooos.gq +wedooos.ml +wedoseoforlawyers.com +wedus.xyz +wee.my +weebd.de +weebers.xyz +weebsterboi.com +weeco.me +weedseedsforsale.com +weekendemail.com +weekfly.com +weekwater.us +weepm.com +weer.de +wef.gr +wefeelgood.tk +wefjo.grn.cc +wefky.com +wefr.online +wefwef.com +weg-beschlussbuch.de +weg-werf-email.de +wegas.ru +wegwerf-email-addressen.de +wegwerf-email-adressen.de +wegwerf-email.at +wegwerf-email.de +wegwerf-email.net +wegwerf-emails.de +wegwerfadresse.de +wegwerfemail.com +wegwerfemail.de +wegwerfemail.info +wegwerfemail.net +wegwerfemail.org +wegwerfemailadresse.com +wegwerfmail.de +wegwerfmail.info +wegwerfmail.net +wegwerfmail.org +wegwerpmailadres.nl +wegwrfmail.de +wegwrfmail.net +wegwrfmail.org +weibomail.net +weibsvolk.de +weibsvolk.org +weieaidz.xyz +weigh.bthow.com +weightbalance.ru +weightloss.info +weightlossandhealth.info +weightlossidealiss.com +weightlosspak.space +weightlossshort.info +weightlossworld.net +weightoffforgood.com +weightrating.com +weihnachts-gruesse.info +weihnachtsgruse.eu +weihnachtswunsche.eu +weijibaike.site +weil4feet.com +weinenvorglueck.de +weinjobs.org +weinzed.com +weinzed.org +weipai.ws +weipl.com +weirby.com +weird3.eu +weirdcups.com +weirdfella.com +weirenqs.space +weishu8.com +weizixu.com +wejr.in +wekawa.com +wel.spymail.one +weldir.cf +welikecookies.com +weliverz.com +well.brainhard.net +well.ploooop.com +well.poisedtoshrike.com +wellc.site +wellcelebritydress.com +wellcelebritydress.net +wellensstarts.com +welleveningdress.com +welleveningdress.net +welleveningdresses.com +welleveningdresses.net +wellhungup.dynu.net +wellick.ru +welljimer.club +welljimer.online +welljimer.space +welljimer.store +welljimer.xyz +wellnessdom.ru +wellnessintexas.info +wellorg.com +wellpromdresses.com +wellpromdresses.net +wellsfargocomcardholders.com +wellstarenergy.com +wellsummary.site +welltryn00b.online +welltryn00b.ru +wellvalleyedu.cf +weloveus.website +welprems.xyz +welshpoultrycentre.co.uk +wem.com +wemail.ru +wemel.site +wemel.top +wemzi.com +wen3xt.xyz +wencai9.com +weniche.com +wenkuu.com +wensenwerk.nl +wentcity.com +weontheworks.bid +wep.email +weprof.it +wer.ez.lv +wer34276869j.ga +wer34276869j.gq +wer34276869j.ml +wer34276869j.tk +werdiwerp.gq +wereviewbiz.com +werj.in +werkbike.com +wermink.com +wernerio.com +werparacinasx.com +werrmai.com +wersumer.us +wertaret.com +wertxdn253eg.cf +wertxdn253eg.ga +wertxdn253eg.gq +wertxdn253eg.ml +wertxdn253eg.tk +wertyu.com +werw436526.cf +werw436526.ga +werw436526.gq +werw436526.ml +werw436526.tk +werwe.in +wes-x.net +wesamnusaer.tech +wesamyezan.cloud +wesandrianto241.ml +wesatikah407.cf +wesatikah407.ml +wesayt.tk +wesazalia927.ga +wescabiescream.cu.cc +wesd.icu +weselne.livenet.pl +weselvina200.tk +weseni427.tk +wesfajria37.tk +wesfajriah489.ml +wesgaluh852.ga +weshasni356.ml +weshutahaean910.ga +wesjuliyanto744.ga +weskusumawardhani993.ga +wesleytatibana.com +wesmailer.com +wesmailer.comdmaildd.com +wesmubasyiroh167.ml +wesmuharia897.ga +wesnadya714.tk +wesnurullah701.tk +wesruslian738.cf +wessastra497.tk +west.shop +westayyoung.com +westblog.me +westcaltractor.net +westjordanshoes.us +westmailer.com +westoverhillsclinic.com +westtelco.com +westvalleycitynewsdaily.com +wesw881.ml +weswibowo593.cf +weswidihastuti191.ml +wesyuliyansih469.tk +weszwestyningrum767.cf +wet-fish.com +wet-lip.com +wetheot.com +wetrainbayarea.com +wetrainbayarea.org +wetters.ml +wetvibes.com +wetzelhealth.org +weuthevwemuo.net +wewantmorenow.com +wewintheylose.com +wewtmail.com +wexcc.com +weyuoi.com +wezuwio.com +wf7722.com +wfacommunity.com +wfaqs.com +wfcz.freeml.net +wfes.site +wfgdfhj.tk +wfjdkng3fg.com +wflt.yomail.info +wfmarion.com +wfn.spymail.one +wfought0o.com +wfrijgt4ke.cf +wfrijgt4ke.ga +wfrijgt4ke.gq +wfrijgt4ke.ml +wfrijgt4ke.tk +wfuj.com +wfxegkfrmfvyvzcwjb.cf +wfxegkfrmfvyvzcwjb.ga +wfxegkfrmfvyvzcwjb.gq +wfxegkfrmfvyvzcwjb.ml +wfxegkfrmfvyvzcwjb.tk +wfyhsfddth.shop +wfz.flymail.tk +wg.emltmp.com +wg.laste.ml +wg0.com +wgby.com +wgetcu0qg9kxmr9yi.ga +wgetcu0qg9kxmr9yi.ml +wgetcu0qg9kxmr9yi.tk +wgiguestsl.com +wgltei.com +wgqfm.anonbox.net +wgraj.com +wgu.freeml.net +wgw365.com +wgz.cz +wgztc71ae.pl +wh4f.org +whaaaaaaaaaat.com +whaaso.tk +whackyourboss.info +whadadeal.com +whale-mail.com +whale-watching.biz +whanuvur.com +what.cowsnbullz.com +what.heartmantwo.com +what.oldoutnewin.com +whatagarbage.com +whataniceday.site +whatiaas.com +whatifanalytics.com +whatisakilowatt.com +whatmailer.com +whatnametogivesite.com +whatowhatboyx.com +whatpaas.com +whatsaas.com +whatsminerelite.cloud +whatsnewjob.com +whatthefish.info +whatwhat.com +whcosts.com +wheatbright.com +wheatbright.net +wheatsunny.com +wheatsunny.net +whecode.com +wheeldown.com +wheelemail.com +wheelie-machine.pl +wheelingfoods.net +wheets.com +when.ploooop.com +whenstert.tk +whentake.org.ua +wherecanibuythe.biz +wherenever.tk +wheretoget-backlinks.com +whgdfkdfkdx.com +whgi.laste.ml +whhsbdp.com +which-code.com +which.cowsnbullz.com +which.poisedtoshrike.com +whichbis.site +whiffles.org +whilarge.site +while.ruimz.com +whilezo.com +whipjoy.com +whiplashh.com +whiskey.xray.ezbunko.top +whiskeyalpha.webmailious.top +whiskeygolf.wollomail.top +whiskeyiota.webmailious.top +whiskonzin.edu +whiskygame.com +whisperfocus.com +whispersum.com +whistleapp.com +whitakers.xyz +white-legion.ru +white-teeth-premium.info +whitealligator.info +whitebot.ru +whitehall-dress.ru +whitehousecalculator.com +whitekazino.com +whitekidneybeanreview.com +whitelinehat.com +whitemail.ga +whitepeoplearesoweird.com +whiteprofile.tk +whitesearch.net +whiteseoromania.tk +whiteshagrug.net +whiteshirtlady.com +whiteshirtlady.net +whitetrait.xyz +whitworthknifecompany.com +whj1wwre4ctaj.ml +whj1wwre4ctaj.tk +whkart.com +whkw6j.com +whlevb.com +whmailtop.com +who-called-de.com +who.cowsnbullz.com +who.poisedtoshrike.com +who.spymail.one +who95.com +whoelsewantstoliveinmyhouse.com +whohq.us +whoisox.com +whoisya.com +whole.bthow.com +wholecustomdesign.com +wholelifetermlifeinsurance.com +wholesale-belts.com +wholesale-cheapjewelrys.com +wholesalebag.info +wholesalecheap-hats.com +wholesalecheapfootballjerseys.com +wholesalediscountshirts.info +wholesalediscountsshoes.info +wholesaleelec.tk +wholesalejordans.xyz +wholesalelove.org +wholesaleshtcphones.info +wholewidget.com +wholey.browndecorationlights.com +wholowpie.com +whoox.com +whopy.com +whorci.site +whose-is-this-phone-number.com +whowlft.com +whstores.com +whwow.com +why.cowsnbullz.com +why.edu.pl +why.warboardplace.com +whydoihaveacne.com +whydrinktea.info +whyflkj.com +whyflyless.com +whyiquit.com +whyitthis.org.ua +whymustyarz.com +whyred.me +whyrun.online +whyspam.me +wi.freeml.net +wiadomosc.pisz.pl +wibb.ru +wibblesmith.com +wibu.online +wibuwibu.studio +wichitahometeam.net +wicked-game.cf +wicked-game.ga +wicked-game.gq +wicked-game.ml +wicked-game.tk +wicked.cricket +wickedrelaxedmindbodyandsoul.com +wickerbydesign.com +wickmail.net +widaryanto.info +widatv.site +wideline-studio.com +wides.co +wideserv.com +wideturtle.com +widget.gg +widikasidmore.art +wie.dropmail.me +wiecejtegoniemieli.eu +wiedrinks.com +wielkanocne-dekoracje.pl +wiemei.com +wieo.emltmp.com +wierie.tk +wiestel.online +wifame.com +wifi-map.net +wificon.eu +wifimaple.com +wifimaples.com +wifioak.com +wifwise.com +wig-catering.com.pl +wiggear.com +wigolive.com +wih.yomail.info +wii999.com +wiibundledeals.us +wiicheat.com +wiipointsgen.com +wikfee.com +wiki24.ga +wiki24.ml +wikiacne.com +wikibacklinks.store +wikidocuslava.ru +wikifortunes.com +wikilibhub.ru +wikinoir.com +wikipedi.biz +wikipedia-inc.cf +wikipedia-inc.ga +wikipedia-inc.gq +wikipedia-inc.ml +wikipedia-inc.tk +wikipedia-llc.cf +wikipedia-llc.ga +wikipedia-llc.gq +wikipedia-llc.ml +wikipedia-llc.tk +wikipedia.org.mx +wikiprofileinc.com +wikisite.co +wikiswearia.info +wikizs.com +wil.kr +wilburn.prometheusx.pl +wild.wiki +wildcardonlinepoker.com +wildhorseranch.com +wildsneaker.ru +wildstar-gold.co.uk +wildstar-gold.us +wildthingsbap.org.uk +wildwoodworkshop.com +wilemail.com +wilify.com +will-hier-weg.de +will.lakemneadows.com +will.ploooop.com +will.poisedtoshrike.com +willakarmazym.pl +willette.com +willhackforfood.biz +williamcastillo.me +williamcxnjer.sbs +willleather.com +willowhavenhome.com +willselfdestruct.com +wilma.com +wilon9937245.xyz +wilsonbuilddirect.jp +wilsonexpress.org +wilsto.com +wiltors.com +wilver.club +wilver.store +wimsg.com +wimw.spymail.one +win-777.net +win.emlpro.com +win11bet.org +winalways.ru +winanipadtips.info +winart.vn +wincep.com +windewa.com +windlady.com +windlady.net +windmine.tk +window-55.net +windowoffice7.com +windows.sos.pl +windows8hosting.info +windows8service.info +windowsicon.info +windowslve.com +windowsmanageddedicatedserver.com +windsream.net +windstrem.net +windt.org +windupmedia.com +windycityui.com +windykacjawpraktyce.pl +winebagohire.org +winemail.net +winemails.com +winemakerscorner.com +winemaven.in +winemaven.info +winevacuumpump.info +winfire.com +winfreegifts.xyz +wingslacrosse.com +winie.club +wink-versicherung.de +winkconstruction.com +winmail.org +winmail.vip +winmails.net +winmargroup.com +winner1.tk +winner2.tk +winner3.tk +winner5.tk +winning365.com +winningeleven365.com +winnweb.net +winnweb.win +winocs.com +wins-await.net +wins.com.br +winsdtream.net +winsomedress.com +winsomedress.net +winsowslive.com +winspins.bid +winspins.party +wintds.org +winter-solstice.info +winter-solstice2011.info +winterabootsboutique.info +winterafootwearonline.info +wintersarea.xyz +wintersbootsonline.info +wintersgf.store +wintersupplement.com +winterx.site +wintoptea.tk +winviag.com +winwinus.xyz +winxmail.com +wip.com +wir-haben-nachwuchs.de +wir-sind-cool.org +wir-sind.com +wirasempana.com +wirawan.cf +wirawanakhmadi.cf +wire-shelving.info +wireconnected.com +wirefreeemail.com +wirelay.com +wireless-alarm-system.info +wirelesspreviews.com +wiremail.host +wiremails.info +wireps.com +wirese.com +wirlwide.com +wiroute.com +wirp.xyz +wirsindcool.de +wirwox.com +wisank.store +wisans.ru +wisatajogja.xyz +wisbuy.shop +wisconsincomedy.com +wisdomsurvival.com +wiseideas.com +wisepromo.com +wiseval.com +wisfkzmitgxim.cf +wisfkzmitgxim.ga +wisfkzmitgxim.gq +wisfkzmitgxim.ml +wisfkzmitgxim.tk +wishan.net +wishboneengineering.se +wishlack.com +wishy.fr +wiskdjfumm.com +wisnick.com +wisofit.com +wit.coffee +wit123.com +witaz.com +witel.com +with-u.us +with.blatnet.com +with.lakemneadows.com +with.oldoutnewin.com +with.ploooop.com +withmusing.site +withould.site +wittenbergpartnership.com +wivstore.com +wix.creou.dev +wix.ptcu.dev +wixcmm.com +wiz2.site +wizardofwalls.com +wizaz.com +wizisay.online +wizisay.site +wizisay.store +wizisay.xyz +wizseoservicesaustralia.com +wizstep.club +wj7qzenox9.cf +wj7qzenox9.ga +wj7qzenox9.gq +wj7qzenox9.ml +wj7qzenox9.tk +wjhndxn.xyz +wjln.laste.ml +wjqudfe3d.com +wjqufmsdx.com +wjsl.freeml.net +wkac.laste.ml +wkc.spymail.one +wkernl.com +wkfgkftndlek.com +wkfndig9w.com +wkfwlsorh.com +wkhaiii.cf +wkhaiii.ga +wkhaiii.gq +wkhaiii.ml +wkhaiii.tk +wkime.pl +wkjrj.com +wklik.com +wkschemesx.com +wksphoto.com +wktoyotaf.com +wkuteraeus.xyz +wkyf.dropmail.me +wkzc.spymail.one +wla9c4em.com +wld.yomail.info +wle.emltmp.com +wlejq.anonbox.net +wlessonijk.com +wlist.ro +wljia.com +wlk.com +wlla.emlpro.com +wlmycn.com +wloo.emlpro.com +wlpyt.com +wlrzapp.com +wlsom.com +wlun.freeml.net +wlv.emltmp.com +wlw.emlhub.com +wly.emltmp.com +wma.yomail.info +wmail.cf +wmail.club +wmail.tk +wmail1.com +wmail2.com +wmail2.net +wmaill.site +wmbadszand2varyb7.cf +wmbadszand2varyb7.ga +wmbadszand2varyb7.gq +wmbadszand2varyb7.ml +wmbadszand2varyb7.tk +wmbfw.anonbox.net +wmg.dropmail.me +wmha.emltmp.com +wmik.de +wmila.com +wml.emlhub.com +wmlorgana.com +wmodz.gq +wmqrhabits.com +wmrefer.ru +wmrmail.com +wmtcorp.com +wmtw.emlpro.com +wmu.freeml.net +wmwha0sgkg4.ga +wmwha0sgkg4.ml +wmwha0sgkg4.tk +wmz.laste.ml +wmzgjewtfudm.cf +wmzgjewtfudm.ga +wmzgjewtfudm.gq +wmzgjewtfudm.ml +wmzgjewtfudm.tk +wn.emltmp.com +wn3wq9irtag62.cf +wn3wq9irtag62.ga +wn3wq9irtag62.gq +wn3wq9irtag62.ml +wn3wq9irtag62.tk +wn5fp.anonbox.net +wn7uz.anonbox.net +wn8c38i.com +wnacg.xyz +wnbaldwy.com +wncnw.com +wngfo.anonbox.net +wnk57.anonbox.net +wnmail.top +wnpop.com +wnsocjnhz.pl +wntk.mailpwr.com +wnuz.yomail.info +wo.emlhub.com +wo0231.com +wo0233.com +wo295ttsarx6uqbo.cf +wo295ttsarx6uqbo.ga +wo295ttsarx6uqbo.gq +wo295ttsarx6uqbo.ml +wo295ttsarx6uqbo.tk +wo4sl.anonbox.net +woa.org.ua +wobz.com +wocall.com +wochaojibang.sbs +wodeda.com +woe.com +woeemail.com +woei.emlhub.com +woelbercole.com +woermawoerma1.info +wofsrm6ty26tt.cf +wofsrm6ty26tt.ga +wofsrm6ty26tt.gq +wofsrm6ty26tt.ml +wofsrm6ty26tt.tk +wogu.emltmp.com +wohenwasai.cc +wohrr.com +wojod.fr +wokcy.com +wokuaifa.com +wolaf.com +wolaila.cc +wolff00.xyz +wolfiexd.me +wolfmail.ml +wolfmission.com +wolfpat.com +wolfsmail.ml +wolfsmail.tk +wolfsmails.tk +wolke7.net +wollan.info +wolukieh89gkj.tk +wolukiyeh88jik.ga +wolulasfeb01.xyz +womanday.us +womannight.us +womanyear.biz +womclub.su +women-at-work.org +women999.com +womenabuse.com +womenbay.ru +womenblazerstoday.com +womencosmetic.info +womendressinfo.com +womenhealthcare.ooo +womenshealthprof.com +womenshealthreports.com +womenstuff.icu +womentopsclothing.com +womentopswear.com +womp-wo.mp +wondeaz.com +wonderfish-recipe2.com +wonderfulblogthemes.info +wonderfulfitnessstores.com +wonderlog.com +wondowslive.com +wondtuce.com +wongndeso.gq +wonrg.com +wonwwf.com +woodlandplumbers.com +woodsmail.bid +woodwilder.com +woofidog.fr.nf +wooh.site +wooljumper.co.uk +woolki.xyz +woolkid.xyz +woolnwaresyarn.com +woolrich-italy.com +woolrichhoutlet.com +woolrichoutlet-itley.com +woolticharticparkaoutlet.com +wooolrichitaly.com +woopre.com +woopros.com +wootap.me +wooter.xyz +wootmail.online +woow.bike +wop.ro +wopc.cf +woppler.ru +wordmail.xyz +wordme.stream +wordmix.pl +wordmr.us +wordpress-speed-up-dashboard.ml +wordpressitaly.com +wordpressmails.com +work-info.ru +work.obask.com +work.oldoutnewin.com +work24h.eu +work4uber.us +work66.ru +workcountry.us +workcrossbow.ml +workers.su +workflowy.club +workflowy.cn +workflowy.top +workflowy.work +workhard.by +workinar.com +workingtall.com +workingturtle.com +workmail.himky.com +worknumber.us +workout-onlinedvd.info +workoutsupplements.com +workright.ru +worksmail.cf +worksmail.ga +worksmail.gq +worksmail.ml +worksmail.tk +worktogetherbetter.com +workwater.us +world-many.ru +world-travel.online +worldatnet.com +worldbaseai.com +worldbibleschool.name +worldcenter.ru +worlddonation.org +worldfridge.com +worldfxza.com +worldgolfdirectory.com +worldinvent.com +worldlylife.store +worldmail.com +worldnews24h.us +worldofemail.info +worldofzoe.com +worldpetcare.cf +worldproai.com +worldquickai.com +worldshealth.org +worldsonlineradios.com +worldspace.link +worldsreversephonelookups.com +worldtrafficsolutions.xyz +worldwide-hungerrelief.org +worldwidebusinesscards.com +worldwidestaffinginc.com +worldwite.com +worldwite.net +worldzip.info +worldzipcodes.net +worlipca.com +wormbrand.net +wormseo.cn +wormusiky.ru +woroskop.co.uk +woroskop.org.uk +worp.site +worryabothings.com +worstcoversever.com +wosenow.com +wosipaskbc.info +wotomail.com +wotsua.com +would.blatnet.com +would.cowsnbullz.com +would.lakemneadows.com +would.ploooop.com +would.poisedtoshrike.com +wousi.com +wouthern.art +wovz.cu.cc +wow-hack.com +wow.royalbrandco.tk +wowauctionguide.com +wowbebe.com +wowcemafacfutpe.com +wowcg.com +wowgoldy.cz +wowgrill.ru +wowgua.com +wowhackgold.com +wowhowmy.com.pl +wowico.org +wowin.pl +wowkoreawow.com +wowmail.gq +wowmailing.com +wowmuffin.top +wowokan.com +wowow.com +wowpizza.ru +wowthis.tk +wowxv.com +woxg.dropmail.me +woxgreat.com +woxvf3xsid13.cf +woxvf3xsid13.ga +woxvf3xsid13.gq +woxvf3xsid13.ml +woxvf3xsid13.tk +wp-admins.com +wp-viralclick.com +wp.company +wp.freeml.net +wp2romantic.com +wpacade.com +wpadye.com +wpbinaq3w7zj5b0.cf +wpbinaq3w7zj5b0.ga +wpbinaq3w7zj5b0.ml +wpbinaq3w7zj5b0.tk +wpcommentservices.info +wpdeveloperguides.com +wpdfs.com +wpdork.com +wpeopwfp099.tk +wpfoo.com +wpg.im +wpgotten.com +wpgun.com +wphs.org +wpkg.de +wpkg.emlhub.com +wpmail.org +wpms9sus.pl +wpower.info +wppd.mailpwr.com +wpsavy.com +wpskews.emltmp.com +wpsneller.nl +wpstorage.org +wptaxi.com +wpuc.emlhub.com +wpy.emlhub.com +wpy.emlpro.com +wq.dropmail.me +wqcefp.com +wqcefp.online +wqdsvbws.com +wqi.spymail.one +wqnbilqgz.pl +wqwc.emlhub.com +wqwwdhjij.pl +wqxhasgkbx88.cf +wqxhasgkbx88.ga +wqxhasgkbx88.gq +wqxhasgkbx88.ml +wqxhasgkbx88.tk +wr.dropmail.me +wr.moeri.org +wr9v6at7.com +wralawfirm.com +wrangler-sale.com +wrayauto.com +wremail.top +wremail.xyz +wri.xyz +wrinklecareproduct.com +writability.net +write-me.xyz +writefornet.com +writeme-lifestyle.com +writeme.us +writeme.xyz +writers.com +writersarticles.be +writersefx.com +writinghelper.top +writingservice.cf +writk.com +written4you.info +wrjadeszd.pl +wrkmen.com +wrlnewstops.space +wrobrus.com +wroclaw-tenis-stolowy.pl +wroglass.br +wrong.bthow.com +wronghead.com +wrongigogod.com +wrpills.com +wrt.dropmail.me +wrwint.com +wryo.com +wrysutgst57.ga +wryzpro.com +wrzshield.xyz +wrzuta.com +ws.emlpro.com +ws.gy +ws1i0rh.pl +wscu73sazlccqsir.cf +wscu73sazlccqsir.ga +wscu73sazlccqsir.gq +wscu73sazlccqsir.ml +wscu73sazlccqsir.tk +wsd88poker.com +wsdbet88.net +wsfjtyk29-privtnyu.website +wsfvyaemfx.ga +wsh72eonlzb5swa22.cf +wsh72eonlzb5swa22.ga +wsh72eonlzb5swa22.gq +wsh72eonlzb5swa22.ml +wsh72eonlzb5swa22.tk +wshv.com +wsj.homes +wsj.promo +wsmeu.com +wsneon.com +wsoparty.com +wsse.us +wsswoodstock.xyz +wsuart.com +wsuse.top +wsvnsbtgq.pl +wsy56.anonbox.net +wsym.de +wsypc.com +wsyy.info +wszqm.anonbox.net +wszystkoolokatach.com.pl +wt-rus.ru +wt.emlhub.com +wt0vkmg1ppm.cf +wt0vkmg1ppm.ga +wt0vkmg1ppm.gq +wt0vkmg1ppm.ml +wt0vkmg1ppm.tk +wt2.orangotango.cf +wta.spymail.one +wtbone.com +wtdmugimlyfgto13b.cf +wtdmugimlyfgto13b.ga +wtdmugimlyfgto13b.gq +wtdmugimlyfgto13b.ml +wtdmugimlyfgto13b.tk +wtec.dropmail.me +wteoq7vewcy5rl.cf +wteoq7vewcy5rl.ga +wteoq7vewcy5rl.gq +wteoq7vewcy5rl.ml +wteoq7vewcy5rl.tk +wtf.astyx.fun +wtfdesign.ru +wti.emlhub.com +wtic.de +wtir.laste.ml +wtklaw.com +wto.com +wtoe.freeml.net +wtq.emlhub.com +wtransit.ru +wtvcolt.ga +wtvcolt.ml +wtyl.com +wu138.club +wu138.top +wu158.club +wu158.top +wu189.top +wu8vx48hyxst.cf +wu8vx48hyxst.ga +wu8vx48hyxst.gq +wu8vx48hyxst.ml +wu8vx48hyxst.tk +wudet.men +wuespdj.xyz +wugxxqrov.pl +wuhl.de +wujicloud.com +wumail.com +wumbo.co +wunschbaum.info +wupics.com +wupta.com +wusehe.com +wusnet.site +wusolar.com +wustl.com +wuuf.emltmp.com +wuupr.com +wuuvo.com +wuvy.emlhub.com +wuyc41hgrf.cf +wuyc41hgrf.ga +wuyc41hgrf.gq +wuyc41hgrf.ml +wuyc41hgrf.tk +wuzak.com +wuzhizheng.mygbiz.com +wuzup.net +wuzupmail.net +wv.yomail.info +wvasueafcq.ga +wvbm.emltmp.com +wvckgenbx.pl +wvclibrary.com +wvk.emlhub.com +wvl238skmf.com +wvnskcxa.com +wvp.emltmp.com +wvphost.com +wvppz7myufwmmgh.cf +wvppz7myufwmmgh.ga +wvppz7myufwmmgh.gq +wvppz7myufwmmgh.ml +wvppz7myufwmmgh.tk +wvpzbsx0bli.cf +wvpzbsx0bli.ga +wvpzbsx0bli.gq +wvpzbsx0bli.ml +wvpzbsx0bli.tk +wvrdwomer3arxsc4n.cf +wvrdwomer3arxsc4n.ga +wvrdwomer3arxsc4n.gq +wvrdwomer3arxsc4n.tk +wvruralhealthpolicy.org +wvtirnrceb.ga +ww.yomail.info +ww00.com +ww4rv.anonbox.net +wwatme7tpmkn4.cf +wwatme7tpmkn4.ga +wwatme7tpmkn4.gq +wwatme7tpmkn4.tk +wwatrakcje.pl +wwc8.com +wwcopyright.com +wwdee.com +wweeerraz.com +wwefd.top +wwf.az.pl +wwfontsele.com +wwgoc.com +wwin-tv.com +wwitvnvq.xyz +wwjltnotun30qfczaae.cf +wwjltnotun30qfczaae.ga +wwjltnotun30qfczaae.gq +wwjltnotun30qfczaae.ml +wwjltnotun30qfczaae.tk +wwjmp.com +wwmails.com +wwokdisjf.com +wwpshop.com +wwrmails.com +wws.emltmp.com +wwstockist.com +wwvk.ru +wwvk.store +www-0419.com +www-email.bid +www-kl.cc +www.barryogorman.com +www.bccto.com +www.bccto.me +www.dmtc.edu.pl +www.eairmail.com +www.gameaaholic.com +www.gishpuppy.com +www.google.com.iki.kr +www.greggamel.net +www.hotmobilephoneoffers.com +www.live.co.kr.beo.kr +www.mailinator.com +www.mykak.us +www.nak-nordhorn.de +www.redpeanut.com +www.thestopplus.com +www1.hotmobilephoneoffers.com +www10.ru +www2.htruckzk.biz +www845d.cc +www96.ru +wwwatrakcje.pl +wwwbox.tk +wwwbrightscope.com +wwwdindon.ga +wwweb.cf +wwweb.ga +wwwemail.bid +wwwemail.racing +wwwemail.stream +wwwemail.trade +wwwemail.win +wwwfotowltaika.pl +wwwfotowoltaika.pl +wwwkreatorzyimprez.pl +wwwlh8828.com +wwwmail.gq +wwwmailru.site +wwwmitel.ga +wwwnew.de +wwwnew.eu +wwwoutmail.cf +wwwpao00.com +wwwtworcyimprez.pl +wx.emlhub.com +wxcv.fr.nf +wxee.dropmail.me +wxmail263.com +wxmn.spymail.one +wxnkf.anonbox.net +wxnw.net +wxsuper.com +wxter.com +wy.laste.ml +wyau.yomail.info +wybory.edu.pl +wybuwy.xyz +wychw.pl +wyeq.dropmail.me +wyg.emltmp.com +wyhb.emlpro.com +wyieiolo.com +wyla13.com +wymarzonesluby.pl +wynajemaauta.pl +wynajemmikolajawarszawa.pl +wynncash01.com +wynncash13.com +wyoming-nedv.ru +wyomingou.com +wyoxafp.com +wyszukiwaramp3.pl +wyu.yomail.info +wyvernia.net +wyvernstor.me +wyvernstores.me +wyw.emlhub.com +wywlg.anonbox.net +wywnxa.com +wyyn.com +wz.emlhub.com +wz5gm.anonbox.net +wz9837.com +wzbhd.anonbox.net +wzeabtfzyd.pl +wzeabtfzyda.pl +wzi.dropmail.me +wzofit.com +wzorymatematyka.pl +wzru.com +wzukltd.com +wzw.emlpro.com +wzwlkysusw.ga +wzxmtb3stvuavbx9hfu.cf +wzxmtb3stvuavbx9hfu.ga +wzxmtb3stvuavbx9hfu.gq +wzxmtb3stvuavbx9hfu.ml +wzxmtb3stvuavbx9hfu.tk +x-bases.ru +x-fuck.info +x-grave.com +x-instruments.edu +x-izvestiya.ru +x-lab.net +x-mail.cf +x-ms.info +x-mule.cf +x-mule.ga +x-mule.gq +x-mule.ml +x-mule.tk +x-musor.ru +x-netmail.com +x-noms.com +x-porno-away.info +x-star.space +x-t.xyz +x-today-x.info +x-x.systems +x.agriturismopavi.it +x.bigpurses.org +x.coloncleanse.club +x.crazymail.website +x.emailfake.ml +x.fackme.gq +x.marrone.cf +x.nadazero.net +x.polosburberry.com +x.puk.ro +x.tonno.cf +x.tonno.gq +x.tonno.ml +x.tonno.tk +x.waterpurifier.club +x.yeastinfectionnomorenow.com +x00x.online +x0q.net +x0w4twkj0.pl +x1.p.pine-and-onyx.xyz +x13x13x13.com +x1bkskmuf4.cf +x1bkskmuf4.ga +x1bkskmuf4.gq +x1bkskmuf4.ml +x1bkskmuf4.tk +x1ix.com +x1mails.com +x1post.com +x1x.spb.ru +x1x22716.com +x24.com +x263.net +x2day.com +x2ewzd983ene0ijo8.cf +x2ewzd983ene0ijo8.ga +x2ewzd983ene0ijo8.gq +x2ewzd983ene0ijo8.ml +x2ewzd983ene0ijo8.tk +x2fsqundvczas.cf +x2fsqundvczas.ga +x2fsqundvczas.gq +x2fsqundvczas.ml +x2fsqundvczas.tk +x3gsbkpu7wnqg.cf +x3gsbkpu7wnqg.ga +x3gsbkpu7wnqg.gq +x3gsbkpu7wnqg.ml +x3mailer.com +x3plk.anonbox.net +x3puf.anonbox.net +x3sbp.anonbox.net +x4ob6.anonbox.net +x4u.me +x4uzm.anonbox.net +x4y.club +x5a9m8ugq.com +x5bj6zb5fsvbmqa.ga +x5bj6zb5fsvbmqa.ml +x5bj6zb5fsvbmqa.tk +x5lyq2xr.osa.pl +x6dqh5d5u.pl +x77.club +x7971.com +x7mail.com +x7tzhbikutpaulpb9.cf +x7tzhbikutpaulpb9.ga +x7tzhbikutpaulpb9.gq +x7tzhbikutpaulpb9.ml +x8h8x941l.com +x8vplxtmrbegkoyms.cf +x8vplxtmrbegkoyms.ga +x8vplxtmrbegkoyms.gq +x8vplxtmrbegkoyms.ml +x8vplxtmrbegkoyms.tk +x9dofwvspm9ll.cf +x9dofwvspm9ll.ga +x9dofwvspm9ll.gq +x9dofwvspm9ll.ml +x9dofwvspm9ll.tk +x9t.xyz +x9vl67yw.edu.pl +xa9f9hbrttiof1ftean.cf +xa9f9hbrttiof1ftean.ga +xa9f9hbrttiof1ftean.gq +xa9f9hbrttiof1ftean.ml +xa9f9hbrttiof1ftean.tk +xablogowicz.com +xabywego.world +xadi.ru +xadoll.com +xaf.emltmp.com +xafrem3456ails.com +xagloo.co +xagloo.com +xagym.com +xahsh.com +xak3qyaso.pl +xakalutu.com +xammersoly.com +xamog.com +xanalx.com +xandermemo.info +xanhvilla.website +xanva.site +xapimail.top +xaqf.laste.ml +xaralabs.com +xartis89.co.uk +xas04oo56df2scl.cf +xas04oo56df2scl.ga +xas04oo56df2scl.gq +xas04oo56df2scl.ml +xas04oo56df2scl.tk +xasamail.com +xasdrugshop.com +xasems.com +xaspecte.com +xasqvz.com +xat.freeml.net +xatg.dropmail.me +xatovzzgb.pl +xaudep.com +xaviestore.xyz +xavnotes.instambox.com +xaxugen.org +xaxx.ml +xaynetsss.ddns.net +xazo.xyz +xb-eco.info +xbaby69.top +xbeq.com +xbestwebdesigners.com +xbm7bx391sm5owt6xe.cf +xbm7bx391sm5owt6xe.ga +xbm7bx391sm5owt6xe.gq +xbm7bx391sm5owt6xe.ml +xbm7bx391sm5owt6xe.tk +xbmyv8qyga0j9.cf +xbmyv8qyga0j9.ga +xbmyv8qyga0j9.gq +xbmyv8qyga0j9.ml +xbmyv8qyga0j9.tk +xbox-zik.com +xboxbeta20117.co.tv +xboxformoney.com +xboxlivegenerator.xyz +xboxppshua.top +xbpantibody.com +xbreg.com +xbtravel.com +xbvrfy45g.ga +xbz.yomail.info +xbz0412.uu.me +xbziv2krqg7h6.cf +xbziv2krqg7h6.ga +xbziv2krqg7h6.gq +xbziv2krqg7h6.ml +xbziv2krqg7h6.tk +xc.freeml.net +xc.yomail.info +xc05fypuj.com +xc40.cf +xc40.ga +xc40.gq +xc40.ml +xc40.tk +xc60.cf +xc60.ga +xc60.gq +xc60.ml +xc60.tk +xc90.cf +xc90.ga +xc90.gq +xc90.ml +xc90.tk +xca.cz +xcapitalhg.com +xcccc.com +xcclectures.com +xccxcsswwws.website +xcekh6p.pl +xcell.ukfreedom.com +xcheesemail.info +xcisade129.ru +xcmexico.com +xcmitm3ve.pl +xcmov.com +xcnmarketingcompany.com +xcode.ro +xcodes.net +xcoex.news +xcoex.org +xcoinsmail.com +xcompress.com +xconstantine.pro +xcoxc.com +xcpy.com +xcqvxcas.com +xcremail.com +xctrade.info +xcufrmogj.pl +xcure.xyz +xcvlolonyancat.com +xcvrtasdqwe.com +xcvv.fun +xcvv.top +xcvv.xyz +xcvzfjrsnsnasd.sbs +xcxqtsfd0ih2l.cf +xcxqtsfd0ih2l.ga +xcxqtsfd0ih2l.gq +xcxqtsfd0ih2l.ml +xcxqtsfd0ih2l.tk +xcygtvytxcv99512.cf +xczffumdemvoi23ugfs.cf +xczffumdemvoi23ugfs.ga +xczffumdemvoi23ugfs.gq +xczffumdemvoi23ugfs.ml +xczffumdemvoi23ugfs.tk +xd.laste.ml +xd2i8lq18.pl +xdavpzaizawbqnivzs0.cf +xdavpzaizawbqnivzs0.ga +xdavpzaizawbqnivzs0.gq +xdavpzaizawbqnivzs0.ml +xdavpzaizawbqnivzs0.tk +xdderetronline.xyz +xdfav.com +xdhhc.com +xdigit.top +xdndg.anonbox.net +xdsedr.tech +xdtf.site +xducation.us +xdvn.dropmail.me +xdvsagsdg4we.ga +xdwg.emltmp.com +xdx.freeml.net +xe.freeml.net +xe2g.com +xeames.net +xeana.co +xeb9xwp7.tk +xedmi.com +xeduh.com +xedutv.com +xeg.spymail.one +xegge.com +xehop.org +xeiex.com +xemaps.com +xemkqxs.com +xemne.com +xemrelim.tk +xenacareholdings.com +xenakenak.xyz +xenamode.shop +xengthreview.com +xenicalprime.com +xenocountryses.com +xenodio.gr +xenofon.gr +xenonheadlightsale.com +xenopharmacophilia.com +xenta.cfd +xents.com +xenzld.com +xeon-e3.ovh +xeosa9gvyb5fv.cf +xeosa9gvyb5fv.ga +xeosa9gvyb5fv.gq +xeosa9gvyb5fv.ml +xeosa9gvyb5fv.tk +xeoty.com +xepa.ru +xermo.info +xerontech.com +xervmail.com +xet.dropmail.me +xeti.com +xeuja98.pl +xevra.sbs +xex88.com +xezle.com +xezo.live +xf.sluteen.com +xfamiliar9.com +xfamilytree.com +xfashionset.com +xfavaj.com +xfcjfsfep.pl +xffbe2l8xiwnw.cf +xffbe2l8xiwnw.ga +xffbe2l8xiwnw.gq +xffbe2l8xiwnw.ml +xffbe2l8xiwnw.tk +xfghzdff75zdfhb.ml +xflight.ir +xfriend.site +xftmail.com +xftz.freeml.net +xfuze.com +xfx.laste.ml +xfxx.com +xg.laste.ml +xgaming.ca +xgas.freeml.net +xgee.emltmp.com +xgenas.com +xgh6.com +xgi.spymail.one +xgi.yomail.info +xgk6dy3eodx9kwqvn.cf +xgk6dy3eodx9kwqvn.ga +xgk6dy3eodx9kwqvn.gq +xgk6dy3eodx9kwqvn.tk +xglrcflghzt.pl +xgmail.com +xgmailoo.com +xgnowherei.com +xgod.cf +xgrxsuldeu.cf +xgrxsuldeu.ga +xgrxsuldeu.gq +xgrxsuldeu.ml +xgrxsuldeu.tk +xh.emlpro.com +xh1118.com +xh9z2af.pl +xhamster.ltd +xhanimatedm.com +xhee.laste.ml +xhhanndifng.info +xhkss.net +xhouse.xyz +xhr10.com +xhrmtujovv.ga +xhyemail.com +xhygii.buzz +xhypm.com +xi.dropmail.me +xi3ly.anonbox.net +xiaobi110.com +xiaomi.onthewifi.com +xiaomie.store +xiaominglu88.com +xiaomitvplus.com +xiaoting.cc +xiaoyangera.com +xibelfast.com +xibm.laste.ml +xidealx.com +xideen.site +xidprinting.com +xiinoo31.com +xijjfjoo.turystyka.pl +xilinous.xyz +xilopro.com +xilor.com +ximenor.site +ximtyl.com +xin88088.com +xinbo.info +xinbox.info +xinchuga.store +xindax.com +xinfi.com.pl +xing886.uu.gl +xinguxperience.online +xingwater.com +xinkubu.com +xinlicn.com +xinmail.info +xinnian.sbs +xinsijitv58.info +xinsijitv74.info +xinzk1ul.com +xio7s7zsx8arq.cf +xio7s7zsx8arq.ga +xio7s7zsx8arq.gq +xio7s7zsx8arq.ml +xio7s7zsx8arq.tk +xiomio.com +xioplop.com +xiotel.com +xipcj6uovohr.cf +xipcj6uovohr.ga +xipcj6uovohr.gq +xipcj6uovohr.ml +xipcj6uovohr.tk +xiql.mailpwr.com +xiqsdqsobs.ga +xirlotamqen.fun +xitimail.com +xitroo.com +xitroo.de +xitroo.fr +xitroo.net +xitroo.org +xitudy.com +xitv.ru +xiuptwzcv.pl +xixigyu.gq +xixigyu.tk +xixs.com +xixx.site +xiyaopin.cn +xiyl.com +xj6600.com +xjb.spymail.one +xjc.freeml.net +xjg.freeml.net +xjgbw.com +xjh.emlpro.com +xjhz.emltmp.com +xjin.xyz +xjkbrsi.pl +xjltaxesiw.com +xjoi.com +xjoslxcovv.ga +xjsi.com +xjyfoa.buzz +xjzodqqhb.pl +xk.spymail.one +xkc.emltmp.com +xkcb.mimimail.me +xkk.yomail.info +xklt4qdifrivcw.cf +xklt4qdifrivcw.ga +xklt4qdifrivcw.gq +xklt4qdifrivcw.ml +xklt4qdifrivcw.tk +xkors.com +xkq.spymail.one +xktyr5.pl +xkuw.yomail.info +xkw.yomail.info +xkx.me +xkxkud.com +xl.cx +xl.dropmail.me +xl.laste.ml +xlby.com +xlchapi.com +xlcool.com +xlef.com +xlekskpwcvl.pl +xlgaokao.com +xll.emlpro.com +xlluck.com +xloveme.top +xlqndaij.pl +xlra5cuttko5.cf +xlra5cuttko5.ga +xlra5cuttko5.gq +xlra5cuttko5.ml +xlra5cuttko5.tk +xlrt.com +xlsmail.com +xltbz8eudlfi6bdb6ru.cf +xltbz8eudlfi6bdb6ru.ga +xltbz8eudlfi6bdb6ru.gq +xltbz8eudlfi6bdb6ru.ml +xltbz8eudlfi6bdb6ru.tk +xlv.yomail.info +xlw.emltmp.com +xlxe.pl +xlzdroj.ru +xm.spymail.one +xmail.com +xmail.edu +xmail.org +xmail2.net +xmail365.net +xmailer.be +xmailg.one +xmaill.com +xmailsme.com +xmailtm.com +xmailweb.com +xmailxz.com +xmaily.com +xmailz.ru +xmasloans.us +xmc26.anonbox.net +xmcybgfd.pl +xmen.work +xmerwdauq.pl +xmet.spymail.one +xmg.emlpro.com +xmgczdjvx.pl +xmgj.laste.ml +xmision.com +xmk.yomail.info +xml.dropmail.me +xmlrhands.com +xmmail.ru +xmrecoveryblogs.info +xmtcx.biz +xmule.cf +xmule.ga +xmule.gq +xmule.ml +xmuss.com +xmxry.anonbox.net +xn--42c9bsq2d4f7a2a.site +xn--4dbceig1b7e.com +xn--53h1310o.ws +xn--5bus4b0yhw29d.online +xn--72ch5b6au4a8deg1qg.com +xn--7e2b.cf +xn--80aabqk5atp.com +xn--9kq967o.com +xn--aufsteckbrsten-kaufen-hic.de +xn--b-dga.vn +xn--b1accdn5bheqm.site +xn--bei.cf +xn--bei.ga +xn--bei.gq +xn--bei.ml +xn--bei.tk +xn--bka.net +xn--bluewn-7va.cf +xn--d-bga.net +xn--gmal-nza.net +xn--gmal-spa.cam +xn--gtvz22d7vt.com +xn--ida.website +xn--iloveand-5z9m0a.gq +xn--j6h.ml +xn--kabeldurchfhrung-tzb.info +xn--kubt-dpa.vn +xn--m3cso0a9e4c3a.com +xn--mgbgvi3fi.com +xn--mll-hoa.email +xn--mllemail-65a.com +xn--mllmail-n2a.com +xn--namnh-7ya4834c.net +xn--odszkodowania-usugi-lgd.waw.pl +xn--oi-jia8q.vn +xn--qei8618m9qa.ws +xn--sd-pla.elk.pl +xn--sdertrnsfjrrvrme-4nbd24ae.se +xn--sngkheep-qcb2527era.com +xn--thepratebay-rcb.org +xn--til-e-emocionante-01b.info +xn--wbuy58e1in.tk +xn--wda.net +xn--wkr.cf +xn--wkr.gq +xn--yaho-sqa.com +xn--ynyz0b.com +xn--z8hxwp135i.ws +xne2jaw.pl +xnefa7dpydciob6wu9.cf +xnefa7dpydciob6wu9.ga +xnefa7dpydciob6wu9.gq +xnefa7dpydciob6wu9.ml +xnefa7dpydciob6wu9.tk +xneopocza.xyz +xneopoczb.xyz +xneopoczc.xyz +xnmail.mooo.com +xnptq.anonbox.net +xnr3h.anonbox.net +xnrn.emlhub.com +xnzmlyhwgi.pl +xo.dropmail.me +xoaao.com +xoballoon.com +xocmoa22.com +xoea.com +xogu.com +xoixa.com +xoju.dropmail.me +xojxe.com +xok.dropmail.me +xolic.me +xolpanel.id +xolymail.cf +xolymail.ga +xolymail.gq +xolymail.ml +xolymail.tk +xomaioosdwlio.cloud +xomawmiux.pl +xomomd.com +xomqirantel.site +xonomax.com +xooit.fr +xoon.com +xoooai.com +xopmail.fun +xoq.emlhub.com +xorp.emlhub.com +xorpaopl.com +xoru.ga +xos.yomail.info +xoso889.net +xost.us +xow.laste.ml +xowxdd4w4h.cf +xowxdd4w4h.ga +xowxdd4w4h.gq +xowxdd4w4h.ml +xowxdd4w4h.tk +xoxo-2012.info +xoxox.cc +xoxy.net +xoxy.uk +xoxy.work +xoyctl.com +xp.laste.ml +xp6tq6vet4tzphy6b0n.cf +xp6tq6vet4tzphy6b0n.ga +xp6tq6vet4tzphy6b0n.gq +xp6tq6vet4tzphy6b0n.ml +xp6tq6vet4tzphy6b0n.tk +xpasystems.com +xpaw.net +xpee.tk +xperiae5.com +xpert.tech +xplannersr.com +xplanningzx.com +xpmm93.com +xpn.emlhub.com +xpoowivo.pl +xpornclub.com +xposenet.ooo +xposeu.com +xpouch.com +xpq.emlpro.com +xprice.co +xproofs.com +xprozacno.com +xps-dl.xyz +xpsatnzenyljpozi.cf +xpsatnzenyljpozi.ga +xpsatnzenyljpozi.gq +xpsatnzenyljpozi.ml +xpsatnzenyljpozi.tk +xptw.dropmail.me +xputy.com +xpx.laste.ml +xpywg888.com +xq.emlhub.com +xq.freeml.net +xq.spymail.one +xqf.emlpro.com +xqm.emlpro.com +xqsdr.com +xquz.emlpro.com +xqxe.emlpro.com +xr.ftpserver.biz +xr158a.com +xr160.com +xr160.info +xr3.elk.pl +xrap.de +xray.lambda.livefreemail.top +xrecruit.online +xredb.com +xrg7vtiwfeluwk.cf +xrg7vtiwfeluwk.ga +xrg7vtiwfeluwk.gq +xrg7vtiwfeluwk.ml +xrg7vtiwfeluwk.tk +xrgz.emltmp.com +xrho.com +xrilop.com +xriveroq.com +xrmail.xyz +xrmailbox.net +xrmop.com +xrnyr.anonbox.net +xronmyer.info +xrp.emltmp.com +xrpmail.com +xrum.xyz +xrumail.com +xrumer.warszawa.pl +xrumercracked.com +xrumerdownload.com +xs-foto.org +xs.yomail.info +xsanity.xyz +xscdouzan.pl +xsdfgh.ru +xsdolls.com +xsecrt.com +xsecurity.org +xsellize.xyz +xsellsy.com +xsil43fw5fgzito.cf +xsil43fw5fgzito.ga +xsil43fw5fgzito.gq +xsil43fw5fgzito.ml +xsil43fw5fgzito.tk +xsl.freeml.net +xslod.xyz +xsmega.com +xsmega645.com +xstyled.net +xsychelped.com +xt-size.info +xt.net.pl +xtc94az.pl +xtdl.com +xtds.net +xteammail.com +xti.freeml.net +xtlf.laste.ml +xtmail.win +xtnr2cd464ivdj6exro.cf +xtnr2cd464ivdj6exro.ga +xtnr2cd464ivdj6exro.gq +xtnr2cd464ivdj6exro.ml +xtnr2cd464ivdj6exro.tk +xtq.dropmail.me +xtq6mk2swxuf0kr.cf +xtq6mk2swxuf0kr.ga +xtq6mk2swxuf0kr.gq +xtq6mk2swxuf0kr.ml +xtq6mk2swxuf0kr.tk +xtra.tv +xtrars.ga +xtrars.ml +xtrasize-funziona-opinioni-blog.it +xtremeconcept.com +xtremewebtraffic.net +xtrempro.com +xtrstudios.com +xtryb.com +xts.dropmail.me +xtsimilar.com +xtsserv.com +xtvy.emlpro.com +xtwcszzpdc.ga +xtwgtpfzxo.pl +xtxfdwe03zhnmrte0e.ga +xtxfdwe03zhnmrte0e.ml +xtxfdwe03zhnmrte0e.tk +xtzqytswu.pl +xuandai.pro +xubqgqyuq98c.cf +xubqgqyuq98c.ga +xubqgqyuq98c.gq +xubqgqyuq98c.ml +xubqgqyuq98c.tk +xuchuyen.com +xucobalt.com +xudttnik4n.cf +xudttnik4n.ga +xudttnik4n.gq +xudttnik4n.ml +xudttnik4n.tk +xuduoshop.com +xufcopied.com +xuge.life +xuld.yomail.info +xulopy.xyz +xumail.cf +xumail.ga +xumail.gq +xumail.ml +xumail.tk +xumchum.com +xumnfhvsdw.ga +xuncoco.es +xuneh.com +xuniyxa.ru +xunleu.com +xunmahe.com +xuogcbcxw.pl +xuongdam.com +xupiv.com +xuseca.cloud +xusieure.com +xusn.com +xutd8o2izswc3ib.xyz +xutemail.info +xuubu.com +xuux.com +xuuxmo1lvrth.cf +xuuxmo1lvrth.ga +xuuxmo1lvrth.gq +xuuxmo1lvrth.ml +xuuxmo1lvrth.tk +xuwphq72clob.cf +xuwphq72clob.ga +xuwphq72clob.gq +xuwphq72clob.ml +xuwphq72clob.tk +xuxx.gq +xuyalter.ru +xuyushuai.com +xv9u9m.com +xvcezxodtqzbvvcfw4a.cf +xvcezxodtqzbvvcfw4a.ga +xvcezxodtqzbvvcfw4a.gq +xvcezxodtqzbvvcfw4a.ml +xvcezxodtqzbvvcfw4a.tk +xvector.org +xvg.freeml.net +xviath.com +xvisioner.com +xvism.site +xvlinjury.com +xvx.us +xw.mailpwr.com +xwanadoo.fr +xwatch.today +xwg.laste.ml +xwgiant.com +xwgpzgajlpw.cf +xwgpzgajlpw.ga +xwgpzgajlpw.gq +xwgpzgajlpw.ml +xwgpzgajlpw.tk +xwiekhduzw.ga +xwkqguild.com +xwoj.dropmail.me +xwpet8imjuihrlgs.cf +xwpet8imjuihrlgs.ga +xwpet8imjuihrlgs.gq +xwpet8imjuihrlgs.ml +xwpet8imjuihrlgs.tk +xwr.emltmp.com +xwvn2.anonbox.net +xwvx.emltmp.com +xww.ro +xwx.emlhub.com +xwxv.emltmp.com +xwxx.com +xwyzperlkx.cf +xwyzperlkx.ga +xwyzperlkx.gq +xwyzperlkx.ml +xwyzperlkx.tk +xwzowgfnuuwcpvm.cf +xwzowgfnuuwcpvm.ga +xwzowgfnuuwcpvm.gq +xwzowgfnuuwcpvm.ml +xwzowgfnuuwcpvm.tk +xx-9.tk +xx-p1.top +xx11.icu +xxgkhlbqi.pl +xxgmaail.com +xxgmail.com +xxgry.pl +xxhamsterxx.ga +xxi2.com +xxjj084.xyz +xxl.rzeszow.pl +xxl.st +xxldruckerei.de +xxloc.com +xxlocanto.us +xxlxx.com +xxlzelte.de +xxme.me +xxolocanto.us +xxosuwi21.com +xxpm12pzxpom6p.cf +xxpm12pzxpom6p.ga +xxpm12pzxpom6p.gq +xxpm12pzxpom6p.ml +xxpm12pzxpom6p.tk +xxqx3802.com +xxsx.site +xxtreamcam.com +xxui.emlhub.com +xxup.site +xxvcongresodeasem.org +xxvk.ru +xxvk.store +xxx-ios.ru +xxx-jino.ru +xxx-movies-tube.ru +xxx-movs-online.ru +xxx-mx.ru +xxx-tower.net +xxx.sytes.net +xxxc.fun +xxxd.fun +xxxhi.cc +xxxhub.biz +xxxi.club +xxxking.site +xxxn.fun +xxxp.fun +xxxs.online +xxxs.site +xxxu.fun +xxxvideos.com +xxxxilo.com +xxxxx.cyou +xxyxi.com +xxzyr.com +xy.dropmail.me +xy1qrgqv3a.cf +xy1qrgqv3a.ga +xy1qrgqv3a.gq +xy1qrgqv3a.ml +xy1qrgqv3a.tk +xy64m.anonbox.net +xy9ce.tk +xycab.com +xycassino.com +xyguja.ru +xylar.ru +xylar.store +xyngular-europe.eu +xyo.spymail.one +xyqp.dropmail.me +xytjjucfljt.atm.pl +xytojios.com +xywdining.com +xyxy.app +xyz-drive.info +xyzcasinositeleri.xyz +xyzfree.net +xyzmail.men +xyzmailhub.com +xyzmailpro.com +xz.dropmail.me +xz.laste.ml +xz5qwrfu7.pl +xz8syw3ymc.cf +xz8syw3ymc.ga +xz8syw3ymc.gq +xz8syw3ymc.ml +xz8syw3ymc.tk +xzavier1121.club +xzcameras.com +xzcn.me +xzcsrv70.life +xzdhmail.tk +xzephzdt.shop +xzhanziyuan.xyz +xzhguyvuygc15742.cf +xzit.com +xzjwtsohya3.cf +xzjwtsohya3.ga +xzjwtsohya3.gq +xzjwtsohya3.ml +xzjwtsohya3.tk +xzotokoah.pl +xzqrepurlrre7.cf +xzqrepurlrre7.ga +xzqrepurlrre7.gq +xzqrepurlrre7.ml +xzqrepurlrre7.tk +xzslwwfxhn.ga +xzsok.com +xzx5l.anonbox.net +xzxgo.com +xzymoe.edu.pl +xzyp.mimimail.me +xzzy.info +y.bcb.ro +y.dfokamail.com +y.dldweb.info +y.iotf.net +y.lochou.fr +y.polosburberry.com +y0brainx6.com +y0ituhabqwjpnua.cf +y0ituhabqwjpnua.ga +y0ituhabqwjpnua.gq +y0ituhabqwjpnua.ml +y0ituhabqwjpnua.tk +y0rkhm246kd0.cf +y0rkhm246kd0.ga +y0rkhm246kd0.gq +y0rkhm246kd0.ml +y0rkhm246kd0.tk +y0up0rn.cf +y0up0rn.ga +y0up0rn.gq +y0up0rn.ml +y0up0rn.tk +y1vmis713bucmc.cf +y1vmis713bucmc.ga +y1vmis713bucmc.gq +y1vmis713bucmc.ml +y1vmis713bucmc.tk +y2b.comx.cf +y2bfjsg3.xorg.pl +y2key.anonbox.net +y2kpz7mstrj.cf +y2kpz7mstrj.ga +y2kpz7mstrj.gq +y2kpz7mstrj.ml +y2kpz7mstrj.tk +y2ube.comx.cf +y2y4.com +y3dvb0bw947k.cf +y3dvb0bw947k.ga +y3dvb0bw947k.gq +y3dvb0bw947k.ml +y3dvb0bw947k.tk +y3elp.com +y4vpy.anonbox.net +y59.jp +y5artmb3.pl +y7bbbbbbbbbbt8.ga +y7hkm.anonbox.net +y8fr9vbap.pl +y97dtdiwf.pl +y9oled.spymail.one +ya-doctor.ru +ya-gamer.ru +ya.yomail.info +yaa.emlhub.com +yaachea.com +yaaoho.com +yaasked.com +yabai-oppai.tk +yabba-dabba-dashery.co.uk +yabes.ovh +yabingu.com +yabumail.com +yacxrz.pl +yadaptorym.com +yadavnaresh.com.np +yadira.jaylyn.paris-gmail.top +yadkincounty.org +yadoo.ru +yaelahrid.net +yaelahtodkokgitu.cf +yaelahtodkokgitu.ga +yaelahtodkokgitu.gq +yaelahtodkokgitu.ml +yaelahtodkokgitu.tk +yafrem3456ails.com +yagatekimi.com +yagg.com +yagmursarkasla.sbs +yagoo.co.uk +yaha.com +yahaoo.co.uk +yahho.gr +yahho.jino.ru +yahikod.online +yahio.co.in +yahj.com +yahkunbang.com +yahmail.top +yahnmtntxwhxtymrs.cf +yahnmtntxwhxtymrs.ga +yahnmtntxwhxtymrs.gq +yahnmtntxwhxtymrs.ml +yahnmtntxwhxtymrs.tk +yaho.co.uk +yaho.com +yaho.gr +yahoa.top +yahobi.com +yaholo.cloud +yahomail.gdn +yahomail.top +yahoo-mail.ga +yahoo.co.au +yahoo.com.es.peyekkolipi.buzz +yahoo.comx.cf +yahoo.cu.uk +yahoo.myvnc.com +yahoo.us +yahoo.vo.uk +yahoo.xo.uk +yahoodashtrick.com +yahooi.aol +yahoomail.fun +yahoon.com +yahooo.com +yahooo.com.mx +yahooproduct.com +yahooproduct.net +yahoots.com +yahooweb.co +yahooz.com +yahooz.xxl.st +yahop.co.uk +yahu.com +yahuu.com.uk +yaihoo.com +yajasoo2.net +yajh.yomail.info +yajoo.de +yakali.me +yakelu.com +yakisoba.ml +yalamail.com +yalc.freeml.net +yaldc.anonbox.net +yale-lisboa.com +yalexonyegues.com +yalhethun.com +yalild.tk +yaloo.fr.nf +yalta.krim.ws +yamaika-nedv.ru +yamail.win +yamails.net +yaman3raby.com +yamanaraby.com +yamandex.com +yammoe.yoga +yammyshop.com +yanasway.com +yandeix.com +yandere.cu.cc +yandere.site +yandex.ca +yandex.cfd +yandex.comx.cf +yandex.net +yandex.uk.com +yandexmail.cf +yandexmail.ga +yandexmail.gq +yandexmailserv.com +yanet.me +yang-ds.top +yang-gtens.pro +yangdaye-ds.cc +yangzhong-sfd.shop +yanimateds.com +yanj.com +yankee.epsilon.coayako.top +yankeeecho.wollomail.top +yannmail.win +yanseti.net +yansoftware.vn +yaoghyth.xyz +yaojiaz.com +yaoo.co +yaoo.fr +yaoshe149.com +yapan-nedv.ru +yapmail.com +yapn.com +yapped.net +yappeg.com +yaqp.com +yaraon.cf +yaraon.ga +yaraon.gq +yaraon.ml +yaraon.tk +yaratakamsl.cfd +yarien.eu +yarmarka-alla.ru +yarnpedia.cf +yarnpedia.ga +yarnpedia.gq +yarnpedia.ml +yarnpedia.tk +yarnsandtails.com +yarpnetb.com +yarzmail.xyz +yasdownload.ir +yasellerbot.xyz +yasenasknj.site +yasewzgmax.pl +yashwantdedcollege.com +yasiok.com +yasiotio.com +yasir.studio +yasminnapper.art +yasser.ru +yastle.com +yasutech.com +yatesmail.men +yaturistt.ru +yaungshop.com +yausmail.com +yavolshebnik.ru +yawemail.com +yaxoo.com +yay.spymail.one +yayo.com +yayobaebyeon.com +yayoo.co.uk +yayoo.com.mx +yazenwesam.site +yazenwesam.tech +yazenwesam.website +yazenwesamnusair.website +yazobo.com +yazoon101.shop +yb.emlpro.com +yb45tyvn8945.cf +yb45tyvn8945.ga +yb45tyvn8945.gq +yb45tyvn8945.ml +yb45tyvn8945.tk +yb5800.com +yb78oim.cf +yb78oim.ga +yb78oim.gq +yb78oim.ml +yb78oim.tk +ybananaulx.com +ybc.laste.ml +ybcfo.anonbox.net +yberyfi.life +ybfphoto.com +ybpxbqt.pl +ybrc8n.site +ybtsb.ml +ybymlcbfwql.pl +yc.emlhub.com +yc9obkmthnla2owe.cf +yc9obkmthnla2owe.ga +yc9obkmthnla2owe.gq +yc9obkmthnla2owe.ml +yc9obkmthnla2owe.tk +ycalstore.com +ycalstore.shop +ycar.yomail.info +ycare.de +ycarpet.com +yccyds.com +yceqsd.tk +ycg.freeml.net +ychatz.ga +yckd.yomail.info +ycm.emlpro.com +ycm.yomail.info +ycm813ebx.pl +ycn.ro +ycnn.mimimail.me +ycoq.emltmp.com +ycxjg4.emlhub.com +ycxrd1hlf.pl +ycykly.com +yd.freeml.net +yd.laste.ml +yd2yd.org +yd3jj.anonbox.net +ydah.me +ydd.emlhub.com +ydeclinegv.com +ydgeimrgd.shop +ydlmkoutletjackets9us.com +ydsbinai.com +ydscontingencia.com +ye.vc +ye5gy.anonbox.net +yeacsns.com +yeafam.com +yeah.com +yeah.net.com +yeah.net.net +yeahdresses.com +yeamail.info +year.cowsnbullz.com +year.lakemneadows.com +year.marksypark.com +year.oldoutnewin.com +year.ploooop.com +yearbooks.xyz +yearheal.site +yearmoon.club +yearmoon.online +yearmoon.site +yearmoon.website +yearmoon.xyz +yearstory.us +yeartz.site +yearway.biz +yeastinfectionnomorenow.com +yeckelk.tech +yecp.ru +yecp.store +yedi.org +yeeeou.org.ua +yeezus.ru +yefchk.shop +yefx.info +yehp.com +yehudabx.com +yeij.freeml.net +yejdnp45ie1to.cf +yejdnp45ie1to.ga +yejdnp45ie1to.gq +yejdnp45ie1to.ml +yejdnp45ie1to.tk +yektara.com +yellnbmv766.cf +yellnbmv766.ga +yellnbmv766.gq +yellnbmv766.ml +yellnbmv766.tk +yellow.flu.cc +yellow.hotakama.tk +yellow.igg.biz +yellow.org.in +yellowbook.com.pl +yellowen.com +yellowt.site +yelloww.ga +yelloww.gq +yelloww.ml +yelloww.tk +yemailme.com +yemenfo.com +yenigulen.xyz +yenimail.site +yentzscholarship.xyz +yeonn.anonbox.net +yep.it +yepbd.com +yepmail.app +yepmail.cc +yepmail.club +yepmail.co +yepmail.email +yepmail.id +yepmail.in +yepmail.to +yepmail.us +yepmail.ws +yepnews.com +yeppee.net +yepwprntw.pl +yermail.net +yert.ye.vc +yertxenon.tk +yertxenor.tk +yes100.com +yesaccounts.net +yesearphone.com +yesey.net +yesgurgaon.com +yesiyu.com +yesmail.edu.pl +yesnauk.com +yesnews.info +yesterday2010.info +yesterdie.me +yeswecanevents.info +yeswep.com +yeswetoys.com +yesyes.site +yetmail.net +yeumark.ga +yeumarknhieu.ga +yeupmail.cf +yevme.com +yeweuqwtru.tk +yewma46eta.ml +yewmail.com +yewtoob.ml +yewtyigrhtyu.co.cc +yeyenlidya.art +yeyj.spymail.one +yf.emlpro.com +yf.laste.ml +yf877.com +yfdaqxglnz.pl +yfgr.emlhub.com +yfi.freeml.net +yfpoloralphlaurenpascher.com +yfqkryxpygz.pl +yfwl.yomail.info +yg.freeml.net +ygdyukttmz.ga +ygfwhcpaqf.pl +ygh.laste.ml +ygmail.pl +ygmx.de +ygnzqh2f97sv.cf +ygnzqh2f97sv.ga +ygnzqh2f97sv.gq +ygnzqh2f97sv.ml +ygnzqh2f97sv.tk +ygop.com +ygroupvideoarchive.com +ygroupvideoarchive.net +yh.laste.ml +yh08c6abpfm17g8cqds.cf +yh08c6abpfm17g8cqds.ga +yh08c6abpfm17g8cqds.gq +yh08c6abpfm17g8cqds.ml +yh08c6abpfm17g8cqds.tk +yh6686.com +yh9837.com +yhcaturkl79jk.tk +yhcaturxc69ol.ml +yhei.laste.ml +yhg.biz +yhjgh65hghgfj.tk +yhldqhvie.pl +yhm.emltmp.com +yhoes.anonbox.net +yhoo.co +yhoo.in +yhrqf.anonbox.net +yhx.emlhub.com +yidongo.xyz +yieldinstitute.org +yifan.net +yihacihuy.top +yikusaomachi.com +yikv.mailpwr.com +yikwvmovcj.pl +yinbox.net +yingeshiye.com +yingka-yule.com +yippamail.info +yipsymail.info +yishengting.dynamailbox.com +yiustrange.com +yixiu.site +yj.yomail.info +yj3nas.cf +yj3nas.ga +yj3nas.gq +yj3nas.ml +yj3nas.tk +yjav28.com +yjcoupone.com +yje.emltmp.com +yjnkteez.pl +yju.emltmp.com +yk.emlhub.com +yk.laste.ml +yk20.com +yk55p.anonbox.net +ykf.emlpro.com +ykj.freeml.net +ykkb.emltmp.com +ykmov.com +yko.xyz +yks247.com +yl66.cfd +yliuetcxaf405.tk +yljthese.com +ylkht.com +yllw.us +ylop.spymail.one +ylouisvuittonoutlet.net +yltemvfak.pl +ylu.emlhub.com +yluxuryshomemn.com +ylv.laste.ml +ylvaj.anonbox.net +ylvk.emltmp.com +ym.cypi.fr +ym.digi-value.fr +ym.freeml.net +ym.spymail.one +ymai.com +ymail.edu +ymail.fr +ymail.net +ymail.org +ymail.site +ymail.villien.net +ymail365.com +ymail4.com +ymails.pw +ymaim.com +ymca-arlington.org +ymcswjdzmx.pl +ymdeeil.com +ymdeiel.com +ymdeil.com +yme7p.anonbox.net +ymedeil.com +ymeeil.com +ymemphisa.com +ymfcbpvxur.ga +ymg.freeml.net +ymggs.tk +ymhis.com +ymonthl.com +ymrnvjjgu.pl +ymt198.com +ymvosiwly.pl +ymyl.com +ymzil.com +yn.laste.ml +yn8jnfb0cwr8.cf +yn8jnfb0cwr8.ga +yn8jnfb0cwr8.gq +yn8jnfb0cwr8.ml +yn8jnfb0cwr8.tk +ynagjie-66.cc +ynamedm.com +ynaturalsl.com +ynbzona.online +yncyjs.com +yndrinks.com +ynfq.emltmp.com +ynifewesu.xyz +ynmerchant.com +ynmrealty.com +ynomagaka.agency +ynq.freeml.net +ynskleboots.com +ynylgo.emlhub.com +yo.dropmail.me +yo.laste.ml +yobe.pl +yocgrer.com +yodaat.com +yodx.ro +yody.cloud +yofibeauty.com +yogainsurancequote.com +yogamaven.com +yoggm.com +yogirt.com +yogivest.com +yogod.com +yogoka.com +yogrow.co +yogurtcereal.com +yogurtdolapta.top +yoh.emlpro.com +yohannex.com +yohomail.ga +yohomail.ml +yohoo.co.in +yohp.emlpro.com +yois.spymail.one +yok.dropmail.me +yokmpqg.pl +yolbiletim.xyz +yoloisforgagsnoob.com +yolooo.top +yomail.com +yomail.edu.pl +yomail.info +yomand.store +yomj.emlhub.com +yompail.com +yonaki.xyz +yone.cam +yone.site +yongshuhan.com +yonisp.site +yoo.ro +yood.org +yop.email +yop.emersion.fr +yop.fexp.io +yop.itram.es +yop.kyriog.fr +yop.mabox.eu +yop.mc-fly.be +yop.milter.int.eu.org +yop.moolee.net +yop.profmusique.com +yop.smeux.com +yop.too.li +yop.uuii.in +yop.ze.cx +yopail.com +yopmai.com +yopmail.biz.st +yopmail.cf +yopmail.co +yopmail.com +yopmail.fr +yopmail.fr.nf +yopmail.gq +yopmail.info +yopmail.kro.kr +yopmail.ml +yopmail.net +yopmail.org +yopmail.ozm.fr +yopmail.pp.ua +yopmail.usa.cc +yopmail2.tk +yopmali.com +yopmsil.com +yopp.com +yoptmail.com +yoptruc.fr.nf +yopweb.com +yor.laste.ml +yordanmail.cf +yorfan.com +yorikoangeline.art +yorkcountygov.co +yorkieandco.com +yormanwhite.ml +yoru-dea.com +yoseek.de +yosemail.com +yosigopix.com +yossif.com +yostn.com +yotmail.com +yotmail.fr.nf +yotogroup.com +yotomail.com +you-qi.com +you-spam.com +you.cowsnbullz.com +you.has.dating +you.makingdomes.com +youanmi.cc +youbestone.pw +youcankeepit.info +youcap.site +youchat.ooo +youcloudme.tech +youdealonline.org +youfffgo.tk +yougotgoated.com +youiejfo03.com +youinspiredfitness.com +youinweb.xyz +youjury.com +youke1.com +youknow.blog +youknowscafftowrsz.com +youlike88box.com +youlikeme.website +youlynx.com +youmail.ga +youmailr.com +youmails.online +youmoos.com +youneedmore.info +young-app-lexacc.com +youngbloodproductions.site +youngbrofessionals.com +youngcrew.ga +younghemp.com +younsih.store +youporn.flu.cc +youporn.igg.biz +youporn.usa.cc +youpymail.com +youquwa.cn +your-free-mail.bid +your-health.store +your-ugg-boots.com +your.fullemedia-deals.info +your.lakemneadows.com +your5.ru +your5.store +youractors24.com +yourannuityadvisors.com +youraquatics.com +yourascsc.com +yourbeautifulphoto.com +yourbesthvac1.com +yourbonus.win +yourbrandsites.com +yourbusiness.com +yourbusinesstips.biz +yourbutt.com +yourcakerecipe.com +yourcolor.net +yourdad.com +yourdemowebsite.info +yourdomain.com +yourdoman.com +youredoewcenter.com +youredoewlive.com +youremail.cf +youremail.info +youremail.top +youremaillist.com +yourent.us +yourewronghereswhy.com +yourfastcashloans.co.uk +yourfastmail.com +yourfilm.pl +yourfilmsite.com +yourfitnessguide.org +yourfreegalleries.net +yourfreemail.bid +yourfreemail.stream +yourfreemail.streammmail.men +yourfreemail.tk +yourfreemail.website +yourfreemailbox.co +yourhealthguide.co.uk +yourhighness5.info +yourhomesecured.net +yourhouselive.com +yourimail.bid +yourimail.download +yourimail.website +yourimbox.cf +yourinbox.co +youripost.bid +youripost.download +yourlabs.org +yourlms.biz +yourlovelive.com +yourluck.com +yourmail.online +yourmail.work +yourmailbox.co +yourmailpro.bid +yourmailpro.stream +yourmailpro.website +yourmailtoday.com +yourmedicinecenter.net +yourmisd23.com +yourmoode.info +yournetsolutions.bid +yournetsolutions.stream +yournetsolutions.website +yournogtrue.top +youroldemail.com +youropinion.ooo +yourphen375.com +yourphoto.pl +yourpochta.tk +yourquickcashloans.co.uk +yourqwik.cf +yours.tools +yoursent.gq +yourseo.name +yourshoesandhandbags.com +yoursmileava.info +yoursmileirea.info +yoursmilejulia.info +yoursmilekylie.info +yoursmilelily.info +yoursmilemia.info +yoursmileriley.info +yourspace.su +yourspamgoesto.space +yourssecuremail.com +yourstat.com +yoursuprise.com +yourtrading.com +yourtube.ml +yourvideoq.com +yourweb.email +yousmail.com +youspam.com +youtext.online +youthexchange.club +youtjube.waw.pl +youtube.comx.cf +youtube2vimeo.info +youvegotgeekonyou.com +youveo.ch +youwatchmovie.com +youwillenjoythis.com +youwinhair.com +youxiang.dev +youzend.net +yow.dropmail.me +yowinbet.info +yowis.xyz +yoyo11.xyz +yoyo69.com +yoyomedia.online +yozgatyazilim.xyz +yp.yomail.info +ypaq.laste.ml +ypd.yomail.info +ype68.com +ypflorencek.com +ypicall.shop +ypmail.sehier.fr +ypmail.webarnak.fr.eu.org +yppm0z5sjif.ga +yppm0z5sjif.gq +yppm0z5sjif.ml +yppm0z5sjif.tk +ypq.yomail.info +yprbcxde1cux.cf +yprbcxde1cux.ga +yprbcxde1cux.gq +yprbcxde1cux.ml +yprbcxde1cux.tk +yps.laste.ml +ypsilantiapartments.com +yq.spymail.one +yq3rh.anonbox.net +yq6iki8l5xa.cf +yq6iki8l5xa.ga +yq6iki8l5xa.gq +yq6iki8l5xa.ml +yq6iki8l5xa.tk +yqcb.tk +yqejb1.site +yquhnhipm.pl +yqww14gpadey.cf +yqww14gpadey.ga +yqww14gpadey.ml +yqww14gpadey.tk +yr.freeml.net +yr.laste.ml +yr22l7.xorg.pl +yraj46a46an43.tk +yreferenta.ru +yreilof.xyz +yremovedr.com +yrgh.yomail.info +yrhirouge.com +yrkw.emltmp.com +yrmno5cxjkcp9qlen8t.cf +yrmno5cxjkcp9qlen8t.ga +yrmno5cxjkcp9qlen8t.gq +yrmno5cxjkcp9qlen8t.ml +yrmno5cxjkcp9qlen8t.tk +yrnt.laste.ml +yroid.com +yrt74748944.cf +yrt74748944.ga +yrt74748944.gq +yrt74748944.ml +yrt74748944.tk +yrukybuc.com +yrxwvnaovm.pl +ysbnkz.com +ysc.co.in +yshdnhh.click +yslighting.com +yslonsale.com +ysmm3.us +yspend.com +ystea.org +ysu.emlpro.com +ysuhd.art +ysweb.id +yt-creator.com +yt-dl.net +yt-google.com +yt.emltmp.com +yt2.club +yt6erya4646yf.gq +ytchanneltips.com +yteb.com +ytg456hjkjh.cf +ytg456hjkjh.ga +ytg456hjkjh.gq +ytg456hjkjh.ml +ytg456hjkjh.tk +yth533.com +ytnhy.com +ytpayy.com +ytransunion.com +yttermurene.ml +yttrevdfd.pl +ytubrrr.motorcycles +ytutrl.co.uk +ytvivekdarji.tk +yu.com +yu.emlhub.com +yu.freeml.net +yu4ss.anonbox.net +yua.emlpro.com +yuandex.ru +yubc.com +yubua.com +yuduma.com +yue.universallightkeys.com +yueluqu.cn +yuf.laste.ml +yufmail.com +yugasandrika.com +yugfbjghbvh8v67.ml +yughfdjg67ff.ga +yugz.com +yuhknow.com +yui.it +yuinhami.com +yuirz.com +yujiehanjiao.cc +yuki.ren +yukiji.org +yukios.web.id +yuliarachman.art +yuljeondong.com +yulk.com +yumimi22.com +yummiesdrip.com +yummy-fast.fr +yummyrecipeswithchicken.com +yumne.anonbox.net +yun.pics +yunail.com +yunchali.com +yungkashsk.com +yunhlay.com +yuniang.club +yunik.in +yunitadavid.art +yunjijiji.com +yunpanke.com +yunsseop.com +yuoia.com +yups.xyz +yurakim.cfd +yuricdos.tech +yurikeprastika.art +yuristpro.xyz +yuslamail.com +yusmpgroup.ru +yusolar.com +yut.com +yut.emltmp.com +yutayutas.com +yutep.com +yutongdt.com +yutrier8e.com +yuurok.com +yuuuyyyyyui.site +yuuywil.date +yuveu.emlpro.com +yuweioaso.tk +yuxuan.mobi +yuyoshop.site +yuz.freeml.net +yuzu.emlhub.com +yvc.com +yvessaintlaurentshoesuk.com +yvgalgu7zt.cf +yvgalgu7zt.ga +yvgalgu7zt.gq +yvgalgu7zt.ml +yvgalgu7zt.tk +yvgscope.com +yvq.freeml.net +yvq.spymail.one +yw.freeml.net +ywamarts.org +ywgv.emltmp.com +ywhg.laste.ml +ywsgeli.com +ywy.info +ywys.emltmp.com +ywzd.laste.ml +ywzmb.top +yx.dns-cloud.net +yx.emlhub.com +yx.emltmp.com +yx26oz76.xzzy.info +yx48bxdv.ga +yxbv0bipacuhtq4f6z.ga +yxbv0bipacuhtq4f6z.gq +yxbv0bipacuhtq4f6z.ml +yxbv0bipacuhtq4f6z.tk +yxd.laste.ml +yxdad.ru +yxdad.store +yxja.dropmail.me +yxjump.ru +yxk.freeml.net +yxo.emltmp.com +yxse.laste.ml +yxzx.net +yy-h2.nut.cc +yy.dropmail.me +yy.spymail.one +yy2h.info +yya.emltmp.com +yyaahooo.com +yyb.laste.ml +yyb.spymail.one +yyhmail.com +yyj295r31.com +yyolf.net +yyoxr.anonbox.net +yytv.ddns.net +yyy.yomail.info +yyymail.pl +yz2wbef.pl +yzbid.com +yzcalo.com +yzenwesam.website +yzhz78hvsxm3zuuod.cf +yzhz78hvsxm3zuuod.ga +yzhz78hvsxm3zuuod.ml +yzi.dropmail.me +yzidaxqyt.pl +yzkrachel.com +yzm.de +yznakandex.ru +yzp.emltmp.com +yzpl.freeml.net +yzqb.yomail.info +yzts.emltmp.com +yzvy.com +yzx12.com +z-7mark.ru +z-mail.cf +z-mail.ga +z-mail.gq +z-mild.ga +z-o-e-v-a.ru +z.polosburberry.com +z.thepinkbee.com +z0210.gmailmirror.com +z0d.eu +z18wgfafghatddm.cf +z18wgfafghatddm.ga +z18wgfafghatddm.gq +z18wgfafghatddm.ml +z18wgfafghatddm.tk +z1p.biz +z1tiixjk7juqix94.ga +z1tiixjk7juqix94.ml +z1tiixjk7juqix94.tk +z2v.ru +z3pbtvrxv76flacp4f.cf +z3pbtvrxv76flacp4f.ga +z3pbtvrxv76flacp4f.gq +z3pbtvrxv76flacp4f.ml +z3pbtvrxv76flacp4f.tk +z3rb.com +z48bk5tvl.pl +z5cpw9pg8oiiuwylva.cf +z5cpw9pg8oiiuwylva.ga +z5cpw9pg8oiiuwylva.gq +z5cpw9pg8oiiuwylva.ml +z5cpw9pg8oiiuwylva.tk +z65hw.anonbox.net +z6dls.anonbox.net +z6enr.anonbox.net +z6z7tosg9.pl +z7az14m.com +z7az14m.com.com +z86.ru +z870wfurpwxadxrk.ga +z870wfurpwxadxrk.gq +z870wfurpwxadxrk.ml +z870wfurpwxadxrk.tk +z8zcx3gpit2kzo.gq +z8zcx3gpit2kzo.ml +z8zcx3gpit2kzo.tk +za.com +za72p.com +zaa.org +zaab.de +zabawki.edu.pl +zabbabox.info +zabfbuaudmf.laste.ml +zabross.com +zachpacks.online +zackstore-re.com +zaderatsky.info +zadowolony-klient.org +zaebbalo.info +zaednoschools.org +zaelmo.com +zaerapremiumbar.com +zafrem3456ails.com +zaftneqz2xsg87.cf +zaftneqz2xsg87.ga +zaftneqz2xsg87.gq +zaftneqz2xsg87.ml +zaftneqz2xsg87.tk +zagorski-artstudios.com +zagrajse.pl +zagvxqywjw.pl +zahav.net +zahsdfes.cloud +zahuy.site +zai.dropmail.me +zaim-fart.ru +zaim-online-na-kartu.su +zaimi-na-karty.ru +zain.com.co +zain.site +zainapi.tech +zainmax.net +zainoyen.online +zaixmeigy.my.id +zak.laste.ml +zakachaisya.org +zakan.ir +zakatdimas.site +zakkaas.com +zakl.org +zaknama.com +zaktouni.fr +zakzsvpgxu.pl +zalmem.com +zalopner87.com +zalotti.com +zalvisual.us +zalzl.com +zamana.com +zamaneta.com +zambia-nedv.ru +zamburu.com +zamd7.anonbox.net +zamena-stekla.ru +zamge.com +zamiana-domu.pl +zamojskie.com.pl +zamownie.pl +zamua.com +zane.is +zane.pro +zane.prometheusx.pl +zane.rocks +zanemail.info +zangcirodic.com +zanichelli.cf +zanichelli.ga +zanichelli.gq +zanichelli.ml +zanichelli.tk +zanmei5.com +zantrax.com +zanzedalo.com +zanzimail.info +zaoonline.com +zap2q0drhxu.cf +zap2q0drhxu.ga +zap2q0drhxu.gq +zap2q0drhxu.ml +zap2q0drhxu.tk +zapak.com +zapak.in +zapatos.sk +zapbox.fr +zapchasti-orig.ru +zapchati-a.ru +zapilou.net +zapstibliri.xyz +zapviral.com +zapzap.dev +zapzap.events +zapzap.host +zapzap.legal +zapzap.solutions +zapzap.space +zapzap.store +zapzap.support +zapzap.video +zapzapcloud.com +zarabar.com +zarabotok-biz.ru +zarabotok-v-internet.ru +zarabotokdoma11.ru +zarada7.co +zaragozatoros.es +zaranew.live +zard.website +zareizen.com +zareta.xyz +zarhq.com +zariaglam.shop +zarkbin.store +zarmail.com +zaromias24.net +zarplatniy-proekt.ru +zaruchku.ru +zarweek.cf +zarweek.ga +zarweek.tk +zasedf.fun +zasns.com +zasod.com +zasve.info +zatopplomi.xyz +zauberfeile.com +zauq.dropmail.me +zauxu.com +zavame.com +zavio.com.pl +zavio.nl +zavodzet.ru +zavvio.com +zaxby.com +zaxoffice.com +zaya.ga +zaym-zaym.ru +zaymi-srochno.ru +zaztraz.ml +zazzerz.com +zb.yomail.info +zbarman.com +zbcya.anonbox.net +zbestcheaphostingforyou.info +zbhh.spymail.one +zbia.freeml.net +zbil.emlpro.com +zbio.freeml.net +zbiznes.ru +zbl43.pl +zbl74.pl +zbnz.mailpwr.com +zbock.com +zbook.site +zbpefn95saft.cf +zbpefn95saft.ga +zbpefn95saft.gq +zbpefn95saft.ml +zbpefn95saft.tk +zbpg.yomail.info +zbpu84wf.edu.pl +zbtxx4iblkgp0qh.cf +zbtxx4iblkgp0qh.ga +zbtxx4iblkgp0qh.gq +zbtxx4iblkgp0qh.ml +zbtxx4iblkgp0qh.tk +zbuteo.buzz +zbw.spymail.one +zbyhis.online +zc.emlpro.com +zc.emltmp.com +zc300.gq +zc3dy5.us +zcai55.com +zcai66.com +zcai77.com +zcasbwvx.com +zcash-cloud.com +zcash.ml +zcash.tk +zcdo.com +zchatz.ga +zcl.laste.ml +zcl.yomail.info +zcovz.ru +zcovz.store +zcqrgaogm.pl +zcqwcax.com +zcrcd.com +zcut.de +zczr2a125d2.com +zczr2a5d2.com +zczr2ad1.com +zczr2ad2.com +zczwnv.emlhub.com +zd.freeml.net +zd6k3a5h65.ml +zdanisphotography.com +zdbgjajg.shop +zde.spymail.one +zdecadesgl.com +zdenka.net +zdesyaigri.ru +zdf.spymail.one +zdfg.mimimail.me +zdfpost.net +zdgvxposc.pl +zdlx.freeml.net +zdorove-polar.ru +zdpuppyiy.com +zdrajcy.xyz +zdrowewlosy.info +zdrowystyl.net +zds.emlhub.com +zdx.emlpro.com +zdx.emltmp.com +ze.cx +ze.dropmail.me +ze.gally.jp +ze.tc +zeah.de +zealouste.com +zealouste.net +zeas.com +zebins.com +zebins.eu +zebra.email +zebrank.com +zebua.cf +zebuaboy.cf +zebuasadis.ml +zeca.com +zecash.ml +zecf.cf +zeczen.ml +zedo8o.cloud +zedsoft.net +zeducation.tech +zedx.laste.ml +zedy.emlpro.com +zeego.site +zeemail.xyz +zeemails.in +zeevoip.com +zeex.tech +zefara.com +zefboxedl.com +zeft-ten.cf +zeft-ten.ga +zeft-ten.gq +zeft-ten.ml +zeft-ten.tk +zegt.de +zehnminuten.de +zehnminutenmail.de +zeinconsulting.info +zekzo.com +zelras.ru +zeltool.xyz +zemail.ga +zemail.ml +zemasia.com +zemliaki.com +zemzar.net +zen.nieok.com +zen43.com.pl +zen74.com.pl +zenarz.esmtp.biz +zenbada.com +zenblogpoczta.com.pl +zenbyul.com +zencart-web.com +zencleansereview.com +zenek-poczta.com.pl +zenekpoczta.com.pl +zenithagedcare.sydney +zenithcalendars.info +zenithinbox.com +zenithlynow.com +zenocoomniki.ru +zenopoker.com +zenpocza.com.pl +zenpoczb.com.pl +zenpoczc.com.pl +zenrz.itemdb.com +zensolutions.info +zenthranet.com +zentrumbox.com +zenze.cf +zep-hyr.com +zepco.ru +zepexo.com +zephrmail.info +zephyrustech.co +zepp.dk +zepter-moscow.biz +zer-0.cf +zer-0.ga +zer-0.gq +zer-0.ml +zero.cowsnbullz.com +zero.makingdomes.com +zero.marksypark.com +zero.net +zero.oldoutnewin.com +zero.ploooop.com +zero.poisedtoshrike.com +zerocopter.dev +zerocoptermail.com +zerodog.icu +zeroe.ml +zeroen-douga.tokyo +zerograv.top +zeroknow.ga +zeromail.ga +zeronerbacomail.com +zeronex.ml +zeroonesi.shop +zeropolly.com +zerotermux.pm +zerotohero-1.com +zertigo.org +zest.me.uk +zesta.cf +zesta.gq +zestroy.info +zeta-telecom.com +zetaquebec.wollomail.top +zetccompany.com +zetfilmy.pl +zetgets.com +zeth.emlpro.com +zetia.in +zetmail.com +zettransport.pl +zeun.emltmp.com +zevars.com +zeveyuse.com +zeveyuse.net +zevionyx.com +zew.emltmp.com +zexal.io +zexeet9i5l49ocke.cf +zexeet9i5l49ocke.ga +zexeet9i5l49ocke.gq +zexeet9i5l49ocke.ml +zexeet9i5l49ocke.tk +zey.emlhub.com +zeyadooo.cloud +zeycan.xyz +zeynepgenc.com +zeytinselesi.com +zezis.ru +zf-boilerplate.com +zf4r34ie.com +zfasao.buzz +zfbj.dropmail.me +zfbxh.anonbox.net +zfilm1.ru +zfilm3.ru +zfilm5.ru +zfilm6.ru +zfke.mimimail.me +zfobo.com +zfshqt.online +zfu.yomail.info +zfvi.laste.ml +zfymail.com +zg.emlhub.com +zg.yomail.info +zgame.zapto.org +zgcc.dropmail.me +zggbzlw.net +zggyfzyxgs.com +zgi.spymail.one +zgjs.freeml.net +zgm-ural.ru +zgs.emlpro.com +zgs.emltmp.com +zgsq.emlpro.com +zgu5la23tngr2molii.cf +zgu5la23tngr2molii.ga +zgu5la23tngr2molii.gq +zgu5la23tngr2molii.ml +zgu5la23tngr2molii.tk +zgxxt.com +zh.ax +zh9.info +zhaohishu.com +zhaoqian.ninja +zhaoyuanedu.cn +zhcne.com +zhcvqqbvdc.ga +zhehot.com +zhendeaiai.top +zhengjiatpou34.info +zhenniubia.top +zherben.com +zhess.xyz +zhewei88.com +zhibo69.com +zhidkiy-gazon.ru +zhiezpremium.store +zhongchengtz.com +zhongsongtaitu.com +zhongy.in +zhorachu.com +zhx.emlhub.com +ziahask.ru +ziawd.com +zib.com +zibiz.me +zibox.info +zidu.pw +zielonadioda.com +zielonyjeczmiennaodchudzanie.xyz +zife.emlpro.com +zigblog.net +zige.my +ziggurattemple.info +zigounet.com +zigtc.anonbox.net +zigurblog.com +zigzagcreations.com +zihaddd12.com +zik.dj +zik2zik.com +zikozikoqq.shop +zikzak.gq +zil4czsdz3mvauc2.cf +zil4czsdz3mvauc2.ga +zil4czsdz3mvauc2.gq +zil4czsdz3mvauc2.ml +zil4czsdz3mvauc2.tk +zillermins.com +zillionsofdollars.info +zilmail.cf +zilmail.ga +zilmail.gq +zilmail.ml +zilmail.tk +zimail.com +zimail.ga +zimbabwe-nedv.ru +zimbail.me +zimbocrowd.info +zimbocrowd.me +zimmermail.info +zimowapomoc.pl +zinany.com +zinfighkildo.ftpserver.biz +zingar.com +zingermail.co +zinggalms.shop +zingmail.shop +zingsingingfitness.com +zinmail.cf +zinmail.ga +zinmail.gq +zinmail.ml +zinmail.tk +zintomex.com +zip1.site +zip3.site +zipa.space +zipab.site +zipac.site +zipada.com +zipaf.site +zipas.site +zipax.site +zipb.site +zipb.space +zipbox.info +zipc.site +zipcad.com +zipcatfish.com +zipd.press +zipd.site +zipd.space +zipdf.biz +zipea.site +zipeb.site +zipec.site +ziped.site +zipee.site +zipef.site +zipeg.site +zipeh.site +zipej.site +zipek.site +zipel.site +zipem.site +zipen.site +zipeo.site +zipep.site +zipeq.site +zipes.site +zipet.site +ziph.site +zipil.site +zipir.site +zipk.site +zipl.online +zipl.site +ziplb.biz +zipn.site +zipo1.cf +zipo1.ga +zipo1.gq +zipo1.ml +zipphonemap.com +zippiex.com +zippydownl.eu +zippymail.in +zippymail.info +zipq.site +zipr.site +ziprol.com +zips.design +zipsa.site +zipsb.site +zipsc.site +zipsd.site +zipsendtest.com +zipsf.site +zipsg.site +zipsh.site +zipsi.site +zipsj.site +zipsk.site +zipsl.site +zipsm.site +zipsmtp.com +zipsn.site +zipso.site +zipsp.site +zipsq.site +zipsr.site +zipss.site +zipst.site +zipsu.site +zipsv.site +zipsw.site +zipsx.site +zipsy.site +zipsz.site +zipt.site +ziptracker49062.info +ziptracker56123.info +ziptracker67311.info +ziptracker67451.info +ziptracker75121.info +ziptracker87612.info +ziptracker90211.info +ziptracker90513.info +zipv.site +zipw.site +zipx.site +zipz.site +zipza.site +zipzaprap.beerolympics.se +zipzaps.de +zipzb.site +zipzc.site +zipzd.site +zipze.site +zipzf.site +zipzg.site +zipzh.site +zipzi.site +zipzj.site +zipzk.site +zipzl.site +zipzm.site +zipzn.site +zipzo.site +zipzp.site +zipzq.site +zipzr.site +zipzs.site +zipzt.site +zipzu.site +zipzv.site +zipzw.site +zipzx.site +zipzy.site +zipzz.site +zira.my +zirafyn.com +ziragold.com +zisustand.site +zita-blog-xxx.ru +zithromaxdc.com +zithromaxonlinesure.com +zithromaxprime.com +ziuta.com +zivella.online +zivox.sbs +ziwiki.com +zixoa.com +ziyap.com +ziza.pl +zizhuxizhu888.info +zizo7.com +zizozizo8818.shop +zj4ym.anonbox.net +zjexmail.com +zjhonda.com +zjhplayback.com +zjl.dropmail.me +zjm.freeml.net +zjs.spymail.one +zjx.yomail.info +zkcckwvt5j.cf +zkcckwvt5j.ga +zkcckwvt5j.gq +zkcckwvt5j.ml +zkcckwvt5j.tk +zkeiw.com +zkgdtarov.pl +zknow.org +zkr.freeml.net +zl0irltxrb2c.cf +zl0irltxrb2c.ga +zl0irltxrb2c.gq +zl0irltxrb2c.ml +zl0irltxrb2c.tk +zlb.dropmail.me +zlcolors.com +zlebyqd34.pl +zledscsuobre9adudxm.cf +zledscsuobre9adudxm.ga +zledscsuobre9adudxm.gq +zledscsuobre9adudxm.ml +zledscsuobre9adudxm.tk +zleg.dropmail.me +zleohkaqpt5.cf +zleohkaqpt5.ga +zleohkaqpt5.gq +zleohkaqpt5.ml +zleohkaqpt5.tk +zljnbvf.xyz +zlmsl0rkw0232hph.cf +zlmsl0rkw0232hph.ga +zlmsl0rkw0232hph.gq +zlmsl0rkw0232hph.ml +zlmsl0rkw0232hph.tk +zlo.freeml.net +zlorkun.com +zltcsmym9xyns1eq.cf +zltcsmym9xyns1eq.tk +zlu.emlhub.com +zm.dropmail.me +zmail.cam +zmail.info.tm +zmailonline.info +zmat.xyz +zmedia.cloud +zmgr.yomail.info +zmho.com +zmiev.ru +zmilkofthecow.info +zmimai.com +zmpoker.info +zmsqlq.website +zmt.plus +zmtbbyqcr.pl +zmti6x70hdop.cf +zmti6x70hdop.ga +zmti6x70hdop.gq +zmti6x70hdop.ml +zmti6x70hdop.tk +zmya.emlpro.com +zmylf33tompym.cf +zmylf33tompym.ga +zmylf33tompym.gq +zmylf33tompym.ml +zmylf33tompym.tk +zmywarkilodz.pl +zn4chyguz9rz2gvjcq.cf +zn4chyguz9rz2gvjcq.ga +zn4chyguz9rz2gvjcq.gq +zn4chyguz9rz2gvjcq.ml +zn4chyguz9rz2gvjcq.tk +znaisvoiprava.ru +znatb25xbul30ui.cf +znatb25xbul30ui.ga +znatb25xbul30ui.gq +znatb25xbul30ui.ml +znatb25xbul30ui.tk +znb.laste.ml +zncqtumbkq.cf +zncqtumbkq.ga +zncqtumbkq.gq +zncqtumbkq.ml +zncqtumbkq.tk +znct.emlpro.com +zndsmail.com +zneep.com +znhoh.anonbox.net +zni1d2bs6fx4lp.cf +zni1d2bs6fx4lp.ga +zni1d2bs6fx4lp.gq +zni1d2bs6fx4lp.ml +zni1d2bs6fx4lp.tk +zniw.emltmp.com +znj.emltmp.com +znkzhidpasdp32423.info +znm.laste.ml +znnxguest.com +znqb.yomail.info +znsz.emlpro.com +znthe6ggfbh6d0mn2f.cf +znthe6ggfbh6d0mn2f.ga +znthe6ggfbh6d0mn2f.gq +znthe6ggfbh6d0mn2f.ml +znthe6ggfbh6d0mn2f.tk +znv.dropmail.me +znv.laste.ml +znyxer.icu +zo.emlpro.com +zoa.spymail.one +zoafemkkre.ga +zoaxe.com +zob.dropmail.me +zocial.ru +zodjbzyb.xyz +zoemail.com +zoemail.net +zoemail.org +zoetropes.org +zoeyexporting.com +zoeyy.com +zoftware.software +zohoseek.com +zoianp.com +zojb.com +zojr.com +zojx.spymail.one +zolingata.club +zomail.org +zomail.ru +zomantidecopics.site +zombie-hive.com +zombo.flu.cc +zombo.igg.biz +zombo.nut.cc +zomg.info +zomoo00.com +zona24.ru +zona7.com +zonamail.ga +zonamilitar.com +zonapara.fun +zonc.xyz +zone10electric.com +zonedating.info +zonedigital.club +zonedigital.online +zonedigital.site +zonedigital.xyz +zonemail.info +zonemail.monster +zontero.top +zontero.win +zooants.com +zoobug.org +zoohier.cfd +zooki.xyz +zooluck.org +zoomafoo.info +zoombbearhota.xyz +zoomclick.ru +zoomdayinst.biz.id +zoomial.info +zoomintens.com +zoomisme.io +zoomku.pro +zoomku.today +zoomm.site +zoomnavi.net +zooms.pro +zoonti.pl +zootree.org +zoozentrum.de +zoparel.com +zoqqa.com +zorg.fr.nf +zoroasterdomain.com +zoroasterplace.com +zoroastersite.com +zoroasterwebsite.com +zoromail.ga +zoromarkets.site +zosce.com +zotyxsod.shop +zouber.site +zoumail.fr +zoutlook.com +zouz.fr.nf +zoviraxprime.com +zozaco.com +zozozo123.com +zp.laste.ml +zpapersek.com +zpcaf8dhq.pl +zpensi.com +zpeo.emlhub.com +zpkdqkozdopc3mnta.cf +zpkdqkozdopc3mnta.ga +zpkdqkozdopc3mnta.gq +zpkdqkozdopc3mnta.ml +zpkdqkozdopc3mnta.tk +zplotsuu.com +zpp.su +zppw.emlpro.com +zpvozwsri4aryzatr.cf +zpvozwsri4aryzatr.ga +zpvozwsri4aryzatr.gq +zpvozwsri4aryzatr.ml +zpvozwsri4aryzatr.tk +zpxi.yomail.info +zq.laste.ml +zqid.laste.ml +zqifo.com +zqo.dropmail.me +zqq.spymail.one +zqrni.com +zqrni.net +zqw.pl +zrah.emltmp.com +zran5yxefwrcpqtcq.cf +zran5yxefwrcpqtcq.ga +zran5yxefwrcpqtcq.gq +zran5yxefwrcpqtcq.ml +zran5yxefwrcpqtcq.tk +zraq.com +zrczefgjv.pl +zre3i49lnsv6qt.cf +zre3i49lnsv6qt.ga +zre3i49lnsv6qt.gq +zre3i49lnsv6qt.ml +zre3i49lnsv6qt.tk +zri.laste.ml +zri.spymail.one +zrmail.ga +zrmail.ml +zrpurhxql.pl +zs.freeml.net +zs.laste.ml +zs2019098.com +zsazsautari.art +zsccyccxea.pl +zsdigital.tech +zsero.com +zsguo.com +zslsz.com +zssgsexdqd.pl +zssticker.com +zsu.yomail.info +zsvrqrmkr.pl +ztahoewgbo.com +ztbw.dropmail.me +ztd5af7qo1drt8.cf +ztd5af7qo1drt8.ga +ztd5af7qo1drt8.gq +ztd5af7qo1drt8.ml +ztd5af7qo1drt8.tk +ztdgrucjg92piejmx.cf +ztdgrucjg92piejmx.ga +ztdgrucjg92piejmx.gq +ztdgrucjg92piejmx.ml +ztdgrucjg92piejmx.tk +ztjspeakmn.com +ztn.emlpro.com +ztrackz.tk +ztunneler.com +ztunnelersik.com +ztuu.com +ztww.emltmp.com +ztxf.freeml.net +ztymm.com +zu.dropmail.me +zu.emlpro.com +zu.spymail.one +zualikhakk.cf +zualikhakk.ga +zualikhakk.gq +zualikhakk.ml +zualikhakk.tk +zuasu.com +zubacteriax.com +zubairnews.com +zubayer.cf +zubz.emlhub.com +zuc.emlpro.com +zudpck.com +zudrm1dxjnikm.cf +zudrm1dxjnikm.ga +zudrm1dxjnikm.gq +zudrm1dxjnikm.ml +zudrm1dxjnikm.tk +zue.emlpro.com +zueastergq.com +zufrans.com +zuhouse.ru +zuigaodeshanfeng.icu +zuilc.com +zuile8.com +zuiquandaohang.xyz +zujb.com +zukk.tk +zukmail.cf +zukmail.ga +zukmail.ml +zukmail.tk +zulamri.com +zuldev.live +zuldev.tech +zumail.net +zumaroll.com +zumpul.com +zumrotin.ml +zumruttepe.com +zumy.dev +zuov.emlhub.com +zuperar.com +zuperholo.com +zupka.anglik.org +zupload.xyz +zupper.ml +zuppyezof.info +zurbex.com +zurigigg12.com +zurosbanda.com +zurtel.cf +zurtel.ga +zurtel.gq +zurtel.ml +zurtel.tk +zuvio.com +zuzana.asia +zv68.com +zvcx.emlpro.com +zvkrv.anonbox.net +zvsn.com +zvsolar.com +zvun.com +zvus.spymail.one +zvvzuv.com +zvx.emlpro.com +zw6provider.com +zwau.com +zwb.spymail.one +zwbc.dropmail.me +zwdt.yomail.info +zweb.in +zwiedzaniebrowaru.com.pl +zwiekszsile.pl +zwiknm.ru +zwoho.com +zwpqjsnpkdjbtu2soc.ga +zwpqjsnpkdjbtu2soc.ml +zwpqjsnpkdjbtu2soc.tk +zwt.freeml.net +zwta.emlhub.com +zwunwvxz.shop +zwwaltered.com +zwwnhmmcec57ziwux.cf +zwwnhmmcec57ziwux.ga +zwwnhmmcec57ziwux.gq +zwwnhmmcec57ziwux.ml +zwwnhmmcec57ziwux.tk +zx.dropmail.me +zx.yomail.info +zx81.ovh +zxb.spymail.one +zxcqwcx.com +zxcv.com +zxcvbn.in +zxcvbnm.cf +zxcvbnm.com +zxcvbnm.tk +zxcvgt.website +zxcvjhjh18924.ga +zxcxc.com +zxcxcva.com +zxczxc2010.space +zxgsd4gydfg.ga +zxhn.spymail.one +zxonkcw91bjdojkn.cf +zxonkcw91bjdojkn.ga +zxonkcw91bjdojkn.gq +zxonkcw91bjdojkn.ml +zxonkcw91bjdojkn.tk +zxpasystems.com +zxre.emlpro.com +zxusnkn0ahscvuk0v.cf +zxusnkn0ahscvuk0v.ga +zxusnkn0ahscvuk0v.gq +zxusnkn0ahscvuk0v.ml +zxusnkn0ahscvuk0v.tk +zxxxz.gq +zxxz.ml +zxz.emlpro.com +zy.mailpwr.com +zy1.com +zybrew.beer +zyczeniurodzinow.pl +zyfyurts.mimimail.me +zyhaier.com +zylpu4cm6hrwrgrqxb.cf +zylpu4cm6hrwrgrqxb.ga +zylpu4cm6hrwrgrqxb.gq +zylpu4cm6hrwrgrqxb.ml +zylpu4cm6hrwrgrqxb.tk +zymail.men +zymuying.com +zynana.cf +zynga-email.com +zyns.com +zynt.freeml.net +zyntgryob.emlpro.com +zyok.freeml.net +zyp.emltmp.com +zyseo.com +zyyberrys.com +zyyu6mute9qn.cf +zyyu6mute9qn.ga +zyyu6mute9qn.gq +zyyu6mute9qn.ml +zyyu6mute9qn.tk +zyzs.freeml.net +zz.beststudentloansx.org +zz75.net +zz77.com +zzag.com +zzcash.ml +zzd.emlpro.com +zzi.us +zzn.dropmail.me +zzoohher.cfd +zzrgg.com +zzsbzs.com +zzuwnakb.pl +zzv2bfja5.pl +zzviarmxjz.ga +zzz.com +zzzmail.pl +zzzz1717.com +zzzzzzzzzzzzz.com diff --git a/backend/app/api/auth/routers/__init__.py b/backend/app/api/auth/routers/__init__.py index a4cb9910..b68f7889 100644 --- a/backend/app/api/auth/routers/__init__.py +++ b/backend/app/api/auth/routers/__init__.py @@ -4,7 +4,9 @@ from .auth import router as auth_router from .frontend import router as frontend_router from .oauth import router as oauth_router +from .oauth_token import router as oauth_token_router from .organizations import router as organization_router +from .users import public_user_router from .users import router as user_router all_routers = [ @@ -12,6 +14,8 @@ frontend_router, organization_router, oauth_router, + oauth_token_router, user_router, + public_user_router, *admin_routers, ] diff --git a/backend/app/api/auth/routers/admin/organizations.py b/backend/app/api/auth/routers/admin/organizations.py index 86165272..9da8f617 100644 --- a/backend/app/api/auth/routers/admin/organizations.py +++ b/backend/app/api/auth/routers/admin/organizations.py @@ -1,56 +1,54 @@ """Admin routes for managing organizations.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, Query, Security +from fastapi import APIRouter, Security +from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4 -from app.api.auth.crud import force_delete_organization +from app.api.auth.crud.organizations import force_delete_organization, get_organization, get_organizations from app.api.auth.dependencies import current_active_superuser +from app.api.auth.filters import OrganizationFilter from app.api.auth.models import Organization from app.api.auth.schemas import OrganizationReadWithRelationships -from app.api.common.crud.base import get_model_by_id, get_models from app.api.common.routers.dependencies import AsyncSessionDep router = APIRouter(prefix="/admin/organizations", tags=["admin"], dependencies=[Security(current_active_superuser)]) -@router.get("", response_model=list[OrganizationReadWithRelationships], summary="Get all organizations") +@router.get( + "", response_model=Page[OrganizationReadWithRelationships], summary="Get all organizations with all relationships" +) async def get_all_organizations( session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "all": {"value": ["owner", "members"]}, - }, - ), - ] = None, -) -> Sequence[Organization]: - """Get all organizations with optional relationships. Only superusers can access this route.""" - return await get_models(session, Organization, include_relationships=include) - - -@router.get("/{organization_id}", response_model=OrganizationReadWithRelationships, summary="Get organization by ID") + org_filter: Annotated[OrganizationFilter, FilterDepends(OrganizationFilter)], +) -> Page[Organization]: + """Get all organizations with all relationships loaded. Only superusers can access this route.""" + return await get_organizations( + session, + loaders={"members"}, + filters=org_filter, + read_schema=OrganizationReadWithRelationships, + ) + + +@router.get( + "/{organization_id}", + response_model=OrganizationReadWithRelationships, + summary="Get organization by ID with all relationships", +) async def get_organization_with_relationships( organization_id: UUID4, session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "all": {"value": ["owner", "members"]}, - }, - ), - ] = None, ) -> Organization: - """Get organization by ID with optional relationships. Only superusers can access this route.""" - return await get_model_by_id(session, Organization, organization_id, include_relationships=include) + """Get organization by ID with all relationships loaded. Only superusers can access this route.""" + return await get_organization( + session, + organization_id, + loaders={"members"}, + read_schema=OrganizationReadWithRelationships, + ) @router.delete("/{organization_id}", status_code=204, summary="Delete organization by ID") diff --git a/backend/app/api/auth/routers/admin/users.py b/backend/app/api/auth/routers/admin/users.py index 77fc1cfc..70e5cc0f 100644 --- a/backend/app/api/auth/routers/admin/users.py +++ b/backend/app/api/auth/routers/admin/users.py @@ -1,20 +1,21 @@ """Admin routes for managing users.""" -from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast -from fastapi import APIRouter, Path, Query, Security +from fastapi import APIRouter, Path, Security from fastapi.responses import RedirectResponse from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4, EmailStr from app.api.auth.crud import get_user_by_username from app.api.auth.dependencies import UserManagerDep, current_active_superuser +from app.api.auth.examples import ADMIN_USERS_RESPONSE_EXAMPLES from app.api.auth.filters import UserFilter from app.api.auth.models import User from app.api.auth.routers.users import router as public_user_router -from app.api.auth.schemas import UserRead, UserReadWithRelationships -from app.api.common.crud.base import get_models +from app.api.auth.schemas import UserRead +from app.api.common.crud.query import page_models from app.api.common.routers.dependencies import AsyncSessionDep router = APIRouter(prefix="/admin/users", tags=["admin"], dependencies=[Security(current_active_superuser)]) @@ -24,47 +25,12 @@ @router.get( "", summary="View all users", - response_model=list[UserRead] | list[UserReadWithRelationships], + response_model=Page[UserRead], responses={ 200: { "description": "List of users", "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Users without relationships", - "value": [ - { - "id": "12345678-cc4e-405c-8553-7806424de2a1", - "username": "alice", - "email": "alice@example.com", - "is_active": True, - "is_superuser": False, - "is_verified": True, - } - ], - }, - "with_organization": { - "summary": "Users with organization", - "value": [ - { - "id": "12345678-cc4e-405c-8553-7806424de2a1", - "username": "alice", - "email": "alice@example.com", - "is_active": True, - "is_superuser": False, - "is_verified": True, - "organization": { - "id": "12345678-cc4e-405c-8553-7806424de2a1", - "name": "University of Example", - "location": "Example City", - "description": "Example organization", - }, - }, - ], - }, - } - }, + "application/json": {"examples": ADMIN_USERS_RESPONSE_EXAMPLES}, }, }, }, @@ -72,26 +38,18 @@ async def get_users( user_filter: Annotated[UserFilter, FilterDepends(UserFilter)], session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "products": {"value": ["products"]}, - "all": {"value": ["products", "organization"]}, - }, - ), - ] = None, -) -> Sequence[User]: - """Get a list of all users with optional filtering and relationships.""" - return await get_models(session, User, include_relationships=include, model_filter=user_filter) +) -> Page[UserRead]: + """Get a list of all users with optional filtering.""" + return cast( + "Page[UserRead]", + await page_models(session, User, filters=user_filter, read_schema=UserRead), + ) @router.get( "/{user_id}", summary="View a single user by ID", - response_model=UserReadWithRelationships, + response_model=UserRead, ) async def get_user( user_id: Annotated[UUID4, Path(description="The user's ID")], diff --git a/backend/app/api/auth/routers/auth.py b/backend/app/api/auth/routers/auth.py index 22a105a5..da33e4f8 100644 --- a/backend/app/api/auth/routers/auth.py +++ b/backend/app/api/auth/routers/auth.py @@ -1,45 +1,70 @@ """Authentication, registration, and login routes.""" -from fastapi import APIRouter -from pydantic import EmailStr +from typing import Annotated -from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserRead -from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend, fastapi_user_manager -from app.api.auth.utils.email_validation import is_disposable_email +from fastapi import APIRouter, Depends +from fastapi.routing import APIRoute +from pydantic import EmailStr # Needed for Fastapi dependency injection + +from app.api.auth.routers import refresh, register +from app.api.auth.schemas import UserRead +from app.api.auth.services.email_checker import EmailChecker, get_email_checker_dependency +from app.api.auth.services.rate_limiter import LOGIN_RATE_LIMIT, VERIFY_RATE_LIMIT, limiter +from app.api.auth.services.user_manager import ( + bearer_auth_backend, + cookie_auth_backend, + fastapi_user_manager, +) from app.api.common.routers.openapi import mark_router_routes_public +LOGIN_PATH = "/login" +REQUEST_VERIFY_TOKEN_PATH = "/request-verify-token" # noqa: S105 # This value is not a secret + router = APIRouter(prefix="/auth", tags=["auth"]) -# Basic authentication routes -# TODO: Allow both username and email logins with custom login router -router.include_router(fastapi_user_manager.get_auth_router(bearer_auth_backend), prefix="/bearer") -router.include_router(fastapi_user_manager.get_auth_router(cookie_auth_backend), prefix="/cookie") -# Mark all routes in the auth router thus far as public -mark_router_routes_public(router) +# Use FastAPI-Users' built-in auth routers with rate limiting on login +bearer_router = fastapi_user_manager.get_auth_router(bearer_auth_backend) +cookie_router = fastapi_user_manager.get_auth_router(cookie_auth_backend) -# Registration, verification, and password reset routes -# TODO: Write custom register router for custom exception handling and use UserReadPublic schema for responses -# This will make the on_after_register and custom create methods in the user manager unnecessary. +# Apply rate limiting to login routes +for route in bearer_router.routes: + if isinstance(route, APIRoute) and route.path == LOGIN_PATH: + route.endpoint = limiter.limit(LOGIN_RATE_LIMIT)(route.endpoint) -router.include_router( - fastapi_user_manager.get_register_router( - UserRead, - UserCreate | UserCreateWithOrganization, # TODO: Investigate this type error - ), -) +for route in cookie_router.routes: + if isinstance(route, APIRoute) and route.path == LOGIN_PATH: + route.endpoint = limiter.limit(LOGIN_RATE_LIMIT)(route.endpoint) -router.include_router( - fastapi_user_manager.get_verify_router(user_schema=UserRead), -) -router.include_router( - fastapi_user_manager.get_reset_password_router(), -) +router.include_router(bearer_router, prefix="/bearer", tags=["auth"]) +router.include_router(cookie_router, prefix="/cookie", tags=["auth"]) + +# Custom registration route +router.include_router(register.router, tags=["auth"]) + +# Refresh token and multi-device session management +router.include_router(refresh.router, tags=["auth"]) + +# Mark all routes in the auth router thus far as public +mark_router_routes_public(router) + +# Verification and password reset routes (rate-limit the email-sending endpoint) +verify_router = fastapi_user_manager.get_verify_router(user_schema=UserRead) +for route in verify_router.routes: + if isinstance(route, APIRoute) and route.path == REQUEST_VERIFY_TOKEN_PATH: + route.endpoint = limiter.limit(VERIFY_RATE_LIMIT)(route.endpoint) +router.include_router(verify_router) +router.include_router(fastapi_user_manager.get_reset_password_router()) @router.get("/validate-email") -async def validate_email(email: EmailStr) -> dict: +async def validate_email( + email: EmailStr, + email_checker: Annotated[EmailChecker | None, Depends(get_email_checker_dependency)], +) -> dict: """Validate email address for registration.""" - is_disposable = await is_disposable_email(email) + is_disposable = False + if email_checker: + is_disposable = await email_checker.is_disposable(email) return {"isValid": not is_disposable, "reason": "Please use a permanent email address" if is_disposable else None} diff --git a/backend/app/api/auth/routers/frontend.py b/backend/app/api/auth/routers/frontend.py index 8a839091..b3fef39c 100644 --- a/backend/app/api/auth/routers/frontend.py +++ b/backend/app/api/auth/routers/frontend.py @@ -6,7 +6,6 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from app.api.admin.config import settings as admin_settings from app.api.auth.dependencies import OptionalCurrentActiveUserDep from app.core.config import settings as core_settings @@ -18,10 +17,7 @@ @router.get("/", response_class=HTMLResponse) -async def index( - request: Request, - user: OptionalCurrentActiveUserDep, -) -> HTMLResponse: +async def index(request: Request, user: OptionalCurrentActiveUserDep) -> HTMLResponse: """Render the landing page.""" return templates.TemplateResponse( "index.html", @@ -30,7 +26,6 @@ async def index( "user": user, "show_full_docs": user.is_superuser if user else False, "frontend_web_url": core_settings.frontend_web_url, - "admin_path": admin_settings.admin_base_url, }, ) @@ -49,6 +44,10 @@ async def login_page( ) -> Response: """Render the login page.""" if user: - return RedirectResponse(url=(next_page or router.url_path_for("index")), status_code=302) + return RedirectResponse(url=str(router.url_path_for("index")), status_code=302) + + # Only allow relative paths to prevent open redirect attacks + if next_page is not None and (not next_page.startswith("/") or next_page.startswith("//")): + next_page = None return templates.TemplateResponse("login.html", {"request": request, "next": next_page}) diff --git a/backend/app/api/auth/routers/oauth.py b/backend/app/api/auth/routers/oauth.py index eba947c3..6a859f09 100644 --- a/backend/app/api/auth/routers/oauth.py +++ b/backend/app/api/auth/routers/oauth.py @@ -1,50 +1,113 @@ """OAuth-related routes.""" -from fastapi import APIRouter, Security +from urllib.parse import urljoin + +from fastapi import APIRouter, status +from sqlalchemy import select from app.api.auth.config import settings -from app.api.auth.dependencies import current_active_superuser +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.auth.exceptions import InvalidOAuthProviderError, OAuthAccountNotLinkedError +from app.api.auth.models import OAuthAccount from app.api.auth.schemas import UserRead -from app.api.auth.services.oauth import github_oauth_client, google_oauth_client -from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend, fastapi_user_manager - -# TODO: include simple UI for OAuth login and association on login page -# TODO: Create single callback endpoint for each provider at /auth/oauth/{provider}/callback -# This requires us to manually set up a single callback route that can handle multiple actions -# (token login, session login, association) +from app.api.auth.services.oauth import ( + CustomOAuthAssociateRouterBuilder, + CustomOAuthRouterBuilder, + github_oauth_client, + google_oauth_client, + google_youtube_oauth_client, +) +from app.api.auth.services.user_manager import ( + bearer_auth_backend, + cookie_auth_backend, + fastapi_user_manager, +) +from app.api.common.routers.dependencies import AsyncSessionDep +from app.core.config import settings as core_settings router = APIRouter( prefix="/auth/oauth", tags=["oauth"], - dependencies=[ # TODO: Remove superuser dependency when enabling public OAuth login - Security(current_active_superuser) - ], ) -for oauth_client in (github_oauth_client, google_oauth_client): - provider_name = oauth_client.name - # Authentication router for token (bearer transport) and session (cookie transport) methods +def _public_callback_url(path: str) -> str: + """Build an absolute callback URL from the configured public API base URL.""" + return urljoin(f"{str(core_settings.backend_api_url).rstrip('/')}/", path.lstrip("/")) - # TODO: Investigate: Session-based Oauth login is currently not redirecting from the auth provider to the callback. - for auth_backend, transport_method in ((bearer_auth_backend, "token"), (cookie_auth_backend, "session")): + +for client in (github_oauth_client, google_oauth_client): + provider_name = client.name + # Google verifies email ownership, so auto-linking by email is safe. + # GitHub does not guarantee verified emails, so we keep it off to prevent account takeover. + associate_by_email = client is google_oauth_client + + # Authentication routers + for auth_backend, transport in ((bearer_auth_backend, "token"), (cookie_auth_backend, "session")): router.include_router( - fastapi_user_manager.get_oauth_router( - oauth_client, + CustomOAuthRouterBuilder( + client, auth_backend, - settings.fastapi_users_secret, - associate_by_email=True, + settings.fastapi_users_secret.get_secret_value(), + redirect_url=_public_callback_url(f"/auth/oauth/{provider_name}/{transport}/callback"), is_verified_by_default=True, - ), - prefix=f"/{provider_name}/{transport_method}", + associate_by_email=associate_by_email, + ).build(), + prefix=f"/{provider_name}/{transport}", ) # Association router router.include_router( - fastapi_user_manager.get_oauth_associate_router( - oauth_client, + CustomOAuthAssociateRouterBuilder( + client, + fastapi_user_manager.authenticator, UserRead, - settings.fastapi_users_secret, - ), + settings.fastapi_users_secret.get_secret_value(), + redirect_url=_public_callback_url(f"/auth/oauth/{provider_name}/associate/callback"), + ).build(), prefix=f"/{provider_name}/associate", ) + + +# YouTube-specific association (requests additional YouTube API scopes). +# Stored as oauth_name="google" — updates the same OAuthAccount record with +# a token that includes YouTube scopes. +router.include_router( + CustomOAuthAssociateRouterBuilder( + google_youtube_oauth_client, + fastapi_user_manager.authenticator, + UserRead, + settings.fastapi_users_secret.get_secret_value(), + redirect_url=_public_callback_url("/auth/oauth/google-youtube/associate/callback"), + route_name_key="google-youtube", + # Force Google to show the consent screen so the user explicitly grants + # YouTube scopes, even if they already authorized the app for base scopes. + # access_type=offline ensures we get a refresh token for background calls. + authorize_extras_params={"access_type": "offline", "prompt": "consent"}, + ).build(), + prefix="/google-youtube/associate", +) + + +@router.delete("/{provider}/associate", status_code=status.HTTP_204_NO_CONTENT) +async def remove_oauth_association( + provider: str, + current_user: CurrentActiveUserDep, + session: AsyncSessionDep, +) -> None: + """Remove a linked OAuth account.""" + if provider not in ("google", "github"): + raise InvalidOAuthProviderError(provider) + + query = select(OAuthAccount).where( + OAuthAccount.user_id == current_user.id, + OAuthAccount.oauth_name == provider, + ) + result = await session.execute(query) + oauth_account = result.scalars().first() + + if not oauth_account: + raise OAuthAccountNotLinkedError(provider) + + await session.delete(oauth_account) + await session.commit() diff --git a/backend/app/api/auth/routers/oauth_token.py b/backend/app/api/auth/routers/oauth_token.py new file mode 100644 index 00000000..4ee4106f --- /dev/null +++ b/backend/app/api/auth/routers/oauth_token.py @@ -0,0 +1,190 @@ +"""Client-side PKCE OAuth token exchange endpoints. + +These endpoints receive tokens obtained by the frontend via expo-auth-session +(PKCE, no backend redirect required) and exchange them for app sessions. + +Currently supported: + POST /auth/oauth/google/bearer/token — returns bearer + refresh tokens + POST /auth/oauth/google/cookie/token — sets httpOnly session cookies + +GitHub keeps using the backend-mediated flow (its OAuth token exchange requires +a client secret and cannot be done client-side). +""" + +import logging +from typing import TYPE_CHECKING, Annotated, cast + +import jwt +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi_users.authentication import Strategy +from jwt import ExpiredSignatureError, InvalidTokenError, PyJWKClient +from pydantic import UUID4, BaseModel + +from app.api.auth.config import settings as auth_settings +from app.api.auth.dependencies import UserManagerDep +from app.api.auth.exceptions import ( + OAuthEmailUnavailableError, + OAuthInactiveUserHTTPError, + OAuthStateDecodeError, + OAuthStateExpiredError, +) +from app.api.auth.models import User +from app.api.auth.services import refresh_token_service +from app.api.auth.services.login_hooks import log_successful_login, update_last_login_metadata +from app.api.auth.services.oauth_clients import google_oauth_client +from app.api.auth.services.user_manager import ( + UserManager, + bearer_auth_backend, + cookie_auth_backend, +) +from app.api.common.routers.openapi import mark_router_routes_public +from app.core.runtime import get_connection_redis + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +_GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" +_GOOGLE_ISSUERS = frozenset({"https://accounts.google.com", "accounts.google.com"}) +# PyJWKClient fetches and caches Google's public keys automatically. +_google_jwks_client = PyJWKClient(_GOOGLE_JWKS_URL, cache_keys=True) + +router = APIRouter(prefix="/auth/oauth", tags=["oauth"]) + + +class GoogleTokenRequest(BaseModel): + """Body for Google PKCE token exchange.""" + + id_token: str + # The Google access token is stored in OAuthAccount for downstream API use + # (e.g. YouTube plugin). Falls back to id_token when not supplied. + access_token: str | None = None + + +class OAuthBearerResponse(BaseModel): + """Response for the bearer transport exchange.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" # noqa: S105 + expires_in: int + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _verify_google_id_token(id_token: str) -> dict: + """Validate a Google ID token and return its verified claims.""" + client_id = auth_settings.google_oauth_client_id.get_secret_value() + if not client_id: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google OAuth not configured.") + + try: + signing_key = _google_jwks_client.get_signing_key_from_jwt(id_token) + payload = jwt.decode( + id_token, + signing_key.key, + algorithms=["RS256"], + audience=client_id, + ) + except ExpiredSignatureError as e: + raise OAuthStateExpiredError from e + except InvalidTokenError as e: + raise OAuthStateDecodeError from e + + if payload.get("iss") not in _GOOGLE_ISSUERS: + raise OAuthStateDecodeError + if not payload.get("email_verified"): + raise OAuthEmailUnavailableError + + return payload + + +async def _user_from_google_token( + body: GoogleTokenRequest, + user_manager: UserManager, + request: Request, +) -> User: + """Validate the Google ID token and resolve (or create) the corresponding user.""" + payload = _verify_google_id_token(body.id_token) + account_id: str = payload["sub"] + email: str = payload["email"] + + # ty false positive: User satisfies UserOAuthProtocol structurally but ty's + # generic Protocol-inheritance checker mishandles multi-level generic Protocols. + # Tracked upstream: https://github.com/astral-sh/ty/issues (invalid-argument-type) + oauth_callback = cast( + "Callable[..., Awaitable[User]]", + user_manager.oauth_callback, + ) + user = await oauth_callback( + google_oauth_client.name, + body.access_token or body.id_token, # real access_token preferred for API storage + account_id, + email, + payload.get("exp"), + request=request, + associate_by_email=True, + is_verified_by_default=True, + ) + if not user.is_active: + raise OAuthInactiveUserHTTPError + return user + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + + +@router.post( + "/google/bearer/token", + response_model=OAuthBearerResponse, + status_code=status.HTTP_201_CREATED, + summary="Exchange Google ID token for bearer + refresh tokens (PKCE flow)", +) +async def google_bearer_token( + body: GoogleTokenRequest, + user_manager: UserManagerDep, + strategy: Annotated[Strategy[User, UUID4], Depends(bearer_auth_backend.get_strategy)], + request: Request, +) -> OAuthBearerResponse: + """Receive a Google ID token obtained client-side via PKCE and issue app tokens.""" + user = await _user_from_google_token(body, user_manager, request) + + access_token = await strategy.write_token(user) + + redis_client = get_connection_redis(request) + refresh_token = await refresh_token_service.create_refresh_token(redis_client, user.id) + + await update_last_login_metadata(user, request, user_manager.user_db.session) + log_successful_login(user) + + return OAuthBearerResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=auth_settings.access_token_ttl_seconds, + ) + + +@router.post( + "/google/cookie/token", + status_code=status.HTTP_204_NO_CONTENT, + summary="Exchange Google ID token for session cookies (PKCE flow)", +) +async def google_cookie_token( + body: GoogleTokenRequest, + user_manager: UserManagerDep, + strategy: Annotated[Strategy[User, UUID4], Depends(cookie_auth_backend.get_strategy)], + request: Request, +) -> Response: + """Receive a Google ID token obtained client-side via PKCE and set session cookies.""" + user = await _user_from_google_token(body, user_manager, request) + + # backend.login sets the auth cookie; on_after_login adds the refresh_token cookie + response = await cookie_auth_backend.login(strategy, user) + await user_manager.on_after_login(user, request, response) + + return response + + +mark_router_routes_public(router) diff --git a/backend/app/api/auth/routers/organizations.py b/backend/app/api/auth/routers/organizations.py index 2a30521f..7bf8b3ab 100644 --- a/backend/app/api/auth/routers/organizations.py +++ b/backend/app/api/auth/routers/organizations.py @@ -1,14 +1,27 @@ """Public routes for managing organizations.""" -from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast -from fastapi import APIRouter from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4 -from app.api.auth import crud -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep, OrgByID +from app.api.auth.crud.organizations import ( + create_organization as create_organization_record, +) +from app.api.auth.crud.organizations import ( + get_organization as get_organization_record, +) +from app.api.auth.crud.organizations import ( + get_organization_members as get_org_members, +) +from app.api.auth.crud.organizations import ( + get_organizations as get_orgs, +) +from app.api.auth.crud.organizations import ( + user_join_organization, +) +from app.api.auth.dependencies import CurrentActiveVerifiedUserDep from app.api.auth.filters import OrganizationFilter from app.api.auth.models import Organization, User from app.api.auth.schemas import ( @@ -18,30 +31,32 @@ UserReadPublic, UserReadWithOrganization, ) -from app.api.common.crud.base import get_models from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import mark_router_routes_public +from app.api.common.routers.openapi import PublicAPIRouter -router = APIRouter(prefix="/organizations", tags=["organizations"]) +router = PublicAPIRouter(prefix="/organizations", tags=["organizations"]) ### Main organization routes ### -@router.get("", summary="View all organizations", response_model=list[OrganizationReadPublic]) +@router.get("", summary="View all organizations", response_model=Page[OrganizationReadPublic]) async def get_organizations( org_filter: Annotated[OrganizationFilter, FilterDepends(OrganizationFilter)], session: AsyncSessionDep -) -> Sequence[Organization]: +) -> Page[OrganizationReadPublic]: """Get a list of all organizations with optional filtering.""" - return await get_models(session, Organization, model_filter=org_filter) + return cast( + "Page[OrganizationReadPublic]", + await get_orgs(session, filters=org_filter, read_schema=OrganizationReadPublic), + ) @router.get( - "/{organization_id}", # noqa: FAST003 # organization_id is used by OrgByID dependency + "/{organization_id}", summary="View a single organization", response_model=OrganizationReadPublic, ) -async def get_organization(organization: OrgByID) -> Organization: +async def get_organization(organization_id: UUID4, session: AsyncSessionDep) -> Organization: """Get an organization by ID.""" - return organization + return await get_organization_record(session, organization_id) @router.post("", response_model=OrganizationRead, status_code=201, summary="Create new organization") @@ -49,34 +64,38 @@ async def create_organization( organization: OrganizationCreate, current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep ) -> Organization: """Create new organization with current user as owner.""" - db_org = await crud.create_organization(session, organization, current_user) - - return db_org + return await create_organization_record(session, organization, current_user) ## Organization member routes ## @router.get( - "/{organization_id}/members", response_model=list[UserReadPublic], summary="Get the members of an organization" + "/{organization_id}/members", response_model=Page[UserReadPublic], summary="Get the members of an organization" ) async def get_organization_members( organization_id: UUID4, current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep -) -> list[User]: +) -> Page[UserReadPublic]: """Get the members of an organization.""" - return await crud.get_organization_members(session, organization_id, current_user) + return cast( + "Page[UserReadPublic]", + await get_org_members( + session, + organization_id, + current_user, + paginate=True, + read_schema=UserReadPublic, + ), + ) @router.post( - "/{organization_id}/members/me", # noqa: FAST003 # organization_id is used by OrgByID dependency + "/{organization_id}/members/me", response_model=UserReadWithOrganization, status_code=201, summary="Join organization", ) async def join_organization( - organization: OrgByID, session: AsyncSessionDep, current_user: CurrentActiveVerifiedUserDep + organization_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveVerifiedUserDep ) -> User: """Join an organization as a member.""" - return await crud.user_join_organization(session, organization, current_user) - - -# TODO: Initializing as PublicRouter doesn't seem to work, need to manually mark all routes as public. Investigate why. -mark_router_routes_public(router) + organization = await get_organization_record(session, organization_id) + return await user_join_organization(session, organization, current_user) diff --git a/backend/app/api/auth/routers/refresh.py b/backend/app/api/auth/routers/refresh.py new file mode 100644 index 00000000..2848d566 --- /dev/null +++ b/backend/app/api/auth/routers/refresh.py @@ -0,0 +1,146 @@ +"""Refresh token and multi-device session management endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, Response, status +from fastapi.security import OAuth2PasswordBearer +from fastapi_users.authentication import Strategy + +from app.api.auth.config import settings as auth_settings +from app.api.auth.dependencies import CurrentActiveUserDep, UserManagerDep +from app.api.auth.exceptions import RefreshTokenNotFoundError, RefreshTokenUserInactiveError +from app.api.auth.schemas import ( + RefreshTokenRequest, + RefreshTokenResponse, +) +from app.api.auth.services import refresh_token_service +from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend +from app.core.config import settings as core_settings +from app.core.redis import OptionalRedisDep + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/bearer/login", auto_error=False) + +router = APIRouter() + + +@router.post( + "/refresh", + name="auth:bearer.refresh", + response_model=RefreshTokenResponse, +) +async def refresh_access_token( + user_manager: UserManagerDep, + strategy: Annotated[Strategy, Depends(bearer_auth_backend.get_strategy)], + redis: OptionalRedisDep, + request: RefreshTokenRequest | None = None, + cookie_refresh_token: Annotated[str | None, Cookie(alias="refresh_token")] = None, +) -> RefreshTokenResponse: + """Refresh access token using refresh token for bearer auth. + + Validates refresh token and issues new access token. + Updates session activity timestamp. + """ + actual_refresh_token = (request.refresh_token.get_secret_value() if request else None) or cookie_refresh_token + if not actual_refresh_token: + raise RefreshTokenNotFoundError + + # Verify refresh token + user_id = await refresh_token_service.verify_refresh_token(redis, actual_refresh_token) + + # Get user + user = await user_manager.get(user_id) + if not user or not user.is_active: + raise RefreshTokenUserInactiveError + + # Generate new access token + access_token = await strategy.write_token(user) + new_refresh_token = await refresh_token_service.rotate_refresh_token(redis, actual_refresh_token) + + return RefreshTokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + token_type="bearer", # noqa: S106 # This value is not a secret + expires_in=auth_settings.access_token_ttl_seconds, + ) + + +@router.post( + "/cookie/refresh", + name="auth:cookie.refresh", + status_code=status.HTTP_204_NO_CONTENT, +) +async def refresh_access_token_cookie( + response: Response, + user_manager: UserManagerDep, + strategy: Annotated[Strategy, Depends(cookie_auth_backend.get_strategy)], + redis: OptionalRedisDep, + refresh_token: Annotated[str | None, Cookie()] = None, +) -> None: + """Refresh access token using refresh token from cookie. + + Validates refresh token cookie and issues new access token cookie. + Updates session activity timestamp. + """ + if not refresh_token: + raise RefreshTokenNotFoundError + + # Verify token first, then rotate after user validation succeeds. + user_id = await refresh_token_service.verify_refresh_token(redis, refresh_token) + + # Get user + user = await user_manager.get(user_id) + if not user or not user.is_active: + raise RefreshTokenUserInactiveError + + # Generate new access token and set cookie + access_token = await strategy.write_token(user) + new_refresh_token = await refresh_token_service.rotate_refresh_token(redis, refresh_token) + response.set_cookie( + key="auth", + value=access_token, + max_age=auth_settings.access_token_ttl_seconds, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + max_age=auth_settings.refresh_token_expire_days * 86_400, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + + +@router.post( + "/logout", + name="auth:logout", + status_code=status.HTTP_204_NO_CONTENT, +) +async def logout( + response: Response, + current_user: CurrentActiveUserDep, + strategy: Annotated[Strategy, Depends(cookie_auth_backend.get_strategy)], + redis: OptionalRedisDep, + cookie_refresh_token: Annotated[str | None, Cookie(alias="refresh_token")] = None, + cookie_auth_token: Annotated[str | None, Cookie(alias="auth")] = None, + bearer_token: Annotated[str | None, Depends(oauth2_scheme)] = None, +) -> None: + """Logout the current user. + + Destroys the current access token in Redis and blacklists the refresh token. + Clears cookies on the client side. + """ + # 1. Destroy access token + token = bearer_token or cookie_auth_token + if token: + await strategy.destroy_token(token, current_user) + + # 2. Clear cookies + response.delete_cookie("auth", secure=core_settings.secure_cookies, httponly=True, samesite="lax") + response.delete_cookie("refresh_token", secure=core_settings.secure_cookies, httponly=True, samesite="lax") + + # 3. Blacklist refresh token + if cookie_refresh_token: + await refresh_token_service.blacklist_token(redis, cookie_refresh_token) diff --git a/backend/app/api/auth/routers/register.py b/backend/app/api/auth/routers/register.py new file mode 100644 index 00000000..66d4bb75 --- /dev/null +++ b/backend/app/api/auth/routers/register.py @@ -0,0 +1,78 @@ +"""Custom registration router for user creation with proper exception handling.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists + +from app.api.auth.crud import add_user_role_in_organization_after_registration, validate_user_create +from app.api.auth.dependencies import UserManagerDep +from app.api.auth.exceptions import ( + RegistrationInvalidPasswordHTTPError, + RegistrationUnexpectedHTTPError, + RegistrationUserAlreadyExistsHTTPError, +) +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserReadPublic +from app.api.auth.services.rate_limiter import REGISTER_RATE_LIMIT, limiter +from app.api.common.exceptions import APIError +from app.core.logging import sanitize_log_value +from app.core.runtime import get_request_email_checker + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post( + "/register", + response_model=UserReadPublic, + status_code=status.HTTP_201_CREATED, + summary="Register a new user", +) +@limiter.limit(REGISTER_RATE_LIMIT) +async def register( + request: Request, + user_create: UserCreate | UserCreateWithOrganization, + user_manager: UserManagerDep, +) -> User: + """Register a new user with optional organization creation or joining. + + Supports two registration modes: + - With organization creation: User creates and owns a new organization + - With organization joining: User joins an existing organization as a member + - No organization: User registers without an organization + """ + try: + email_checker = get_request_email_checker(request) + + # Validate user creation data (username uniqueness, disposable email, organization) + user_create = await validate_user_create(user_manager.user_db, user_create, email_checker) + + # Create the user through UserManager (handles password hashing, validation) + user = await user_manager.create(user_create, safe=True, request=request) + + # Add user to organization if specified + user = await add_user_role_in_organization_after_registration(user_manager.user_db, user, request) + + # Request email verification automatically (this triggers on_after_request_verify -> sends email) + await user_manager.request_verify(user, request) + + logger.info("User %s registered successfully", sanitize_log_value(user.email)) + + except UserAlreadyExists as e: + raise RegistrationUserAlreadyExistsHTTPError from e + + except InvalidPasswordException as e: + raise RegistrationInvalidPasswordHTTPError(e.reason) from e + + except APIError as e: + raise HTTPException(status_code=e.http_status_code, detail=str(e)) from e + + except Exception as e: + logger.exception("Unexpected error during user registration") + raise RegistrationUnexpectedHTTPError from e + else: + return user diff --git a/backend/app/api/auth/routers/users.py b/backend/app/api/auth/routers/users.py index 624468af..ebe046ab 100644 --- a/backend/app/api/auth/routers/users.py +++ b/backend/app/api/auth/routers/users.py @@ -1,26 +1,55 @@ """Public user management routes.""" -from fastapi import APIRouter, HTTPException, Security +from __future__ import annotations -from app.api.auth import crud -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep, OrgAsOwner, current_active_user -from app.api.auth.exceptions import UserHasNoOrgError +from typing import Annotated, cast +from uuid import UUID + +from fastapi import HTTPException, Security +from fastapi_pagination import Page +from sqlalchemy import select + +from app.api.auth.crud.organizations import ( + delete_organization_as_owner, + update_user_organization, +) +from app.api.auth.crud.organizations import ( + get_organization_members as get_org_members, +) +from app.api.auth.crud.organizations import ( + leave_organization as leave_org, +) +from app.api.auth.dependencies import ( + CurrentActiveVerifiedUserDep, + CurrentUserOrgDep, + CurrentUserOwnedOrgDep, + current_active_user, + optional_current_active_user, +) from app.api.auth.models import Organization, User from app.api.auth.schemas import ( OrganizationRead, OrganizationReadPublic, OrganizationUpdate, + PublicProfileView, UserRead, UserReadPublic, UserUpdate, ) +from app.api.auth.services.privacy import ( + VISIBILITY_COMMUNITY, + VISIBILITY_PRIVATE, + VISIBILITY_PUBLIC, +) +from app.api.auth.services.stats import recompute_user_stats from app.api.auth.services.user_manager import fastapi_user_manager from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import mark_router_routes_public +from app.api.common.routers.openapi import PublicAPIRouter +from app.core.cache import cache ### User self-management routes ### -router = APIRouter(prefix="/users", tags=["users"], dependencies=[Security(current_active_user)]) +router = PublicAPIRouter(prefix="/users", tags=["users"], dependencies=[Security(current_active_user)]) # Include autogenerated user management routes (for self-management) router.include_router( @@ -32,38 +61,42 @@ @router.get( "/me/organization", response_model=OrganizationReadPublic, summary="Get the organization of the current user" ) -async def get_user_organization(current_user: CurrentActiveVerifiedUserDep) -> Organization: +async def get_user_organization(current_user_organization: CurrentUserOrgDep) -> Organization: """Get the organization of the current user.""" - if not current_user.organization: - raise UserHasNoOrgError(user_id=current_user.id) - return current_user.organization + return current_user_organization @router.get( "/me/organization/members", - response_model=list[UserReadPublic], + response_model=Page[UserReadPublic], summary="Get the members of the organization of the current user", ) async def get_user_organization_members( + current_user_organization: CurrentUserOrgDep, current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep, -) -> list[User]: +) -> Page[UserReadPublic]: """Get the members of the organization of the current user.""" - if current_user.organization_id is None: - raise HTTPException(status_code=404, detail="User does not belong to an organization.") - return await crud.get_organization_members(session, current_user.organization_id, current_user) + return cast( + "Page[UserReadPublic]", + await get_org_members( + session, + current_user_organization.id, + current_user, + paginate=True, + read_schema=UserReadPublic, + ), + ) @router.patch("/me/organization", response_model=OrganizationRead, summary="Update your organization") async def update_organization( - db_organization: OrgAsOwner, + db_organization: CurrentUserOwnedOrgDep, organization_in: OrganizationUpdate, session: AsyncSessionDep, ) -> Organization: """Update organization as owner.""" - db_org = await crud.update_user_organization(session, db_organization, organization_in) - - return db_org + return await update_user_organization(session, db_organization, organization_in) @router.delete("/me/organization", status_code=204, summary="Delete your organization as owner") @@ -72,7 +105,7 @@ async def delete_my_organization( current_user: CurrentActiveVerifiedUserDep, ) -> None: """Delete organization as owner. Fails if organization has members.""" - return await crud.delete_organization_as_owner(session, current_user) + return await delete_organization_as_owner(session, current_user) @router.delete("/me/organization/membership", status_code=204, summary="Leave current organization") @@ -81,8 +114,70 @@ async def leave_organization( current_user: CurrentActiveVerifiedUserDep, ) -> None: """Leave current organization. Cannot be used by organization owner.""" - await crud.leave_organization(session, current_user) + await leave_org(session, current_user) + + +## Public Profile Routes ## +public_user_router = PublicAPIRouter(prefix="/users", tags=["users"]) -# TODO: Initializing as PublicRouter doesn't seem to work, need to manually mark all routes as public. Investigate why. -mark_router_routes_public(router) + +@public_user_router.get( + "/{identifier}/profile", + response_model=PublicProfileView, + summary="Get public profile of a user", +) +@cache(expire=3600, namespace="profiles") +async def get_public_profile( + identifier: str, + session: AsyncSessionDep, + current_user: Annotated[User | None, Security(optional_current_active_user)], +) -> PublicProfileView: + """Get public profile statistics for a specified user by their username or UUID. + + Returns 404 if the user is not found or if the profile is marked as private (and you are not the user). + Includes lazy initialization of stats if they are missing. + """ + # 1. Look up user by UUID or Username + user = None + try: + # Check if identifier is a valid UUID + user_uuid = UUID(identifier) + user = await session.get(User, user_uuid) + except ValueError, AttributeError: + # Not a valid UUID, search by username + stmt = select(User).where(User.username == identifier) + result = await session.execute(stmt) + user = result.unique().scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + + # 2. Check privacy settings + profile_visibility = user.preferences.get("profile_visibility", VISIBILITY_PUBLIC) + + if profile_visibility == VISIBILITY_PRIVATE: + is_self = current_user and current_user.id == user.id + is_admin = current_user and current_user.is_superuser + if not (is_self or is_admin): + raise HTTPException(status_code=404, detail="Profile not found") + + elif profile_visibility == VISIBILITY_COMMUNITY: + if not current_user: + raise HTTPException(status_code=404, detail="Profile not found") + + # 3. Lazy initialization of stats if cache is empty + stats = user.stats_cache + if not stats: + # Recompute on the fly and save to user + stats = await recompute_user_stats(session, user.id) + await session.commit() + + return PublicProfileView( + username=user.username, + created_at=user.created_at, + product_count=stats.get("product_count", 0), + total_weight_kg=stats.get("total_weight_kg", 0.0), + image_count=stats.get("image_count", 0), + top_category=stats.get("top_category", "None"), + ) diff --git a/backend/app/api/auth/schemas.py b/backend/app/api/auth/schemas.py index 9f694819..936f4bb8 100644 --- a/backend/app/api/auth/schemas.py +++ b/backend/app/api/auth/schemas.py @@ -1,24 +1,46 @@ """DTO schemas for users.""" -import uuid -from typing import Annotated, Optional - -from fastapi_users import schemas -from pydantic import UUID4, ConfigDict, EmailStr, Field, StringConstraints +from __future__ import annotations +import uuid +from datetime import datetime # noqa: TC003 # Used at runtime for Pydantic model annotations +from typing import Annotated + +from fastapi_users import schemas as fastapi_users_schemas +from pydantic import ( + UUID4, + BaseModel, + ConfigDict, + EmailStr, + Field, + SecretStr, + StringConstraints, + field_validator, +) + +from app.api.auth.examples import ( + ORGANIZATION_CREATE_EXAMPLES, + REFRESH_TOKEN_REQUEST_EXAMPLES, + REFRESH_TOKEN_RESPONSE_EXAMPLES, + USER_CREATE_EXAMPLES, + USER_CREATE_WITH_ORGANIZATION_EXAMPLES, + USER_READ_EXAMPLES, + USER_UPDATE_EXAMPLES, +) from app.api.auth.models import OrganizationBase, UserBase -from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp, BaseUpdateSchema, ProductRead +from app.api.common.schemas.base import BaseCreateSchema, BaseUpdateSchema, UUIDIdReadSchemaWithTimeStamp -# TODO: Refactor into separate files for each model. -# This is tricky due to circular imports and the way SQLAlchemy and Pydantic handle schema building. +# Note: These auth schemas stay together to avoid circular imports during model/schema construction. ### Organizations ### class OrganizationCreate(BaseCreateSchema, OrganizationBase): """Create schema for organizations.""" + model_config = ConfigDict(json_schema_extra={"examples": ORGANIZATION_CREATE_EXAMPLES}) -class OrganizationReadPublic(BaseReadSchemaWithTimeStamp, OrganizationBase): + +class OrganizationReadPublic(UUIDIdReadSchemaWithTimeStamp, OrganizationBase): """Read schema for organizations.""" @@ -28,37 +50,77 @@ class OrganizationRead(OrganizationBase): owner_id: UUID4 = Field(description="ID of the organization owner.") -class OrganizationReadWithRelationshipsPublic(BaseReadSchemaWithTimeStamp, OrganizationBase): +class OrganizationReadWithRelationshipsPublic(UUIDIdReadSchemaWithTimeStamp, OrganizationBase): """Read schema for organizations, including relationships.""" - members: list["UserReadPublic"] = Field(default_factory=list, description="List of users in the organization.") + members: list[UserReadPublic] = Field(default_factory=list, description="List of users in the organization.") -class OrganizationReadWithRelationships(BaseReadSchemaWithTimeStamp, OrganizationBase): +class OrganizationReadWithRelationships(UUIDIdReadSchemaWithTimeStamp, OrganizationBase): """Read schema for organizations, including relationships.""" - members: list["UserRead"] = Field(default_factory=list, description="List of users in the organization.") + members: list[UserRead] = Field(default_factory=list, description="List of users in the organization.") class OrganizationUpdate(BaseUpdateSchema): """Update schema for organizations.""" - name: str = Field(min_length=2, max_length=100) + name: str | None = Field(default=None, min_length=2, max_length=100) location: str | None = Field(default=None, max_length=100) description: str | None = Field(default=None, max_length=500) - - # TODO: Handle transfer of ownership + owner_id: UUID4 | None = Field( + default=None, + description="ID of the member who should become the new owner.", + ) ### Users ### -class UserCreateBase(UserBase, schemas.BaseUserCreate): + +# Validation constraints for username field +ValidatedUsername = Annotated[ + str | None, StringConstraints(strip_whitespace=True, pattern=r"^\w+$", min_length=2, max_length=50) +] + +RESERVED_USERNAMES = { + "me", + "self", + "admin", + "api", + "root", + "profile", + "profiles", + "newsletter", + "users", + "settings", + "health", + "docs", + "redoc", + "openapi.json", +} + + +def validate_username_not_reserved(v: str | None) -> str | None: + """Validate that the username is not on the reserved list.""" + if v and v.lower() in RESERVED_USERNAMES: + err_msg = f"'{v}' is a reserved username." + raise ValueError(err_msg) + return v + + +class UserCreateBase(UserBase, fastapi_users_schemas.BaseUserCreate): """Base schema for user creation.""" - # Override for validation - username: Annotated[str | None, StringConstraints(strip_whitespace=True)] = None + # Override for username field validation + username: ValidatedUsername = None + + @field_validator("username") + @classmethod + def username_not_reserved(cls, v: str | None) -> str | None: + """Reject reserved usernames.""" + return validate_username_not_reserved(v) # Override for OpenAPI schema configuration - password: str = Field(json_schema_extra={"format": "password"}) + password: str = Field(json_schema_extra={"format": "password"}, min_length=8) class UserCreate(UserCreateBase): @@ -66,41 +128,25 @@ class UserCreate(UserCreateBase): organization_id: UUID4 | None = None - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "email": "user@example.com", - "password": "fakepassword", - "username": "username", - "organization_id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", - } - ] - } - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": USER_CREATE_EXAMPLES}) class UserCreateWithOrganization(UserCreateBase): """Create schema for users with organization to create and own.""" - organization: "OrganizationCreate" - - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "email": "user@example.com", - "password": "fakepassword", - "username": "username", - "organization": {"name": "organization", "location": "location", "description": "description"}, - } - ] - } - } - ) + organization: OrganizationCreate + + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": USER_CREATE_WITH_ORGANIZATION_EXAMPLES}) + + +class OAuthAccountRead(BaseModel): + """Read schema for OAuth accounts.""" + + model_config: ConfigDict = ConfigDict(from_attributes=True) + + oauth_name: str + account_id: str + account_email: str class UserReadPublic(UserBase): @@ -109,62 +155,76 @@ class UserReadPublic(UserBase): email: EmailStr -class UserRead(UserBase, schemas.BaseUser[uuid.UUID]): +class UserReadProfile(UserBase): + """Basic public profile info.""" + + created_at: datetime | None + + +class PublicProfileView(UserReadProfile): + """Detailed public profile view with aggregated stats.""" + + product_count: int = Field(default=0, description="Number of products registered.") + total_weight_kg: float = Field(default=0.0, description="Aggregate weight of products in kg.") + image_count: int = Field(default=0, description="Total images uploaded.") + top_category: str = Field(default="None", description="Most common product type.") + + +class UserRead(UserBase, fastapi_users_schemas.BaseUser[uuid.UUID]): """Read schema for users.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "is_active": True, - "is_superuser": False, - "is_verified": True, - "username": "username", - } - ] - } - } + oauth_accounts: list[OAuthAccountRead] = Field(default_factory=list, description="List of linked OAuth accounts.") + preferences: dict[str, object] = Field( + default_factory=dict, + description="User preferences.", ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": USER_READ_EXAMPLES}) + class UserReadWithOrganization(UserRead): """Read schema for users with organization.""" - organization: Optional["OrganizationRead"] = Field(default=None, description="Organization the user belongs to.") - + organization: OrganizationRead | None = Field(default=None, description="Organization the user belongs to.") -class UserReadWithRelationships(UserReadWithOrganization): - """Read schema for users, including relationships.""" - products: list[ProductRead] = Field(default_factory=list, description="List of products owned by the user.") +class UserUpdate(UserBase, fastapi_users_schemas.BaseUserUpdate): + """Update schema for users.""" + # Override for username field validation + username: ValidatedUsername = None -class UserUpdate(UserBase, schemas.BaseUserUpdate): - """Update schema for users.""" + @field_validator("username") + @classmethod + def username_not_reserved(cls, v: str | None) -> str | None: + """Reject reserved usernames.""" + return validate_username_not_reserved(v) - username: Annotated[str | None, StringConstraints(strip_whitespace=True)] = None organization_id: UUID4 | None = None # Override password field to include password format in JSON schema - password: str | None = Field(default=None, json_schema_extra={"format": "password"}) - - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "password": "newpassword", - "email": "user@example.com", - "is_active": True, - "is_superuser": True, - "is_verified": True, - "username": "username", - "organization_id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", - } - ] - } - } - ) + password: str | None = Field(default=None, json_schema_extra={"format": "password"}, min_length=8) + + preferences: dict[str, object] | None = Field(default=None, description="User preferences (partial merge).") + + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": USER_UPDATE_EXAMPLES}) + + +### Authentication & Sessions ### +class RefreshTokenRequest(BaseModel): + """Request schema for refreshing access token.""" + + model_config = ConfigDict(json_schema_extra={"examples": REFRESH_TOKEN_REQUEST_EXAMPLES}) + + refresh_token: SecretStr = Field(description="Refresh token obtained from login") + + +class RefreshTokenResponse(BaseModel): + """Response for token refresh.""" + + model_config = ConfigDict(json_schema_extra={"examples": REFRESH_TOKEN_RESPONSE_EXAMPLES}) + + access_token: str = Field(description="New JWT access token") + refresh_token: str = Field(description="Rotated refresh token") + token_type: str = Field(default="bearer", description="Token type (always 'bearer')") + expires_in: int = Field(description="Access token expiration time in seconds") diff --git a/backend/app/api/auth/services/auth_backends.py b/backend/app/api/auth/services/auth_backends.py new file mode 100644 index 00000000..8748f6be --- /dev/null +++ b/backend/app/api/auth/services/auth_backends.py @@ -0,0 +1,76 @@ +"""Authentication backend and transport wiring.""" + +import ipaddress +from typing import cast +from urllib.parse import urlparse + +from fastapi import HTTPException +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + CookieTransport, + JWTStrategy, + RedisStrategy, + Strategy, +) +from pydantic import UUID4, SecretStr + +from app.api.auth.config import settings as auth_settings +from app.api.auth.models import User +from app.core.config import Environment +from app.core.config import settings as core_settings +from app.core.redis import OptionalRedisDep + +ACCESS_TOKEN_TTL = auth_settings.access_token_ttl_seconds +SECRET: SecretStr = auth_settings.fastapi_users_secret + + +def build_cookie_domain(frontend_url: str) -> str | None: + """Build a cookie domain from the configured frontend URL.""" + hostname = urlparse(frontend_url).hostname or "" + try: + ipaddress.ip_address(hostname) + except ValueError: + parts = hostname.split(".") + return f".{'.'.join(parts[-2:])}" if len(parts) >= 2 else None + else: + return None + + +cookie_transport = CookieTransport( + cookie_name="auth", + cookie_max_age=ACCESS_TOKEN_TTL, + cookie_domain=build_cookie_domain(str(core_settings.frontend_web_url)), + cookie_secure=core_settings.secure_cookies, +) + +bearer_transport = BearerTransport(tokenUrl="auth/bearer/login") + + +def get_token_strategy(redis: OptionalRedisDep) -> Strategy[User, UUID4]: + """Return an authentication token strategy.""" + if redis: + return cast("Strategy[User, UUID4]", RedisStrategy(redis, lifetime_seconds=ACCESS_TOKEN_TTL)) + + if core_settings.environment not in (Environment.DEV, Environment.TESTING): + raise HTTPException(status_code=503, detail="Authentication service unavailable: Redis is required.") + + return cast( + "Strategy[User, UUID4]", + JWTStrategy(secret=SECRET.get_secret_value(), lifetime_seconds=ACCESS_TOKEN_TTL), + ) + + +def build_authentication_backends() -> tuple[AuthenticationBackend[User, UUID4], AuthenticationBackend[User, UUID4]]: + """Create the bearer and cookie authentication backends.""" + bearer_auth_backend = AuthenticationBackend( + name="bearer", + transport=bearer_transport, + get_strategy=get_token_strategy, + ) + cookie_auth_backend = AuthenticationBackend( + name="cookie", + transport=cookie_transport, + get_strategy=get_token_strategy, + ) + return bearer_auth_backend, cookie_auth_backend diff --git a/backend/app/api/auth/services/email_checker.py b/backend/app/api/auth/services/email_checker.py new file mode 100644 index 00000000..7e78136f --- /dev/null +++ b/backend/app/api/auth/services/email_checker.py @@ -0,0 +1,140 @@ +"""Disposable-email validation service for auth flows.""" +# spell-checker: ignore hget, hset + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +import httpx +from fastapi import Request +from redis.exceptions import RedisError + +from app.core.background_tasks import PeriodicBackgroundTask +from app.core.config import Environment, settings +from app.core.env import BACKEND_DIR +from app.core.runtime import get_request_services + +if TYPE_CHECKING: + from collections.abc import Awaitable + from pathlib import Path + + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +DISPOSABLE_DOMAINS_URL = "https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt" +DISPOSABLE_DOMAINS_FALLBACK_PATH = BACKEND_DIR / "app" / "api" / "auth" / "resources" / "disposable_email_domains.txt" +_REDIS_DOMAINS_HASH = "temp_domains" + +_RECOVERABLE_ERRORS = (RuntimeError, ValueError, ConnectionError, OSError, RedisError, httpx.HTTPError) + + +def load_local_disposable_domains(path: Path = DISPOSABLE_DOMAINS_FALLBACK_PATH) -> set[str]: + """Load the committed fallback list of disposable email domains.""" + return { + line.strip().lower() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + +class EmailChecker(PeriodicBackgroundTask): + """Disposable-email blocker with optional Redis-backed domain storage.""" + + def __init__(self, redis_client: Redis | None) -> None: + super().__init__(interval_seconds=60 * 60 * 24) + self.redis_client = redis_client + self._domains: set[str] = set() + self._initialized = False + + async def initialize(self) -> None: + """Seed domains and start the periodic refresh loop.""" + try: + await self._seed_domains() + self._initialized = True + await super().initialize() + except _RECOVERABLE_ERRORS as e: + logger.warning("Failed to initialize disposable email checker: %s", e) + + async def run_once(self) -> None: + """Refresh disposable domains (called periodically by the base class loop).""" + try: + domains = await self._fetch_remote_domains() + await self._store_domains(domains) + logger.info("Disposable email domains refreshed successfully") + except _RECOVERABLE_ERRORS: + logger.exception("Failed to refresh disposable email domains:") + + async def close(self) -> None: + """Cancel the background loop.""" + await super().close() + self._initialized = False + + async def is_disposable(self, email: str) -> bool: + """Check if an email's domain is disposable. Fails open on errors.""" + if not self._initialized: + logger.warning("Email checker not initialized, allowing registration") + return False + try: + domain = email.rsplit("@", 1)[-1].lower() + if self.redis_client is not None: + return bool(await cast("Awaitable[str | None]", self.redis_client.hget(_REDIS_DOMAINS_HASH, domain))) + except _RECOVERABLE_ERRORS: + logger.exception("Failed to check if email is disposable: %s. Allowing registration.", email) + return False + else: + return domain in self._domains + + async def _seed_domains(self) -> None: + """Seed from the committed fallback file, skipping if Redis already has data.""" + domains = load_local_disposable_domains() + if self.redis_client is None: + self._domains = domains + logger.info("Loaded %d disposable domains from local fallback (in-memory)", len(domains)) + return + + if await self.redis_client.exists(_REDIS_DOMAINS_HASH): + logger.info("Disposable domains already cached in Redis, skipping seed") + return + + await self._store_domains(domains) + logger.info("Seeded Redis with %d disposable domains from local fallback", len(domains)) + + async def _store_domains(self, domains: set[str]) -> None: + """Replace the stored domain set (in-memory or Redis).""" + if self.redis_client is None: + self._domains = domains + return + + pipe = self.redis_client.pipeline() + pipe.delete(_REDIS_DOMAINS_HASH) + if domains: + pipe.hset(_REDIS_DOMAINS_HASH, mapping=dict.fromkeys(domains, 1)) + await pipe.execute() + + async def _fetch_remote_domains(self) -> set[str]: + """Fetch the latest disposable domain list from the remote source.""" + async with httpx.AsyncClient() as client: + response = await client.get(DISPOSABLE_DOMAINS_URL, timeout=10.0) + response.raise_for_status() + return {line.strip().lower() for line in response.text.splitlines() if line.strip()} + + +def get_email_checker_dependency(request: Request) -> EmailChecker | None: + """FastAPI dependency to get EmailChecker from app state.""" + return get_request_services(request).email_checker + + +async def init_email_checker(redis: Redis | None) -> EmailChecker | None: + """Initialize the EmailChecker instance.""" + if settings.environment in (Environment.DEV, Environment.TESTING): + return None + try: + checker = EmailChecker(redis) + await checker.initialize() + except (RuntimeError, ValueError, ConnectionError) as e: + logger.warning("Failed to initialize email checker: %s", e) + return None + else: + return checker diff --git a/backend/app/api/auth/services/emails.py b/backend/app/api/auth/services/emails.py new file mode 100644 index 00000000..6d19bba1 --- /dev/null +++ b/backend/app/api/auth/services/emails.py @@ -0,0 +1,140 @@ +"""Email delivery helpers for authentication and newsletter flows.""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType +from pydantic import AnyUrl, EmailStr + +from app.api.auth.config import settings as auth_settings +from app.core.config import settings as core_settings + +if TYPE_CHECKING: + from fastapi import BackgroundTasks + +logger: logging.Logger = logging.getLogger(__name__) +email_settings = auth_settings.email + +TEMPLATE_FOLDER = Path(__file__).parent.parent.parent.parent / "templates" / "emails" / "build" +email_conf = ConnectionConfig( + MAIL_USERNAME=email_settings.username, + MAIL_PASSWORD=email_settings.password, + MAIL_FROM=email_settings.sender.email if email_settings.sender else "", + MAIL_FROM_NAME=email_settings.sender.name if email_settings.sender else None, + MAIL_PORT=email_settings.port, + MAIL_SERVER=email_settings.host, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + TEMPLATE_FOLDER=TEMPLATE_FOLDER, + SUPPRESS_SEND=core_settings.mock_emails, +) +fm = FastMail(email_conf) + + +def generate_token_link(token: str, route: str, base_url: str | AnyUrl | None = None) -> str: + """Generate a link with the specified token and route.""" + if base_url is None: + base_url = str(core_settings.frontend_app_url) + return urljoin(str(base_url), f"{route}?token={token}") + + +def mask_email_for_log(email: EmailStr, *, mask: bool = True, max_len: int = 80) -> str: + """Mask emails for logging.""" + string = "".join(ch for ch in str(email) if ch.isprintable()).replace("\n", "").replace("\r", "") + local, sep, domain = string.partition("@") + masked = (f"{local[0]}***@{domain}" if len(local) > 1 else f"*@{domain}") if sep and mask else string + return f"{masked[: max_len - 3]}..." if len(masked) > max_len else masked + + +async def send_email_with_template( + to_email: EmailStr, + subject: str, + template_name: str, + template_body: dict[str, Any], + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send an HTML email using a template.""" + message = MessageSchema( + subject=subject, + recipients=[email_settings.recipient(to_email)], + template_body=template_body, + subtype=MessageType.html, + reply_to=[email_settings.reply_to] if email_settings.reply_to else [], + ) + + if background_tasks: + background_tasks.add_task(fm.send_message, message, template_name=template_name) + logger.info( + "Email queued for background sending to %s using template %s", mask_email_for_log(to_email), template_name + ) + else: + await fm.send_message(message, template_name=template_name) + logger.info("Email sent to %s using template %s", mask_email_for_log(to_email), template_name) + + +async def send_registration_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send a registration email with verification token.""" + verification_link = generate_token_link(token, "/verify") + await send_email_with_template( + to_email=to_email, + subject="Welcome to Reverse Engineering Lab - Verify Your Email", + template_name="registration.html", + template_body={"username": username or to_email, "verification_link": verification_link}, + background_tasks=background_tasks, + ) + + +async def send_reset_password_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send a reset password email with the token.""" + reset_link = generate_token_link(token, "/reset-password") + await send_email_with_template( + to_email=to_email, + subject="Password Reset", + template_name="password_reset.html", + template_body={"username": username or to_email, "reset_link": reset_link}, + background_tasks=background_tasks, + ) + + +async def send_verification_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send a verification email with the token.""" + verification_link = generate_token_link(token, "/verify") + await send_email_with_template( + to_email=to_email, + subject="Email Verification", + template_name="verification.html", + template_body={"username": username or to_email, "verification_link": verification_link}, + background_tasks=background_tasks, + ) + + +async def send_post_verification_email( + to_email: EmailStr, + username: str | None, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send a post-verification email.""" + await send_email_with_template( + to_email=to_email, + subject="Email Verified", + template_name="post_verification.html", + template_body={"username": username or to_email}, + background_tasks=background_tasks, + ) diff --git a/backend/app/api/auth/services/login_hooks.py b/backend/app/api/auth/services/login_hooks.py new file mode 100644 index 00000000..34b56f49 --- /dev/null +++ b/backend/app/api/auth/services/login_hooks.py @@ -0,0 +1,58 @@ +"""Post-login side effects for auth flows.""" + +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.config import settings as auth_settings +from app.api.auth.models import User +from app.api.auth.services import refresh_token_service +from app.core.config import settings as core_settings +from app.core.logging import sanitize_log_value +from app.core.runtime import get_request_services + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +async def update_last_login_metadata(user: User, request: Request | None, session: AsyncSession) -> None: + """Persist the latest login timestamp and IP address.""" + user.last_login_at = datetime.now(UTC).replace(tzinfo=None) + if request and request.client: + user.last_login_ip = request.client.host + await session.commit() + + +async def maybe_set_refresh_token_cookie(user: User, request: Request | None, response: Response | None) -> None: + """Create and attach a refresh token cookie when Redis is available.""" + if not request: + return + + redis = get_request_services(request).redis + if redis is None: + return + refresh_token = await refresh_token_service.create_refresh_token(redis, user.id) + + if response is not None: + response.set_cookie( + key="refresh_token", + value=refresh_token, + max_age=auth_settings.refresh_token_expire_days * 86_400, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + + +def log_successful_login(user: User) -> None: + """Log a successful login event.""" + logger.info( + "User %s logged in from %s", + sanitize_log_value(user.email), + sanitize_log_value(user.last_login_ip), + ) diff --git a/backend/app/api/auth/services/oauth.py b/backend/app/api/auth/services/oauth.py deleted file mode 100644 index 1d12052f..00000000 --- a/backend/app/api/auth/services/oauth.py +++ /dev/null @@ -1,23 +0,0 @@ -"""OAuth services.""" - -from httpx_oauth.clients.github import GitHubOAuth2 -from httpx_oauth.clients.google import BASE_SCOPES as GOOGLE_BASE_SCOPES -from httpx_oauth.clients.google import GoogleOAuth2 - -from app.api.auth.config import settings - -### Google OAuth ### -# Standard Google OAuth (no YouTube) -google_oauth_client = GoogleOAuth2( - settings.google_oauth_client_id, settings.google_oauth_client_secret, scopes=GOOGLE_BASE_SCOPES -) - -# YouTube-specific OAuth (only used for RPi-cam plugin) -GOOGLE_YOUTUBE_SCOPES = GOOGLE_BASE_SCOPES + settings.youtube_api_scopes -google_youtube_oauth_client = GoogleOAuth2( - settings.google_oauth_client_id, settings.google_oauth_client_secret, scopes=GOOGLE_YOUTUBE_SCOPES -) - - -### GitHub OAuth ### -github_oauth_client = GitHubOAuth2(settings.github_oauth_client_id, settings.github_oauth_client_secret) diff --git a/backend/app/api/auth/services/oauth/__init__.py b/backend/app/api/auth/services/oauth/__init__.py new file mode 100644 index 00000000..2fc32b85 --- /dev/null +++ b/backend/app/api/auth/services/oauth/__init__.py @@ -0,0 +1,40 @@ +"""OAuth services and router builders.""" + +from app.api.auth.services.oauth_clients import ( + GOOGLE_YOUTUBE_SCOPES, + github_oauth_client, + google_oauth_client, + google_youtube_oauth_client, +) +from app.api.auth.services.oauth_utils import ( + ACCESS_TOKEN_KEY, + CSRF_TOKEN_COOKIE_NAME, + CSRF_TOKEN_KEY, + STATE_TOKEN_AUDIENCE, + OAuth2AuthorizeResponse, + OAuthCookieSettings, + generate_csrf_token, + generate_state_token, +) + +from .associate import CustomOAuthAssociateRouterBuilder +from .base import BaseOAuthRouterBuilder +from .login import CustomOAuthRouterBuilder + +__all__ = [ + "ACCESS_TOKEN_KEY", + "CSRF_TOKEN_COOKIE_NAME", + "CSRF_TOKEN_KEY", + "GOOGLE_YOUTUBE_SCOPES", + "STATE_TOKEN_AUDIENCE", + "BaseOAuthRouterBuilder", + "CustomOAuthAssociateRouterBuilder", + "CustomOAuthRouterBuilder", + "OAuth2AuthorizeResponse", + "OAuthCookieSettings", + "generate_csrf_token", + "generate_state_token", + "github_oauth_client", + "google_oauth_client", + "google_youtube_oauth_client", +] diff --git a/backend/app/api/auth/services/oauth/associate.py b/backend/app/api/auth/services/oauth/associate.py new file mode 100644 index 00000000..71765d8e --- /dev/null +++ b/backend/app/api/auth/services/oauth/associate.py @@ -0,0 +1,231 @@ +"""OAuth account association router builder.""" +# spell-checker: ignore annotationlib + +from typing import TYPE_CHECKING, Annotated, Any, cast + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import Response as FastAPIResponse +from fastapi_users import schemas +from fastapi_users.models import UserOAuthProtocol +from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback +from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token # Used at runtime for FastAPI validation +from pydantic import UUID4 +from sqlalchemy import select + +from app.api.auth.exceptions import ( + OAuthAccountAlreadyLinkedError, + OAuthEmailUnavailableError, + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, +) +from app.api.auth.models import OAuthAccount, User +from app.api.auth.services.oauth_utils import ( + CSRF_TOKEN_KEY, + OAuth2AuthorizeResponse, + OAuthCookieSettings, + generate_csrf_token, + generate_state_token, +) +from app.api.auth.services.user_manager import UserManager, fastapi_user_manager + +from .base import BaseOAuthRouterBuilder + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from fastapi_users.authentication import Authenticator + from fastapi_users.jwt import SecretType + + +class CustomOAuthAssociateRouterBuilder(BaseOAuthRouterBuilder): + """Builder for the OAuth association router.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + authenticator: Authenticator[User, UUID4], + user_schema: type[schemas.U], + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + *, + requires_verification: bool = False, + route_name_key: str | None = None, + authorize_extras_params: dict[str, Any] | None = None, + ) -> None: + """Initialize association router builder. + + ``route_name_key`` overrides the key used in FastAPI route names + (e.g. ``oauth-associate:{key}.callback``). Useful when registering two + clients that share the same OAuth ``name`` (e.g. ``google`` and + ``google-youtube``) to avoid duplicate route-name conflicts. + + ``authorize_extras_params`` is forwarded to + ``oauth_client.get_authorization_url`` as ``extras_params``. Use this + to pass provider-specific flags such as ``{"access_type": "offline", + "prompt": "consent"}`` for the Google YouTube scope-upgrade flow. + """ + super().__init__(oauth_client, state_secret, redirect_url, cookie_settings) + self.authenticator = authenticator + self.user_schema = user_schema + self.requires_verification = requires_verification + self.authorize_extras_params = authorize_extras_params + key = route_name_key if route_name_key is not None else oauth_client.name + self.callback_route_name = f"oauth-associate:{key}.callback" + + def build(self) -> APIRouter: + """Construct the APIRouter.""" + router = APIRouter() + get_current_active_user = self.authenticator.current_user(active=True, verified=self.requires_verification) + + callback_route_name = self.callback_route_name + if self.redirect_url is not None: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, redirect_url=self.redirect_url) + else: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, route_name=callback_route_name) + + authorize_route_name = self.callback_route_name.replace(".callback", ".authorize") + + @router.get( + "/authorize", + name=authorize_route_name, + response_model=OAuth2AuthorizeResponse, + ) + async def authorize( + request: Request, + response: Response, + user: Annotated[User, Depends(get_current_active_user)], + scopes: Annotated[list[str] | None, Query()] = None, + ) -> OAuth2AuthorizeResponse: + return await self._get_authorize_handler(request, response, user, scopes) + + # Python 3.14 (annotationlib) cannot resolve local-scope variables referenced in + # annotations of inner functions when Pydantic rebuilds the schema. Setting + # __annotations__ explicitly (as a plain dict of already-evaluated types) bypasses + # annotationlib's lazy ForwardRef evaluation. + async def callback(request, user, access_token_state, user_manager): # noqa: ANN001, ANN202 + return await self._get_callback_handler(request, user, access_token_state, user_manager) + + callback.__annotations__ = { + "request": Request, + "user": Annotated[User, Depends(get_current_active_user)], + "access_token_state": Annotated[tuple[OAuth2Token, str], Depends(oauth2_authorize_callback)], + "user_manager": Annotated[UserManager, Depends(fastapi_user_manager.get_user_manager)], + "return": Response | schemas.U, + } + + router.add_api_route( + "/callback", + callback, + response_model=self.user_schema, + name=callback_route_name, + methods=["GET"], + description="The response varies based on the authentication backend used.", + ) + + return router + + async def _get_authorize_handler( + self, + request: Request, + response: Response, + user: User, + scopes: list[str] | None, + ) -> OAuth2AuthorizeResponse: + authorize_redirect_url = self.redirect_url + if authorize_redirect_url is None: + authorize_redirect_url = str(request.url_for(self.callback_route_name)) + + csrf_token = generate_csrf_token() + state_data: dict[str, str] = {"sub": str(user.id), CSRF_TOKEN_KEY: csrf_token} + + redirect_uri = request.query_params.get("redirect_uri") + if redirect_uri: + if not self._is_allowed_frontend_redirect(redirect_uri): + raise OAuthInvalidRedirectURIError + state_data["frontend_redirect_uri"] = redirect_uri + + state = generate_state_token(state_data, self.state_secret) + authorization_url = await self.oauth_client.get_authorization_url( + authorize_redirect_url, + state, + scopes, + extras_params=self.authorize_extras_params, + ) + + self.set_csrf_cookie(response, csrf_token) + return OAuth2AuthorizeResponse(authorization_url=authorization_url) + + async def _get_callback_handler( + self, + request: Request, + user: User, + access_token_state: tuple[OAuth2Token, str], + user_manager: UserManager, + ) -> Response | schemas.U: + token, state = access_token_state + state_data = self.verify_state(request, state) + + if state_data.get("sub") != str(user.id): + raise OAuthInvalidStateError + + account_id, account_email = await self.oauth_client.get_id_email(token["access_token"]) + if account_email is None: + raise OAuthEmailUnavailableError + + session = user_manager.user_db.session + existing_account = ( + ( + await session.execute( + select(OAuthAccount).where( + OAuthAccount.oauth_name == self.oauth_client.name, + OAuthAccount.account_id == account_id, + ) + ) + ) + .scalars() + .first() + ) + + if existing_account and existing_account.user_id != user.id: + raise OAuthAccountAlreadyLinkedError + + if existing_account: + # Same user — upgrade the stored token in-place. + # This happens when re-running an associate flow to gain additional + # OAuth scopes (e.g. upgrading a plain Google token to include + # YouTube API scopes). fastapi-users' oauth_associate_callback calls + # add_oauth_account (INSERT), which would fail on the unique + # constraint — so we update directly instead. + updated_user = await user_manager.user_db.update_oauth_account( + cast("UserOAuthProtocol[UUID4, OAuthAccount]", user), + existing_account, + { + "access_token": token["access_token"], + "expires_at": token.get("expires_at"), + "refresh_token": token.get("refresh_token"), + }, + ) + user = cast("User", updated_user) + else: + oauth_associate_callback = cast( + "Callable[..., Awaitable[User]]", + user_manager.oauth_associate_callback, + ) + + user = await oauth_associate_callback( + user, + self.oauth_client.name, + token["access_token"], + account_id, + account_email, + token.get("expires_at"), + token.get("refresh_token"), + request, + ) + + frontend_redirect = state_data.get("frontend_redirect_uri") + if frontend_redirect: + return self._create_success_redirect(frontend_redirect, FastAPIResponse()) + + return cast("schemas.U", self.user_schema.model_validate(user)) diff --git a/backend/app/api/auth/services/oauth/base.py b/backend/app/api/auth/services/oauth/base.py new file mode 100644 index 00000000..0cc2474f --- /dev/null +++ b/backend/app/api/auth/services/oauth/base.py @@ -0,0 +1,153 @@ +"""Shared OAuth router builder behavior.""" + +from __future__ import annotations + +import re +import secrets +from typing import Any # noqa: TC003 # Used at runtime for FastAPI validation +from urllib.parse import ParseResult, parse_qsl, urlencode, urlparse, urlunparse + +import jwt +from fastapi import Request, Response +from fastapi.responses import RedirectResponse +from fastapi_users.jwt import SecretType, decode_jwt +from httpx_oauth.oauth2 import BaseOAuth2 # noqa: TC002 # Used at runtime for FastAPI validation + +from app.api.auth.config import settings +from app.api.auth.exceptions import ( + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, + OAuthStateDecodeError, + OAuthStateExpiredError, +) +from app.api.auth.services.oauth_utils import ( + ACCESS_TOKEN_KEY, + CSRF_TOKEN_KEY, + SET_COOKIE_HEADER, + STATE_TOKEN_AUDIENCE, + OAuthCookieSettings, + set_csrf_cookie, +) +from app.core.config import settings as core_settings + + +class BaseOAuthRouterBuilder: + """Base class for building OAuth routers with dynamic redirects.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + ) -> None: + """Initialize base builder properties.""" + self.oauth_client = oauth_client + self.state_secret = state_secret + self.redirect_url = redirect_url + self.cookie_settings = cookie_settings or OAuthCookieSettings() + + def set_csrf_cookie(self, response: Response, csrf_token: str) -> None: + """Set the CSRF cookie on the response.""" + set_csrf_cookie(response, self.cookie_settings, csrf_token) + + def verify_state(self, request: Request, state: str) -> dict[str, Any]: + """Decode the state JWT and verify CSRF protection.""" + try: + state_data = decode_jwt(state, self.state_secret, [STATE_TOKEN_AUDIENCE]) + except jwt.DecodeError as err: + raise OAuthStateDecodeError from err + except jwt.ExpiredSignatureError as err: + raise OAuthStateExpiredError from err + + cookie_csrf_token = request.cookies.get(self.cookie_settings.name) + state_csrf_token = state_data.get(CSRF_TOKEN_KEY) + + if ( + not cookie_csrf_token + or not state_csrf_token + or not secrets.compare_digest(cookie_csrf_token, state_csrf_token) + ): + raise OAuthInvalidStateError + + return state_data + + def _create_success_redirect( + self, + frontend_redirect: str, + response: Response, + ) -> Response: + """Create a redirect to the frontend with cookies and success status.""" + parts = list(urlparse(frontend_redirect)) + query = dict(parse_qsl(parts[4])) + + query.pop(ACCESS_TOKEN_KEY, None) + query["success"] = "true" + + parts[4] = urlencode(query) + redirect_response = RedirectResponse(urlunparse(parts)) + + for raw_header in response.raw_headers: + if raw_header[0].lower() == SET_COOKIE_HEADER: + redirect_response.headers.append("set-cookie", raw_header[1].decode("latin-1")) + return redirect_response + + @staticmethod + def _create_error_redirect(frontend_redirect: str, detail: str) -> Response: + """Create a redirect to the frontend with an error detail in the query string.""" + parts = list(urlparse(frontend_redirect)) + query = dict(parse_qsl(parts[4])) + query.pop(ACCESS_TOKEN_KEY, None) + query["success"] = "false" + query["detail"] = detail + parts[4] = urlencode(query) + return RedirectResponse(urlunparse(parts)) + + @staticmethod + def _normalize_origin(url: str) -> str: + """Normalize a URL into scheme://host[:port].""" + parsed = urlparse(url) + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}".rstrip("/") + + @staticmethod + def _normalize_redirect_target(url: str) -> str: + """Normalize a redirect target to scheme://netloc/path with no query/fragment.""" + parsed = urlparse(url) + return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), parsed.path, "", "", "")).rstrip("/") + + @staticmethod + def _is_allowed_redirect_path(path: str) -> bool: + """Validate the redirect path against the optional allowlist.""" + return not settings.oauth_allowed_redirect_paths or path in settings.oauth_allowed_redirect_paths + + def _is_allowed_http_redirect(self, redirect_uri: str, parsed_redirect: ParseResult) -> bool: + """Validate an HTTP(S) frontend redirect against trusted origins.""" + if not parsed_redirect.netloc: + return False + + redirect_origin = self._normalize_origin(redirect_uri) + if core_settings.cors_origin_regex and re.fullmatch(core_settings.cors_origin_regex, redirect_origin): + return self._is_allowed_redirect_path(parsed_redirect.path) + + return redirect_origin in core_settings.allowed_origins and self._is_allowed_redirect_path(parsed_redirect.path) + + def _is_allowed_native_redirect(self, redirect_uri: str) -> bool: + """Validate a native deep-link callback against the explicit allowlist.""" + normalized_redirect = self._normalize_redirect_target(redirect_uri) + allowed_native_redirects = { + self._normalize_redirect_target(uri) for uri in settings.oauth_allowed_native_redirect_uris + } + return normalized_redirect in allowed_native_redirects + + def _is_allowed_frontend_redirect(self, redirect_uri: str) -> bool: + """Validate whether a frontend redirect URI is explicitly allowed.""" + parsed = urlparse(redirect_uri) + if not parsed.scheme or parsed.username or parsed.password or parsed.fragment: + return False + + if parsed.scheme in {"http", "https"}: + return self._is_allowed_http_redirect(redirect_uri, parsed) + + if not self._is_allowed_native_redirect(redirect_uri): + raise OAuthInvalidRedirectURIError + return True diff --git a/backend/app/api/auth/services/oauth/login.py b/backend/app/api/auth/services/oauth/login.py new file mode 100644 index 00000000..827f9b75 --- /dev/null +++ b/backend/app/api/auth/services/oauth/login.py @@ -0,0 +1,188 @@ +"""OAuth login router builder.""" + +# spell-checker: ignore annotationlib + +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi_users.authentication import Strategy # Used at runtime in __annotations__ dict +from fastapi_users.exceptions import UserAlreadyExists +from fastapi_users.router.common import ErrorCode +from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback +from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token # Used at runtime for FastAPI validation +from pydantic import UUID4 + +from app.api.auth.exceptions import ( + OAuthEmailUnavailableError, + OAuthInactiveUserHTTPError, + OAuthInvalidRedirectURIError, + OAuthUserAlreadyExistsHTTPError, +) +from app.api.auth.models import User +from app.api.auth.services.oauth_utils import ( + ACCESS_TOKEN_KEY, + CSRF_TOKEN_KEY, + OAuth2AuthorizeResponse, + OAuthCookieSettings, + generate_csrf_token, + generate_state_token, +) +from app.api.auth.services.user_manager import UserManager, fastapi_user_manager + +from .base import BaseOAuthRouterBuilder + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from fastapi_users.authentication import AuthenticationBackend + from fastapi_users.jwt import SecretType + + +class CustomOAuthRouterBuilder(BaseOAuthRouterBuilder): + """Builder for the main OAuth authentication router.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + backend: AuthenticationBackend[User, UUID4], + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + *, + associate_by_email: bool = False, + is_verified_by_default: bool = False, + ) -> None: + """Initialize the router builder.""" + super().__init__(oauth_client, state_secret, redirect_url, cookie_settings) + self.backend = backend + self.associate_by_email = associate_by_email + self.is_verified_by_default = is_verified_by_default + self.callback_route_name = f"oauth:{oauth_client.name}.{backend.name}.callback" + + def build(self) -> APIRouter: + """Construct the APIRouter.""" + router = APIRouter() + + callback_route_name = self.callback_route_name + if self.redirect_url is not None: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, redirect_url=self.redirect_url) + else: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, route_name=callback_route_name) + + @router.get( + "/authorize", + name=f"oauth:{self.oauth_client.name}.{self.backend.name}.authorize", + response_model=OAuth2AuthorizeResponse, + ) + async def authorize( + request: Request, + response: Response, + scopes: Annotated[list[str] | None, Query()] = None, + ) -> OAuth2AuthorizeResponse: + return await self._get_authorize_handler(request, response, scopes) + + # Python 3.14 (annotationlib) cannot resolve local-scope variables referenced in + # annotations of inner functions when Pydantic rebuilds the schema. Setting + # __annotations__ explicitly (as a plain dict of already-evaluated types) bypasses + # annotationlib's lazy ForwardRef evaluation. + async def callback(request, access_token_state, user_manager, strategy): # noqa: ANN001, ANN202 + return await self._get_callback_handler(request, access_token_state, user_manager, strategy) + + callback.__annotations__ = { + "request": Request, + "access_token_state": Annotated[tuple[OAuth2Token, str], Depends(oauth2_authorize_callback)], + "user_manager": Annotated[UserManager, Depends(fastapi_user_manager.get_user_manager)], + "strategy": Annotated[Strategy[User, UUID4], Depends(self.backend.get_strategy)], + "return": Response, + } + + router.add_api_route( + "/callback", + callback, + name=callback_route_name, + methods=["GET"], + description="The response varies based on the authentication backend used.", + ) + + return router + + async def _get_authorize_handler( + self, + request: Request, + response: Response, + scopes: list[str] | None, + ) -> OAuth2AuthorizeResponse: + authorize_redirect_url = self.redirect_url + if authorize_redirect_url is None: + authorize_redirect_url = str(request.url_for(self.callback_route_name)) + + csrf_token = generate_csrf_token() + state_data: dict[str, str] = {CSRF_TOKEN_KEY: csrf_token} + + redirect_uri = request.query_params.get("redirect_uri") + if redirect_uri: + if not self._is_allowed_frontend_redirect(redirect_uri): + raise OAuthInvalidRedirectURIError + state_data["frontend_redirect_uri"] = redirect_uri + + state = generate_state_token(state_data, self.state_secret) + authorization_url = await self.oauth_client.get_authorization_url( + authorize_redirect_url, + state, + scopes, + ) + + self.set_csrf_cookie(response, csrf_token) + return OAuth2AuthorizeResponse(authorization_url=authorization_url) + + async def _get_callback_handler( + self, + request: Request, + access_token_state: tuple[OAuth2Token, str], + user_manager: UserManager, + strategy: Strategy[User, UUID4], + ) -> Response: + token, state = access_token_state + state_data = self.verify_state(request, state) + frontend_redirect = state_data.get("frontend_redirect_uri") + + account_id, account_email = await self.oauth_client.get_id_email(token["access_token"]) + if account_email is None: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL.value) + raise OAuthEmailUnavailableError + + oauth_callback = cast( + "Callable[..., Awaitable[User]]", + user_manager.oauth_callback, + ) + + try: + user = await oauth_callback( + self.oauth_client.name, + token[ACCESS_TOKEN_KEY], + account_id, + account_email, + token.get("expires_at"), + token.get("refresh_token"), + request, + associate_by_email=self.associate_by_email, + is_verified_by_default=self.is_verified_by_default, + ) + except UserAlreadyExists as err: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.OAUTH_USER_ALREADY_EXISTS.value) + raise OAuthUserAlreadyExistsHTTPError from err + + if not user.is_active: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.LOGIN_BAD_CREDENTIALS.value) + raise OAuthInactiveUserHTTPError + + response = await self.backend.login(strategy, user) + await user_manager.on_after_login(user, request, response) + + if frontend_redirect: + return self._create_success_redirect(frontend_redirect, response) + + return response diff --git a/backend/app/api/auth/services/oauth_clients.py b/backend/app/api/auth/services/oauth_clients.py new file mode 100644 index 00000000..199af014 --- /dev/null +++ b/backend/app/api/auth/services/oauth_clients.py @@ -0,0 +1,28 @@ +"""OAuth client instances and scope definitions.""" + +from httpx_oauth.clients.github import GitHubOAuth2 +from httpx_oauth.clients.google import BASE_SCOPES as GOOGLE_BASE_SCOPES +from httpx_oauth.clients.google import GoogleOAuth2 + +from app.api.auth.config import settings + +# Google +google_oauth_client = GoogleOAuth2( + settings.google_oauth_client_id.get_secret_value(), + settings.google_oauth_client_secret.get_secret_value(), + scopes=GOOGLE_BASE_SCOPES, +) + +# YouTube (only used for RPi-cam plugin) +GOOGLE_YOUTUBE_SCOPES = GOOGLE_BASE_SCOPES + settings.youtube_api_scopes +google_youtube_oauth_client = GoogleOAuth2( + settings.google_oauth_client_id.get_secret_value(), + settings.google_oauth_client_secret.get_secret_value(), + scopes=GOOGLE_YOUTUBE_SCOPES, +) + +# GitHub +github_oauth_client = GitHubOAuth2( + settings.github_oauth_client_id.get_secret_value(), + settings.github_oauth_client_secret.get_secret_value(), +) diff --git a/backend/app/api/auth/services/oauth_utils.py b/backend/app/api/auth/services/oauth_utils.py new file mode 100644 index 00000000..ee3e2206 --- /dev/null +++ b/backend/app/api/auth/services/oauth_utils.py @@ -0,0 +1,65 @@ +"""OAuth helper DTOs and token utilities.""" +# spell-checker: ignore fastapiusersoauthcsrf + +import secrets +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from fastapi import Response +from fastapi_users.jwt import SecretType, generate_jwt +from pydantic import BaseModel + +from app.api.auth.config import settings as auth_settings +from app.core.config import settings as core_settings + +if TYPE_CHECKING: + from typing import Literal + +STATE_TOKEN_AUDIENCE = "fastapi-users:oauth-state" # noqa: S105 # This value is not a secret +CSRF_TOKEN_KEY = "csrftoken" # noqa: S105 # This value is not a secret +CSRF_TOKEN_COOKIE_NAME = "fastapiusersoauthcsrf" # noqa: S105 # This value is not a secret +SET_COOKIE_HEADER = b"set-cookie" +ACCESS_TOKEN_KEY = "access_token" # noqa: S105 # This value is not a secret + + +class OAuth2AuthorizeResponse(BaseModel): + """Response model for OAuth2 authorization endpoint.""" + + authorization_url: str + + +def generate_state_token(data: dict[str, str], secret: SecretType, lifetime_seconds: int | None = None) -> str: + """Generate a JWT state token for OAuth flows.""" + data["aud"] = STATE_TOKEN_AUDIENCE + return generate_jwt(data, secret, lifetime_seconds or auth_settings.oauth_state_token_ttl_seconds) + + +def generate_csrf_token() -> str: + """Generate a CSRF token for OAuth flows.""" + return secrets.token_urlsafe(32) + + +@dataclass +class OAuthCookieSettings: + """Configuration for OAuth CSRF cookies.""" + + name: str = CSRF_TOKEN_COOKIE_NAME + path: str = "/" + domain: str | None = None + secure: bool = core_settings.secure_cookies + httponly: bool = True + samesite: Literal["lax", "strict", "none"] = "lax" + + +def set_csrf_cookie(response: Response, cookie_settings: OAuthCookieSettings, csrf_token: str) -> None: + """Set the CSRF cookie on the response.""" + response.set_cookie( + cookie_settings.name, + csrf_token, + max_age=auth_settings.oauth_state_token_ttl_seconds, + path=cookie_settings.path, + domain=cookie_settings.domain, + secure=cookie_settings.secure, + httponly=cookie_settings.httponly, + samesite=cookie_settings.samesite, + ) diff --git a/backend/app/api/auth/services/password_validator.py b/backend/app/api/auth/services/password_validator.py new file mode 100644 index 00000000..d0286296 --- /dev/null +++ b/backend/app/api/auth/services/password_validator.py @@ -0,0 +1,84 @@ +"""Password validation service. + +Extracted from UserManager per ADR-012 to keep auth business logic +in services rather than fastapi-users hooks. +""" + +import hashlib +import logging + +import zxcvbn as zxcvbn_checker +from fastapi_users import InvalidPasswordException +from httpx import AsyncClient, HTTPError +from pydantic import SecretStr + +logger = logging.getLogger(__name__) + +# zxcvbn score threshold: 0=very weak, 1=weak, 2=fair, 3=good, 4=strong +MIN_PASSWORD_STRENGTH_SCORE = 1 + + +async def check_pwned_password(password: str, http_client: AsyncClient) -> int: + """Return how many times this password appears in HaveIBeenPwned breach data. + + Uses k-anonymity: only the first 5 hex chars of the SHA-1 hash are sent; + the plaintext password never leaves this process. + Fails open (returns 0) if the API is unreachable. + """ + # Have I Been Pwned's range API requires SHA-1 for its k-anonymity protocol. + # The digest is used only to derive the range prefix for the outbound lookup, + # never for password storage or local password verification. + sha1 = hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper() + prefix, suffix = sha1[:5], sha1[5:] + try: + response = await http_client.get( + f"https://api.pwnedpasswords.com/range/{prefix}", + headers={"Add-Padding": "true"}, + timeout=5.0, + ) + response.raise_for_status() + for line in response.text.splitlines(): + h, _, count = line.partition(":") + if h == suffix: + return int(count) + except HTTPError: + logger.warning("Have I Been Pwnd breach check unavailable, skipping for this request") + return 0 + + +async def validate_password( + password: str | SecretStr, + *, + email: str, + username: str | None = None, + http_client: AsyncClient | None = None, + skip_breach_check: bool = False, +) -> None: + """Validate password meets security requirements. + + Raises: + InvalidPasswordException: If the password fails any check. + """ + if isinstance(password, SecretStr): + password = password.get_secret_value() + + if len(password) < 8: + raise InvalidPasswordException(reason="Password should be at least 8 characters") + if email in password: + raise InvalidPasswordException(reason="Password should not contain e-mail") + if username and username in password: + raise InvalidPasswordException(reason="Password should not contain username") + + # Strength check: reject passwords that are too guessable + user_inputs = [s for s in [email, username] if s] + result = zxcvbn_checker.zxcvbn(password, user_inputs=user_inputs) + if result["score"] < MIN_PASSWORD_STRENGTH_SCORE: + feedback = result.get("feedback", {}).get("warning") or "try a longer phrase or mix of characters" + raise InvalidPasswordException(reason=f"Password is too weak: {feedback}") + + if not skip_breach_check and http_client: + breach_count = await check_pwned_password(password, http_client) + if breach_count > 0: + raise InvalidPasswordException( + reason="Password has appeared in a known data breach. Please choose a different password." + ) diff --git a/backend/app/api/auth/services/privacy.py b/backend/app/api/auth/services/privacy.py new file mode 100644 index 00000000..00253928 --- /dev/null +++ b/backend/app/api/auth/services/privacy.py @@ -0,0 +1,54 @@ +"""Privacy and redaction utilities for the platform.""" + +from sqlalchemy import inspect +from sqlalchemy.exc import NoInspectionAvailable +from sqlalchemy.orm.base import ATTR_EMPTY + +from app.api.auth.models import User +from app.api.data_collection.models.product import Product + +VISIBILITY_PUBLIC = "public" +VISIBILITY_COMMUNITY = "community" +VISIBILITY_PRIVATE = "private" + + +def should_redact_owner(owner: User, viewer: User | None) -> bool: + """Return True when the owner's identity should be hidden from the viewer. + + Rules: + - Admins always see everything. + - public → never redact. + - community → redact only for unauthenticated guests. + - private → redact for everyone except the owner themselves. + """ + if viewer and viewer.is_superuser: + return False + + preferences: dict = owner.preferences or {} + visibility: str = preferences.get("profile_visibility", VISIBILITY_PUBLIC) + + if visibility == VISIBILITY_PRIVATE: + return not viewer or viewer.id != owner.id + if visibility == VISIBILITY_COMMUNITY: + return viewer is None + return False # public + + +def redact_product_owner(product: Product, viewer: User | None) -> None: + """Null out the owner relationship on *product* when privacy rules require it. + + Operates in-place on the ORM model **before** Pydantic serialisation so + that ``owner_username`` (a @property that reads ``owner.username``) and + ``owner_id`` both become ``None`` naturally when the schema is built. + """ + try: + product_state = inspect(product) + except NoInspectionAvailable: + return + + owner = product_state.attrs[Product.owner.key].loaded_value + if owner is ATTR_EMPTY: + return + if owner and should_redact_owner(owner, viewer): + product.owner = None + product.owner_id = None diff --git a/backend/app/api/auth/services/programmatic_user_crud.py b/backend/app/api/auth/services/programmatic_user_crud.py new file mode 100644 index 00000000..6d06c169 --- /dev/null +++ b/backend/app/api/auth/services/programmatic_user_crud.py @@ -0,0 +1,72 @@ +"""Programmatic user-creation helpers built on top of FastAPI Users.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists + +from app.api.auth.services.user_manager import get_user_db, get_user_manager +from app.core.database import async_session_context + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + from app.api.auth.schemas import UserCreate + from app.api.auth.services.user_manager import UserManager + +get_async_user_db_context = asynccontextmanager(get_user_db) +get_async_user_manager_context = asynccontextmanager(get_user_manager) + + +@asynccontextmanager +async def get_chained_async_user_manager_context( + session: AsyncSession | None = None, +) -> AsyncGenerator[UserManager]: + """Yield a user manager, optionally reusing an existing database session.""" + if session is not None: + async with ( + get_async_user_db_context(session) as user_db, + get_async_user_manager_context(user_db) as user_manager, + ): + yield user_manager + return + + async with ( + async_session_context() as db_session, + get_async_user_db_context(db_session) as user_db, + get_async_user_manager_context(user_db) as user_manager, + ): + yield user_manager + + +async def create_user( + async_session: AsyncSession, + user_create: UserCreate, + *, + send_registration_email: bool = False, + skip_breach_check: bool = False, + skip_password_validation: bool = False, +) -> User: + """Programmatically create a new user in the database.""" + try: + async with get_chained_async_user_manager_context(async_session) as user_manager: + user_manager.skip_breach_check = skip_breach_check + user_manager.skip_password_validation = skip_password_validation + user: User = await user_manager.create(user_create) + + if send_registration_email: + await user_manager.request_verify(user) + + return user + + except UserAlreadyExists: + err_msg = f"User with email {user_create.email} already exists." + raise UserAlreadyExists(err_msg) from None + except InvalidPasswordException as e: + err_msg = f"Password is invalid: {e.reason}." + raise InvalidPasswordException(err_msg) from e diff --git a/backend/app/api/auth/services/rate_limiter.py b/backend/app/api/auth/services/rate_limiter.py new file mode 100644 index 00000000..a5cb8aa4 --- /dev/null +++ b/backend/app/api/auth/services/rate_limiter.py @@ -0,0 +1,121 @@ +"""Lightweight rate limiter backed by the `limits` library. + +Replaces the unmaintained slowapi package with a minimal implementation +that covers exactly the features this project uses: a per-route ``@limiter.limit()`` +decorator, Redis-backed storage, and a fixed-window strategy. +""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar + +from fastapi import Request +from fastapi.responses import JSONResponse +from limits import parse +from limits.storage import storage_from_string +from limits.strategies import STRATEGIES + +from app.api.auth.config import settings as auth_settings +from app.core.config import settings as core_settings +from app.core.middleware.client_ip import get_client_ip +from app.core.responses import build_problem_response + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + +P = ParamSpec("P") +R = TypeVar("R") + + +class RateLimitExceededError(Exception): + """Raised when a client exceeds the configured rate limit.""" + + def __init__(self, detail: str = "Rate limit exceeded") -> None: + self.detail = detail + super().__init__(detail) + + +class Limiter: + """Minimal rate limiter compatible with FastAPI route decorators.""" + + def __init__( + self, + *, + key_func: Callable[[Request], str], + storage_uri: str, + strategy: str = "fixed-window", + enabled: bool = True, + ) -> None: + self._key_func = key_func + self.enabled = enabled + if enabled: + self._storage = storage_from_string(storage_uri) + self._limiter = STRATEGIES[strategy](self._storage) + else: + self._storage = None + self._limiter = None + + def limit( + self, rate_string: str + ) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]: + """Decorator that enforces *rate_string* on an async endpoint.""" + parsed = parse(rate_string) + + def decorator( + func: Callable[P, Coroutine[Any, Any, R]], + ) -> Callable[P, Coroutine[Any, Any, R]]: + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + if self.enabled: + limiter = self._limiter + request: Request | None = next( + (v for v in (*args, *kwargs.values()) if isinstance(v, Request)), + None, + ) + if request is not None and limiter is not None: + key = self._key_func(request) + if not limiter.hit(parsed, key): + raise RateLimitExceededError + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def rate_limit_exceeded_handler(request: Request, exc: Exception) -> JSONResponse: + """Return a 429 JSON response for rate-limited requests.""" + detail = exc.detail if isinstance(exc, RateLimitExceededError) else "Rate limit exceeded" + return build_problem_response( + request=request, + status_code=429, + detail=detail, + code="RateLimitExceeded", + type_="https://httpstatuses.com/429", + ) + + +def _find_request(args: tuple[object, ...], kwargs: dict[str, object]) -> Request | None: + """Extract the ``Request`` instance from endpoint arguments.""" + for val in (*args, *kwargs.values()): + if isinstance(val, Request): + return val + return None + + +# --------------------------------------------------------------------------- +# Singleton limiter instance & rate-limit strings +# --------------------------------------------------------------------------- + +limiter = Limiter( + key_func=get_client_ip, + storage_uri=core_settings.cache_url, + strategy="fixed-window", + enabled=core_settings.enable_rate_limit, +) + +LOGIN_RATE_LIMIT = f"{auth_settings.rate_limit_login_attempts_per_minute}/minute" +REGISTER_RATE_LIMIT = f"{auth_settings.rate_limit_register_attempts_per_hour}/hour" +VERIFY_RATE_LIMIT = f"{auth_settings.rate_limit_verify_attempts_per_hour}/hour" +PASSWORD_RESET_RATE_LIMIT = f"{auth_settings.rate_limit_password_reset_attempts_per_hour}/hour" diff --git a/backend/app/api/auth/services/refresh_token_service.py b/backend/app/api/auth/services/refresh_token_service.py new file mode 100644 index 00000000..d3177267 --- /dev/null +++ b/backend/app/api/auth/services/refresh_token_service.py @@ -0,0 +1,213 @@ +"""Refresh token service for managing long-lived authentication tokens. + +This module supports both Redis-backed storage and an in-memory fallback +used when Redis is unavailable (convenient for local development). +""" +# spell-checker: ignore setex + +from __future__ import annotations + +import secrets +import time +from typing import TYPE_CHECKING, cast +from uuid import UUID + +from pydantic import UUID4 + +from app.api.auth.config import settings +from app.api.auth.exceptions import RefreshTokenInvalidError, RefreshTokenRevokedError +from app.core.constants import HOUR + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from redis.asyncio import Redis + + +# In-memory stores used when Redis is not available. Keys are the raw token strings. +# Values for _memory_tokens: token -> (user_id_str, expire_ts) +# Values for _memory_blacklist: token -> expire_ts +_memory_tokens: dict[str, tuple[str, float]] = {} +_memory_blacklist: dict[str, float] = {} + +_USER_TOKENS_KEY_PREFIX = "auth:rt:user:" + + +async def create_refresh_token( + redis: Redis | None, + user_id: UUID4, +) -> str: + """Create a new refresh token. + + Args: + redis: Redis client or None for in-memory fallback + user_id: User's UUID + + Returns: + Refresh token string + """ + token = secrets.token_urlsafe(48) + + ttl = settings.refresh_token_expire_days * 86400 + + if redis is None: + expire_ts = time.time() + ttl + _memory_tokens[token] = (str(user_id), expire_ts) + return token + + # Store token with user_id mapping in Redis and add to user's token set + token_key = f"auth:rt:{token}" + user_tokens_key = f"{_USER_TOKENS_KEY_PREFIX}{user_id}" + await redis.setex(token_key, ttl, str(user_id)) + await cast("Awaitable[int]", redis.sadd(user_tokens_key, token)) + await redis.expire(user_tokens_key, ttl) + return token + + +async def verify_refresh_token( + redis: Redis | None, + token: str, +) -> UUID: + """Verify a refresh token and return the user ID. + + Args: + redis: Redis client + token: Refresh token to verify + + Returns: + UUID of the user + + Raises: + RefreshTokenError: If token is invalid, expired, or blacklisted + """ + # Check if token is blacklisted + if redis is None: + # In-memory blacklist check + bl_expire = _memory_blacklist.get(token) + if bl_expire and bl_expire > time.time(): + raise RefreshTokenRevokedError + else: + blacklist_key = f"auth:rt_blacklist:{token}" + if await redis.exists(blacklist_key): + raise RefreshTokenRevokedError + + if redis is None: + token_data = _memory_tokens.get(token) + if not token_data or token_data[1] <= time.time(): + raise RefreshTokenInvalidError + user_id_str = token_data[0] + else: + token_key = f"auth:rt:{token}" + user_id_str = await redis.get(token_key) + + if not user_id_str: + raise RefreshTokenInvalidError + + return UUID(user_id_str if isinstance(user_id_str, str) else user_id_str.decode("utf-8")) + + +async def blacklist_token( + redis: Redis | None, + token: str, + ttl_seconds: int | None = None, +) -> None: + """Blacklist a refresh token and delete it. + + Args: + redis: Redis client + token: Refresh token to blacklist + ttl_seconds: TTL for blacklist entry (if None, uses remaining token TTL) + """ + token_key = f"auth:rt:{token}" + + if redis is None: + # Determine ttl from token if not provided + if ttl_seconds is None: + token_data = _memory_tokens.get(token) + ttl_seconds = max(int(token_data[1] - time.time()), HOUR) if token_data else HOUR + + _memory_blacklist[token] = time.time() + ttl_seconds + _memory_tokens.pop(token, None) + return + + if ttl_seconds is None: + # Get remaining TTL from the token itself + ttl_seconds = await redis.ttl(token_key) + if ttl_seconds <= 0: + ttl_seconds = HOUR # Default 1 hour if token already expired + + # Get user_id before deleting the token key (needed to clean up user set) + user_id_str = await redis.get(token_key) + + # Add to blacklist and delete the token + blacklist_key = f"auth:rt_blacklist:{token}" + await redis.setex(blacklist_key, ttl_seconds, "1") + await redis.delete(token_key) + + # Remove from user's token set + if user_id_str: + user_tokens_key = f"{_USER_TOKENS_KEY_PREFIX}{user_id_str}" + await cast("Awaitable[int]", redis.srem(user_tokens_key, token)) + + +async def revoke_all_user_tokens( + redis: Redis | None, + user_id: UUID4, +) -> None: + """Revoke all active refresh tokens for a user. + + Args: + redis: Redis client or None for in-memory fallback + user_id: User's UUID + """ + user_id_str = str(user_id) + + if redis is None: + tokens_to_revoke = [t for t, (uid, _) in list(_memory_tokens.items()) if uid == user_id_str] + for token in tokens_to_revoke: + await blacklist_token(redis, token) + return + + user_tokens_key = f"{_USER_TOKENS_KEY_PREFIX}{user_id_str}" + tokens = await cast("Awaitable[set[str]]", redis.smembers(user_tokens_key)) + for token in tokens: + token_key = f"auth:rt:{token}" + ttl_seconds = await redis.ttl(token_key) + if ttl_seconds <= 0: + ttl_seconds = HOUR + blacklist_key = f"auth:rt_blacklist:{token}" + await redis.setex(blacklist_key, ttl_seconds, "1") + await redis.delete(token_key) + await redis.delete(user_tokens_key) + + +async def rotate_refresh_token( + redis: Redis | None, + old_token: str, +) -> str: + """Rotate a refresh token (create new, blacklist old). + + Args: + redis: Redis client + old_token: Old refresh token + + Returns: + New refresh token + + Raises: + RefreshTokenError: If old token is invalid + """ + # Verify old token + user_id = await verify_refresh_token(redis, old_token) + + # Create new token + new_token = await create_refresh_token(redis, user_id) + + # Blacklist old token; if it fails, invalidate the new token too so neither is usable + try: + await blacklist_token(redis, old_token) + except Exception: + await blacklist_token(redis, new_token) + raise + + return new_token diff --git a/backend/app/api/auth/services/stats.py b/backend/app/api/auth/services/stats.py new file mode 100644 index 00000000..78ba14ec --- /dev/null +++ b/backend/app/api/auth/services/stats.py @@ -0,0 +1,80 @@ +"""Service for recomputing user statistics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import UUID4 +from sqlalchemy import func, select + +from app.api.auth.models import User +from app.api.background_data.models import ProductType +from app.api.data_collection.models.product import Product +from app.api.file_storage.models import Image, MediaParentType + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def recompute_user_stats(session: AsyncSession, user_id: UUID4) -> dict[str, Any]: + """Recompute statistics for a given user and update their stats_cache. + + Stats included: + - product_count: Total number of products owned. + - total_weight_kg: Sum of product weights in kilograms. + - image_count: Total images uploaded for all products. + - top_category: Most frequent product type name. + """ + # 1. Product count and weight + # We use coalesce(sum(...), 0) to handle users with no products + stmt = select( + func.count(Product.id).label("product_count"), func.sum(Product.weight_g).label("total_weight_g") + ).where(Product.owner_id == user_id) + + res = await session.execute(stmt) + row = res.fetchone() + product_count = row.product_count if row else 0 + total_weight_kg = (row.total_weight_g / 1000.0) if row and row.total_weight_g else 0.0 + + # 2. Image count + # Join with Product to only count images for products owned by this user + image_stmt = ( + select(func.count(Image.id)) + .join(Product, (Product.id == Image.parent_id) & (Image.parent_type == MediaParentType.PRODUCT)) + .where(Product.owner_id == user_id) + ) + + image_res = await session.execute(image_stmt) + image_count = image_res.scalar_one_or_none() or 0 + + # 3. Top category + # Find most frequent product_type_id among user's products + top_cat_stmt = ( + select(ProductType.name) + .join(Product, Product.product_type_id == ProductType.id) + .where(Product.owner_id == user_id) + .group_by(ProductType.name) + .order_by(func.count(Product.id).desc()) + .limit(1) + ) + top_cat_res = await session.execute(top_cat_stmt) + top_category = top_cat_res.scalar_one_or_none() or "None" + + # Assemble stats + stats = { + "product_count": product_count, + "total_weight_kg": round(total_weight_kg, 2), + "image_count": image_count, + "top_category": top_category, + } + + # Update user record + update_stmt = select(User).where(User.id == user_id) + user_res = await session.execute(update_stmt) + user = user_res.unique().scalar_one_or_none() + if user: + user.stats_cache = stats + session.add(user) + # Note: Caller is responsible for committing/flushing + + return stats diff --git a/backend/app/api/auth/services/user_database.py b/backend/app/api/auth/services/user_database.py new file mode 100644 index 00000000..d33b662e --- /dev/null +++ b/backend/app/api/auth/services/user_database.py @@ -0,0 +1,179 @@ +"""SQLAlchemy adapter for FastAPI Users. + +Provides the base user/OAuth models and async database interface that +FastAPI Users requires. +""" +# spell-checker: ignore UOAP + +import uuid +from typing import TYPE_CHECKING, Annotated, Any, cast + +from fastapi import Depends +from fastapi_users.db.base import BaseUserDatabase +from fastapi_users.models import ID, OAP, UOAP, UP +from sqlalchemy import String, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column, selectinload + +from app.api.common.crud.loading import relationship_attr +from app.api.common.models.base import Base + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping + + from pydantic import UUID4 + + from app.api.auth.models import User + + +class BaseUserDB(Base): + """Base user table fields expected by FastAPI Users.""" + + __tablename__ = "user" + __abstract__ = True + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String, unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String) + + is_active: Mapped[bool] = mapped_column(default=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + is_verified: Mapped[bool] = mapped_column(default=False) + + +class BaseOAuthAccountDB(Base): + """Base OAuth account fields expected by FastAPI Users.""" + + __tablename__ = "oauthaccount" + __abstract__ = True + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(nullable=False) + oauth_name: Mapped[str] = mapped_column(String, index=True) + access_token: Mapped[str] = mapped_column(String) + expires_at: Mapped[int | None] = mapped_column(default=None) + refresh_token: Mapped[str | None] = mapped_column(default=None) + account_id: Mapped[str] = mapped_column(String, index=True) + account_email: Mapped[str] = mapped_column(String) + + +class OAuthAccountWithUser(BaseOAuthAccountDB): + """Typing helper for OAuth account models that expose a ``user`` relationship.""" + + __abstract__ = True + user: Any + + +class UserDatabaseAsync(BaseUserDatabase[UP, ID]): + """Async SQLAlchemy user adapter for FastAPI Users.""" + + session: AsyncSession + user_model: type[UP] + oauth_account_model: type[BaseOAuthAccountDB] | None + + def __init__( + self, + session: AsyncSession, + user_model: type[UP], + oauth_account_model: type[BaseOAuthAccountDB] | None = None, + ) -> None: + self.session = session + self.user_model = user_model + self.oauth_account_model = oauth_account_model + + async def get(self, id: ID) -> UP | None: # noqa: A002 # Reuse FastAPI Users' "id" parameter name for compatibility + """Get a single user by ID, with oauth_accounts eagerly loaded.""" + statement = select(self.user_model).where(self.user_model.id == id) + if self.oauth_account_model is not None: + oauth_accounts = relationship_attr(cast("type[Base]", self.user_model), "oauth_accounts") + statement = statement.options(selectinload(oauth_accounts)) + results = await self.session.execute(statement) + return results.scalars().unique().one_or_none() + + async def get_by_email(self, email: str) -> UP | None: + """Get a single user by email, with oauth_accounts eagerly loaded.""" + statement = select(self.user_model).where(func.lower(self.user_model.email) == func.lower(email)) + if self.oauth_account_model is not None: + oauth_accounts = relationship_attr(cast("type[Base]", self.user_model), "oauth_accounts") + statement = statement.options(selectinload(oauth_accounts)) + results = await self.session.execute(statement) + return results.scalars().unique().one_or_none() + + async def get_by_oauth_account(self, oauth: str, account_id: str) -> UP | None: + """Get a single user by OAuth account ID.""" + if self.oauth_account_model is None: + raise NotImplementedError + + oauth_account_model = cast("type[OAuthAccountWithUser]", self.oauth_account_model) + statement = ( + select(oauth_account_model) + .where(oauth_account_model.oauth_name == oauth) + .where(oauth_account_model.account_id == account_id) + .options(selectinload(relationship_attr(oauth_account_model, "user"))) + ) + results = await self.session.execute(statement) + oauth_account = results.scalars().unique().one_or_none() + if oauth_account: + return cast("UP", oauth_account.user) + return None + + async def create(self, create_dict: Mapping[str, Any]) -> UP: + """Create a user.""" + user = self.user_model(**dict(create_dict)) + self.session.add(user) + await self.session.commit() + await self.session.refresh(user) + return user + + async def update(self, user: UP, update_dict: Mapping[str, Any]) -> UP: + """Update a user in place.""" + for key, value in update_dict.items(): + setattr(user, key, value) + self.session.add(user) + await self.session.commit() + await self.session.refresh(user) + return user + + async def delete(self, user: UP) -> None: + """Delete a user.""" + await self.session.delete(user) + await self.session.commit() + + async def add_oauth_account(self, user: UOAP, create_dict: dict[str, Any]) -> UOAP: + """Attach an OAuth account to a user.""" + if self.oauth_account_model is None: + raise NotImplementedError + + oauth_account = self.oauth_account_model(**dict(create_dict)) + user.oauth_accounts.append(oauth_account) + self.session.add(user) + await self.session.commit() + return user + + async def update_oauth_account(self, user: UOAP, oauth_account: OAP, update_dict: dict[str, Any]) -> UOAP: + """Update an existing OAuth account.""" + if self.oauth_account_model is None: + raise NotImplementedError + + for key, value in update_dict.items(): + setattr(oauth_account, key, value) + self.session.add(oauth_account) + await self.session.commit() + return user + + +async def get_auth_async_session() -> AsyncGenerator[AsyncSession]: + """Yield the shared async database session for auth request dependencies.""" + from app.core.database import get_async_session # noqa: PLC0415 + + async for session in get_async_session(): + yield session + + +async def get_user_db( + session: Annotated[AsyncSession, Depends(get_auth_async_session)], +) -> AsyncGenerator[UserDatabaseAsync[User, UUID4]]: + """Build the FastAPI Users database adapter from the shared DB session.""" + from app.api.auth.models import OAuthAccount, User # noqa: PLC0415 + + yield UserDatabaseAsync(session, User, OAuthAccount) diff --git a/backend/app/api/auth/services/user_manager.py b/backend/app/api/auth/services/user_manager.py index daad8b77..3ecc3f2b 100644 --- a/backend/app/api/auth/services/user_manager.py +++ b/backend/app/api/auth/services/user_manager.py @@ -1,183 +1,170 @@ """User management service.""" import logging -from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, cast -import tldextract from fastapi import Depends -from fastapi_users import FastAPIUsers, InvalidPasswordException, UUIDIDMixin -from fastapi_users.authentication import AuthenticationBackend, BearerTransport, CookieTransport, JWTStrategy -from fastapi_users.jwt import SecretType +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_users import FastAPIUsers, UUIDIDMixin, schemas from fastapi_users.manager import BaseUserManager -from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync -from pydantic import UUID4, SecretStr -from starlette.requests import Request +from pydantic import UUID4, EmailStr, SecretStr, TypeAdapter, ValidationError +from sqlalchemy import select from app.api.auth.config import settings as auth_settings -from app.api.auth.crud import ( - add_user_role_in_organization_after_registration, - create_user_override, - update_user_override, -) -from app.api.auth.exceptions import AuthCRUDError -from app.api.auth.models import OAuthAccount, User -from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserUpdate -from app.api.auth.utils.programmatic_emails import ( +from app.api.auth.crud.users import update_user_override +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate, UserUpdate +from app.api.auth.services import refresh_token_service +from app.api.auth.services.auth_backends import build_authentication_backends +from app.api.auth.services.emails import ( send_post_verification_email, - send_registration_email, send_reset_password_email, send_verification_email, ) -from app.api.common.routers.dependencies import AsyncSessionDep -from app.core.config import settings as core_settings - +from app.api.auth.services.login_hooks import ( + log_successful_login, + maybe_set_refresh_token_cookie, + update_last_login_metadata, +) +from app.api.auth.services.password_validator import validate_password as _validate_password +from app.api.auth.services.user_database import get_user_db +from app.api.common.routers.dependencies import get_external_http_client +from app.core.logging import sanitize_log_value +from app.core.runtime import get_request_services + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi_users.authentication import AuthenticationBackend + from fastapi_users.jwt import SecretType + from httpx import AsyncClient + from starlette.requests import Request + from starlette.responses import Response + + from app.api.auth.services.user_database import UserDatabaseAsync # Set up logging logger = logging.getLogger(__name__) # Declare constants -SECRET: str = auth_settings.fastapi_users_secret +SECRET: SecretStr = auth_settings.fastapi_users_secret ACCESS_TOKEN_TTL = auth_settings.access_token_ttl_seconds RESET_TOKEN_TTL = auth_settings.reset_password_token_ttl_seconds VERIFICATION_TOKEN_TTL = auth_settings.verification_token_ttl_seconds -class UserManager(UUIDIDMixin, BaseUserManager[User, UUID4]): +_AUTH_COOKIE_PREFIX = "auth=" +_SET_COOKIE_HEADER = "set-cookie" + + +class UserManager(UUIDIDMixin, BaseUserManager[User, UUID4]): # spell-checker: ignore UUIDID """User manager class for FastAPI-Users.""" + # We will initialize the user manager with a UserDatabaseAsync instance in the dependency function below + user_db: UserDatabaseAsync + + def __init__(self, user_db: UserDatabaseAsync, http_client: AsyncClient) -> None: + super().__init__(user_db) + self.http_client = http_client + self.skip_breach_check = False + self.skip_password_validation = False + # Set up token secrets and lifetimes - reset_password_token_secret: SecretType = SECRET + reset_password_token_secret: SecretType = SECRET.get_secret_value() reset_password_token_lifetime_seconds = RESET_TOKEN_TTL - verification_token_secret: SecretType = SECRET + verification_token_secret: SecretType = SECRET.get_secret_value() verification_token_lifetime_seconds = VERIFICATION_TOKEN_TTL - async def create( - self, - user_create: UserCreate | UserCreateWithOrganization, - safe: bool = False, # noqa: FBT001, FBT002 # This boolean-typed positional argument is expected by the `create` function signature - request: Request | None = None, - ) -> User: - """Override of base user creation with additional username uniqueness check and organization creation.""" + async def authenticate(self, credentials: OAuth2PasswordRequestForm) -> User | None: + """Support login with either email or username.""" + is_email = False try: - user_create = await create_user_override(self.user_db, user_create) - # HACK: This is a temporary solution to allow error propagation for username and organization creation errors. - # The built-in UserManager register route can only catch UserAlreadyExists and InvalidPasswordException errors. - # TODO: Implement custom exceptions in custom register router, this will also simplify user creation crud. - except AuthCRUDError as e: - raise InvalidPasswordException( - reason="WARNING: This is an AuthCRUDError error, not an InvalidPasswordException. To be fixed. " - + str(e) - ) from e - return await super().create(user_create, safe, request) + TypeAdapter(EmailStr).validate_python(credentials.username) + is_email = True + except ValidationError: + pass + + if not is_email: + statement = select(User).where(User.username == credentials.username) + result = await self.user_db.session.execute(statement) + db_user = result.scalars().unique().one_or_none() + if db_user: + credentials.username = db_user.email + return await super().authenticate(credentials) + + async def validate_password( + self, + password: str | SecretStr, + user: UserCreate | User, + ) -> None: + """Delegate password validation to the dedicated service.""" + if self.skip_password_validation: + return + await _validate_password( + password, + email=user.email, + username=getattr(user, "username", None), + http_client=self.http_client, + skip_breach_check=self.skip_breach_check, + ) async def update( self, - user_update: UserUpdate, + user_update: schemas.UU, user: User, - safe: bool = False, # noqa: FBT001, FBT002 # This boolean-typed positional argument is expected by the `create` function signature + safe: bool = False, # noqa: FBT002, FBT001 # Expected by parent class signature request: Request | None = None, ) -> User: - """Override of base user update with additional username and organization validation.""" - try: - user_update = await update_user_override(self.user_db, user, user_update) - # HACK: This is a temporary solution to allow error propagation for username and organization creation errors. - # The built-in UserManager register route can only catch UserAlreadyExists and InvalidPasswordException errors. - # TODO: Implement custom exceptions in custom update router, this will also simplify user creation crud. - except AuthCRUDError as e: - raise InvalidPasswordException( - reason="WARNING: This is an AuthCRUDError error, not an InvalidPasswordException. To be fixed. " - + str(e) - ) from e - - return await super().update(user_update, user, safe, request) - - async def validate_password( # pyright: ignore [reportIncompatibleMethodOverride] # Allow overriding user type in method - self, - password: str | SecretStr, - user: UserCreate | User, - ) -> None: - if isinstance(password, SecretStr): - password = password.get_secret_value() - if len(password) < 8: - raise InvalidPasswordException(reason="Password should be at least 8 characters") - if user.email in password: - raise InvalidPasswordException(reason="Password should not contain e-mail") - if user.username and user.username in password: - raise InvalidPasswordException(reason="Password should not contain username") - - async def on_after_register(self, user: User, request: Request | None = None) -> None: - if not request: - err_msg = "Request object is required for user registration" - raise RuntimeError(err_msg) - - user = await add_user_role_in_organization_after_registration(self.user_db, user, request) - - # HACK: Skip sending registration email for programmatically created users by using synthetic request state - if request and hasattr(request.state, "send_registration_email") and not request.state.send_registration_email: - logger.info("Skipping registration email for user %s", user.email) - return + """Update a user, injecting custom organization & username validation first.""" + # Will raise exceptions like UserNameAlreadyExistsError if validation fails + real_user_update = cast("UserUpdate", user_update) + real_user_update = await update_user_override(self.user_db, user, real_user_update) + user_update = cast("schemas.UU", real_user_update) + + # Proceed with base FastAPI User update logic + return await super().update(user_update, user, safe=safe, request=request) - # HACK: Create synthetic request to specify sending registration email with verification token - # instead of normal verification email - request = Request(scope={"type": "http"}) - request.state.send_registration_email = True - await self.request_verify(user, request) - - async def on_after_request_verify( - self, user: User, token: str, request: Request | None = None - ) -> None: # Request argument is expected in the method signature - if request and hasattr(request.state, "send_registration_email") and request.state.send_registration_email: - # Send registration email with verification token if synthetic request state is set - await send_registration_email(user.email, user.username, token) - logger.info("Registration email sent to user %s", user.email) - else: - await send_verification_email(user.email, user.username, token) - logger.info("Verification email sent to user %s", user.email) + async def on_after_request_verify(self, user: User, token: str, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature + """Send verification email after verification is requested.""" + await send_verification_email(user.email, user.username, token) + logger.info("Verification email sent to user %s", sanitize_log_value(user.email)) async def on_after_verify(self, user: User, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature - logger.info("User %s has been verified.", user.email) + """Send welcome email after user verifies their email.""" + logger.info("User %s has been verified.", sanitize_log_value(user.email)) await send_post_verification_email(user.email, user.username) async def on_after_forgot_password(self, user: User, token: str, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature - logger.info("User %s has forgot their password. Reset token: %s", user.email, token) + """Send password reset email.""" + logger.info("User %s has forgot their password. Sending reset token", sanitize_log_value(user.email)) await send_reset_password_email(user.email, user.username, token) + async def on_after_update(self, user: User, update_dict: dict, request: Request | None = None) -> None: + """Revoke all refresh tokens when a user is deactivated.""" + if update_dict.get("is_active") is False: + redis = get_request_services(request).redis if request else None + await refresh_token_service.revoke_all_user_tokens(redis, user.id) -async def get_user_db(session: AsyncSessionDep) -> AsyncGenerator[SQLModelUserDatabaseAsync]: - """Async generator for the user database.""" - yield SQLModelUserDatabaseAsync(session, User, OAuthAccount) + async def on_after_login( + self, user: User, request: Request | None = None, response: Response | None = None + ) -> None: + """Update last login timestamp, create refresh token and session after successful authentication.""" + await update_last_login_metadata(user, request, self.user_db.session) + await maybe_set_refresh_token_cookie(user, request, response) + log_successful_login(user) -async def get_user_manager(user_db: SQLModelUserDatabaseAsync = Depends(get_user_db)) -> AsyncGenerator[UserManager]: +async def get_user_manager( + user_db: UserDatabaseAsync[User, UUID4] = Depends(get_user_db), + http_client: AsyncClient = Depends(get_external_http_client), +) -> AsyncGenerator[UserManager]: """Async generator for the user manager.""" - yield UserManager(user_db) - - -# Bearer Transport -bearer_transport = BearerTransport(tokenUrl="auth/bearer/login") - - -# Cookie Transport - -# Set the cookie domain to the main host, including subdomains (hence the dot prefix) -url_extract = tldextract.extract(str(core_settings.frontend_web_url)) -cookie_domain = f".{url_extract.domain}.{url_extract.suffix}" if url_extract.domain and url_extract.suffix else None - -cookie_transport = CookieTransport( - cookie_name="auth", - cookie_max_age=ACCESS_TOKEN_TTL, - cookie_domain=cookie_domain, -) - - -def get_jwt_strategy() -> JWTStrategy: - """Get a JWT strategy to be used in authentication backends.""" - return JWTStrategy(secret=SECRET, lifetime_seconds=ACCESS_TOKEN_TTL) + yield UserManager(user_db, http_client) -# Authentication backends -bearer_auth_backend = AuthenticationBackend(name="bearer", transport=bearer_transport, get_strategy=get_jwt_strategy) -cookie_auth_backend = AuthenticationBackend(name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy) +bearer_auth_backend: AuthenticationBackend[User, UUID4] +cookie_auth_backend: AuthenticationBackend[User, UUID4] +bearer_auth_backend, cookie_auth_backend = build_authentication_backends() # User manager singleton fastapi_user_manager = FastAPIUsers[User, UUID4](get_user_manager, [bearer_auth_backend, cookie_auth_backend]) diff --git a/backend/app/api/auth/utils/__init__.py b/backend/app/api/auth/utils/__init__.py deleted file mode 100644 index f2ce5c9e..00000000 --- a/backend/app/api/auth/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Utility functions for the auth package.""" diff --git a/backend/app/api/auth/utils/context_managers.py b/backend/app/api/auth/utils/context_managers.py deleted file mode 100644 index 9823ba65..00000000 --- a/backend/app/api/auth/utils/context_managers.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Async context managers for user database and user manager.""" - -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -from sqlmodel.ext.asyncio.session import AsyncSession - -from app.api.auth.services.user_manager import get_user_db, get_user_manager -from app.core.database import async_session_context - -if TYPE_CHECKING: - from app.api.auth.services.user_manager import UserManager - -get_async_user_db_context = asynccontextmanager(get_user_db) -get_async_user_manager_context = asynccontextmanager(get_user_manager) - - -@asynccontextmanager -async def get_chained_async_user_manager_context( - session: AsyncSession | None = None, -) -> AsyncGenerator["UserManager"]: - """Provides a user manager context using the user database and an async database session. - - If a session is provided, it will be used; otherwise, a new session for the default database will be created. - """ - if session is not None: - async with ( - get_async_user_db_context(session) as user_db, - get_async_user_manager_context(user_db) as user_manager, - ): - yield user_manager - else: - async with ( - async_session_context() as db_session, - get_async_user_db_context(db_session) as user_db, - get_async_user_manager_context(user_db) as user_manager, - ): - yield user_manager diff --git a/backend/app/api/auth/utils/email_validation.py b/backend/app/api/auth/utils/email_validation.py deleted file mode 100644 index b4e3ac1a..00000000 --- a/backend/app/api/auth/utils/email_validation.py +++ /dev/null @@ -1,54 +0,0 @@ -# backend/app/api/auth/utils/email_validation.py -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import anyio -import httpx -from fastapi import HTTPException - -DISPOSABLE_DOMAINS_URL = "https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt" -BASE_DIR: Path = (Path(__file__).parents[4]).resolve() - -CACHE_FILE = BASE_DIR / "data" / "cache" / "disposable_domains_cache.txt" -CACHE_DURATION = timedelta(days=1) - - -async def get_disposable_domains() -> set[str]: - """Get disposable email domains, using cache if fresh.""" - # Check if cache exists and is fresh - if CACHE_FILE.exists(): - cache_age = datetime.now(tz=UTC) - datetime.fromtimestamp(CACHE_FILE.stat().st_mtime, tz=UTC) - if cache_age < CACHE_DURATION: - async with await anyio.open_file(CACHE_FILE, "r") as f: - content = await f.read() # Read the entire file first - return {line.strip().lower() for line in content.splitlines() if line.strip()} - - # Fetch fresh list - try: - async with httpx.AsyncClient() as client: - response = await client.get(DISPOSABLE_DOMAINS_URL, timeout=10.0) - response.raise_for_status() - domains = {line.strip().lower() for line in response.text.splitlines() if line.strip()} - - # Ensure cache directory exists - CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - - # Update cache - async with await anyio.open_file(CACHE_FILE, "w") as f: - await f.write("\n".join(sorted(domains))) - - return domains - except Exception as e: - # If fetch fails and cache exists, use stale cache - if CACHE_FILE.exists(): - async with await anyio.open_file(CACHE_FILE, "r") as f: - content = await f.read() # Read the entire file first - return {line.strip().lower() for line in content.splitlines() if line.strip()} - raise HTTPException(status_code=503, detail="Email validation service unavailable") from e - - -async def is_disposable_email(email: str) -> bool: - """Check if email domain is disposable.""" - domain = email.split("@")[-1].lower() - disposable_domains = await get_disposable_domains() - return domain in disposable_domains diff --git a/backend/app/api/auth/utils/programmatic_emails.py b/backend/app/api/auth/utils/programmatic_emails.py deleted file mode 100644 index dc1cefa0..00000000 --- a/backend/app/api/auth/utils/programmatic_emails.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Utilities for sending authentication-related emails.""" - -import logging -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from enum import Enum -from urllib.parse import urljoin - -import markdown -from aiosmtplib import SMTP, SMTPException - -from app.api.auth.config import settings as auth_settings -from app.core.config import settings as core_settings - -logger: logging.Logger = logging.getLogger(__name__) - - -### Common email functions ### -# TODO: Move to using MJML or similar templating system for email content. - - -class TextContentType(str, Enum): - """Type for specifying the content type of the email body.""" - - PLAIN = "plain" - HTML = "html" - MARKDOWN = "markdown" - - def body_to_mimetext(self, body: str) -> MIMEText: - """Convert an email body to MIMEText format.""" - match self: - case TextContentType.PLAIN: - return MIMEText(body, "plain") - case TextContentType.HTML: - return MIMEText(body, "html") - case TextContentType.MARKDOWN: - # Convert Markdown to HTML - html = markdown.markdown(body) - return MIMEText(html, "html") - - -async def send_email( - to_email: str, - subject: str, - body: str, - content_type: TextContentType = TextContentType.PLAIN, - headers: dict | None = None, -) -> None: - """Send an email with the specified subject and body.""" - msg = MIMEMultipart() - msg["From"] = auth_settings.email_from - msg["Reply-To"] = auth_settings.email_reply_to - msg["To"] = to_email - msg["Subject"] = subject - - # Add additional headers if provided - if headers: - for key, value in headers.items(): - msg[key] = value - - # Attach the body in the specified content type - msg.attach(content_type.body_to_mimetext(body)) - - try: - # TODO: Investigate use of managed outlook address for sending emails - smtp = SMTP( - hostname=auth_settings.email_host, - port=auth_settings.email_port, - ) - await smtp.connect() - # logger.info("Sending email to %s", auth_settings.__dict__) - await smtp.login(auth_settings.email_username, auth_settings.email_password) - await smtp.send_message(msg) - await smtp.quit() - logger.info("Email sent to %s", to_email) - except SMTPException as e: - error_message = f"Error sending email: {e}" - raise SMTPException(error_message) from e - - -def generate_token_link(token: str, route: str) -> str: - """Generate a link with the specified token and route.""" - # TODO: Check that the base url works in remote deployment - return urljoin(str(core_settings.frontend_app_url), f"{route}?token={token}") - - -### Email content ### -async def send_registration_email(to_email: str, username: str | None, token: str) -> None: - """Send a registration email with verification token.""" - # TODO: Store frontend paths required by the backend in a shared .env or other config file in the root directory - # Alternatively, we can send the right path as a parameter from the frontend to the backend - verification_link = generate_token_link(token, "/verify") - subject = "Welcome to Reverse Engineering Lab - Verify Your Email" - body = f""" -Hello {username if username else to_email}, - -Thank you for registering! Please verify your email by clicking the link below: - -{verification_link} - -This link will expire in 1 hour. - -If you did not register for this service, please ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - - await send_email(subject=subject, body=body, to_email=to_email) - - -async def send_reset_password_email(to_email: str, username: str | None, token: str) -> None: - """Send a reset password email with the token.""" - request_password_link = generate_token_link(token, "/reset-password") - subject = "Password Reset" - body = f""" -Hello {username if username else to_email}, - -Please reset your password by clicking the link below: - -{request_password_link} - -This link will expire in 1 hour. - -If you did not request a password reset, please ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) - - -async def send_verification_email(to_email: str, username: str | None, token: str) -> None: - """Send a verification email with the token.""" - verification_link = generate_token_link(token, "/verify") - subject = "Email Verification" - body = f""" -Hello {username if username else to_email}, - -Please verify your email by clicking the link below: - -{verification_link} - -This link will expire in 1 hour. - -If you did not request verification, please ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) - - -async def send_post_verification_email(to_email: str, username: str | None) -> None: - """Send a post-verification email.""" - subject = "Email Verified" - body = f""" -Hello {username if username else to_email}, - -Your email has been verified! - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) diff --git a/backend/app/api/auth/utils/programmatic_user_crud.py b/backend/app/api/auth/utils/programmatic_user_crud.py deleted file mode 100644 index 3ecc12d5..00000000 --- a/backend/app/api/auth/utils/programmatic_user_crud.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Programmatic CRUD operations for FastAPI-users.""" - -from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists -from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.requests import Request - -from app.api.auth.models import User -from app.api.auth.schemas import UserCreate -from app.api.auth.utils.context_managers import get_chained_async_user_manager_context - - -async def create_user( - async_session: AsyncSession, user_create: UserCreate, *, send_registration_email: bool = False -) -> User: - """Programmatically create a new user in the database.""" - try: - async with get_chained_async_user_manager_context(async_session) as user_manager: - # HACK: Synthetic request to avoid sending emails for programmatically created users - request = Request(scope={"type": "http"}) - request._body = b"{}" - request.state.send_registration_email = send_registration_email - - user: User = await user_manager.create(user_create, request=request) - return user - except UserAlreadyExists: - err_msg: str = f"User with email {user_create.email} already exists." - raise UserAlreadyExists(err_msg) from None - except InvalidPasswordException as e: - err_msg: str = f"Password is invalid: {e.reason}." - raise InvalidPasswordException(err_msg) from e diff --git a/backend/app/api/background_data/__init__.py b/backend/app/api/background_data/__init__.py index 0fb16357..aa4dc563 100644 --- a/backend/app/api/background_data/__init__.py +++ b/backend/app/api/background_data/__init__.py @@ -1 +1 @@ -"""Background data module.""" +"""Routes for interacting with background data.""" diff --git a/backend/app/api/background_data/crud.py b/backend/app/api/background_data/crud.py deleted file mode 100644 index 999b3c8f..00000000 --- a/backend/app/api/background_data/crud.py +++ /dev/null @@ -1,629 +0,0 @@ -"""CRUD operations for the background data models.""" - -from collections.abc import Sequence - -from sqlalchemy import Delete, delete -from sqlalchemy.orm import selectinload -from sqlalchemy.orm.attributes import set_committed_value -from sqlmodel import col, select -from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql._expression_select_cls import SelectOfScalar - -from app.api.background_data.filters import ( - CategoryFilter, - CategoryFilterWithRelationships, - TaxonomyFilter, -) -from app.api.background_data.models import ( - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, - TaxonomyDomain, -) -from app.api.background_data.schemas import ( - CategoryCreateWithinCategoryWithSubCategories, - CategoryCreateWithinTaxonomyWithSubCategories, - CategoryCreateWithSubCategories, - CategoryUpdate, - MaterialCreate, - MaterialCreateWithCategories, - MaterialUpdate, - ProductTypeCreate, - ProductTypeCreateWithCategories, - ProductTypeUpdate, - TaxonomyCreate, - TaxonomyCreateWithCategories, - TaxonomyUpdate, -) -from app.api.common.crud.associations import create_model_links -from app.api.common.crud.base import get_model_by_id -from app.api.common.crud.utils import ( - db_get_model_with_id_if_it_exists, - db_get_models_with_ids_if_they_exist, - enum_set_to_str, - set_to_str, - validate_linked_items_exist, - validate_model_with_id_exists, - validate_no_duplicate_linked_items, -) -from app.api.file_storage.crud import ( - ParentStorageOperations, - create_file, - create_image, - delete_file, - delete_image, -) -from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType -from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm - -# NOTE: GET operations are implemented in the crud.common.base module - -# TODO: Extract common CRUD operations to class-based factories in a separate module. This includes basic CRUD, -# filter generation, relationship handling, category links, and file management for all models. -# See the parent-file operations in the file_storage module for an example of how to refactor these operations - - -### Category CRUD operations ### -## Utilities ## -async def validate_category_creation( - db: AsyncSession, - category: CategoryCreateWithSubCategories - | CategoryCreateWithinCategoryWithSubCategories - | CategoryCreateWithinTaxonomyWithSubCategories, - taxonomy_id: int | None = None, - supercategory_id: int | None = None, -) -> tuple[int, Category | None]: - """Validate category creation parameters and return taxonomy_id and supercategory.""" - if supercategory_id: - supercategory: Category = await db_get_model_with_id_if_it_exists(db, Category, supercategory_id) - - taxonomy_id = taxonomy_id or supercategory.taxonomy_id - if supercategory.taxonomy_id != taxonomy_id: - err_msg: str = f"Supercategory with id {supercategory_id} does not belong to taxonomy with id {taxonomy_id}" - raise ValueError(err_msg) - return taxonomy_id, supercategory - - taxonomy_id = taxonomy_id or getattr(category, "taxonomy_id", None) - - if not taxonomy_id: - err_msg = "Taxonomy ID is required for top-level categories" - raise ValueError(err_msg) - - # Check if taxonomy exists - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - return taxonomy_id, None - - -async def validate_category_taxonomy_domains( - db: AsyncSession, category_ids: set[int], expected_domains: TaxonomyDomain | set[TaxonomyDomain] -) -> None: - """Validate that categories belong to taxonomies with expected domains. - - Args: - db: Database session - category_ids: Collection of category IDs to validate - expected_domains: Set of allowed taxonomy domains - - Raises: - ValueError: If categories don't exist or belong to wrong domains - """ - categories_statement: SelectOfScalar[Category] = ( - select(Category) - .join(Taxonomy) - .where(col(Category.id).in_(category_ids)) - .options(selectinload(Category.taxonomy)) - ) - categories: Sequence[Category] = (await db.exec(categories_statement)).all() - - if len(categories) != len(category_ids): - missing = set(category_ids) - {c.id for c in categories} - err_msg: str = f"Categories with id {set_to_str(missing)} not found" - raise ValueError(err_msg) - - # Cast single domain to set if needed - if isinstance(expected_domains, TaxonomyDomain): - expected_domains = {expected_domains} - - invalid = { - c.id - for c in categories - if not (set(c.taxonomy.domains) & expected_domains) # Check for domain overlap - } - if invalid: - err_msg: str = ( - f"Categories with id {set_to_str(invalid)} belong to taxonomies " - f"outside of domains: {enum_set_to_str(expected_domains)}" - ) - raise ValueError(err_msg) - - -## Basic CRUD operations ## -async def get_category_trees( - db: AsyncSession, - recursion_depth: int = 1, - *, - supercategory_id: int | None = None, - taxonomy_id: int | None = None, - category_filter: CategoryFilter | CategoryFilterWithRelationships | None = None, -) -> Sequence[Category]: - """Get categories with their subcategories up to specified depth. - - If supercategory_id is None, get top-level categories. - """ - # Provide either supercategory_id or taxonomy_id - if supercategory_id and taxonomy_id: - err_msg = "Provide either supercategory_id or taxonomy_id, not both" - raise ValueError(err_msg) - - # Validate that supercategory or taxonomy exists - if supercategory_id: - await db_get_model_with_id_if_it_exists(db, Category, supercategory_id) - - if taxonomy_id: - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - statement: SelectOfScalar[Category] = select(Category).where(Category.supercategory_id == supercategory_id) - - if taxonomy_id: - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - statement = statement.where(Category.taxonomy_id == taxonomy_id) - - if category_filter: - statement = category_filter.filter(statement) - - # Load subcategories recursively - statement = statement.options(selectinload(Category.subcategories, recursion_depth=recursion_depth)) - - return (await db.exec(statement)).all() - - -async def create_category( - db: AsyncSession, - category: CategoryCreateWithSubCategories - | CategoryCreateWithinCategoryWithSubCategories - | CategoryCreateWithinTaxonomyWithSubCategories, - taxonomy_id: int | None = None, - supercategory_id: int | None = None, - *, - _is_recursive_call: bool = False, # Flag to track recursive calls -) -> Category: - """Create a new category in the database and handle subcategory categories recursively.""" - # Validate and get taxonomy_id and supercategory - taxonomy_id, supercategory = await validate_category_creation( - db, - category, - taxonomy_id, - supercategory_id - if isinstance(category, CategoryCreateWithinCategoryWithSubCategories) - else category.supercategory_id, - ) - - # Create category - db_category = Category( - name=category.name, - description=category.description, - taxonomy_id=taxonomy_id, - supercategory_id=supercategory.id if supercategory else None, - ) - db.add(db_category) - await db.flush() # Assign an ID to the category - - # Create subcategories recursively - if category.subcategories: - for subcategory in category.subcategories: - await create_category( - db, - subcategory, - taxonomy_id=taxonomy_id, - supercategory_id=db_category.id, - _is_recursive_call=True, # Mark recursive calls - ) - - # Commit only when it's not a recursive call - if not _is_recursive_call: - await db.commit() - await db.refresh(db_category) - - return db_category - - -async def update_category(db: AsyncSession, category_id: int, category: CategoryUpdate) -> Category: - """Update an existing category in the database.""" - db_category: Category = await db_get_model_with_id_if_it_exists(db, Category, category_id) - - category_data = category.model_dump(exclude_unset=True) - db_category.sqlmodel_update(category_data) - - db.add(db_category) - await db.commit() - await db.refresh(db_category) - return db_category - - -async def delete_category(db: AsyncSession, category_id: int) -> None: - """Delete a category from the database.""" - db_category: Category = await db_get_model_with_id_if_it_exists(db, Category, category_id) - - await db.delete(db_category) - await db.commit() - - -### Taxonomy CRUD operations ### -## Basic CRUD operations ## -async def get_taxonomies( - db: AsyncSession, - *, - include_base_categories: bool = False, - taxonomy_filter: TaxonomyFilter | None = None, - statement: SelectOfScalar[Taxonomy] | None = None, -) -> Sequence[Taxonomy]: - """Get taxonomies with optional filtering and base categories.""" - if statement is None: - statement = select(Taxonomy) - - if taxonomy_filter: - statement = taxonomy_filter.filter(statement) - - # Only load base categories if requested - if include_base_categories: - statement = statement.options( - selectinload(Taxonomy.categories.and_(Category.supercategory_id == None)) # noqa: E711 # SQLalchemy 'select' statement requires '== None' for 'IS NULL' - ) - - result: Sequence[Taxonomy] = (await db.exec(statement)).all() - - # Set empty categories list if not included - if not include_base_categories: - for taxonomy in result: - set_committed_value(taxonomy, "categories", []) - - return result - - -async def get_taxonomy_by_id(db: AsyncSession, taxonomy_id: int, *, include_base_categories: bool = False) -> Taxonomy: - """Get taxonomy by ID with specified relationships.""" - statement: SelectOfScalar[Taxonomy] = select(Taxonomy).where(Taxonomy.id == taxonomy_id) - - if include_base_categories: - statement = statement.options( - selectinload(Taxonomy.categories.and_(Category.supercategory_id == None)) # noqa: E711 # SQLalchemy 'select' statement requires '== None' for 'IS NULL' - ) - - taxonomy: Taxonomy = validate_model_with_id_exists((await db.exec(statement)).one_or_none(), Taxonomy, taxonomy_id) - if not include_base_categories: - set_committed_value(taxonomy, "categories", []) - return taxonomy - - -async def create_taxonomy(db: AsyncSession, taxonomy: TaxonomyCreate | TaxonomyCreateWithCategories) -> Taxonomy: - """Create a new taxonomy in the database.""" - taxonomy_data = taxonomy.model_dump(exclude={"categories"}) - db_taxonomy = Taxonomy(**taxonomy_data) - - db.add(db_taxonomy) - await db.flush() # Assigns an ID to taxonomy - - # Handle categories if provided - if isinstance(taxonomy, TaxonomyCreateWithCategories) and taxonomy.categories: - for category_data in taxonomy.categories: - await create_category(db, category_data, taxonomy_id=db_taxonomy.id) - - await db.commit() - await db.refresh(db_taxonomy) - return db_taxonomy - - -async def update_taxonomy(db: AsyncSession, taxonomy_id: int, taxonomy: TaxonomyUpdate) -> Taxonomy: - """Update an existing taxonomy in the database.""" - db_taxonomy: Taxonomy = await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - taxonomy_data = taxonomy.model_dump(exclude_unset=True) - - db_taxonomy.sqlmodel_update(taxonomy_data) - - db.add(db_taxonomy) - await db.commit() - await db.refresh(db_taxonomy) - return db_taxonomy - - -async def delete_taxonomy(db: AsyncSession, taxonomy_id: int) -> None: - """Delete a taxonomy from the database, including its categories.""" - db_taxonomy: Taxonomy = await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - await db.delete(db_taxonomy) - await db.commit() - - -### Material CRUD operations ### -## Basic CRUD operations ## -async def create_material(db: AsyncSession, material: MaterialCreate | MaterialCreateWithCategories) -> Material: - """Create a new material in the database, optionally with category links.""" - # Create material - material_data = material.model_dump(exclude={"category_ids"}) - db_material = Material(**material_data) - db.add(db_material) - await db.flush() # Get material ID - - # Add category links if provided - if isinstance(material, MaterialCreateWithCategories) and material.category_ids: - # Validate categories exist - await db_get_models_with_ids_if_they_exist(db, Category, material.category_ids) - - # Validate category domains - await validate_category_taxonomy_domains(db, material.category_ids, {TaxonomyDomain.MATERIALS}) - - # Create links - await create_model_links( - db, - id1=db_material.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="material_id", - id2_set=material.category_ids, - id2_field="category_id", - link_model=CategoryMaterialLink, - ) - - await db.commit() - await db.refresh(db_material) - return db_material - - -async def update_material(db: AsyncSession, material_id: int, material: MaterialUpdate) -> Material: - """Update an existing material in the database.""" - db_material: Material = await db_get_model_with_id_if_it_exists(db, Material, material_id) - - material_data = material.model_dump(exclude_unset=True) - db_material.sqlmodel_update(material_data) - - db.add(db_material) - await db.commit() - await db.refresh(db_material) - return db_material - - -async def delete_material(db: AsyncSession, material_id: int) -> None: - """Delete a material from the database.""" - db_material: Material = await db_get_model_with_id_if_it_exists(db, Material, material_id) - - # Delete storage files - await material_files_crud.delete_all(db, material_id) - await material_images_crud.delete_all(db, material_id) - - await db.delete(db_material) - await db.commit() - - -## Category links operations ## -async def add_categories_to_material( - db: AsyncSession, material_id: int, category_ids: int | set[int] -) -> Sequence[Category]: - """Add categories to a material.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate material exists - db_material: Material = await get_model_by_id( - db, Material, model_id=material_id, include_relationships={"categories"} - ) - - # Validate categories exist and belong to the correct domain - db_categories: Sequence[Category] = await db_get_models_with_ids_if_they_exist(db, Category, category_ids) - await validate_category_taxonomy_domains(db, category_ids, {TaxonomyDomain.MATERIALS}) - - if db_material.categories: - validate_no_duplicate_linked_items(category_ids, db_material.categories, "Categories") - - await create_model_links( - db, - id1=db_material.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="material_id", - id2_set=category_ids, - id2_field="category_id", - link_model=CategoryMaterialLink, - ) - - await db.commit() - await db.refresh(db_material) - return db_categories - - -async def add_category_to_material(db: AsyncSession, material_id: int, category_id: int) -> Category: - """Add a category to a material.""" - db_category_list: Sequence[Category] = await add_categories_to_material(db, material_id, {category_id}) - - if len(db_category_list) != 1: - err_msg: str = ( - f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" - ) - raise RuntimeError(err_msg) - - return db_category_list[0] - - -async def remove_categories_from_material(db: AsyncSession, material_id: int, category_ids: int | set[int]) -> None: - """Remove categories from a material.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate material exists - db_material: Material = await get_model_by_id( - db, Material, model_id=material_id, include_relationships={"categories"} - ) - - # Check that categories are actually assigned - validate_linked_items_exist(category_ids, db_material.categories, "Categories") - - statement: Delete = ( - delete(CategoryMaterialLink) - .where(col(CategoryMaterialLink.material_id) == material_id) - .where(col(CategoryMaterialLink.category_id).in_(category_ids)) - ) - await db.execute(statement) - await db.commit() - - -## File Management ## -material_files_crud = ParentStorageOperations[Material, File, FileCreate, FileFilter]( - parent_model=Material, - storage_model=File, - parent_type=FileParentType.MATERIAL, - parent_field="material_id", - create_func=create_file, - delete_func=delete_file, -) - -material_images_crud = ParentStorageOperations[Material, Image, ImageCreateFromForm, ImageFilter]( - parent_model=Material, - storage_model=Image, - parent_type=ImageParentType.MATERIAL, - parent_field="material_id", - create_func=create_image, - delete_func=delete_image, -) - - -### ProductType CRUD operations ### -## Basic CRUD operations ## -async def create_product_type( - db: AsyncSession, product_type: ProductTypeCreate | ProductTypeCreateWithCategories -) -> ProductType: - """Create a new product type in the database, optionally with category links.""" - # Create product type - product_type_data = product_type.model_dump(exclude={"category_ids"}) - db_product_type = ProductType(**product_type_data) - db.add(db_product_type) - await db.flush() # Get product type ID - - # Add category links if provided - if isinstance(product_type, ProductTypeCreateWithCategories) and product_type.category_ids: - await create_model_links( - db, - id1=db_product_type.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="product_type", - id2_set=product_type.category_ids, - id2_field="category_id", - link_model=CategoryProductTypeLink, - ) - await db.commit() - await db.refresh(db_product_type) - return db_product_type - - -async def update_product_type(db: AsyncSession, product_type_id: int, product_type: ProductTypeUpdate) -> ProductType: - """Update an existing product type in the database.""" - db_product_type: ProductType = await db_get_model_with_id_if_it_exists(db, ProductType, product_type_id) - - product_type_data = product_type.model_dump(exclude_unset=True) - db_product_type.sqlmodel_update(product_type_data) - - db.add(db_product_type) - await db.commit() - await db.refresh(db_product_type) - return db_product_type - - -async def delete_product_type(db: AsyncSession, product_type_id: int) -> None: - """Delete a product type from the database.""" - db_product_type: ProductType = await db_get_model_with_id_if_it_exists(db, ProductType, product_type_id) - - # Delete storage files - await product_type_files.delete_all(db, product_type_id) - await product_type_images.delete_all(db, product_type_id) - - await db.delete(db_product_type) - await db.commit() - - -## Category links operations ## -# Basic GET operations are implemented in the associations CRUD operations - - -async def add_categories_to_product_type( - db: AsyncSession, product_type_id: int, category_ids: set[int] -) -> Sequence[Category]: - """Add categories to a product type.""" - # Validate product type exists - db_product_type: ProductType = await get_model_by_id( - db, ProductType, product_type_id, include_relationships={"categories"} - ) - - # Validate categories exist and belong to the correct domain - db_categories: Sequence[Category] = await db_get_models_with_ids_if_they_exist(db, Category, category_ids) - await validate_category_taxonomy_domains(db, category_ids, {TaxonomyDomain.PRODUCTS}) - - if db_product_type.categories: - validate_no_duplicate_linked_items(category_ids, db_product_type.categories, "Categories") - - await create_model_links( - db, - id1=db_product_type.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="product_type", - id2_set=category_ids, - id2_field="category_id", - link_model=CategoryProductTypeLink, - ) - await db.commit() - - return db_categories - - -async def add_category_to_product_type(db: AsyncSession, product_type_id: int, category_id: int) -> Category: - """Add a category to a product type.""" - db_category_list: Sequence[Category] = await add_categories_to_product_type(db, product_type_id, {category_id}) - - if len(db_category_list) != 1: - err_msg: str = ( - f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" - ) - raise RuntimeError(err_msg) - - return db_category_list[0] - - -async def remove_categories_from_product_type( - db: AsyncSession, product_type_id: int, category_ids: int | set[int] -) -> None: - """Remove categories from a product type.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate product type exists - db_product_type: ProductType = await get_model_by_id( - db, ProductType, product_type_id, include_relationships={"categories"} - ) - - # Check that categories are actually assigned - validate_linked_items_exist(category_ids, db_product_type.categories, "Categories") - - statement: Delete = ( - delete(CategoryProductTypeLink) - .where(col(CategoryProductTypeLink.product_type_id) == product_type_id) - .where(col(CategoryProductTypeLink.category_id).in_(category_ids)) - ) - await db.execute(statement) - await db.commit() - - -## File management ## -product_type_files = ParentStorageOperations[ProductType, File, FileCreate, FileFilter]( - parent_model=ProductType, - storage_model=File, - parent_type=FileParentType.PRODUCT_TYPE, - parent_field="product_type_id", - create_func=create_file, - delete_func=delete_file, -) - -product_type_images = ParentStorageOperations[ProductType, Image, ImageCreateFromForm, ImageFilter]( - parent_model=ProductType, - storage_model=Image, - parent_type=ImageParentType.PRODUCT_TYPE, - parent_field="product_type_id", - create_func=create_image, - delete_func=delete_image, -) diff --git a/backend/app/api/background_data/crud/__init__.py b/backend/app/api/background_data/crud/__init__.py new file mode 100644 index 00000000..0f17f6fa --- /dev/null +++ b/backend/app/api/background_data/crud/__init__.py @@ -0,0 +1 @@ +"""Background-data CRUD package.""" diff --git a/backend/app/api/background_data/crud/categories.py b/backend/app/api/background_data/crud/categories.py new file mode 100644 index 00000000..fa761679 --- /dev/null +++ b/backend/app/api/background_data/crud/categories.py @@ -0,0 +1,177 @@ +"""Category CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import QueryableAttribute + +from app.api.background_data.filters import CategoryFilter, CategoryFilterWithRelationships +from app.api.background_data.models import Category, Taxonomy, TaxonomyDomain +from app.api.background_data.schemas import ( + CategoryCreateWithinCategoryWithSubCategories, + CategoryCreateWithinTaxonomyWithSubCategories, + CategoryCreateWithSubCategories, + CategoryUpdate, +) +from app.api.common.crud.query import require_model +from app.api.common.crud.utils import enum_format_id_set, format_id_set +from app.api.common.exceptions import BadRequestError + +from .shared import delete_background_model, update_background_model + +if TYPE_CHECKING: + from sqlalchemy import Select + from sqlalchemy.ext.asyncio import AsyncSession + + +async def validate_category_creation( + db: AsyncSession, + category: CategoryCreateWithSubCategories + | CategoryCreateWithinCategoryWithSubCategories + | CategoryCreateWithinTaxonomyWithSubCategories, + taxonomy_id: int | None = None, + supercategory_id: int | None = None, +) -> tuple[int, Category | None]: + """Validate category creation parameters and return taxonomy_id and supercategory.""" + if supercategory_id: + supercategory: Category = await require_model(db, Category, supercategory_id) + + taxonomy_id = taxonomy_id or supercategory.taxonomy_id + if supercategory.taxonomy_id != taxonomy_id: + err_msg = f"Supercategory with id {supercategory_id} does not belong to taxonomy with id {taxonomy_id}" + raise BadRequestError(err_msg) + return taxonomy_id, supercategory + + taxonomy_id = taxonomy_id or getattr(category, "taxonomy_id", None) + + if not taxonomy_id: + err_msg = "Taxonomy ID is required for top-level categories" + raise BadRequestError(err_msg) + + await require_model(db, Taxonomy, taxonomy_id) + + return taxonomy_id, None + + +async def validate_category_taxonomy_domains( + db: AsyncSession, category_ids: set[int], expected_domains: TaxonomyDomain | set[TaxonomyDomain] +) -> None: + """Validate that categories belong to taxonomies with expected domains.""" + categories_statement: Select[tuple[Category]] = ( + select(Category) + .join(Taxonomy) + .where(Category.id.in_(category_ids)) + .options(selectinload(cast("QueryableAttribute[Any]", Category.taxonomy))) + ) + categories = list((await db.execute(categories_statement)).scalars().all()) + + if len(categories) != len(category_ids): + missing = set(category_ids) - {c.id for c in categories} + err_msg = f"Categories with id {format_id_set(missing)} not found" + raise BadRequestError(err_msg) + + if isinstance(expected_domains, TaxonomyDomain): + expected_domains = {expected_domains} + + invalid = {c.id for c in categories if not (set(c.taxonomy.domains) & expected_domains)} + if invalid: + err_msg = ( + f"Categories with id {format_id_set(invalid)} belong to taxonomies " + f"outside of domains: {enum_format_id_set(expected_domains)}" + ) + raise BadRequestError(err_msg) + + +async def get_category_trees( + db: AsyncSession, + recursion_depth: int = 1, + *, + supercategory_id: int | None = None, + taxonomy_id: int | None = None, + category_filter: CategoryFilter | CategoryFilterWithRelationships | None = None, +) -> list[Category]: + """Get categories with their subcategories up to specified depth.""" + if supercategory_id and taxonomy_id: + err_msg = "Provide either supercategory_id or taxonomy_id, not both" + raise BadRequestError(err_msg) + + if supercategory_id: + await require_model(db, Category, supercategory_id) + + if taxonomy_id: + await require_model(db, Taxonomy, taxonomy_id) + + statement: Select[tuple[Category]] = ( + select(Category).where(Category.supercategory_id == supercategory_id).execution_options(populate_existing=True) + ) + + if taxonomy_id: + statement = statement.where(Category.taxonomy_id == taxonomy_id) + + if category_filter: + statement = cast("Select[tuple[Category]]", category_filter.filter(statement)) + + statement = statement.options( + selectinload(cast("QueryableAttribute[Any]", Category.subcategories), recursion_depth=recursion_depth) + ) + + return list((await db.execute(statement)).scalars().all()) + + +async def create_category( + db: AsyncSession, + category: CategoryCreateWithSubCategories + | CategoryCreateWithinCategoryWithSubCategories + | CategoryCreateWithinTaxonomyWithSubCategories, + taxonomy_id: int | None = None, + supercategory_id: int | None = None, + *, + _is_recursive_call: bool = False, +) -> Category: + """Create a new category in the database and handle subcategory categories recursively.""" + taxonomy_id, supercategory = await validate_category_creation( + db, + category, + taxonomy_id, + supercategory_id + if isinstance(category, CategoryCreateWithinCategoryWithSubCategories) + else category.supercategory_id, + ) + + db_category = Category( + name=category.name, + description=category.description, + taxonomy_id=taxonomy_id, + supercategory_id=supercategory.id if supercategory else None, + ) + db.add(db_category) + await db.flush() + + if category.subcategories: + for subcategory in category.subcategories: + await create_category( + db, + subcategory, + taxonomy_id=taxonomy_id, + supercategory_id=db_category.id, + _is_recursive_call=True, + ) + + if not _is_recursive_call: + await db.commit() + await db.refresh(db_category) + + return db_category + + +async def update_category(db: AsyncSession, category_id: int, category: CategoryUpdate) -> Category: + """Update an existing category in the database.""" + return await update_background_model(db, Category, category_id, category) + + +async def delete_category(db: AsyncSession, category_id: int) -> None: + """Delete a category from the database.""" + await delete_background_model(db, Category, category_id) diff --git a/backend/app/api/background_data/crud/materials.py b/backend/app/api/background_data/crud/materials.py new file mode 100644 index 00000000..98e6f694 --- /dev/null +++ b/backend/app/api/background_data/crud/materials.py @@ -0,0 +1,234 @@ +"""Material CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.background_data.models import Category, CategoryMaterialLink, Material, TaxonomyDomain +from app.api.background_data.schemas import MaterialCreate, MaterialCreateWithCategories, MaterialUpdate +from app.api.common.crud.associations import add_links +from app.api.common.crud.persistence import commit_and_refresh +from app.api.common.crud.query import require_model, require_models +from app.api.common.exceptions import InternalServerError +from app.api.file_storage.crud.parent_media import ( + create_parent_media, + delete_all_parent_media, + delete_parent_media, + get_parent_media, + list_parent_media, +) +from app.api.file_storage.crud.support_services import file_storage_service, image_storage_service +from app.api.file_storage.models import File, Image, MediaParentType + +from .categories import validate_category_taxonomy_domains +from .shared import ( + add_categories_to_parent_model, + create_background_model, + remove_categories_from_parent_model, + update_background_model, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pydantic import UUID4 + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.file_storage.filters import FileFilter, ImageFilter + from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm + + +async def create_material(db: AsyncSession, material: MaterialCreate | MaterialCreateWithCategories) -> Material: + """Create a new material in the database, optionally with category links.""" + db_material = await create_background_model(db, Material, material, exclude_fields={"category_ids"}) + + if isinstance(material, MaterialCreateWithCategories) and material.category_ids: + await require_models(db, Category, material.category_ids) + await validate_category_taxonomy_domains(db, material.category_ids, {TaxonomyDomain.MATERIALS}) + + await add_links( + db, + id1=db_material.id, + id1_attr=CategoryMaterialLink.material_id, + id2_set=material.category_ids, + id2_attr=CategoryMaterialLink.category_id, + link_model=CategoryMaterialLink, + ) + + return await commit_and_refresh(db, db_material, add_before_commit=False) + + +async def update_material(db: AsyncSession, material_id: int, material: MaterialUpdate) -> Material: + """Update an existing material in the database.""" + return await update_background_model(db, Material, material_id, material) + + +async def delete_material(db: AsyncSession, material_id: int) -> None: + """Delete a material from the database.""" + db_material = await require_model(db, Material, material_id) + + await delete_all_material_files(db, material_id) + await delete_all_material_images(db, material_id) + + await db.delete(db_material) + await db.commit() + + +async def add_categories_to_material( + db: AsyncSession, material_id: int, category_ids: int | set[int] +) -> Sequence[Category]: + """Add categories to a material.""" + db_material, db_categories = await add_categories_to_parent_model( + db, + parent_model=Material, + parent_id=material_id, + category_ids=category_ids, + expected_domains={TaxonomyDomain.MATERIALS}, + link_model=CategoryMaterialLink, + link_parent_id_field="material_id", + validate_category_taxonomy_domains=validate_category_taxonomy_domains, + ) + + await db.commit() + await db.refresh(db_material) + return db_categories + + +async def add_category_to_material(db: AsyncSession, material_id: int, category_id: int) -> Category: + """Add a category to a material.""" + db_category_list = await add_categories_to_material(db, material_id, {category_id}) + + if len(db_category_list) != 1: + err_msg = f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" + raise InternalServerError(log_message=err_msg) + + return db_category_list[0] + + +async def remove_categories_from_material(db: AsyncSession, material_id: int, category_ids: int | set[int]) -> None: + """Remove categories from a material.""" + await remove_categories_from_parent_model( + db, + parent_model=Material, + parent_id=material_id, + category_ids=category_ids, + link_model=CategoryMaterialLink, + link_parent_id_field="material_id", + ) + await db.commit() + + +async def list_material_files(db: AsyncSession, material_id: int, *, filter_params: FileFilter) -> Sequence[File]: + """List files attached to a material.""" + return await list_parent_media( + db, + parent_model=Material, + parent_type=MediaParentType.MATERIAL, + storage_model=File, + parent_id=material_id, + filter_params=filter_params, + ) + + +async def get_material_file(db: AsyncSession, material_id: int, file_id: UUID4) -> File: + """Load one file attached to a material.""" + return await get_parent_media( + db, + parent_model=Material, + storage_model=File, + parent_id=material_id, + item_id=file_id, + ) + + +async def create_material_file(db: AsyncSession, material_id: int, payload: FileCreate) -> File: + """Create a file attached to a material.""" + return await create_parent_media( + db, + parent_id=material_id, + parent_type=MediaParentType.MATERIAL, + storage_service=file_storage_service, + item_data=payload, + ) + + +async def delete_material_file(db: AsyncSession, material_id: int, file_id: UUID4) -> None: + """Delete a file attached to a material.""" + await delete_parent_media( + db, + parent_model=Material, + storage_model=File, + parent_id=material_id, + item_id=file_id, + storage_service=file_storage_service, + ) + + +async def delete_all_material_files(db: AsyncSession, material_id: int) -> None: + """Delete all files attached to a material.""" + await delete_all_parent_media( + db, + parent_model=Material, + parent_type=MediaParentType.MATERIAL, + storage_model=File, + parent_id=material_id, + storage_service=file_storage_service, + ) + + +async def list_material_images(db: AsyncSession, material_id: int, *, filter_params: ImageFilter) -> Sequence[Image]: + """List images attached to a material.""" + return await list_parent_media( + db, + parent_model=Material, + parent_type=MediaParentType.MATERIAL, + storage_model=Image, + parent_id=material_id, + filter_params=filter_params, + ) + + +async def get_material_image(db: AsyncSession, material_id: int, image_id: UUID4) -> Image: + """Load one image attached to a material.""" + return await get_parent_media( + db, + parent_model=Material, + storage_model=Image, + parent_id=material_id, + item_id=image_id, + ) + + +async def create_material_image(db: AsyncSession, material_id: int, payload: ImageCreateFromForm) -> Image: + """Create an image attached to a material.""" + return await create_parent_media( + db, + parent_id=material_id, + parent_type=MediaParentType.MATERIAL, + storage_service=image_storage_service, + item_data=payload, + ) + + +async def delete_material_image(db: AsyncSession, material_id: int, image_id: UUID4) -> None: + """Delete an image attached to a material.""" + await delete_parent_media( + db, + parent_model=Material, + storage_model=Image, + parent_id=material_id, + item_id=image_id, + storage_service=image_storage_service, + ) + + +async def delete_all_material_images(db: AsyncSession, material_id: int) -> None: + """Delete all images attached to a material.""" + await delete_all_parent_media( + db, + parent_model=Material, + parent_type=MediaParentType.MATERIAL, + storage_model=Image, + parent_id=material_id, + storage_service=image_storage_service, + ) diff --git a/backend/app/api/background_data/crud/product_types.py b/backend/app/api/background_data/crud/product_types.py new file mode 100644 index 00000000..8b064200 --- /dev/null +++ b/backend/app/api/background_data/crud/product_types.py @@ -0,0 +1,240 @@ +"""Product-type CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.background_data.models import Category, CategoryProductTypeLink, ProductType, TaxonomyDomain +from app.api.background_data.schemas import ProductTypeCreate, ProductTypeCreateWithCategories, ProductTypeUpdate +from app.api.common.crud.associations import add_links +from app.api.common.crud.persistence import commit_and_refresh +from app.api.common.crud.query import require_model +from app.api.common.exceptions import InternalServerError +from app.api.file_storage.crud.parent_media import ( + create_parent_media, + delete_all_parent_media, + delete_parent_media, + get_parent_media, + list_parent_media, +) +from app.api.file_storage.crud.support_services import file_storage_service, image_storage_service +from app.api.file_storage.models import File, Image, MediaParentType + +from .categories import validate_category_taxonomy_domains +from .shared import ( + add_categories_to_parent_model, + create_background_model, + remove_categories_from_parent_model, + update_background_model, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pydantic import UUID4 + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.file_storage.filters import FileFilter, ImageFilter + from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm + + +async def create_product_type( + db: AsyncSession, product_type: ProductTypeCreate | ProductTypeCreateWithCategories +) -> ProductType: + """Create a new product type in the database, optionally with category links.""" + db_product_type = await create_background_model(db, ProductType, product_type, exclude_fields={"category_ids"}) + + if isinstance(product_type, ProductTypeCreateWithCategories) and product_type.category_ids: + await validate_category_taxonomy_domains(db, product_type.category_ids, {TaxonomyDomain.PRODUCTS}) + await add_links( + db, + id1=db_product_type.id, + id1_attr=CategoryProductTypeLink.product_type_id, + id2_set=product_type.category_ids, + id2_attr=CategoryProductTypeLink.category_id, + link_model=CategoryProductTypeLink, + ) + return await commit_and_refresh(db, db_product_type, add_before_commit=False) + + return await commit_and_refresh(db, db_product_type, add_before_commit=False) + + +async def update_product_type(db: AsyncSession, product_type_id: int, product_type: ProductTypeUpdate) -> ProductType: + """Update an existing product type in the database.""" + return await update_background_model(db, ProductType, product_type_id, product_type) + + +async def delete_product_type(db: AsyncSession, product_type_id: int) -> None: + """Delete a product type from the database.""" + db_product_type = await require_model(db, ProductType, product_type_id) + + await delete_all_product_type_files(db, product_type_id) + await delete_all_product_type_images(db, product_type_id) + + await db.delete(db_product_type) + await db.commit() + + +async def add_categories_to_product_type( + db: AsyncSession, product_type_id: int, category_ids: set[int] +) -> Sequence[Category]: + """Add categories to a product type.""" + _, db_categories = await add_categories_to_parent_model( + db, + parent_model=ProductType, + parent_id=product_type_id, + category_ids=category_ids, + expected_domains={TaxonomyDomain.PRODUCTS}, + link_model=CategoryProductTypeLink, + link_parent_id_field="product_type_id", + validate_category_taxonomy_domains=validate_category_taxonomy_domains, + ) + await db.commit() + + return db_categories + + +async def add_category_to_product_type(db: AsyncSession, product_type_id: int, category_id: int) -> Category: + """Add a category to a product type.""" + db_category_list = await add_categories_to_product_type(db, product_type_id, {category_id}) + + if len(db_category_list) != 1: + err_msg = f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" + raise InternalServerError(log_message=err_msg) + + return db_category_list[0] + + +async def remove_categories_from_product_type( + db: AsyncSession, product_type_id: int, category_ids: int | set[int] +) -> None: + """Remove categories from a product type.""" + await remove_categories_from_parent_model( + db, + parent_model=ProductType, + parent_id=product_type_id, + category_ids=category_ids, + link_model=CategoryProductTypeLink, + link_parent_id_field="product_type_id", + ) + await db.commit() + + +async def list_product_type_files( + db: AsyncSession, product_type_id: int, *, filter_params: FileFilter +) -> Sequence[File]: + """List files attached to a product type.""" + return await list_parent_media( + db, + parent_model=ProductType, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_model=File, + parent_id=product_type_id, + filter_params=filter_params, + ) + + +async def get_product_type_file(db: AsyncSession, product_type_id: int, file_id: UUID4) -> File: + """Load one file attached to a product type.""" + return await get_parent_media( + db, + parent_model=ProductType, + storage_model=File, + parent_id=product_type_id, + item_id=file_id, + ) + + +async def create_product_type_file(db: AsyncSession, product_type_id: int, payload: FileCreate) -> File: + """Create a file attached to a product type.""" + return await create_parent_media( + db, + parent_id=product_type_id, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_service=file_storage_service, + item_data=payload, + ) + + +async def delete_product_type_file(db: AsyncSession, product_type_id: int, file_id: UUID4) -> None: + """Delete a file attached to a product type.""" + await delete_parent_media( + db, + parent_model=ProductType, + storage_model=File, + parent_id=product_type_id, + item_id=file_id, + storage_service=file_storage_service, + ) + + +async def delete_all_product_type_files(db: AsyncSession, product_type_id: int) -> None: + """Delete all files attached to a product type.""" + await delete_all_parent_media( + db, + parent_model=ProductType, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_model=File, + parent_id=product_type_id, + storage_service=file_storage_service, + ) + + +async def list_product_type_images( + db: AsyncSession, product_type_id: int, *, filter_params: ImageFilter +) -> Sequence[Image]: + """List images attached to a product type.""" + return await list_parent_media( + db, + parent_model=ProductType, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_model=Image, + parent_id=product_type_id, + filter_params=filter_params, + ) + + +async def get_product_type_image(db: AsyncSession, product_type_id: int, image_id: UUID4) -> Image: + """Load one image attached to a product type.""" + return await get_parent_media( + db, + parent_model=ProductType, + storage_model=Image, + parent_id=product_type_id, + item_id=image_id, + ) + + +async def create_product_type_image(db: AsyncSession, product_type_id: int, payload: ImageCreateFromForm) -> Image: + """Create an image attached to a product type.""" + return await create_parent_media( + db, + parent_id=product_type_id, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_service=image_storage_service, + item_data=payload, + ) + + +async def delete_product_type_image(db: AsyncSession, product_type_id: int, image_id: UUID4) -> None: + """Delete an image attached to a product type.""" + await delete_parent_media( + db, + parent_model=ProductType, + storage_model=Image, + parent_id=product_type_id, + item_id=image_id, + storage_service=image_storage_service, + ) + + +async def delete_all_product_type_images(db: AsyncSession, product_type_id: int) -> None: + """Delete all images attached to a product type.""" + await delete_all_parent_media( + db, + parent_model=ProductType, + parent_type=MediaParentType.PRODUCT_TYPE, + storage_model=Image, + parent_id=product_type_id, + storage_service=image_storage_service, + ) diff --git a/backend/app/api/background_data/crud/shared.py b/backend/app/api/background_data/crud/shared.py new file mode 100644 index 00000000..62879fcf --- /dev/null +++ b/backend/app/api/background_data/crud/shared.py @@ -0,0 +1,148 @@ +"""Shared helpers for background-data CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import select + +from app.api.background_data.models import ( + Category, + CategoryMaterialLink, + CategoryProductTypeLink, + Material, + ProductType, + Taxonomy, +) +from app.api.common.crud.associations import add_links +from app.api.common.crud.persistence import SupportsModelDump, delete_and_commit, update_and_commit +from app.api.common.crud.query import require_model, require_models +from app.api.common.crud.utils import ( + validate_linked_items_exist, + validate_no_duplicate_linked_items, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Sequence + + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.background_data.models import TaxonomyDomain + + type ValidateCategoryDomainsFn = Callable[ + [AsyncSession, set[int], TaxonomyDomain | set[TaxonomyDomain]], + Awaitable[None], + ] + + +def normalize_category_ids(category_ids: int | set[int]) -> set[int]: + """Normalize single category IDs into a set-based API.""" + return {category_ids} if isinstance(category_ids, int) else category_ids + + +async def create_background_model[ModelT: Taxonomy | Material | ProductType]( + db: AsyncSession, + model: type[ModelT], + payload: SupportsModelDump, + *, + exclude_fields: set[str], +) -> ModelT: + """Create and flush a background-data model from a request payload.""" + model_data = cast("dict[str, Any]", payload.model_dump(exclude=exclude_fields)) + db_model = model(**model_data) + db.add(db_model) + await db.flush() + return db_model + + +async def update_background_model[ModelT: Taxonomy | Material | ProductType | Category]( + db: AsyncSession, + model: type[ModelT], + model_id: int, + payload: SupportsModelDump, +) -> ModelT: + """Apply a partial update and persist the model.""" + db_model: ModelT = await require_model(db, model, model_id) + return await update_and_commit(db, db_model, payload) + + +async def delete_background_model[ModelT: Taxonomy | Material | ProductType | Category]( + db: AsyncSession, + model: type[ModelT], + model_id: int, +) -> ModelT: + """Delete a model after resolving it from the database.""" + db_model: ModelT = await require_model(db, model, model_id) + await delete_and_commit(db, db_model) + return db_model + + +async def add_categories_to_parent_model[ParentT: Material | ProductType]( + db: AsyncSession, + *, + parent_model: type[ParentT], + parent_id: int, + category_ids: int | set[int], + expected_domains: set[TaxonomyDomain], + link_model: type[CategoryMaterialLink | CategoryProductTypeLink], + link_parent_id_field: str, + validate_category_taxonomy_domains: ValidateCategoryDomainsFn, +) -> tuple[ParentT, Sequence[Category]]: + """Create validated category links for a material-like parent model.""" + normalized_category_ids = normalize_category_ids(category_ids) + + db_parent = await require_model( + db, + parent_model, + model_id=parent_id, + loaders={"categories"}, + ) + + db_categories: Sequence[Category] = await require_models(db, Category, normalized_category_ids) + await validate_category_taxonomy_domains(db, normalized_category_ids, expected_domains) + + if db_parent.categories: + validate_no_duplicate_linked_items(normalized_category_ids, db_parent.categories, "Categories") + + parent_id_attr = getattr(link_model, link_parent_id_field) + await add_links( + db, + id1=parent_id, + id1_attr=parent_id_attr, + id2_set=normalized_category_ids, + id2_attr=link_model.category_id, + link_model=link_model, + ) + + return db_parent, db_categories + + +async def remove_categories_from_parent_model[ParentT: Material | ProductType]( + db: AsyncSession, + *, + parent_model: type[ParentT], + parent_id: int, + category_ids: int | set[int], + link_model: type[CategoryMaterialLink | CategoryProductTypeLink], + link_parent_id_field: str, +) -> None: + """Remove validated category links from a material-like parent model.""" + normalized_category_ids = normalize_category_ids(category_ids) + + db_parent = await require_model( + db, + parent_model, + model_id=parent_id, + loaders={"categories"}, + ) + + validate_linked_items_exist(normalized_category_ids, db_parent.categories, "Categories") + + statement = ( + select(link_model) + .where(getattr(link_model, link_parent_id_field) == parent_id) + .where(link_model.category_id.in_(normalized_category_ids)) + ) + results = await db.execute(statement) + for category_link in results.scalars().all(): + await db.delete(category_link) diff --git a/backend/app/api/background_data/crud/taxonomies.py b/backend/app/api/background_data/crud/taxonomies.py new file mode 100644 index 00000000..7a2201c3 --- /dev/null +++ b/backend/app/api/background_data/crud/taxonomies.py @@ -0,0 +1,31 @@ +"""Taxonomy CRUD operations.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.background_data.models import Taxonomy +from app.api.background_data.schemas import TaxonomyCreate, TaxonomyCreateWithCategories, TaxonomyUpdate +from app.api.common.crud.persistence import commit_and_refresh + +from .categories import create_category +from .shared import create_background_model, delete_background_model, update_background_model + + +async def create_taxonomy(db: AsyncSession, taxonomy: TaxonomyCreate | TaxonomyCreateWithCategories) -> Taxonomy: + """Create a new taxonomy in the database.""" + db_taxonomy = await create_background_model(db, Taxonomy, taxonomy, exclude_fields={"categories"}) + + if isinstance(taxonomy, TaxonomyCreateWithCategories) and taxonomy.categories: + for category_data in taxonomy.categories: + await create_category(db, category_data, taxonomy_id=db_taxonomy.id) + + return await commit_and_refresh(db, db_taxonomy, add_before_commit=False) + + +async def update_taxonomy(db: AsyncSession, taxonomy_id: int, taxonomy: TaxonomyUpdate) -> Taxonomy: + """Update an existing taxonomy in the database.""" + return await update_background_model(db, Taxonomy, taxonomy_id, taxonomy) + + +async def delete_taxonomy(db: AsyncSession, taxonomy_id: int) -> None: + """Delete a taxonomy from the database, including its categories.""" + await delete_background_model(db, Taxonomy, taxonomy_id) diff --git a/backend/app/api/background_data/examples.py b/backend/app/api/background_data/examples.py new file mode 100644 index 00000000..5b9c9695 --- /dev/null +++ b/backend/app/api/background_data/examples.py @@ -0,0 +1,112 @@ +"""Centralized OpenAPI examples for background-data schemas and routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +CATEGORY_READ_AS_SUBCATEGORY_EXAMPLES = [ + { + "id": 2, + "name": "Ferrous metals", + "description": "Iron and its alloys", + } +] + +CATEGORY_READ_EXAMPLES = [ + { + "id": 2, + "name": "Ferrous metals", + "description": "Iron and its alloys", + "taxonomy_id": 1, + "supercategory_id": 1, + } +] + +CATEGORY_READ_RECURSIVE_EXAMPLES = [ + { + "id": 1, + "name": "Metals", + "description": "All kinds of metals", + "subcategories": [ + { + "id": 2, + "name": "Ferrous metals", + "description": "Iron and its alloys", + "subcategories": [ + { + "id": 3, + "name": "Steel", + "description": "Steel alloys", + } + ], + } + ], + } +] + +CATEGORY_UPDATE_EXAMPLES = [ + { + "name": "Metals", + "description": "All kinds of metals", + } +] + +TAXONOMY_READ_EXAMPLES = [ + { + "name": "Materials Taxonomy", + "description": "Taxonomy for materials", + "domains": ["materials"], + "source": "DOI:10.2345/12345", + } +] + +TAXONOMY_READ_WITH_TREE_EXAMPLES = [ + { + "name": "Materials Taxonomy", + "description": "Taxonomy for materials", + "domains": ["materials"], + "source": "DOI:10.2345/12345", + "categories": [ + { + "id": 1, + "name": "Metals", + "description": "All kinds of metals", + "subcategories": [ + { + "name": "Ferrous metals", + "description": "Iron and its alloys", + "subcategories": [{"name": "Steel", "description": "Steel alloys"}], + } + ], + } + ], + } +] + +CATEGORY_INCLUDE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + none=openapi_example([]), + materials=openapi_example(["materials"]), + all=openapi_example(["materials", "product_types", "subcategories"]), +) + +BACKGROUND_DATA_RESOURCE_INCLUDE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + none=openapi_example([]), + categories=openapi_example(["categories"]), + all=openapi_example(["categories", "files", "images", "product_links"]), +) + +TAXONOMY_CATEGORY_INCLUDE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + none=openapi_example([]), + taxonomy=openapi_example(["taxonomy"]), + all=openapi_example(["taxonomy", "subcategories"]), +) + +CATEGORY_IDS_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + multiple_category_ids=openapi_example([1, 2, 3], summary="Assign multiple categories"), +) diff --git a/backend/app/api/background_data/filters.py b/backend/app/api/background_data/filters.py index ac5992a9..9e3a490c 100644 --- a/backend/app/api/background_data/filters.py +++ b/backend/app/api/background_data/filters.py @@ -1,9 +1,13 @@ """FastAPI-Filter schemas for filtering database queries on background data models.""" +from typing import Any, cast + from fastapi_filter import FilterDepends, with_prefix from fastapi_filter.contrib.sqlalchemy import Filter +from sqlalchemy import ColumnElement from app.api.background_data.models import Category, Material, ProductType, Taxonomy +from app.api.common.search_utils import TSVectorSearchMixin class TaxonomyFilter(Filter): @@ -16,6 +20,8 @@ class TaxonomyFilter(Filter): search: str | None = None + order_by: list[str] | None = None + # TODO: Add custom domain filtering (given a list of domains, return all taxonomies that have at least one of them). # See https://github.com/arthurio/fastapi-filter/issues/556 for inspiration. Or move to https://github.com/OleksandrZhydyk/FastAPI-SQLAlchemy-Filters. @@ -23,14 +29,14 @@ class Constants(Filter.Constants): """FilterAPI class configuration.""" model = Taxonomy - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override + search_model_fields: list[str] = [ # noqa: RUF012 # fastapi-filter excepts this syntax "name", "description", "version", ] -class CategoryFilter(Filter): +class CategoryFilter(TSVectorSearchMixin, Filter): """FastAPI-filter class for Category filtering.""" name__ilike: str | None = None @@ -39,24 +45,31 @@ class CategoryFilter(Filter): search: str | None = None + order_by: list[str] | None = None + + @classmethod + def _search_vector_col(cls) -> ColumnElement[Any]: + return cast("ColumnElement[Any]", Category.search_vector) + + @classmethod + def _trigram_cols(cls) -> list[Any]: + return [Category.name] + class Constants(Filter.Constants): """FilterAPI class configuration.""" model = Category - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - ] + # search_model_fields intentionally omitted; handled by TSVectorSearchMixin class CategoryFilterWithRelationships(CategoryFilter): """FastAPI-filter class for Category filtering, with linked relationships.""" # Linked relationships - taxonomy: CategoryFilter | None = FilterDepends(with_prefix("taxonomy", CategoryFilter)) + taxonomy: TaxonomyFilter | None = FilterDepends(with_prefix("taxonomy", TaxonomyFilter)) -class MaterialFilter(Filter): +class MaterialFilter(TSVectorSearchMixin, Filter): """FastAPI-filter class for Material filtering.""" name__ilike: str | None = None @@ -68,15 +81,21 @@ class MaterialFilter(Filter): search: str | None = None + order_by: list[str] | None = None + + @classmethod + def _search_vector_col(cls) -> ColumnElement[Any]: + return cast("ColumnElement[Any]", Material.search_vector) + + @classmethod + def _trigram_cols(cls) -> list[Any]: + return [Material.name] + class Constants(Filter.Constants): """FilterAPI class configuration.""" model = Material - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - "source", - ] + # search_model_fields intentionally omitted; handled by TSVectorSearchMixin class MaterialFilterWithRelationships(MaterialFilter): @@ -86,22 +105,30 @@ class MaterialFilterWithRelationships(MaterialFilter): categories: CategoryFilter | None = FilterDepends(with_prefix("categories", CategoryFilter)) -class ProductTypeFilter(Filter): +class ProductTypeFilter(TSVectorSearchMixin, Filter): """FastAPI-Filter class for ProductType filtering.""" name__ilike: str | None = None + name__in: list[str] | None = None description__ilike: str | None = None search: str | None = None + order_by: list[str] | None = None + + @classmethod + def _search_vector_col(cls) -> ColumnElement[Any]: + return cast("ColumnElement[Any]", ProductType.search_vector) + + @classmethod + def _trigram_cols(cls) -> list[Any]: + return [ProductType.name] + class Constants(Filter.Constants): """FilterAPI class configuration.""" model = ProductType - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - ] + # search_model_fields intentionally omitted; handled by TSVectorSearchMixin class ProductTypeFilterWithRelationships(ProductTypeFilter): diff --git a/backend/app/api/background_data/models.py b/backend/app/api/background_data/models.py index d03a2956..f4da4ff4 100644 --- a/backend/app/api/background_data/models.py +++ b/backend/app/api/background_data/models.py @@ -1,174 +1,261 @@ """Database models for background data.""" -from enum import Enum -from typing import TYPE_CHECKING, Optional +# spell-checker: ignore trgm -from pydantic import ConfigDict +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import Computed, ForeignKey, Index, String, and_ from sqlalchemy import Enum as SAEnum -from sqlalchemy.dialects.postgresql import ARRAY -from sqlmodel import Column, Field, Relationship +from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR +from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship -from app.api.common.models.base import CustomBase, CustomLinkingModelBase, TimeStampMixinBare -from app.api.file_storage.models.models import File, Image +from app.api.common.models.base import Base, TimeStampMixinBare +from app.api.file_storage.models import File, Image, MediaParentType -if TYPE_CHECKING: - from app.api.common.models.associations import MaterialProductLink - from app.api.data_collection.models import Product +### Enums ### +class TaxonomyDomain(StrEnum): + """Enumeration of taxonomy domains.""" -### Linking Models ### -class CategoryMaterialLink(CustomLinkingModelBase, table=True): - """Association table to link Category with Material.""" + MATERIALS = "materials" + PRODUCTS = "products" + OTHER = "other" - category_id: int = Field(foreign_key="category.id", primary_key=True) - material_id: int = Field(foreign_key="material.id", primary_key=True) +### Pydantic base schemas (shared with schemas.py) ### +class TaxonomyBase(BaseModel): + """Base schema for Taxonomy. Used by Pydantic schemas only, not ORM.""" -class CategoryProductTypeLink(CustomLinkingModelBase, table=True): - """Association table to link Category with ProductType.""" + name: str = Field(min_length=2, max_length=100) + version: str | None = Field(default=None, min_length=1, max_length=50) + description: str | None = Field(default=None, max_length=500) + domains: set[TaxonomyDomain] = set() + source: str | None = Field(default=None, max_length=500) - category_id: int = Field(foreign_key="category.id", primary_key=True) - product_type_id: int = Field(foreign_key="producttype.id", primary_key=True) + model_config: ConfigDict = ConfigDict(use_enum_values=True) -### Taxonomy Model ### -class TaxonomyDomain(str, Enum): - """Enumeration of taxonomy domains.""" +class CategoryBase(BaseModel): + """Base schema for Category. Used by Pydantic schemas only, not ORM.""" - MATERIALS = "materials" - PRODUCTS = "products" - OTHER = "other" + name: str = Field(min_length=2, max_length=250) + description: str | None = Field(default=None, max_length=500) + external_id: str | None = None -class TaxonomyBase(CustomBase): - """Base model for Taxonomy.""" +class MaterialBase(BaseModel): + """Base schema for Material. Used by Pydantic schemas only, not ORM.""" - name: str = Field(index=True, min_length=2, max_length=100) - version: str | None = Field(min_length=1, max_length=50) + name: str = Field(min_length=2, max_length=100) description: str | None = Field(default=None, max_length=500) - domains: set[TaxonomyDomain] = Field( - sa_column=Column(ARRAY(SAEnum(TaxonomyDomain))), - description=f"Domains of the taxonomy, e.g. {{{', '.join([d.value for d in TaxonomyDomain][:3])}}}", - ) + source: str | None = Field(default=None, max_length=100) + density_kg_m3: float | None = Field(default=None, gt=0) + is_crm: bool | None = None - # TODO: Implement Source model - source: str | None = Field( - default=None, max_length=500, description="Source of the taxonomy data, e.g. URL, IRI or citation key" - ) - model_config: ConfigDict = ConfigDict(use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 +class ProductTypeBase(BaseModel): + """Base schema for ProductType. Used by Pydantic schemas only, not ORM.""" + + name: str = Field(min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) -class Taxonomy(TaxonomyBase, TimeStampMixinBare, table=True): +### Linking Models ### +class CategoryMaterialLink(Base): + """Association table to link Category with Material.""" + + __tablename__ = "categorymateriallink" + + category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), primary_key=True) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), primary_key=True) + + +class CategoryProductTypeLink(Base): + """Association table to link Category with ProductType.""" + + __tablename__ = "categoryproducttypelink" + + category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), primary_key=True) + product_type_id: Mapped[int] = mapped_column(ForeignKey("producttype.id"), primary_key=True) + + +### Taxonomy Model ### +class Taxonomy(TimeStampMixinBare, Base): """Database model for Taxonomy.""" - id: int | None = Field(default=None, primary_key=True) + __tablename__ = "taxonomy" - categories: list["Category"] = Relationship(back_populates="taxonomy", cascade_delete=True) + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), index=True) + version: Mapped[str | None] = mapped_column(String(50)) + description: Mapped[str | None] = mapped_column(String(500), default=None) + domains: Mapped[set[TaxonomyDomain]] = mapped_column(ARRAY(SAEnum(TaxonomyDomain))) + source: Mapped[str | None] = mapped_column(String(500), default=None) - model_config: ConfigDict = ConfigDict(use_enum_values=True, arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + categories: Mapped[list[Category]] = relationship(back_populates="taxonomy", cascade="all, delete-orphan") - # Magic methods def __str__(self) -> str: return f"{self.name} (id: {self.id})" ### Category Model ### -class CategoryBase(CustomBase): - """Base model for Category.""" +class Category(TimeStampMixinBare, Base): + """Database model for Category.""" - name: str = Field(index=True, min_length=2, max_length=250, description="Name of the category") - description: str | None = Field(default=None, max_length=500, description="Description of the category") - external_id: str | None = Field(default=None, description="ID of the category in the external taxonomy") + __tablename__ = "category" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(250), index=True) + description: Mapped[str | None] = mapped_column(String(500), default=None) + external_id: Mapped[str | None] = mapped_column(default=None) -class Category(CategoryBase, TimeStampMixinBare, table=True): - """Database model for Category.""" + __table_args__ = ( + Index("category_search_vector_idx", "search_vector", postgresql_using="gin"), + Index("category_name_trgm_idx", "name", postgresql_using="gin", postgresql_ops={"name": "gin_trgm_ops"}), + ) - id: int | None = Field(default=None, primary_key=True) + search_vector: Mapped[str | None] = mapped_column( + TSVECTOR(), + Computed( + "to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, ''))", + persisted=True, + ), + default=None, + ) # Self-referential relationship - supercategory_id: int | None = Field(foreign_key="category.id", default=None, nullable=True) - supercategory: Optional["Category"] = Relationship( + supercategory_id: Mapped[int | None] = mapped_column(ForeignKey("category.id"), default=None) + supercategory: Mapped[Category | None] = relationship( back_populates="subcategories", - sa_relationship_kwargs={"remote_side": "Category.id", "lazy": "selectin", "join_depth": 1}, + remote_side="Category.id", + lazy="selectin", + join_depth=1, ) - subcategories: list["Category"] | None = Relationship( + subcategories: Mapped[list[Category] | None] = relationship( back_populates="supercategory", - sa_relationship_kwargs={"lazy": "selectin", "join_depth": 1}, - cascade_delete=True, + lazy="selectin", + join_depth=1, + cascade="all, delete-orphan", ) # Many-to-one relationships - taxonomy_id: int = Field(foreign_key="taxonomy.id") - taxonomy: Taxonomy = Relationship(back_populates="categories") + taxonomy_id: Mapped[int] = mapped_column(ForeignKey("taxonomy.id")) + taxonomy: Mapped[Taxonomy] = relationship(back_populates="categories") - # Many-to-many relationships. This is ugly but SQLModel doesn't allow for polymorphic association. - materials: list["Material"] | None = Relationship(back_populates="categories", link_model=CategoryMaterialLink) - product_types: list["ProductType"] | None = Relationship( - back_populates="categories", link_model=CategoryProductTypeLink + # Many-to-many relationships + materials: Mapped[list[Material] | None] = relationship( + back_populates="categories", secondary="categorymateriallink" + ) + product_types: Mapped[list[ProductType] | None] = relationship( + back_populates="categories", secondary="categoryproducttypelink" ) - # Magic methods def __str__(self) -> str: return f"{self.name} (id: {self.id})" ### Material Model ### -class MaterialBase(CustomBase): - """Base model for Material.""" +class Material(TimeStampMixinBare, Base): + """Database model for Material.""" - name: str = Field(index=True, min_length=2, max_length=100, description="Name of the Material") - description: str | None = Field(default=None, max_length=500, description="Description of the Material") - source: str | None = Field( - default=None, max_length=100, description="Source of the material data, e.g. URL, IRI or citation key" - ) - density_kg_m3: float | None = Field(default=None, gt=0, description="Volumetric density (kg/m³) ") - is_crm: bool | None = Field(default=None, description="Is this material a Critical Raw Material (CRM)?") + __tablename__ = "material" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), index=True) + description: Mapped[str | None] = mapped_column(String(500), default=None) + source: Mapped[str | None] = mapped_column(String(100), default=None) + density_kg_m3: Mapped[float | None] = mapped_column(default=None) + is_crm: Mapped[bool | None] = mapped_column(default=None) -class Material(MaterialBase, TimeStampMixinBare, table=True): - """Database model for Material.""" + __table_args__ = ( + Index("material_search_vector_idx", "search_vector", postgresql_using="gin"), + Index("material_name_trgm_idx", "name", postgresql_using="gin", postgresql_ops={"name": "gin_trgm_ops"}), + ) - id: int | None = Field(default=None, primary_key=True) + search_vector: Mapped[str | None] = mapped_column( + TSVECTOR(), + Computed( + "to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || " + "coalesce(source, ''))", + persisted=True, + ), + default=None, + ) - # One-to-many relationships - images: list[Image] | None = Relationship(cascade_delete=True) - files: list[File] | None = Relationship(cascade_delete=True) + # One-to-many relationships (generic FK) + images: Mapped[list[Image] | None] = relationship( + primaryjoin=lambda: and_( + Material.id == foreign(Image.parent_id), + Image.parent_type == MediaParentType.MATERIAL, + ), + cascade="all, delete-orphan", + overlaps="files,images", + ) + files: Mapped[list[File] | None] = relationship( + primaryjoin=lambda: and_( + Material.id == foreign(File.parent_id), + File.parent_type == MediaParentType.MATERIAL, + ), + cascade="all, delete-orphan", + overlaps="files,images", + ) # Many-to-many relationships - categories: list[Category] | None = Relationship(back_populates="materials", link_model=CategoryMaterialLink) - product_links: list["MaterialProductLink"] | None = Relationship(back_populates="material") + categories: Mapped[list[Category] | None] = relationship( + back_populates="materials", secondary="categorymateriallink" + ) - # Magic methods def __str__(self) -> str: return f"{self.name} (id: {self.id})" ### ProductType Model ### -class ProductTypeBase(CustomBase): - """Base model for ProductType.""" +class ProductType(TimeStampMixinBare, Base): + """Database model for ProductType.""" - name: str = Field(index=True, min_length=2, max_length=100, description="Name of the Product Type.") - description: str | None = Field(default=None, max_length=500, description="Description of the Product Type.") + __tablename__ = "producttype" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), index=True) + description: Mapped[str | None] = mapped_column(String(500), default=None) -class ProductType(ProductTypeBase, TimeStampMixinBare, table=True): - """Database model for ProductType.""" + __table_args__ = ( + Index("producttype_search_vector_idx", "search_vector", postgresql_using="gin"), + Index("producttype_name_trgm_idx", "name", postgresql_using="gin", postgresql_ops={"name": "gin_trgm_ops"}), + ) - id: int | None = Field(default=None, primary_key=True) + search_vector: Mapped[str | None] = mapped_column( + TSVECTOR(), + Computed( + "to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, ''))", + persisted=True, + ), + default=None, + ) - # One-to-many relationships - products: list["Product"] | None = Relationship(back_populates="product_type") - files: list[File] | None = Relationship(back_populates="product_type", cascade_delete=True) - images: list[Image] | None = Relationship(back_populates="product_type", cascade_delete=True) + # One-to-many relationships (generic FK) + files: Mapped[list[File] | None] = relationship( + primaryjoin=lambda: and_( + ProductType.id == foreign(File.parent_id), + File.parent_type == MediaParentType.PRODUCT_TYPE, + ), + cascade="all, delete-orphan", + overlaps="files,images", + ) + images: Mapped[list[Image] | None] = relationship( + primaryjoin=lambda: and_( + ProductType.id == foreign(Image.parent_id), + Image.parent_type == MediaParentType.PRODUCT_TYPE, + ), + cascade="all, delete-orphan", + overlaps="files,images", + ) # Many-to-many relationships - categories: list[Category] | None = Relationship( - back_populates="product_types", - link_model=CategoryProductTypeLink, + categories: Mapped[list[Category] | None] = relationship( + back_populates="product_types", secondary="categoryproducttypelink" ) - # Magic methods def __str__(self) -> str: return f"{self.name} (id: {self.id})" diff --git a/backend/app/api/background_data/routers/__init__.py b/backend/app/api/background_data/routers/__init__.py index e69de29b..aa4dc563 100644 --- a/backend/app/api/background_data/routers/__init__.py +++ b/backend/app/api/background_data/routers/__init__.py @@ -0,0 +1 @@ +"""Routes for interacting with background data.""" diff --git a/backend/app/api/background_data/routers/admin.py b/backend/app/api/background_data/routers/admin.py index cdc4fa1f..73e372b8 100644 --- a/backend/app/api/background_data/routers/admin.py +++ b/backend/app/api/background_data/routers/admin.py @@ -1,53 +1,17 @@ -"""Admin routers for background data models.""" +"""Admin background-data router composition.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, Body, Path, Security -from pydantic import PositiveInt +from fastapi import APIRouter, Path, Security from app.api.auth.dependencies import current_active_superuser -from app.api.background_data import crud -from app.api.background_data.models import ( - Category, - Material, - ProductType, - Taxonomy, -) -from app.api.background_data.schemas import ( - CategoryCreateWithinCategoryWithSubCategories, - CategoryCreateWithinTaxonomyWithSubCategories, - CategoryCreateWithSubCategories, - CategoryRead, - CategoryUpdate, - MaterialCreate, - MaterialCreateWithCategories, - MaterialRead, - MaterialUpdate, - ProductTypeCreateWithCategories, - ProductTypeRead, - ProductTypeUpdate, - TaxonomyCreate, - TaxonomyCreateWithCategories, - TaxonomyRead, - TaxonomyUpdate, -) -from app.api.common.crud.base import get_nested_model_by_id -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes - -# TODO: Extract common logic and turn into router-factory functions. -# See FileStorageRouterFactory in common/router_factories.py for an example. - -# TODO: Improve HTTP method choices for linked resources -# (e.g., POST vs PATCH for adding categories to material, or DELETE vs. PATCH for removing categories) - -# TODO: Improve HTTP status codes (e.g., 201 for creation) and error handling. -# TODO: Consider supporting comma-separated list of relationships to include, -# TODO: Add paging and sorting to filters - +from app.api.background_data.routers.admin_categories import router as category_router +from app.api.background_data.routers.admin_materials import router as material_router +from app.api.background_data.routers.admin_product_types import router as product_type_router +from app.api.background_data.routers.admin_taxonomies import router as taxonomy_router +from app.core.cache import clear_cache_namespace +from app.core.config import CacheNamespace -# Initialize API router router = APIRouter( prefix="/admin", tags=["admin"], @@ -55,629 +19,15 @@ ) -### Category routers ### -category_router = APIRouter(prefix="/categories", tags=["categories"]) - - -@category_router.post( - "", - response_model=CategoryRead, - summary="Create a new category", - status_code=201, -) -async def create_category( - category: Annotated[ - CategoryCreateWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic category", - "description": "Create a category without subcategories", - "value": {"name": "Metals", "description": "All kinds of metals", "taxonomy_id": 1}, - }, - "nested": { - "summary": "Category with subcategories", - "description": "Create a category with nested subcategories", - "value": { - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "subcategories": [ - { - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - {"name": "Steel", "description": "Steel alloys"}, - ], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new category, optionally with subcategories.""" - return await crud.create_category(session, category) - # TODO: Figure out how to deduplicate this type of exception handling logic - - -@category_router.patch("/{category_id}", response_model=CategoryRead, summary="Update category") -async def update_category( - category_id: PositiveInt, - category: Annotated[ - CategoryUpdate, - Body( - openapi_examples={ - "name": {"summary": "Update name", "value": {"name": "Updated Metal Category"}}, - "description": { - "summary": "Update description", - "value": {"description": "Updated description for metals category"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Update an existing category.""" - return await crud.update_category(session, category_id, category) - - -@category_router.delete( - "/{category_id}", - summary="Delete category", - status_code=204, -) -async def delete_category(category_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a category by ID, including its subcategories.""" - await crud.delete_category(session, category_id) - - -## Subcategory routers ## -@category_router.post("/{category_id}/subcategories", response_model=CategoryRead, status_code=201) -async def create_subcategory( - category_id: PositiveInt, - category: Annotated[ - CategoryCreateWithinCategoryWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic subcategory", - "description": "Create a subcategory without nested subcategories", - "value": { - "name": "Ferrous metals", - "description": "Iron and its alloys", - }, - }, - "nested": { - "summary": "Category with subcategories", - "description": "Create a subcategory with nested subcategories", - "value": { - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - {"name": "Steel", "description": "Steel alloys"}, - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new subcategory under an existing category.""" - new_category: Category = await crud.create_category( - db=session, - category=category, - supercategory_id=category_id, - ) - - return new_category - - -@category_router.delete( - "/{category_id}/subcategories/{subcategory_id}", - summary="Delete category", - status_code=204, -) -async def delete_subcategory(category_id: PositiveInt, subcategory_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a subcategory by ID, including its subcategories.""" - # Validate existence of subcategory - await get_nested_model_by_id(session, Category, category_id, Category, subcategory_id, "supercategory_id") - - # Delete subcategory - await crud.delete_category(session, subcategory_id) - - -### Taxonomy routers ### -taxonomy_router = APIRouter(prefix="/taxonomies", tags=["taxonomies"]) - - -@taxonomy_router.post( - "", - response_model=TaxonomyRead, - summary="Create a new taxonomy", - status_code=201, -) -async def create_taxonomy( - taxonomy: Annotated[ - TaxonomyCreate | TaxonomyCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic taxonomy", - "description": "Create a taxonomy without categories", - "value": { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domains": ["materials"], - "source": "DOI:10.2345/12345", - }, - }, - "nested": { - "summary": "Taxonomy with categories", - "description": "Create a taxonomy with initial category tree", - "value": { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domains": ["materials"], - "source": "DOI:10.2345/12345", - "categories": [ - { - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [{"name": "Ferrous metals", "description": "Iron and its alloys"}], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Taxonomy: - """Create a new taxonomy, optionally with categories.""" - return await crud.create_taxonomy(session, taxonomy) - - -@taxonomy_router.patch("/{taxonomy_id}", response_model=TaxonomyRead, summary="Update taxonomy") -async def update_taxonomy( - taxonomy_id: PositiveInt, - taxonomy: Annotated[ - TaxonomyUpdate, - Body( - openapi_examples={ - "simple": { - "summary": "Update basic info", - "value": {"name": "Updated Materials Taxonomy", "description": "Updated taxonomy for materials"}, - }, - "advanced": { - "summary": "Update domain and source", - "value": {"domain": "materials", "source": "https://new-source.com/taxonomy"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Taxonomy: - """Update an existing taxonomy.""" - return await crud.update_taxonomy(session, taxonomy_id, taxonomy) - - -@taxonomy_router.delete( - "/{taxonomy_id}", - summary="Delete taxonomy, including categories", - status_code=204, -) -async def delete_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a taxonomy by ID, including its categories.""" - await crud.delete_taxonomy(session, taxonomy_id) - - -## Taxonomy Category routers ## -@taxonomy_router.post( - "/{taxonomy_id}/categories", - response_model=CategoryRead, - summary="Create a new category in a taxonomy", - status_code=201, -) -async def create_category_in_taxonomy( - taxonomy_id: PositiveInt, - category: Annotated[ - CategoryCreateWithinTaxonomyWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic category", - "value": {"name": "Metals", "description": "All kinds of metals"}, - }, - "with_subcategories": { - "summary": "Category with subcategories", - "value": { - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [{"name": "Steel", "description": "Steel materials"}], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new category in a taxonomy, optionally with subcategories.""" - new_category: Category = await crud.create_category( - db=session, - category=category, - taxonomy_id=taxonomy_id, - ) - - return new_category - - -@taxonomy_router.delete( - "/{taxonomy_id}/categories/{category_id}", - summary="Delete category in a taxonomy", - status_code=204, -) -async def delete_category_in_taxonomy( - taxonomy_id: PositiveInt, category_id: PositiveInt, session: AsyncSessionDep -) -> None: - """Delete a category by ID, including its subcategories.""" - # Validate existence of taxonomy and category - await get_nested_model_by_id(session, Taxonomy, taxonomy_id, Category, category_id, "taxonomy_id") - - # Delete category - await crud.delete_category(session, category_id) - - -### Material routers ### - -material_router = APIRouter(prefix="/materials", tags=["materials"]) - - -## POST routers ## -@material_router.post( - "", - response_model=MaterialRead, - summary="Create a new material, optionally with category assignments", - status_code=201, -) -async def create_material( - material: Annotated[ - MaterialCreate | MaterialCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic material", - "description": "Create a material without categories", - "value": { - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "source": "EN 10025-2", - "is_crm": False, - }, - }, - "with_categories": { - "summary": "Material with categories", - "description": "Create a material with category assignments", - "value": { - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "source": "EN 10025-2", - "is_crm": False, - "category_ids": [1, 2], # e.g., Metals, Ferrous Metals - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Material: - """Create a new material, optionally with category assignments.""" - return await crud.create_material(session, material) - - -## PATCH routers ## -@material_router.patch("/{material_id}", response_model=MaterialRead, summary="Update material") -async def update_material( - material_id: PositiveInt, - material: Annotated[ - MaterialUpdate, - Body( - openapi_examples={ - "simple": { - "summary": "Update basic info", - "value": {"name": "Carbon Steel", "description": "Updated description for steel"}, - }, - "properties": { - "summary": "Update properties", - "value": {"density_kg_m3": 7870, "source": "Updated standard", "is_crm": True}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Material: - """Update an existing material.""" - return await crud.update_material(session, material_id, material) - - -## DELETE routers ## -@material_router.delete( - "/{material_id}", - responses={ - 204: { - "description": "Successfully deleted material", - }, - 404: {"description": "Material not found"}, - }, -) -async def delete_material(material_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a material.""" - await crud.delete_material(session, material_id) - - -## Material Category routers ## -@material_router.post( - "/{material_id}/categories", - response_model=list[CategoryRead], - summary="Add multiple categories to the material", - status_code=201, -) -async def add_categories_to_material( - material_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to assign to the material", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> Sequence[Category]: - """Add multiple categories to the material.""" - return await crud.add_categories_to_material(session, material_id, category_ids) - - -@material_router.post( - "/{material_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Add a category to the material.", - status_code=201, -) -async def add_category_to_material( - material_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path(description="ID of category to add to the material"), - ], - session: AsyncSessionDep, -) -> Category: - """Add a category to the material.""" - return await crud.add_category_to_material(session, material_id, category_id) - - -@material_router.delete( - "/{material_id}/categories", - status_code=204, - summary="Remove multiple categories from the material", -) -async def remove_categories_from_material_bulk( - material_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to remove from the material", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple categories from the material.""" - await crud.remove_categories_from_material(session, material_id, category_ids) - - -@material_router.delete( - "/{material_id}/categories/{category_id}", - status_code=204, - summary="Remove a category from the material", -) -async def remove_category_from_material( - material_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path( - description="ID of category to remove from the material", - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove a category from the material.""" - return await crud.remove_categories_from_material(session, material_id, category_id) - - -## Material Storage routers ## -add_storage_routes( - router=material_router, - parent_api_model_name=Material.get_api_model_name(), - files_crud=crud.material_files_crud, - images_crud=crud.material_images_crud, - include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - modify_auth_dep=current_active_superuser, # Only superusers can edit Material files -) - -### ProductType routers ### - -product_type_router = APIRouter(prefix="/product-types", tags=["product-types"]) - +@router.post("/cache/clear/{namespace}", summary="Clear cache by namespace") +async def clear_cache_by_namespace( + namespace: Annotated[CacheNamespace, Path(description="Cache namespace to clear")], +) -> dict[str, str]: + """Clear cached responses for a specific namespace.""" + await clear_cache_namespace(namespace) + return {"status": "cleared", "namespace": namespace} -## Basic CRUD routers ## -@product_type_router.post("", response_model=ProductTypeRead, summary="Create product type", status_code=201) -async def create_product_type( - product_type: Annotated[ - ProductTypeCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic product type", - "description": "Create a product type without categories", - "value": {"name": "Smartphone", "description": "Mobile phone with smart capabilities"}, - }, - "with_categories": { - "summary": "Product type with categories", - "description": "Create a product type and assign it to categories", - "value": { - "name": "Smartphone", - "description": "Mobile phone with smart capabilities", - "category_ids": [1, 2], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> ProductType: - """Create a new product type, optionally assigning it to categories.""" - return await crud.create_product_type(session, product_type) - - -@product_type_router.patch("/{product_type_id}", response_model=ProductTypeRead, summary="Update product type") -async def update_product_type( - product_type_id: PositiveInt, - product_type: Annotated[ - ProductTypeUpdate, - Body( - openapi_examples={ - "name": {"summary": "Update name", "value": {"name": "Mobile Phone"}}, - "description": { - "summary": "Update description", - "value": {"description": "Updated description for mobile phones"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> ProductType: - """Update an existing product type.""" - return await crud.update_product_type(session, product_type_id, product_type) - - -## DELETE routers ## -@product_type_router.delete( - "/{product_type_id}", - responses={ - 204: { - "description": "Successfully deleted product_type", - }, - 404: {"description": "ProductType not found"}, - }, - status_code=204, -) -async def delete_product_type(product_type_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a product type.""" - await crud.delete_product_type(session, product_type_id) - - -## ProductType Category routers ## -# TODO: deduplicate category routers for materials and product types and move to the common.router_factories module - - -@product_type_router.post( - "/{product_type_id}/categories", - response_model=list[CategoryRead], - summary="Add multiple categories to the product type", - status_code=201, -) -async def add_categories_to_product_type_bulk( - product_type_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to assign to the product type", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> Sequence[Category]: - """Add multiple categories to the product type.""" - return await crud.add_categories_to_product_type(session, product_type_id, category_ids) - - -@product_type_router.post( - "/{product_type_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Add an existing category to the product type", - status_code=201, -) -async def add_categories_to_product_type( - product_type_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path(description="ID of category to add to the product type"), - ], - session: AsyncSessionDep, -) -> Category: - """Add an existing category to the product type.""" - return await crud.add_category_to_product_type(session, product_type_id, category_id) - - -@product_type_router.delete( - "/{product_type_id}/categories", - status_code=204, - summary="Remove multiple categories from the product type", -) -async def remove_categories_from_product_type_bulk( - product_type_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to remove from the product type", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple categories from the product type.""" - await crud.remove_categories_from_product_type(session, product_type_id, category_ids) - - -@product_type_router.delete( - "/{product_type_id}/categories/{category_id}", - status_code=204, - summary="Remove a category from the product type", -) -async def remove_categories_from_product_type( - product_type_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path( - description="ID of category to remove from the product type", - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove a category from the product type.""" - return await crud.remove_categories_from_product_type(session, product_type_id, category_id) - - -## ProductType Storage routers ## -add_storage_routes( - router=product_type_router, - parent_api_model_name=ProductType.get_api_model_name(), - files_crud=crud.product_type_files, - images_crud=crud.product_type_images, - include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - modify_auth_dep=current_active_superuser, # Only superusers can edit ProductType files -) -### Router inclusion ### router.include_router(category_router) router.include_router(taxonomy_router) router.include_router(material_router) diff --git a/backend/app/api/background_data/routers/admin_categories.py b/backend/app/api/background_data/routers/admin_categories.py new file mode 100644 index 00000000..29e32d7d --- /dev/null +++ b/backend/app/api/background_data/routers/admin_categories.py @@ -0,0 +1,72 @@ +"""Admin category routers for background data.""" + +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import PositiveInt +from sqlalchemy import select + +from app.api.background_data.crud.categories import create_category as create_category_record +from app.api.background_data.crud.categories import delete_category as delete_category_record +from app.api.background_data.crud.categories import update_category as update_category_record +from app.api.background_data.models import Category +from app.api.background_data.schemas import ( + CategoryCreateWithinCategoryWithSubCategories, + CategoryCreateWithSubCategories, + CategoryRead, + CategoryUpdate, +) +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.post("", response_model=CategoryRead, summary="Create a new category", status_code=201) +async def create_category( + category: CategoryCreateWithSubCategories, + session: AsyncSessionDep, +) -> Category: + """Create a new category, optionally with subcategories.""" + return await create_category_record(session, category) + + +@router.patch("/{category_id}", response_model=CategoryRead, summary="Update category") +async def update_category( + category_id: PositiveInt, + category: CategoryUpdate, + session: AsyncSessionDep, +) -> Category: + """Update an existing category.""" + return await update_category_record(session, category_id, category) + + +@router.delete("/{category_id}", summary="Delete category", status_code=204) +async def delete_category(category_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a category by ID, including its subcategories.""" + await delete_category_record(session, category_id) + + +@router.post("/{category_id}/subcategories", response_model=CategoryRead, status_code=201) +async def create_subcategory( + category_id: PositiveInt, + category: CategoryCreateWithinCategoryWithSubCategories, + session: AsyncSessionDep, +) -> Category: + """Create a new subcategory under an existing category.""" + return await create_category_record(db=session, category=category, supercategory_id=category_id) + + +@router.delete("/{category_id}/subcategories/{subcategory_id}", summary="Delete category", status_code=204) +async def delete_subcategory(category_id: PositiveInt, subcategory_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a subcategory by ID, including its subcategories.""" + subcategory = await require_model(session, Category, subcategory_id) + if subcategory.supercategory_id != category_id: + raise DependentModelOwnershipError(Category, subcategory_id, Category, category_id) + exists = await session.scalar( + select(Category.id).where(Category.id == subcategory_id, Category.supercategory_id == category_id) + ) + if exists is None: + raise DependentModelOwnershipError(Category, subcategory_id, Category, category_id) + await delete_category_record(session, subcategory_id) diff --git a/backend/app/api/background_data/routers/admin_materials.py b/backend/app/api/background_data/routers/admin_materials.py new file mode 100644 index 00000000..d7a236e2 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_materials.py @@ -0,0 +1,280 @@ +"""Admin material routers for background data.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Annotated + +from fastapi import APIRouter, Body, Form, Path, Security, UploadFile +from fastapi import File as FastAPIFile +from pydantic import UUID4, BeforeValidator, PositiveInt + +from app.api.auth.dependencies import current_active_superuser +from app.api.background_data.crud.materials import ( + add_categories_to_material as add_material_categories, +) +from app.api.background_data.crud.materials import ( + add_category_to_material as add_material_category, +) +from app.api.background_data.crud.materials import ( + create_material as create_material_record, +) +from app.api.background_data.crud.materials import ( + create_material_file, + create_material_image, +) +from app.api.background_data.crud.materials import ( + delete_material as delete_material_record, +) +from app.api.background_data.crud.materials import ( + delete_material_file as delete_material_file_record, +) +from app.api.background_data.crud.materials import ( + delete_material_image as delete_material_image_record, +) +from app.api.background_data.crud.materials import ( + remove_categories_from_material as remove_material_categories, +) +from app.api.background_data.crud.materials import ( + update_material as update_material_record, +) +from app.api.background_data.examples import CATEGORY_IDS_OPENAPI_EXAMPLES +from app.api.background_data.models import Category, Material +from app.api.background_data.schemas import CategoryRead, MaterialCreateWithCategories, MaterialRead, MaterialUpdate +from app.api.common.openapi_examples import IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.schemas import ( + FileCreate, + FileReadWithinParent, + ImageCreateFromForm, + ImageReadWithinParent, + empty_str_to_none, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +router = APIRouter(prefix="/materials", tags=["materials"]) + + +def _material_file_create(material_id: int, *, file: UploadFile, description: str | None) -> FileCreate: + """Build the canonical material file create payload.""" + return FileCreate( + file=file, + description=description, + parent_id=material_id, + parent_type=MediaParentType.MATERIAL, + ) + + +def _material_image_create( + material_id: int, + *, + file: UploadFile, + description: str | None, + image_metadata: str | None, +) -> ImageCreateFromForm: + """Build the canonical material image create payload.""" + return ImageCreateFromForm.model_validate( + { + "file": file, + "description": description, + "image_metadata": json.loads(image_metadata) if image_metadata is not None else None, + "parent_id": material_id, + "parent_type": MediaParentType.MATERIAL, + } + ) + + +@router.post( + "", + response_model=MaterialRead, + summary="Create material", + status_code=201, +) +async def create_material( + session: AsyncSessionDep, + payload: MaterialCreateWithCategories, +) -> Material: + """Create a material.""" + return await create_material_record(session, payload) + + +@router.patch( + "/{material_id}", + response_model=MaterialRead, + summary="Update material", +) +async def update_material( + material_id: Annotated[PositiveInt, Path(description="Material ID")], + session: AsyncSessionDep, + payload: MaterialUpdate, +) -> Material: + """Update a material.""" + return await update_material_record(session, material_id, payload) + + +@router.delete( + "/{material_id}", + summary="Delete material", + status_code=204, +) +async def delete_material( + material_id: Annotated[PositiveInt, Path(description="Material ID")], + session: AsyncSessionDep, +) -> None: + """Delete a material.""" + await delete_material_record(session, material_id) + + +@router.post( + "/{material_id}/categories", + response_model=list[CategoryRead], + summary="Add multiple categories to the material", + status_code=201, +) +async def add_categories_to_material( + material_id: Annotated[int, Path(description="Material ID", gt=0)], + session: AsyncSessionDep, + category_ids: Annotated[ + set[int], + Body( + description="Category IDs to assign to the material", + openapi_examples=CATEGORY_IDS_OPENAPI_EXAMPLES, + ), + ], +) -> Sequence[Category]: + """Add multiple categories to a material.""" + return await add_material_categories(session, material_id, set(category_ids)) + + +@router.post( + "/{material_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Add a category to the material", + status_code=201, +) +async def add_category_to_material( + material_id: Annotated[int, Path(description="Material ID", gt=0)], + category_id: Annotated[int, Path(description="ID of category to add to the material", gt=0)], + session: AsyncSessionDep, +) -> Category: + """Add a single category to a material.""" + return await add_material_category(session, material_id, category_id) + + +@router.delete( + "/{material_id}/categories", + summary="Remove multiple categories from the material", + status_code=204, +) +async def remove_categories_from_material( + material_id: Annotated[int, Path(description="Material ID", gt=0)], + session: AsyncSessionDep, + category_ids: Annotated[ + set[int], + Body( + description="Category IDs to remove from the material", + openapi_examples=CATEGORY_IDS_OPENAPI_EXAMPLES, + ), + ], +) -> None: + """Remove multiple categories from a material.""" + await remove_material_categories(session, material_id, set(category_ids)) + + +@router.delete( + "/{material_id}/categories/{category_id}", + summary="Remove a category from the material", + status_code=204, +) +async def remove_category_from_material( + material_id: Annotated[int, Path(description="Material ID", gt=0)], + category_id: Annotated[int, Path(description="ID of category to remove from the material", gt=0)], + session: AsyncSessionDep, +) -> None: + """Remove a single category from a material.""" + await remove_material_categories(session, material_id, category_id) + + +@router.post( + "/{material_id}/files", + response_model=FileReadWithinParent, + status_code=201, + dependencies=[Security(current_active_superuser)], + summary="Add File to Material", +) +async def upload_material_file( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + session: AsyncSessionDep, + file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], + description: Annotated[str | None, Form()] = None, +) -> FileReadWithinParent: + """Upload a new file for the material.""" + item = await create_material_file( + session, + material_id, + _material_file_create(material_id, file=file, description=description), + ) + return FileReadWithinParent.model_validate(item) + + +@router.delete( + "/{material_id}/files/{file_id}", + dependencies=[Security(current_active_superuser)], + summary="Remove File from Material", + status_code=204, +) +async def delete_material_file( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> None: + """Remove a file from the material.""" + await delete_material_file_record(session, material_id, file_id) + + +@router.post( + "/{material_id}/images", + response_model=ImageReadWithinParent, + status_code=201, + dependencies=[Security(current_active_superuser)], + summary="Add Image to Material", +) +async def upload_material_image( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + session: AsyncSessionDep, + file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], + description: Annotated[str | None, Form()] = None, + image_metadata: Annotated[ + str | None, + Form( + description="Image metadata in JSON string format", + openapi_examples=IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES, + ), + BeforeValidator(empty_str_to_none), + ] = None, +) -> ImageReadWithinParent: + """Upload a new image for the material.""" + item = await create_material_image( + session, + material_id, + _material_image_create(material_id, file=file, description=description, image_metadata=image_metadata), + ) + return ImageReadWithinParent.model_validate(item) + + +@router.delete( + "/{material_id}/images/{image_id}", + dependencies=[Security(current_active_superuser)], + summary="Remove Image from Material", + status_code=204, +) +async def delete_material_image( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> None: + """Remove an image from the material.""" + await delete_material_image_record(session, material_id, image_id) diff --git a/backend/app/api/background_data/routers/admin_product_types.py b/backend/app/api/background_data/routers/admin_product_types.py new file mode 100644 index 00000000..5de41100 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_product_types.py @@ -0,0 +1,295 @@ +"""Admin product-type routers for background data.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Annotated + +from fastapi import APIRouter, Body, Form, Path, Security, UploadFile +from fastapi import File as FastAPIFile +from pydantic import UUID4, BeforeValidator, PositiveInt + +from app.api.auth.dependencies import current_active_superuser +from app.api.background_data.crud.product_types import ( + add_categories_to_product_type as add_product_type_categories, +) +from app.api.background_data.crud.product_types import ( + add_category_to_product_type as add_product_type_category, +) +from app.api.background_data.crud.product_types import ( + create_product_type as create_product_type_record, +) +from app.api.background_data.crud.product_types import ( + create_product_type_file, + create_product_type_image, +) +from app.api.background_data.crud.product_types import ( + delete_product_type as delete_product_type_record, +) +from app.api.background_data.crud.product_types import ( + delete_product_type_file as delete_product_type_file_record, +) +from app.api.background_data.crud.product_types import ( + delete_product_type_image as delete_product_type_image_record, +) +from app.api.background_data.crud.product_types import ( + remove_categories_from_product_type as remove_product_type_categories, +) +from app.api.background_data.crud.product_types import ( + update_product_type as update_product_type_record, +) +from app.api.background_data.examples import CATEGORY_IDS_OPENAPI_EXAMPLES +from app.api.background_data.models import Category, ProductType +from app.api.background_data.schemas import ( + CategoryRead, + ProductTypeCreateWithCategories, + ProductTypeRead, + ProductTypeUpdate, +) +from app.api.common.openapi_examples import IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.schemas import ( + FileCreate, + FileReadWithinParent, + ImageCreateFromForm, + ImageReadWithinParent, + empty_str_to_none, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +router = APIRouter(prefix="/product-types", tags=["product-types"]) + + +def _product_type_file_create( + product_type_id: int, + *, + file: UploadFile, + description: str | None, +) -> FileCreate: + """Build the canonical product-type file create payload.""" + return FileCreate( + file=file, + description=description, + parent_id=product_type_id, + parent_type=MediaParentType.PRODUCT_TYPE, + ) + + +def _product_type_image_create( + product_type_id: int, + *, + file: UploadFile, + description: str | None, + image_metadata: str | None, +) -> ImageCreateFromForm: + """Build the canonical product-type image create payload.""" + return ImageCreateFromForm.model_validate( + { + "file": file, + "description": description, + "image_metadata": json.loads(image_metadata) if image_metadata is not None else None, + "parent_id": product_type_id, + "parent_type": MediaParentType.PRODUCT_TYPE, + } + ) + + +@router.post( + "", + response_model=ProductTypeRead, + summary="Create product type", + status_code=201, +) +async def create_product_type( + session: AsyncSessionDep, + payload: ProductTypeCreateWithCategories, +) -> ProductType: + """Create a product type.""" + return await create_product_type_record(session, payload) + + +@router.patch( + "/{product_type_id}", + response_model=ProductTypeRead, + summary="Update product type", +) +async def update_product_type( + product_type_id: Annotated[PositiveInt, Path(description="Product Type ID")], + session: AsyncSessionDep, + payload: ProductTypeUpdate, +) -> ProductType: + """Update a product type.""" + return await update_product_type_record(session, product_type_id, payload) + + +@router.delete( + "/{product_type_id}", + summary="Delete product type", + status_code=204, +) +async def delete_product_type( + product_type_id: Annotated[PositiveInt, Path(description="Product Type ID")], + session: AsyncSessionDep, +) -> None: + """Delete a product type.""" + await delete_product_type_record(session, product_type_id) + + +@router.post( + "/{product_type_id}/categories", + response_model=list[CategoryRead], + summary="Add multiple categories to the product type", + status_code=201, +) +async def add_categories_to_product_type( + product_type_id: Annotated[int, Path(description="Product Type ID", gt=0)], + session: AsyncSessionDep, + category_ids: Annotated[ + set[int], + Body( + description="Category IDs to assign to the product type", + openapi_examples=CATEGORY_IDS_OPENAPI_EXAMPLES, + ), + ], +) -> Sequence[Category]: + """Add multiple categories to a product type.""" + return await add_product_type_categories(session, product_type_id, set(category_ids)) + + +@router.post( + "/{product_type_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Add a category to the product type", + status_code=201, +) +async def add_category_to_product_type( + product_type_id: Annotated[int, Path(description="Product Type ID", gt=0)], + category_id: Annotated[int, Path(description="ID of category to add to the product type", gt=0)], + session: AsyncSessionDep, +) -> Category: + """Add a single category to a product type.""" + return await add_product_type_category(session, product_type_id, category_id) + + +@router.delete( + "/{product_type_id}/categories", + summary="Remove multiple categories from the product type", + status_code=204, +) +async def remove_categories_from_product_type( + product_type_id: Annotated[int, Path(description="Product Type ID", gt=0)], + session: AsyncSessionDep, + category_ids: Annotated[ + set[int], + Body( + description="Category IDs to remove from the product type", + openapi_examples=CATEGORY_IDS_OPENAPI_EXAMPLES, + ), + ], +) -> None: + """Remove multiple categories from a product type.""" + await remove_product_type_categories(session, product_type_id, set(category_ids)) + + +@router.delete( + "/{product_type_id}/categories/{category_id}", + summary="Remove a category from the product type", + status_code=204, +) +async def remove_category_from_product_type( + product_type_id: Annotated[int, Path(description="Product Type ID", gt=0)], + category_id: Annotated[int, Path(description="ID of category to remove from the product type", gt=0)], + session: AsyncSessionDep, +) -> None: + """Remove a single category from a product type.""" + await remove_product_type_categories(session, product_type_id, category_id) + + +@router.post( + "/{product_type_id}/files", + response_model=FileReadWithinParent, + status_code=201, + dependencies=[Security(current_active_superuser)], + summary="Add File to Product Type", +) +async def upload_product_type_file( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + session: AsyncSessionDep, + file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], + description: Annotated[str | None, Form()] = None, +) -> FileReadWithinParent: + """Upload a new file for the product type.""" + item = await create_product_type_file( + session, + product_type_id, + _product_type_file_create(product_type_id, file=file, description=description), + ) + return FileReadWithinParent.model_validate(item) + + +@router.delete( + "/{product_type_id}/files/{file_id}", + dependencies=[Security(current_active_superuser)], + summary="Remove File from Product Type", + status_code=204, +) +async def delete_product_type_file( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> None: + """Remove a file from the product type.""" + await delete_product_type_file_record(session, product_type_id, file_id) + + +@router.post( + "/{product_type_id}/images", + response_model=ImageReadWithinParent, + status_code=201, + dependencies=[Security(current_active_superuser)], + summary="Add Image to Product Type", +) +async def upload_product_type_image( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + session: AsyncSessionDep, + file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], + description: Annotated[str | None, Form()] = None, + image_metadata: Annotated[ + str | None, + Form( + description="Image metadata in JSON string format", + openapi_examples=IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES, + ), + BeforeValidator(empty_str_to_none), + ] = None, +) -> ImageReadWithinParent: + """Upload a new image for the product type.""" + item = await create_product_type_image( + session, + product_type_id, + _product_type_image_create( + product_type_id, + file=file, + description=description, + image_metadata=image_metadata, + ), + ) + return ImageReadWithinParent.model_validate(item) + + +@router.delete( + "/{product_type_id}/images/{image_id}", + dependencies=[Security(current_active_superuser)], + summary="Remove Image from Product Type", + status_code=204, +) +async def delete_product_type_image( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> None: + """Remove an image from the product type.""" + await delete_product_type_image_record(session, product_type_id, image_id) diff --git a/backend/app/api/background_data/routers/admin_taxonomies.py b/backend/app/api/background_data/routers/admin_taxonomies.py new file mode 100644 index 00000000..db854b91 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_taxonomies.py @@ -0,0 +1,83 @@ +"""Admin taxonomy routers for background data.""" + +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import PositiveInt +from sqlalchemy import select + +from app.api.background_data.crud.categories import create_category as create_category_record +from app.api.background_data.crud.categories import delete_category as delete_category_record +from app.api.background_data.crud.taxonomies import create_taxonomy as create_taxonomy_record +from app.api.background_data.crud.taxonomies import delete_taxonomy as delete_taxonomy_record +from app.api.background_data.crud.taxonomies import update_taxonomy as update_taxonomy_record +from app.api.background_data.models import Category, Taxonomy +from app.api.background_data.schemas import ( + CategoryCreateWithinTaxonomyWithSubCategories, + CategoryRead, + TaxonomyCreate, + TaxonomyCreateWithCategories, + TaxonomyRead, + TaxonomyUpdate, +) +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep + +router = APIRouter(prefix="/taxonomies", tags=["taxonomies"]) + + +@router.post("", response_model=TaxonomyRead, summary="Create a new taxonomy", status_code=201) +async def create_taxonomy( + taxonomy: TaxonomyCreate | TaxonomyCreateWithCategories, + session: AsyncSessionDep, +) -> Taxonomy: + """Create a new taxonomy, optionally with categories.""" + return await create_taxonomy_record(session, taxonomy) + + +@router.patch("/{taxonomy_id}", response_model=TaxonomyRead, summary="Update taxonomy") +async def update_taxonomy( + taxonomy_id: PositiveInt, + taxonomy: TaxonomyUpdate, + session: AsyncSessionDep, +) -> Taxonomy: + """Update an existing taxonomy.""" + return await update_taxonomy_record(session, taxonomy_id, taxonomy) + + +@router.delete("/{taxonomy_id}", summary="Delete taxonomy, including categories", status_code=204) +async def delete_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a taxonomy by ID, including its categories.""" + await delete_taxonomy_record(session, taxonomy_id) + + +@router.post( + "/{taxonomy_id}/categories", + response_model=CategoryRead, + summary="Create a new category in a taxonomy", + status_code=201, +) +async def create_category_in_taxonomy( + taxonomy_id: PositiveInt, + category: CategoryCreateWithinTaxonomyWithSubCategories, + session: AsyncSessionDep, +) -> Category: + """Create a new category in a taxonomy, optionally with subcategories.""" + return await create_category_record(db=session, category=category, taxonomy_id=taxonomy_id) + + +@router.delete("/{taxonomy_id}/categories/{category_id}", summary="Delete category in a taxonomy", status_code=204) +async def delete_category_in_taxonomy( + taxonomy_id: PositiveInt, category_id: PositiveInt, session: AsyncSessionDep +) -> None: + """Delete a category by ID, including its subcategories.""" + category = await require_model(session, Category, category_id) + if category.taxonomy_id != taxonomy_id: + raise DependentModelOwnershipError(Category, category_id, Taxonomy, taxonomy_id) + exists = await session.scalar( + select(Category.id).where(Category.id == category_id, Category.taxonomy_id == taxonomy_id) + ) + if exists is None: + raise DependentModelOwnershipError(Category, category_id, Taxonomy, taxonomy_id) + await delete_category_record(session, category_id) diff --git a/backend/app/api/background_data/routers/public.py b/backend/app/api/background_data/routers/public.py index eb0406be..896c0875 100644 --- a/backend/app/api/background_data/routers/public.py +++ b/backend/app/api/background_data/routers/public.py @@ -1,1058 +1,19 @@ -"""Admin routers for background data models.""" +"""Public background-data router composition.""" -from collections.abc import Sequence -from typing import Annotated +from fastapi import APIRouter -from fastapi import APIRouter, Path, Query -from pydantic import PositiveInt -from sqlmodel import select +from app.api.background_data.routers.public_categories import router as category_router +from app.api.background_data.routers.public_materials import router as material_router +from app.api.background_data.routers.public_product_types import router as product_type_router +from app.api.background_data.routers.public_support import RecursionDepthQueryParam +from app.api.background_data.routers.public_taxonomies import router as taxonomy_router +from app.api.background_data.routers.public_units import router as unit_router -from app.api.background_data import crud -from app.api.background_data.dependencies import ( - CategoryFilterDep, - CategoryFilterWithRelationshipsDep, - MaterialFilterWithRelationshipsDep, - ProductTypeFilterWithRelationshipsDep, - TaxonomyFilterDep, -) -from app.api.background_data.models import ( - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, -) -from app.api.background_data.schemas import ( - CategoryRead, - CategoryReadAsSubCategoryWithRecursiveSubCategories, - CategoryReadWithRecursiveSubCategories, - CategoryReadWithRelationshipsAndFlatSubCategories, - MaterialReadWithRelationships, - ProductTypeReadWithRelationships, - TaxonomyRead, -) -from app.api.common.crud.associations import get_linked_model_by_id, get_linked_models -from app.api.common.crud.base import get_model_by_id, get_models, get_nested_model_by_id -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import PublicAPIRouter -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes - -# TODO: Extract common logic and turn into router-factory functions. -# See FileStorageRouterFactory in common/router_factories.py for an example. - -# TODO: Improve HTTP method choices for linked resources -# (e.g., POST vs PATCH for adding categories to material, or DELETE vs. PATCH for removing categories) - -# TODO: Improve HTTP status codes (e.g., 201 for creation) and error handling. -# TODO: Consider supporting comma-separated list of relationships to include, -# TODO: Add paging and sorting to filters - - -# Initialize API router router = APIRouter() - -### Category routers ### -category_router = PublicAPIRouter(prefix="/categories", tags=["categories"]) - - -## Utilities ## -def convert_subcategories_to_read_model( - subcategories: list[Category], max_depth: int = 1, current_depth: int = 0 -) -> list[CategoryReadAsSubCategoryWithRecursiveSubCategories]: - """Convert subcategories to read model recursively.""" - if current_depth >= max_depth: - return [] - - return [ - CategoryReadAsSubCategoryWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth, current_depth + 1 - ) - }, - ) - for category in subcategories - ] - - -RecursionDepthQueryParam = Annotated[int, Query(ge=1, le=5, description="Maximum recursion depth")] - - -## GET routers ## -@category_router.get( - "", - response_model=list[CategoryReadWithRelationshipsAndFlatSubCategories], - summary="Get all categories with optional filtering and relationships", - responses={ - 200: { - "description": "List of categories", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic categories", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All metals", - "materials": [], - "product_types": [], - "subcategories": [], - } - ], - }, - "with_relationships": { - "summary": "With relationships", - "value": [ - { - "id": 1, - "name": "Metals", - "materials": [{"id": 1, "name": "Steel"}], - "product_types": [{"id": 1, "name": "Metal Chair"}], - "subcategories": [{"id": 2, "name": "Ferrous Metals"}], - } - ], - }, - } - } - }, - } - }, -) -async def get_categories( - session: AsyncSessionDep, - category_filter: CategoryFilterWithRelationshipsDep, - # TODO: Create include Query param factory - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Sequence[Category]: - """Get all categories with specified relationships.""" - return await get_models(session, Category, include_relationships=include, model_filter=category_filter) - - -@category_router.get( - "/tree", - response_model=list[CategoryReadWithRecursiveSubCategories], - summary="Get categories tree", - responses={ - 200: { - "description": "Category tree with subcategories", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple category tree", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [], - }, - { - "id": 2, - "name": "Plastics", - "description": "All kinds of plastics", - "subcategories": [], - }, - ], - }, - "nested_tree": { - "summary": "Nested category tree", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - } - ], - }, - { - "id": 4, - "name": "Plastics", - "description": "All kinds of plastics", - "subcategories": [ - { - "id": 5, - "name": "Thermoplastics", - "description": "Plastics that can be melted and reshaped", - "subcategories": [], - } - ], - }, - ], - }, - } - } - }, - } - }, -) -async def get_categories_tree( - session: AsyncSessionDep, - category_filter: CategoryFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get all base categories and their subcategories in a tree structure.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@category_router.get( - "/{category_id}", - response_model=CategoryReadWithRelationshipsAndFlatSubCategories, - responses={ - 200: { - "description": "Category found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic category", - "value": { - "id": 1, - "name": "Metals", - "materials": [], - "product_types": [], - "subcategories": [], - }, - }, - "with_relationships": { - "summary": "With relationships", - "value": { - "id": 1, - "name": "Metals", - "materials": [{"id": 1, "name": "Steel"}], - "product_types": [{"id": 1, "name": "Metal Chair"}], - "subcategories": [{"id": 2, "name": "Ferrous Metals"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Category not found", - "content": {"application/json": {"example": {"detail": "Category with id 999 not found"}}}, - }, - }, -) -async def get_category( - session: AsyncSessionDep, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get category by ID with specified relationships.""" - return await get_model_by_id(session, Category, category_id, include_relationships=include) - - -## Subcategory routers ## -@category_router.get( - "{category_id}/subcategories", - response_model=list[CategoryReadWithRelationshipsAndFlatSubCategories], - summary="Get category subcategories with optional filtering and relationships", -) -async def get_subcategories( - category_id: Annotated[PositiveInt, Path(description="Category ID")], - category_filter: CategoryFilterDep, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Sequence[Category]: - """Get all categories with specified relationships.""" - # Validate existence of category - await get_model_by_id(session, Category, category_id) - - # Get subcategories - statement = select(Category).where(Category.supercategory_id == category_id) - return await get_models( - session, Category, include_relationships=include, model_filter=category_filter, statement=statement - ) - - -@category_router.get( - "/{category_id}/subcategories/tree", - summary="Get category subtree", - response_model=list[CategoryReadWithRecursiveSubCategories], - responses={ - 200: { - "description": "Category tree with subcategories", - "content": { - "application/json": { - "examples": { - "stub_tree": { - "summary": "Category stub tree", - "value": {}, - }, - "nested_tree": { - "summary": "Nested category tree", - "value": { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - }, - }, - } - }, - }, - }, - 404: { - "description": "Category not found", - "content": {"application/json": {"example": {"detail": "Category with id 99 not found"}}}, - }, - }, -) -async def get_category_subtree( - category_id: PositiveInt, - category_filter: CategoryFilterDep, - session: AsyncSessionDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get a category subcategories in a tree structure, up to a specified depth.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth=recursion_depth, supercategory_id=category_id, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@category_router.get( - "/{category_id}/subcategories/{subcategory_id}", - response_model=CategoryReadWithRelationshipsAndFlatSubCategories, - summary="Get subcategory by ID", -) -async def get_subcategory( - category_id: PositiveInt, - subcategory_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get subcategory by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Category, category_id, Category, subcategory_id, "supercategory_id", include_relationships=include - ) - - -### Taxonomy routers ### -taxonomy_router = PublicAPIRouter(prefix="/taxonomies", tags=["taxonomies"]) - - -## GET routers ## -@taxonomy_router.get( - "", - response_model=list[TaxonomyRead], - summary="Get all taxonomies with optional filtering and base categories", - responses={ - 200: { - "description": "List of taxonomies", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic taxonomies", - "value": [ - { - "id": 1, - "name": "Materials", - "description": "Materials taxonomy", - "domains": ["materials"], - "categories": [], - } - ], - }, - "with_categories": { - "summary": "With categories", - "value": [{"id": 1, "name": "Materials", "categories": [{"id": 1, "name": "Metals"}]}], - }, - } - } - }, - } - }, -) -async def get_taxonomies( - taxonomy_filter: TaxonomyFilterDep, - session: AsyncSessionDep, - *, - include_base_categories: Annotated[ - bool, - Query(description="Whether to include base categories"), - ] = False, -) -> Sequence[Taxonomy]: - """Get all taxonomies with specified relationships.""" - return await crud.get_taxonomies( - session, taxonomy_filter=taxonomy_filter, include_base_categories=include_base_categories - ) - - -@taxonomy_router.get( - "/{taxonomy_id}", - response_model=TaxonomyRead, - responses={ - 200: { - "description": "Taxonomy found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic taxonomy", - "value": {"id": 1, "name": "Materials", "categories": []}, - }, - "with_categories": { - "summary": "With categories", - "value": {"id": 1, "name": "Materials", "categories": [{"id": 1, "name": "Metals"}]}, - }, - } - } - }, - }, - 404: { - "description": "Taxonomy not found", - "content": {"application/json": {"example": {"detail": "Taxonomy with id 999 not found"}}}, - }, - }, -) -async def get_taxonomy( - taxonomy_id: PositiveInt, - session: AsyncSessionDep, - *, - include_base_categories: Annotated[ - bool, - Query(description="Whether to include base categories"), - ] = False, -) -> Taxonomy: - """Get taxonomy by ID with base categories.""" - return await crud.get_taxonomy_by_id(session, taxonomy_id, include_base_categories=include_base_categories) - - -## Taxonomy Category routers ## -@taxonomy_router.get( - "/{taxonomy_id}/categories", - response_model=list[CategoryReadWithRecursiveSubCategories], - summary="Get the categories of a taxonomy", - responses={ - 200: { - "description": "Taxonomy with category tree", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple taxonomy", - "value": { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [], - }, - }, - "nested_tree": { - "summary": "Taxonomy with nested categories", - "value": { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - } - ], - }, - }, - }, - } - }, - }, - }, -) -async def get_taxonomy_category_tree( - taxonomy_id: PositiveInt, - session: AsyncSessionDep, - category_filter: CategoryFilterDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get a taxonomy with its category tree structure.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth, taxonomy_id=taxonomy_id, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@taxonomy_router.get( - "/{taxonomy_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get taxonomy category by ID", -) -async def get_taxonomy_category( - taxonomy_id: PositiveInt, - category_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get category by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Taxonomy, taxonomy_id, Category, category_id, "taxonomy_id", include_relationships=include - ) - - -### Material routers ### -material_router = PublicAPIRouter(prefix="/materials", tags=["materials"]) - - -## GET routers ## -@material_router.get( - "", - response_model=list[MaterialReadWithRelationships], - summary="Get all materials with optional relationships", - responses={ - 200: { - "description": "List of materials", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Materials without relationships", - "value": [ - { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "categories": [], - "product_links": [], - "images": [], - "files": [], - } - ], - }, - "with_categories": { - "summary": "Materials with categories", - "value": [ - { - "id": 1, - "name": "Steel", - "categories": [{"id": 1, "name": "Metals"}], - "product_links": [], - "images": [], - "files": [], - } - ], - }, - } - } - }, - } - }, -) -async def get_materials( - session: AsyncSessionDep, - material_filter: MaterialFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": {"categories"}}, - "all": {"value": ["categories", "files", "images", "product_links"]}, - }, - ), - ] = None, -) -> Sequence[Material]: - """Get all materials with specified relationships.""" - return await get_models(session, Material, include_relationships=include, model_filter=material_filter) - - -@material_router.get( - "/{material_id}", - response_model=MaterialReadWithRelationships, - responses={ - 200: { - "description": "Material found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic material", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - }, - }, - "with_categories": { - "summary": "With categories", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - "categories": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "super_category_id": None, - } - ], - }, - }, - "with_all": { - "summary": "All relationships", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - "categories": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "super_category_id": None, - } - ], - "images": [{"id": 1, "url": "/images/steel.jpg"}], - "files": [{"id": 1, "url": "/files/steel.csv"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Material not found", - "content": {"application/json": {"example": {"detail": "Material with id 999 not found"}}}, - }, - }, -) -async def get_material( - session: AsyncSessionDep, - material_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": ["categories"]}, - "all": {"value": ["categories", "images", "files"]}, - }, - ), - ] = None, -) -> Material: - """Get material by ID with specified relationships.""" - return await get_model_by_id(session, Material, model_id=material_id, include_relationships=include) - - -## Material Category routers ## -@material_router.get( - "/{material_id}/categories", - response_model=list[CategoryRead], - summary="View categories of material", -) -async def get_categories_for_material( - material_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - category_filter: CategoryFilterDep, -) -> Sequence[Category]: - """View categories of a material.""" - return await get_linked_models( - session, - Material, - material_id, - Category, - CategoryMaterialLink, - "material_id", - include_relationships=include, - model_filter=category_filter, - ) - - -@material_router.get( - "/{material_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get category by ID", -) -async def get_category_for_material( - material_id: PositiveInt, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - session: AsyncSessionDep, -) -> Category: - """Get a category by ID for a specific material.""" - return await get_linked_model_by_id( - session, - Material, - material_id, - Category, - category_id, - CategoryMaterialLink, - "material_id", - "category_id", - include=include, - ) - - -## Material Storage routers ## -add_storage_routes( - router=material_router, - parent_api_model_name=Material.get_api_model_name(), - files_crud=crud.material_files_crud, - images_crud=crud.material_images_crud, - include_methods={StorageRouteMethod.GET}, # Non-superusers can only read Material files -) - -### ProductType routers ### -product_type_router = PublicAPIRouter(prefix="/product-types", tags=["product-types"]) - - -## Basic CRUD routers ## -@product_type_router.get( - "", - summary="Get all product types", - responses={ - 200: { - "description": "List of product types", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Product types without relationships", - "value": [ - { - "id": 1, - "name": "Chair", - "description": "Basic chair", - "categories": [], - "products": [], - "images": [], - "files": [], - } - ], - }, - "with_categories": { - "summary": "Product types with categories", - "value": [ - { - "id": 1, - "name": "Chair", - "categories": [{"id": 1, "name": "Furniture"}], - "products": [], - "images": [], - "files": [], - } - ], - }, - } - } - }, - } - }, -) -async def get_product_types( - session: AsyncSessionDep, - product_type_filter: ProductTypeFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, # TODO: Consider supporting comma-separated list of relationships to include - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": {"categories"}}, - "all": {"value": ["categories", "files", "images", "product_links"]}, - }, - ), - ] = None, -) -> Sequence[ProductType]: - """Get a list of all product types.""" - return await get_models(session, ProductType, include_relationships=include, model_filter=product_type_filter) - - -@product_type_router.get( - "/{product_type_id}", - response_model=ProductTypeReadWithRelationships, - summary="Get product type by ID", - responses={ - 200: { - "description": "Product type found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic product type", - "value": { - "id": 1, - "name": "Chair", - "description": "Basic chair", - "categories": [], - "products": [], - "images": [], - "files": [], - }, - }, - "with_relationships": { - "summary": "With relationships", - "value": { - "id": 1, - "name": "Chair", - "categories": [{"id": 1, "name": "Furniture"}], - "products": [{"id": 1, "name": "IKEA Chair"}], - "images": [{"id": 1, "url": "/images/chair.jpg"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Product type not found", - "content": {"application/json": {"example": {"detail": "ProductType with id 999 not found"}}}, - }, - }, -) -async def get_product_type( - session: AsyncSessionDep, - product_type_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": ["categories"]}, - "all": {"value": ["categories", "images", "files"]}, - }, - ), - ] = None, -) -> ProductType: - """Get a single product type by ID with its categories and products.""" - return await get_model_by_id(session, ProductType, product_type_id, include_relationships=include) - - -## ProductType Category routers ## -# TODO: deduplicate category routers for materials and product types and move to the common.router_factories module -@product_type_router.get( - "/{product_type_id}/categories", - response_model=list[CategoryRead], - summary="View categories of product type", -) -async def get_categories_for_product_type( - product_type_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - category_filter: CategoryFilterDep, -) -> Sequence[Category]: - """View categories of a product type.""" - return await get_linked_models( - session, - ProductType, - product_type_id, - Category, - CategoryProductTypeLink, - "product_type_id", - include_relationships=include, - model_filter=category_filter, - ) - - -@product_type_router.get( - "/{product_type_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get category by ID", -) -async def get_category_for_product_type( - product_type_id: PositiveInt, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - session: AsyncSessionDep, -) -> Category: - """Get a category by ID for a product type.""" - return await get_linked_model_by_id( - session, - ProductType, - product_type_id, - Category, - category_id, - CategoryProductTypeLink, - "product_type_id", - "category_id", - include=include, - ) - - -## ProductType Storage routers ## -add_storage_routes( - router=product_type_router, - parent_api_model_name=ProductType.get_api_model_name(), - files_crud=crud.product_type_files, - images_crud=crud.product_type_images, - include_methods={StorageRouteMethod.GET}, # Non-superusers can only read ProductType files -) - -### Router inclusion ### router.include_router(category_router) router.include_router(taxonomy_router) router.include_router(material_router) router.include_router(product_type_router) +router.include_router(unit_router) + +__all__ = ["RecursionDepthQueryParam", "router"] diff --git a/backend/app/api/background_data/routers/public_categories.py b/backend/app/api/background_data/routers/public_categories.py new file mode 100644 index 00000000..bf32310d --- /dev/null +++ b/backend/app/api/background_data/routers/public_categories.py @@ -0,0 +1,194 @@ +"""Public category routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import Path, Request +from fastapi_pagination import Page +from pydantic import PositiveInt +from sqlalchemy import select + +from app.api.background_data.crud.categories import get_category_trees +from app.api.background_data.dependencies import CategoryFilterDep, CategoryFilterWithRelationshipsDep +from app.api.background_data.models import Category +from app.api.background_data.routers.public_support import ( + BackgroundDataAPIRouter, + RecursionDepthQueryParam, + convert_categories_to_tree, +) +from app.api.background_data.schemas import ( + CategoryReadWithRecursiveSubCategories, + CategoryReadWithRelationshipsAndFlatSubCategories, +) +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep +from app.core.responses import conditional_json_response + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import Select + from starlette.responses import Response + +router = BackgroundDataAPIRouter(prefix="/categories", tags=["categories"]) + + +async def _require_category_with_relationships(session: AsyncSessionDep, category_id: PositiveInt) -> Category: + """Load one category with the standard public relationships.""" + return await require_model( + session, + Category, + category_id, + loaders={"taxonomy", "subcategories", "materials", "product_types"}, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + + +async def _page_categories_with_relationships( + session: AsyncSessionDep, + *, + category_filter: CategoryFilterWithRelationshipsDep, +) -> Page[Category]: + """Page categories using an explicit public read query.""" + statement: Select[tuple[Category]] = select(Category) + statement = apply_filter(statement, Category, category_filter) + statement = apply_loader_profile( + statement, + Category, + {"taxonomy", "subcategories", "materials", "product_types"}, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + return await paginate_select(session, statement, model=Category) + + +async def _page_subcategories( + session: AsyncSessionDep, + *, + category_id: PositiveInt, + category_filter: CategoryFilterDep, +) -> Page[Category]: + """Page direct subcategories for one parent category.""" + statement: Select[tuple[Category]] = select(Category).where(Category.supercategory_id == category_id) + statement = apply_filter(statement, Category, category_filter) + statement = apply_loader_profile( + statement, + Category, + {"taxonomy", "subcategories", "materials", "product_types"}, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + return await paginate_select(session, statement, model=Category) + + +@router.get( + "", + response_model=Page[CategoryReadWithRelationshipsAndFlatSubCategories], + summary="Get all categories with optional filtering and all relationships", +) +async def get_categories( + request: Request, + session: AsyncSessionDep, + category_filter: CategoryFilterWithRelationshipsDep, +) -> Page[Category] | Response: + """Get all categories with all relationships loaded.""" + payload = await _page_categories_with_relationships(session, category_filter=category_filter) + return conditional_json_response(request, payload) + + +@router.get( + "/tree", + response_model=list[CategoryReadWithRecursiveSubCategories], + summary="Get categories tree", +) +async def get_categories_tree( + request: Request, + session: AsyncSessionDep, + category_filter: CategoryFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[CategoryReadWithRecursiveSubCategories] | Response: + """Get all base categories and their subcategories in a tree structure.""" + categories: Sequence[Category] = await get_category_trees(session, recursion_depth, category_filter=category_filter) + payload = convert_categories_to_tree(list(categories), recursion_depth=recursion_depth) + return conditional_json_response(request, payload) + + +@router.get( + "/{category_id}", + response_model=CategoryReadWithRelationshipsAndFlatSubCategories, +) +async def get_category( + request: Request, + session: AsyncSessionDep, + category_id: PositiveInt, +) -> Category | Response: + """Get category by ID with all relationships.""" + payload = await _require_category_with_relationships(session, category_id) + updated_at = getattr(payload, "updated_at", None) + etag_seed = f"category:{category_id}:{updated_at}" + return conditional_json_response(request, payload, etag_seed=etag_seed) + + +@router.get( + "{category_id}/subcategories", + response_model=Page[CategoryReadWithRelationshipsAndFlatSubCategories], + summary="Get category subcategories with optional filtering and all relationships", +) +async def get_subcategories( + category_id: Annotated[PositiveInt, Path(description="Category ID")], + category_filter: CategoryFilterDep, + session: AsyncSessionDep, +) -> Page[Category]: + """Get paginated subcategories of a category with all relationships loaded.""" + await require_model(session, Category, category_id) + return await _page_subcategories(session, category_id=category_id, category_filter=category_filter) + + +@router.get( + "/{category_id}/subcategories/tree", + summary="Get category subtree", + response_model=list[CategoryReadWithRecursiveSubCategories], +) +async def get_category_subtree( + category_id: PositiveInt, + category_filter: CategoryFilterDep, + session: AsyncSessionDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[CategoryReadWithRecursiveSubCategories]: + """Get a category subcategories in a tree structure, up to a specified depth.""" + categories: Sequence[Category] = await get_category_trees( + session, recursion_depth=recursion_depth, supercategory_id=category_id, category_filter=category_filter + ) + return convert_categories_to_tree(list(categories), recursion_depth=recursion_depth) + + +@router.get( + "/{category_id}/subcategories/{subcategory_id}", + response_model=CategoryReadWithRelationshipsAndFlatSubCategories, + summary="Get subcategory by ID with all relationships", +) +async def get_subcategory( + category_id: PositiveInt, + subcategory_id: PositiveInt, + session: AsyncSessionDep, +) -> Category: + """Get subcategory by ID with all relationships loaded.""" + await _require_category_with_relationships(session, category_id) + statement = select(Category).where(Category.id == subcategory_id, Category.supercategory_id == category_id) + statement = apply_loader_profile( + statement, + Category, + {"taxonomy", "subcategories", "materials", "product_types"}, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + subcategory = (await session.execute(statement)).scalars().unique().one_or_none() + if subcategory is not None: + return subcategory + + existing = await _require_category_with_relationships(session, subcategory_id) + if existing.supercategory_id != category_id: + raise DependentModelOwnershipError(Category, subcategory_id, Category, category_id) + return existing diff --git a/backend/app/api/background_data/routers/public_materials.py b/backend/app/api/background_data/routers/public_materials.py new file mode 100644 index 00000000..d889373e --- /dev/null +++ b/backend/app/api/background_data/routers/public_materials.py @@ -0,0 +1,229 @@ +"""Public material routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Path, Request +from fastapi_filter import FilterDepends +from fastapi_pagination import Page +from pydantic import UUID4, PositiveInt +from sqlalchemy import Select, select + +from app.api.background_data.crud.materials import ( + get_material_file as load_material_file, +) +from app.api.background_data.crud.materials import ( + get_material_image as load_material_image, +) +from app.api.background_data.crud.materials import ( + list_material_files, + list_material_images, +) +from app.api.background_data.dependencies import CategoryFilterDep, MaterialFilterWithRelationshipsDep +from app.api.background_data.models import Category, CategoryMaterialLink, Material +from app.api.background_data.routers.public_support import BackgroundDataAPIRouter +from app.api.background_data.schemas import CategoryRead, MaterialReadWithRelationships +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.query import require_model +from app.api.common.exceptions import BadRequestError +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.file_storage.filters import FileFilter, ImageFilter +from app.api.file_storage.schemas import FileReadWithinParent, ImageReadWithinParent +from app.core.responses import conditional_json_response + +if TYPE_CHECKING: + from collections.abc import Sequence + + from starlette.responses import Response + +router = BackgroundDataAPIRouter(prefix="/materials", tags=["materials"]) + + +async def _require_material(session: AsyncSessionDep, material_id: PositiveInt) -> Material: + """Load a material with the standard public relationships.""" + return await require_model( + session, + Material, + model_id=material_id, + loaders={"categories", "images", "files"}, + read_schema=MaterialReadWithRelationships, + ) + + +async def _page_materials( + session: AsyncSessionDep, + *, + material_filter: MaterialFilterWithRelationshipsDep, +) -> Page[Material]: + """Page public materials from an explicit material query.""" + statement: Select[tuple[Material]] = select(Material) + statement = apply_filter(statement, Material, material_filter) + statement = apply_loader_profile( + statement, + Material, + {"categories", "images", "files"}, + read_schema=MaterialReadWithRelationships, + ) + return await paginate_select(session, statement, model=Material) + + +async def _get_linked_material_category( + session: AsyncSessionDep, + *, + material_id: PositiveInt, + category_id: PositiveInt, +) -> Category: + """Load one category linked to a material.""" + await require_model(session, Material, material_id) + statement = ( + select(Category) + .join(CategoryMaterialLink, Category.id == CategoryMaterialLink.category_id) + .where(CategoryMaterialLink.material_id == material_id, Category.id == category_id) + ) + statement = apply_loader_profile(statement, Category, read_schema=CategoryRead) + category = (await session.execute(statement)).scalars().unique().one_or_none() + if category is None: + msg = "Category is not linked to Material" + raise BadRequestError(msg) + return category + + +async def _list_material_categories( + session: AsyncSessionDep, + *, + material_id: PositiveInt, + category_filter: CategoryFilterDep, +) -> Sequence[Category]: + """List categories linked to a material.""" + await require_model(session, Material, material_id) + statement: Select[tuple[Category]] = ( + select(Category) + .join(CategoryMaterialLink, Category.id == CategoryMaterialLink.category_id) + .where(CategoryMaterialLink.material_id == material_id) + ) + statement = cast("Select[tuple[Category]]", category_filter.filter(statement)) + statement = cast( + "Select[tuple[Category]]", + apply_loader_profile(statement, Category, read_schema=CategoryRead), + ) + return list((await session.execute(statement)).scalars().unique().all()) + + +@router.get( + "", + response_model=Page[MaterialReadWithRelationships], + summary="Get all materials with all relationships", +) +async def get_materials( + request: Request, + session: AsyncSessionDep, + material_filter: MaterialFilterWithRelationshipsDep, +) -> Page[Material] | Response: + """Get all materials with all relationships loaded.""" + payload = await _page_materials(session, material_filter=material_filter) + return conditional_json_response(request, payload) + + +@router.get( + "/{material_id}", + response_model=MaterialReadWithRelationships, +) +async def get_material( + request: Request, + session: AsyncSessionDep, + material_id: PositiveInt, +) -> Material | Response: + """Get material by ID with all relationships loaded.""" + payload = await _require_material(session, material_id) + return conditional_json_response(request, payload) + + +@router.get( + "/{material_id}/categories", + response_model=list[CategoryRead], + summary="View categories of material", +) +async def get_material_categories( + material_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, +) -> Sequence[Category]: + """Get categories linked to a material.""" + return await _list_material_categories(session, material_id=material_id, category_filter=category_filter) + + +@router.get( + "/{material_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Get category by ID", +) +async def get_material_category( + material_id: PositiveInt, + category_id: PositiveInt, + session: AsyncSessionDep, +) -> Category: + """Get a material category by ID.""" + return await _get_linked_material_category(session, material_id=material_id, category_id=category_id) + + +@router.get( + "/{material_id}/files", + response_model=list[FileReadWithinParent], + summary="Get Material Files", +) +async def get_material_files( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + session: AsyncSessionDep, + item_filter: FileFilter = FilterDepends(FileFilter), +) -> list[FileReadWithinParent]: + """Get all files associated with a material.""" + items = await list_material_files(session, material_id, filter_params=item_filter) + return [FileReadWithinParent.model_validate(item) for item in items] + + +@router.get( + "/{material_id}/files/{file_id}", + response_model=FileReadWithinParent, + summary="Get specific Material File", +) +async def get_material_file( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> FileReadWithinParent: + """Get a specific file associated with a material.""" + item = await load_material_file(session, material_id, file_id) + return FileReadWithinParent.model_validate(item) + + +@router.get( + "/{material_id}/images", + response_model=list[ImageReadWithinParent], + summary="Get Material Images", +) +async def get_material_images( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + session: AsyncSessionDep, + item_filter: ImageFilter = FilterDepends(ImageFilter), +) -> list[ImageReadWithinParent]: + """Get all images associated with a material.""" + items = await list_material_images(session, material_id, filter_params=item_filter) + return [ImageReadWithinParent.model_validate(item) for item in items] + + +@router.get( + "/{material_id}/images/{image_id}", + response_model=ImageReadWithinParent, + summary="Get specific Material Image", +) +async def get_material_image( + material_id: Annotated[PositiveInt, Path(description="ID of the Material")], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> ImageReadWithinParent: + """Get a specific image associated with a material.""" + item = await load_material_image(session, material_id, image_id) + return ImageReadWithinParent.model_validate(item) diff --git a/backend/app/api/background_data/routers/public_product_types.py b/backend/app/api/background_data/routers/public_product_types.py new file mode 100644 index 00000000..6fae61dc --- /dev/null +++ b/backend/app/api/background_data/routers/public_product_types.py @@ -0,0 +1,227 @@ +"""Public product-type routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Path +from fastapi_filter import FilterDepends +from fastapi_pagination import Page +from pydantic import UUID4, PositiveInt +from sqlalchemy import Select, select + +from app.api.background_data.crud.product_types import ( + get_product_type_file as load_product_type_file, +) +from app.api.background_data.crud.product_types import ( + get_product_type_image as load_product_type_image, +) +from app.api.background_data.crud.product_types import ( + list_product_type_files, + list_product_type_images, +) +from app.api.background_data.dependencies import CategoryFilterDep, ProductTypeFilterWithRelationshipsDep +from app.api.background_data.models import Category, CategoryProductTypeLink, ProductType +from app.api.background_data.routers.public_support import BackgroundDataAPIRouter +from app.api.background_data.schemas import CategoryRead, ProductTypeReadWithRelationships +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.query import require_model +from app.api.common.exceptions import BadRequestError +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.file_storage.filters import FileFilter, ImageFilter +from app.api.file_storage.schemas import FileReadWithinParent, ImageReadWithinParent + +if TYPE_CHECKING: + from collections.abc import Sequence + +router = BackgroundDataAPIRouter(prefix="/product-types", tags=["product-types"]) + + +async def _require_product_type(session: AsyncSessionDep, product_type_id: PositiveInt) -> ProductType: + """Load a product type with the standard public relationships.""" + return await require_model( + session, + ProductType, + product_type_id, + loaders={"categories", "images", "files"}, + read_schema=ProductTypeReadWithRelationships, + ) + + +async def _page_product_types( + session: AsyncSessionDep, + *, + product_type_filter: ProductTypeFilterWithRelationshipsDep, +) -> Page[ProductType]: + """Page public product types from an explicit product-type query.""" + statement: Select[tuple[ProductType]] = select(ProductType) + statement = apply_filter(statement, ProductType, product_type_filter) + statement = apply_loader_profile( + statement, + ProductType, + {"categories", "images", "files"}, + read_schema=ProductTypeReadWithRelationships, + ) + return await paginate_select(session, statement, model=ProductType) + + +async def _get_linked_product_type_category( + session: AsyncSessionDep, + *, + product_type_id: PositiveInt, + category_id: PositiveInt, +) -> Category: + """Load one category linked to a product type.""" + await require_model(session, ProductType, product_type_id) + statement = ( + select(Category) + .join(CategoryProductTypeLink, Category.id == CategoryProductTypeLink.category_id) + .where(CategoryProductTypeLink.product_type_id == product_type_id, Category.id == category_id) + ) + statement = apply_loader_profile(statement, Category, read_schema=CategoryRead) + category = (await session.execute(statement)).scalars().unique().one_or_none() + if category is None: + msg = "Category is not linked to ProductType" + raise BadRequestError(msg) + return category + + +async def _list_product_type_categories( + session: AsyncSessionDep, + *, + product_type_id: PositiveInt, + category_filter: CategoryFilterDep, +) -> Sequence[Category]: + """List categories linked to a product type.""" + await require_model(session, ProductType, product_type_id) + statement: Select[tuple[Category]] = ( + select(Category) + .join(CategoryProductTypeLink, Category.id == CategoryProductTypeLink.category_id) + .where(CategoryProductTypeLink.product_type_id == product_type_id) + ) + statement = cast("Select[tuple[Category]]", category_filter.filter(statement)) + statement = cast( + "Select[tuple[Category]]", + apply_loader_profile(statement, Category, read_schema=CategoryRead), + ) + return list((await session.execute(statement)).scalars().unique().all()) + + +@router.get( + "", + response_model=Page[ProductTypeReadWithRelationships], + summary="Get all product types with all relationships", +) +async def get_product_types( + session: AsyncSessionDep, + product_type_filter: ProductTypeFilterWithRelationshipsDep, +) -> Page[ProductType]: + """Get a list of all product types with all relationships loaded.""" + return await _page_product_types(session, product_type_filter=product_type_filter) + + +@router.get( + "/{product_type_id}", + response_model=ProductTypeReadWithRelationships, + summary="Get product type by ID with all relationships", +) +async def get_product_type( + session: AsyncSessionDep, + product_type_id: PositiveInt, +) -> ProductType: + """Get a single product type by ID with all relationships loaded.""" + return await _require_product_type(session, product_type_id) + + +@router.get( + "/{product_type_id}/categories", + response_model=list[CategoryRead], + summary="View categories of product type", +) +async def get_product_type_categories( + product_type_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, +) -> Sequence[Category]: + """Get categories linked to a product type.""" + return await _list_product_type_categories( + session, + product_type_id=product_type_id, + category_filter=category_filter, + ) + + +@router.get( + "/{product_type_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Get category by ID", +) +async def get_product_type_category( + product_type_id: PositiveInt, + category_id: PositiveInt, + session: AsyncSessionDep, +) -> Category: + """Get a product type category by ID.""" + return await _get_linked_product_type_category(session, product_type_id=product_type_id, category_id=category_id) + + +@router.get( + "/{product_type_id}/files", + response_model=list[FileReadWithinParent], + summary="Get Product Type Files", +) +async def get_product_type_files( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + session: AsyncSessionDep, + item_filter: FileFilter = FilterDepends(FileFilter), +) -> list[FileReadWithinParent]: + """Get all files associated with a product type.""" + items = await list_product_type_files(session, product_type_id, filter_params=item_filter) + return [FileReadWithinParent.model_validate(item) for item in items] + + +@router.get( + "/{product_type_id}/files/{file_id}", + response_model=FileReadWithinParent, + summary="Get specific Product Type File", +) +async def get_product_type_file( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> FileReadWithinParent: + """Get a specific file associated with a product type.""" + item = await load_product_type_file(session, product_type_id, file_id) + return FileReadWithinParent.model_validate(item) + + +@router.get( + "/{product_type_id}/images", + response_model=list[ImageReadWithinParent], + summary="Get Product Type Images", +) +async def get_product_type_images( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + session: AsyncSessionDep, + item_filter: ImageFilter = FilterDepends(ImageFilter), +) -> list[ImageReadWithinParent]: + """Get all images associated with a product type.""" + items = await list_product_type_images(session, product_type_id, filter_params=item_filter) + return [ImageReadWithinParent.model_validate(item) for item in items] + + +@router.get( + "/{product_type_id}/images/{image_id}", + response_model=ImageReadWithinParent, + summary="Get specific Product Type Image", +) +async def get_product_type_image( + product_type_id: Annotated[PositiveInt, Path(description="ID of the Product Type")], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> ImageReadWithinParent: + """Get a specific image associated with a product type.""" + item = await load_product_type_image(session, product_type_id, image_id) + return ImageReadWithinParent.model_validate(item) diff --git a/backend/app/api/background_data/routers/public_support.py b/backend/app/api/background_data/routers/public_support.py new file mode 100644 index 00000000..2503ced6 --- /dev/null +++ b/backend/app/api/background_data/routers/public_support.py @@ -0,0 +1,118 @@ +"""Shared utilities for public background-data routers.""" + +from __future__ import annotations + +from http import HTTPMethod +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Query +from fastapi.types import DecoratedCallable +from sqlalchemy import inspect +from sqlalchemy.exc import NoInspectionAvailable +from sqlalchemy.orm.base import ATTR_EMPTY + +from app.api.background_data.models import Category +from app.api.background_data.schemas import ( + CategoryRead, + CategoryReadAsSubCategory, + CategoryReadAsSubCategoryWithRecursiveSubCategories, + CategoryReadWithRecursiveSubCategories, +) +from app.api.common.routers.openapi import PublicAPIRouter +from app.core.cache import cache +from app.core.config import CacheNamespace, settings + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + +class BackgroundDataAPIRouter(PublicAPIRouter): + """Public background data router that caches all GET endpoints.""" + + def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]: # noqa: ANN401 # Any-typed (kw)args are expected by the parent method signatures + """Override api_route to apply caching to all GET endpoints.""" + methods = {method.upper() for method in (kwargs.get("methods") or [])} + decorator = super().api_route(path, *args, **kwargs) + + if HTTPMethod.GET.value not in methods: + return decorator + + def wrapper(func: DecoratedCallable) -> DecoratedCallable: + cached = cache( + expire=settings.cache.ttls[CacheNamespace.BACKGROUND_DATA], + namespace=CacheNamespace.BACKGROUND_DATA, + )(func) + return cast("DecoratedCallable", decorator(cached)) + + return wrapper + + +def _loaded_subcategories(category: Category) -> list[Category]: + """Return preloaded subcategories without triggering lazy loads.""" + try: + state = inspect(category) + except NoInspectionAvailable: + return [] + + loaded_value = state.attrs[Category.subcategories.key].loaded_value + if loaded_value is ATTR_EMPTY or loaded_value is None: + return [] + return list(cast("list[Category]", loaded_value)) + + +def convert_subcategories_to_read_model( + subcategories: list[Category], + max_depth: int = 1, + current_depth: int = 0, + *, + visited: set[int] | None = None, +) -> list[CategoryReadAsSubCategoryWithRecursiveSubCategories]: + """Convert preloaded subcategories to recursive read models without lazy loading.""" + if current_depth >= max_depth: + return [] + + visited = visited or set() + read_subcategories: list[CategoryReadAsSubCategoryWithRecursiveSubCategories] = [] + for category in subcategories: + if category.id in visited: + continue + next_visited = visited | {category.id} + base = CategoryReadAsSubCategory.model_validate(category).model_dump() + read_subcategories.append( + CategoryReadAsSubCategoryWithRecursiveSubCategories( + **base, + subcategories=convert_subcategories_to_read_model( + _loaded_subcategories(category), + max_depth, + current_depth + 1, + visited=next_visited, + ), + ) + ) + return read_subcategories + + +def convert_categories_to_tree( + categories: list[Category], + *, + recursion_depth: int, +) -> list[CategoryReadWithRecursiveSubCategories]: + """Convert top-level categories to recursive read models without ORM recursion.""" + tree_items: list[CategoryReadWithRecursiveSubCategories] = [] + for category in categories: + base = CategoryRead.model_validate(category).model_dump() + tree_items.append( + CategoryReadWithRecursiveSubCategories( + **base, + subcategories=convert_subcategories_to_read_model( + _loaded_subcategories(category), + max_depth=recursion_depth - 1, + visited={category.id}, + ), + ) + ) + return tree_items + + +RecursionDepthQueryParam = Annotated[int, Query(ge=1, le=5, description="Maximum recursion depth")] diff --git a/backend/app/api/background_data/routers/public_taxonomies.py b/backend/app/api/background_data/routers/public_taxonomies.py new file mode 100644 index 00000000..c5288791 --- /dev/null +++ b/backend/app/api/background_data/routers/public_taxonomies.py @@ -0,0 +1,150 @@ +"""Public taxonomy routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from fastapi import Depends +from fastapi_pagination import Page, Params, create_page +from pydantic import PositiveInt +from sqlalchemy import select + +from app.api.background_data.crud.categories import get_category_trees +from app.api.background_data.dependencies import CategoryFilterDep, TaxonomyFilterDep +from app.api.background_data.models import Category, Taxonomy +from app.api.background_data.routers.public_support import ( + BackgroundDataAPIRouter, + RecursionDepthQueryParam, + convert_categories_to_tree, +) +from app.api.background_data.schemas import CategoryRead, CategoryReadWithRecursiveSubCategories, TaxonomyRead +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import Select + +router = BackgroundDataAPIRouter(prefix="/taxonomies", tags=["taxonomies"]) + + +async def _require_taxonomy(session: AsyncSessionDep, taxonomy_id: PositiveInt) -> Taxonomy: + """Load one taxonomy with the public read schema.""" + return await require_model(session, Taxonomy, taxonomy_id, read_schema=TaxonomyRead) + + +async def _get_taxonomy_category( + session: AsyncSessionDep, + *, + taxonomy_id: PositiveInt, + category_id: PositiveInt, +) -> Category: + """Load one category scoped to a taxonomy.""" + statement = select(Category).where(Category.id == category_id, Category.taxonomy_id == taxonomy_id) + statement = apply_loader_profile(statement, Category, read_schema=CategoryRead) + scoped = (await session.execute(statement)).scalars().unique().one_or_none() + if scoped is not None: + return scoped + + category = await require_model(session, Category, category_id) + if category.taxonomy_id != taxonomy_id: + raise DependentModelOwnershipError(Category, category_id, Taxonomy, taxonomy_id) + raise DependentModelOwnershipError(Category, category_id, Taxonomy, taxonomy_id) + + +async def _page_taxonomy_categories( + session: AsyncSessionDep, + *, + taxonomy_id: PositiveInt, + category_filter: CategoryFilterDep, +) -> Page[CategoryRead]: + """Page categories scoped to one taxonomy.""" + statement: Select[tuple[Category]] = select(Category).where(Category.taxonomy_id == taxonomy_id) + statement = apply_filter(statement, Category, category_filter) + statement = apply_loader_profile(statement, Category, read_schema=CategoryRead) + return cast("Page[CategoryRead]", await paginate_select(session, statement, model=Category)) + + +async def _page_taxonomies( + session: AsyncSessionDep, + *, + taxonomy_filter: TaxonomyFilterDep, +) -> Page[TaxonomyRead]: + """Page public taxonomies from an explicit taxonomy query.""" + statement: Select[tuple[Taxonomy]] = select(Taxonomy) + statement = apply_filter(statement, Taxonomy, taxonomy_filter) + statement = apply_loader_profile(statement, Taxonomy, read_schema=TaxonomyRead) + return cast("Page[TaxonomyRead]", await paginate_select(session, statement, model=Taxonomy)) + + +@router.get("", response_model=Page[TaxonomyRead]) +async def get_taxonomies(taxonomy_filter: TaxonomyFilterDep, session: AsyncSessionDep) -> Page[TaxonomyRead]: + """Get all taxonomies with optional filtering.""" + return await _page_taxonomies(session, taxonomy_filter=taxonomy_filter) + + +@router.get("/{taxonomy_id}", response_model=TaxonomyRead) +async def get_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> TaxonomyRead: + """Get taxonomy by ID.""" + taxonomy = await _require_taxonomy(session, taxonomy_id) + return TaxonomyRead.model_validate(taxonomy) + + +@router.get( + "/{taxonomy_id}/categories/tree", + response_model=Page[CategoryReadWithRecursiveSubCategories], + summary="Get the category tree of a taxonomy", +) +async def get_taxonomy_category_tree( + taxonomy_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, + params: Params = Depends(), + recursion_depth: RecursionDepthQueryParam = 1, +) -> Page[CategoryReadWithRecursiveSubCategories]: + """Get paginated top-level categories of a taxonomy with their recursive subcategory trees.""" + categories: Sequence[Category] = await get_category_trees( + session, + recursion_depth, + taxonomy_id=taxonomy_id, + category_filter=category_filter, + ) + tree_items = convert_categories_to_tree(list(categories), recursion_depth=recursion_depth) + return cast( + "Page[CategoryReadWithRecursiveSubCategories]", + create_page(tree_items, total=len(tree_items), params=params), + ) + + +@router.get( + "/{taxonomy_id}/categories", + response_model=Page[CategoryRead], + summary="View categories of taxonomy", +) +async def get_taxonomy_categories( + taxonomy_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, +) -> Page[CategoryRead]: + """Get taxonomy categories with optional filtering.""" + await _require_taxonomy(session, taxonomy_id) + return await _page_taxonomy_categories(session, taxonomy_id=taxonomy_id, category_filter=category_filter) + + +@router.get( + "/{taxonomy_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Get category by ID", +) +async def get_taxonomy_category_by_id( + taxonomy_id: PositiveInt, + category_id: PositiveInt, + session: AsyncSessionDep, +) -> Category: + """Get a taxonomy category by ID.""" + return await _get_taxonomy_category(session, taxonomy_id=taxonomy_id, category_id=category_id) diff --git a/backend/app/api/background_data/routers/public_units.py b/backend/app/api/background_data/routers/public_units.py new file mode 100644 index 00000000..ecc4099d --- /dev/null +++ b/backend/app/api/background_data/routers/public_units.py @@ -0,0 +1,13 @@ +"""Public unit router for background data.""" + +from app.api.common.models.enums import Unit + +from .public_support import BackgroundDataAPIRouter + +router = BackgroundDataAPIRouter(prefix="/units", tags=["units"], include_in_schema=True) + + +@router.get("") +async def get_units() -> list[str]: + """Get a list of available units.""" + return [unit.value for unit in Unit] diff --git a/backend/app/api/background_data/schemas.py b/backend/app/api/background_data/schemas.py index b82b2808..59f00005 100644 --- a/backend/app/api/background_data/schemas.py +++ b/backend/app/api/background_data/schemas.py @@ -2,6 +2,14 @@ from pydantic import ConfigDict, Field, PositiveInt +from app.api.background_data.examples import ( + CATEGORY_READ_AS_SUBCATEGORY_EXAMPLES, + CATEGORY_READ_EXAMPLES, + CATEGORY_READ_RECURSIVE_EXAMPLES, + CATEGORY_UPDATE_EXAMPLES, + TAXONOMY_READ_EXAMPLES, + TAXONOMY_READ_WITH_TREE_EXAMPLES, +) from app.api.background_data.models import ( CategoryBase, MaterialBase, @@ -12,11 +20,12 @@ from app.api.common.schemas.associations import MaterialProductLinkReadWithinMaterial from app.api.common.schemas.base import ( BaseCreateSchema, - BaseReadSchema, BaseUpdateSchema, + IntIdReadSchema, + IntIdReadSchemaWithTimeStamp, MaterialRead, - ProductRead, ) +from app.api.common.schemas.field_mixins import CategoryFields, ProductTypeFields, TaxonomyFields from app.api.file_storage.schemas import FileRead, ImageRead @@ -33,8 +42,8 @@ class CategoryCreateWithinCategoryWithSubCategories(BaseCreateSchema, CategoryBa """Schema for creating a new category within a category, with optional subcategories.""" # Database model has a None default, but Pydantic model has empty set default for consistent API type handling - subcategories: set["CategoryCreateWithinCategoryWithSubCategories"] = Field( - default_factory=set, + subcategories: list[CategoryCreateWithinCategoryWithSubCategories] = Field( + default_factory=list, description="List of subcategories", ) @@ -56,20 +65,10 @@ class CategoryCreateWithSubCategories(CategoryCreateWithinTaxonomyWithSubCategor ## Read Schemas ## -class CategoryReadAsSubCategory(BaseReadSchema, CategoryBase): +class CategoryReadAsSubCategory(IntIdReadSchema, CategoryFields): """Schema for reading subcategory information.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - json_schema_extra={ - "examples": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - } - ] - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": CATEGORY_READ_AS_SUBCATEGORY_EXAMPLES}) class CategoryRead(CategoryReadAsSubCategory): @@ -78,26 +77,14 @@ class CategoryRead(CategoryReadAsSubCategory): taxonomy_id: PositiveInt = Field(description="ID of the taxonomy") supercategory_id: PositiveInt | None = None - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see - json_schema_extra={ - "examples": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "taxonomy_id": 1, - "supercategory_id": 1, - } - ] - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": CATEGORY_READ_EXAMPLES}) class CategoryReadWithRelationships(CategoryRead): """Schema for reading category information with all relationships.""" materials: list[MaterialRead] = Field(default_factory=list, description="List of materials linked to the category") - product_types: list["ProductTypeRead"] = Field( + product_types: list[ProductTypeRead] = Field( default_factory=list, description="List of product types linked to the category" ) @@ -111,35 +98,11 @@ class CategoryReadWithRelationshipsAndFlatSubCategories(CategoryReadWithRelation class CategoryReadAsSubCategoryWithRecursiveSubCategories(CategoryReadAsSubCategory): """Schema for reading category information with recursive subcategories.""" - subcategories: list["CategoryReadAsSubCategoryWithRecursiveSubCategories"] = Field( + subcategories: list[CategoryReadAsSubCategoryWithRecursiveSubCategories] = Field( default_factory=list, description="List of subcategories" ) - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - json_schema_extra={ - "examples": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - } - ], - } - ], - } - ] - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": CATEGORY_READ_RECURSIVE_EXAMPLES}) # # Rebuild schema to allow for nested subcategories @@ -168,18 +131,7 @@ class CategoryUpdate(BaseUpdateSchema): name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the category") description: str | None = Field(default=None, max_length=500, description="Description of the category") - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "name": "Metals", - "description": "All kinds of metals", - } - ] - } - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": CATEGORY_UPDATE_EXAMPLES}) ### Taxonomy Schemas ### @@ -191,29 +143,16 @@ class TaxonomyCreate(BaseCreateSchema, TaxonomyBase): class TaxonomyCreateWithCategories(BaseCreateSchema, TaxonomyBase): """Schema for creating a new taxonomy, optionally with new categories.""" - categories: set[CategoryCreateWithinTaxonomyWithSubCategories] = Field( - default_factory=set, description="Set of subcategories" + categories: list[CategoryCreateWithinTaxonomyWithSubCategories] = Field( + default_factory=list, description="Set of subcategories" ) ## Read Schemas ## -class TaxonomyRead(BaseReadSchema, TaxonomyBase): +class TaxonomyRead(IntIdReadSchemaWithTimeStamp, TaxonomyFields): """Schema for reading minimal taxonomy information.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domain": "materials", - "source": "DOI:10.2345/12345", - } - ] - } - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": TAXONOMY_READ_EXAMPLES}) class TaxonomyReadWithCategoryTree(TaxonomyRead): @@ -223,34 +162,7 @@ class TaxonomyReadWithCategoryTree(TaxonomyRead): default_factory=set, description="Set of categories in the taxonomy" ) - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - { - "json_schema_extra": { - "examples": [ - { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domain": "materials", - "source": "DOI:10.2345/12345", - "categories": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [{"name": "Steel", "description": "Steel alloys"}], - } - ], - } - ], - } - ] - } - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": TAXONOMY_READ_WITH_TREE_EXAMPLES}) class TaxonomyUpdate(BaseUpdateSchema): @@ -260,7 +172,8 @@ class TaxonomyUpdate(BaseUpdateSchema): version: str | None = Field(default=None, min_length=1, max_length=50) description: str | None = Field(default=None, max_length=500) domains: set[TaxonomyDomain] | None = Field( - description="Domains of the taxonomy, e.g. {" + f"{', '.join([d.value for d in TaxonomyDomain][:3])}" + "}" + default=None, + description="Domains of the taxonomy, e.g. {" + f"{', '.join([d.value for d in TaxonomyDomain][:3])}" + "}", ) source: str | None = Field(default=None, max_length=50, description="Source of the taxonomy data") @@ -299,12 +212,10 @@ class MaterialReadWithRelationships(MaterialRead): class MaterialUpdate(BaseUpdateSchema): """Schema for a partial update of a material.""" - name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the Material") - description: str | None = Field(default=None, max_length=500, description="Description of the Material") + name: str | None = Field(default=None, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) source: str | None = Field( - default=None, - max_length=50, - description="Source of the material data, e.g. URL, IRI or citation key", + default=None, max_length=50, description="Source of the material data, e.g. URL, IRI or citation key" ) density_kg_m3: float | None = Field(default=None, gt=0, description="Volumetric density (kg/m³) ") is_crm: bool | None = Field(default=None, description="Is this material a Critical Raw Material (CRM)?") @@ -319,30 +230,25 @@ class ProductTypeCreate(BaseCreateSchema, ProductTypeBase): class ProductTypeCreateWithCategories(BaseCreateSchema, ProductTypeBase): """Schema for creating a product type with links to existing categories.""" - category_ids: set[int] = Field(default_factory=set, description="List of category IDs") + category_ids: set[int] = Field(default_factory=set) ## Read Schemas ## -class ProductTypeRead(BaseReadSchema, ProductTypeBase): +class ProductTypeRead(IntIdReadSchema, ProductTypeFields): """Schema for reading flat product type information.""" class ProductTypeReadWithRelationships(ProductTypeRead): """Schema for reading product type information with all relationships.""" - products: list[ProductRead] = Field( - default_factory=list, description="List of products that have this product type" - ) - categories: list[CategoryRead] = Field( - default_factory=list, description="List of categories linked to the product type" - ) - images: list[ImageRead] = Field(default_factory=list, description="List of images for the product type") - files: list[FileRead] = Field(default_factory=list, description="List of files for the product type") + categories: list[CategoryRead] = Field(default_factory=list) + images: list[ImageRead] = Field(default_factory=list) + files: list[FileRead] = Field(default_factory=list) ## Update Schemas ## class ProductTypeUpdate(BaseUpdateSchema): """Schema for a partial update of a product type.""" - name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the Product Type.") - description: str | None = Field(default=None, max_length=500, description="Description of the Product Type.") + name: str | None = Field(default=None, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) diff --git a/backend/app/api/common/config.py b/backend/app/api/common/config.py index de626597..ee1bfee9 100644 --- a/backend/app/api/common/config.py +++ b/backend/app/api/common/config.py @@ -1,15 +1,9 @@ -"""Configuration for common API components.""" +"""Static OpenAPI metadata shared across common API routers.""" -from pathlib import Path - -from pydantic import BaseModel -from pydantic_settings import BaseSettings +from pydantic import BaseModel, Field from app.__version__ import version -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[3]).resolve() - class OpenAPISettings(BaseModel): """Base OpenAPI settings.""" @@ -21,11 +15,9 @@ class OpenAPISettings(BaseModel): x_tag_groups: list[dict[str, str | list[str]]] -class APISettings(BaseSettings): - """Settings class to store settings related to common API components.""" - - # OpenAPI docs metadata - public_docs: OpenAPISettings = OpenAPISettings( +def build_public_docs() -> OpenAPISettings: + """Build public OpenAPI metadata.""" + return OpenAPISettings( title="Reverse Engineering Lab - Data Collection API", description="Data collection app for the reverse engineering lab project at CML.", version=version, @@ -41,10 +33,20 @@ class APISettings(BaseSettings): ], ) - full_docs: OpenAPISettings = public_docs.model_copy( + +def build_full_docs() -> OpenAPISettings: + """Build internal OpenAPI metadata from the public docs shape.""" + public_docs = build_public_docs() + return public_docs.model_copy( update={"x_tag_groups": [*public_docs.x_tag_groups, {"name": "Admin", "tags": ["admin"]}]} ) -# Create a settings instance that can be imported throughout the app +class APISettings(BaseModel): + """Static OpenAPI metadata shared across the API.""" + + public_docs: OpenAPISettings = Field(default_factory=build_public_docs) + full_docs: OpenAPISettings = Field(default_factory=build_full_docs) + + settings = APISettings() diff --git a/backend/app/api/common/crud/associations.py b/backend/app/api/common/crud/associations.py index 926fc6b4..073f9cf0 100644 --- a/backend/app/api/common/crud/associations.py +++ b/backend/app/api/common/crud/associations.py @@ -1,179 +1,47 @@ -"""CRUD utility functions for association models between many-to-many relationships.""" +"""Typed helpers for association/link models.""" -from collections.abc import Sequence -from enum import Enum -from typing import TYPE_CHECKING, overload -from uuid import UUID +from typing import TYPE_CHECKING -from fastapi_filter.contrib.sqlalchemy import Filter -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import InstrumentedAttribute -from app.api.common.crud.base import get_model_by_id, get_models -from app.api.common.models.custom_types import DT, IDT, LMT, MT +from app.api.common.exceptions import BadRequestError +from app.api.common.models.base import get_model_label +from app.api.common.models.custom_types import LMT if TYPE_CHECKING: - from sqlmodel.sql._expression_select_cls import SelectOfScalar + from uuid import UUID + from sqlalchemy import Select -### Association Utilities ### -async def get_linking_model_with_ids_if_it_exists( - db: AsyncSession, model_type: type[LMT], id1: int | UUID, id2: int | UUID, id1_field: str, id2_field: str -) -> LMT: - """Get a linking model instance by composite keys if it exists in the database. - - Args: - db: AsyncSession to use for the database query - model_type: Type of the linking model instance - id1: First ID that was queried - id2: Second ID that was queried - id1_field: Field name for the first ID in the linking model - id2_field: Field name for the second ID in the linking model - - Returns: - LMT: The linking model instance if it exists - - Raises: - ValueError: If linking model instance is None - """ - statement: SelectOfScalar[LMT] = select(model_type).where( - getattr(model_type, id1_field) == id1, getattr(model_type, id2_field) == id2 - ) - result: LMT | None = (await db.exec(statement)).one_or_none() - if not result: - model_name: str = model_type.get_api_model_name().name_capital - err_msg: str = f"{model_name} with {id1_field} {id1} and {id2_field} {id2} not found" - raise ValueError(err_msg) - return result - - -class LinkedModelReturnType(str, Enum): - """Enum for linked model return types.""" - - DEPENDENT = "dependent" - LINK = "link" - -@overload -async def get_linked_model_by_id( +async def require_link( db: AsyncSession, - parent_model: type[MT], - parent_id: IDT, - dependent_model: type[DT], - dependent_id: IDT, link_model: type[LMT], - parent_link_field: str, - dependent_link_field: str, - *, - return_type: LinkedModelReturnType = LinkedModelReturnType.DEPENDENT, - include: set[str] | None = None, -) -> DT: ... - - -@overload -async def get_linked_model_by_id( - db: AsyncSession, - parent_model: type[MT], - parent_id: IDT, - dependent_model: type[DT], - dependent_id: IDT, - link_model: type[LMT], - parent_link_field: str, - dependent_link_field: str, - *, - return_type: LinkedModelReturnType = LinkedModelReturnType.LINK, - include: set[str] | None = None, -) -> LMT: ... - - -async def get_linked_model_by_id( - db: AsyncSession, - parent_model: type[MT], - parent_id: IDT, - dependent_model: type[DT], - dependent_id: IDT, - link_model: type[LMT], - parent_link_field: str, - dependent_link_field: str, - *, - return_type: LinkedModelReturnType = LinkedModelReturnType.DEPENDENT, - include: set[str] | None = None, -) -> DT | LMT: - """Get dependent or linking model via linking table relationship. - - Args: - db: Database session - parent_model: Parent model class - parent_id: Parent ID - dependent_model: Dependent model class - dependent_id: Dependent ID - link_model: Linking model class - parent_link_field: Parent ID field in link model - dependent_link_field: Dependent ID field in link model - return_type: Type of result to return (dependent model or linking model) - include: Optional relationships to include - """ - # Validate both models exist - await get_model_by_id(db, parent_model, parent_id) - dependent: DT = await get_model_by_id(db, dependent_model, dependent_id, include_relationships=include) - - # Validate link exists - try: - link: LMT = await get_linking_model_with_ids_if_it_exists( - db, link_model, parent_id, dependent_id, parent_link_field, dependent_link_field - ) - except ValueError as e: - dependent_model_name: str = dependent_model.get_api_model_name().name_capital - parent_model_name: str = parent_model.get_api_model_name().name_capital - err_msg: str = f"{dependent_model_name} is not linked to {parent_model_name}" - raise ValueError(err_msg) from e - - return link if return_type == LinkedModelReturnType.LINK else dependent - - -async def get_linked_models( - db: AsyncSession, - parent_model: type[MT], - parent_id: int, - dependent_model: type[DT], - link_model: type[LMT], - parent_link_field: str, - *, - include_relationships: set[str] | None = None, - model_filter: Filter | None = None, -) -> Sequence[DT]: - """Get all linked dependent models for a parent.""" - # Validate parent exists - await get_model_by_id(db, parent_model, parent_id) - - # Build base query - statement: SelectOfScalar[DT] = ( - select(dependent_model).join(link_model).where(getattr(link_model, parent_link_field) == parent_id) - ) - - # Get filtered models with includes - return await get_models( - db, dependent_model, include_relationships=include_relationships, model_filter=model_filter, statement=statement - ) + id1: int | UUID, + id2: int | UUID, + id1_attr: InstrumentedAttribute[int | UUID], + id2_attr: InstrumentedAttribute[int | UUID], +) -> LMT: + """Return a link row for two IDs or raise BadRequestError.""" + statement: Select[tuple[LMT]] = select(link_model).where(id1_attr == id1, id2_attr == id2) + result = (await db.execute(statement)).scalar_one_or_none() + if result is None: + model_name = get_model_label(link_model) + err_msg = f"{model_name} with {id1_attr.key} {id1} and {id2_attr.key} {id2} not found" + raise BadRequestError(err_msg) + return result -async def create_model_links( +async def add_links( db: AsyncSession, id1: int, - id1_field: str, + id1_attr: InstrumentedAttribute[int | UUID], id2_set: set[int] | set[UUID], - id2_field: str, + id2_attr: InstrumentedAttribute[int | UUID], link_model: type[LMT], ) -> None: - """Create links between two sets of IDs using a linking model. - - Args: - db: Database session - id1: ID of the first model instance - id1_field: Field name for the first model ID in the linking model - id2_set: Set of IDs of the second model instances - id2_field: Field name for the second model ID in the linking model - link_model: Linking model class - """ - links: list[LMT] = [link_model(**{id1_field: id1, id2_field: id2}) for id2 in id2_set] + """Create association rows between one parent ID and many dependent IDs.""" + links = [link_model(**{id1_attr.key: id1, id2_attr.key: id2}) for id2 in id2_set] db.add_all(links) diff --git a/backend/app/api/common/crud/base.py b/backend/app/api/common/crud/base.py deleted file mode 100644 index 2864aaaf..00000000 --- a/backend/app/api/common/crud/base.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Base CRUD operations for SQLAlchemy models.""" - -from collections.abc import Sequence - -from fastapi_filter.contrib.sqlalchemy import Filter -from fastapi_pagination import Page -from fastapi_pagination.ext.sqlmodel import apaginate -from sqlalchemy import Select -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql._expression_select_cls import SelectOfScalar - -from app.api.common.crud.exceptions import DependentModelOwnershipError -from app.api.common.crud.utils import ( - AttributeSettingStrategy, - add_relationship_options, - set_empty_relationships, - validate_model_with_id_exists, -) -from app.api.common.models.custom_types import DT, IDT, MT - - -def should_apply_filter(filter_obj: Filter) -> bool: - """Check if any field in the filter (including nested filters) has a non-None value.""" - for value in filter_obj.__dict__.values(): - if isinstance(value, Filter): - if should_apply_filter(value): - return True - elif value is not None: - return True - return False - - -def add_filter_joins( - statement: Select, - model: type[MT], - filter_obj: Filter, - path: list[str] | None = None, -) -> Select: - """Recursively add joins for filter relationships.""" - path = path or [] - - if not should_apply_filter(filter_obj): - return statement - - relationship_filters = {name: value for name, value in filter_obj.__dict__.items() if isinstance(value, Filter)} - - for rel_name, nested_filter in relationship_filters.items(): - if not should_apply_filter(nested_filter): - continue - - # Get the relationship attribute from the current model - current_model = model - current_path = [] - - for ancestor in path: - current_model = getattr(current_model, ancestor).property.entity.entity - current_path.append(ancestor) - - relationship = getattr(current_model, rel_name) - prop = relationship.property - target = prop.entity.entity - - # Add joins with proper isouter parameter - if getattr(prop, "secondary", None) is not None: - statement = statement.join(prop.secondary, isouter=bool(current_path)).join( - target, isouter=bool(current_path) - ) - else: - statement = statement.join(target, prop.primaryjoin, isouter=bool(current_path)) - - # Recursively process nested filters - statement = add_filter_joins(statement, model, nested_filter, path=[*path, rel_name]) - - return statement - - -def get_models_query( - model: type[MT], - *, - include_relationships: set[str] | None = None, - model_filter: Filter | None = None, - statement: SelectOfScalar[MT] | None = None, - read_schema: type[MT] | None = None, -) -> tuple[SelectOfScalar[MT], dict[str, bool]]: - """Generic function to get models with optional filtering and relationships. - - It returns the SQLAlchemy statement and relationship info. - """ - if statement is None: - statement = select(model) - - if model_filter: - # Add all necessary joins for filtering - statement = add_filter_joins(statement, model, model_filter) - # Apply the filter - statement = model_filter.filter(statement) - - relationships_to_exclude = [] - statement, relationships_to_exclude = add_relationship_options( - statement, model, include_relationships, read_schema=read_schema - ) - - return statement, relationships_to_exclude - - -async def get_models( - db: AsyncSession, - model: type[MT], - *, - include_relationships: set[str] | None = None, - model_filter: Filter | None = None, - statement: SelectOfScalar[MT] | None = None, -) -> Sequence[MT]: - """Generic function to get models with optional filtering and relationships.""" - statement, relationships_to_exclude = get_models_query( - model, - include_relationships=include_relationships, - model_filter=model_filter, - statement=statement, - ) - result: Sequence[MT] = (await db.exec(statement)).unique().all() - - return set_empty_relationships(result, relationships_to_exclude) - - -async def get_paginated_models( - db: AsyncSession, - model: type[MT], - *, - include_relationships: set[str] | None = None, - model_filter: Filter | None = None, - statement: SelectOfScalar[MT] | None = None, - read_schema: type[MT] | None = None, -) -> Page[Sequence[DT]]: - """Generic function to get paginated models with optional filtering and relationships.""" - statement, relationships_to_exclude = get_models_query( - model, - include_relationships=include_relationships, - model_filter=model_filter, - statement=statement, - read_schema=read_schema, - ) - - result_page: Page[Sequence[DT]] = await apaginate(db, statement, params=None) - - result_page.items = set_empty_relationships( - result_page.items, relationships_to_exclude, setattr_strat=AttributeSettingStrategy.PYDANTIC - ) - - return result_page - - -async def get_model_by_id( - db: AsyncSession, model: type[MT], model_id: IDT, *, include_relationships: set[str] | None = None -) -> MT: - """Generic function to get a model by ID with specified relationships. - - Args: - db: AsyncSession for database operations - model: The SQLAlchemy model class - model_id: ID of the model instance to retrieve - include_relationships: Optional set of relationship names to include - - Returns: - Model instance - """ - if not hasattr(model, "id"): - err_msg: str = f"Model {model} does not have an id field." - raise ValueError(err_msg) - - statement: SelectOfScalar[MT] = select(model).where( - model.id == model_id # TODO: Fix this type error by creating a custom database model type that has id. - ) - - statement, relationships_to_exclude = add_relationship_options(statement, model, include_relationships) - - result: MT | None = (await db.exec(statement)).unique().one_or_none() - - result = validate_model_with_id_exists(result, model, model_id) - return set_empty_relationships(result, relationships_to_exclude) - - -async def get_nested_model_by_id( - db: AsyncSession, - parent_model: type[MT], - parent_id: IDT, - dependent_model: type[DT], - dependent_id: IDT, - parent_fk_name: str, - *, - include_relationships: set[str] | None = None, -) -> DT: - """Get nested model by checking foreign key relationship. - - Args: - db: Database session - parent_model: Parent model class - parent_id: Parent ID - dependent_model: Dependent model class - dependent_id: Dependent ID - parent_fk_name: Name of parent foreign key in dependent model - include_relationships: Optional relationships to include - """ - dependent_model_name = dependent_model.get_api_model_name().name_capital - parent_model_name = parent_model.get_api_model_name().name_capital - - # Validate foreign key exists on dependent - if not hasattr(dependent_model, parent_fk_name): - err_msg: str = f"{dependent_model_name} does not have a {parent_fk_name} field" - raise KeyError(err_msg) - - # Get both models and validate existence - await get_model_by_id(db, parent_model, parent_id) - dependent: DT = await get_model_by_id( - db, dependent_model, dependent_id, include_relationships=include_relationships - ) - - # Check relationship - if getattr(dependent, parent_fk_name) != parent_id: - err_msg = f"{dependent_model_name} {dependent_id} does not belong to {parent_model_name} {parent_id}" - raise DependentModelOwnershipError( - dependent_model=dependent_model, - dependent_id=dependent_id, - parent_model=parent_model, - parent_id=parent_id, - ) - - return dependent diff --git a/backend/app/api/common/crud/exceptions.py b/backend/app/api/common/crud/exceptions.py index af1a2d85..99170b3f 100644 --- a/backend/app/api/common/crud/exceptions.py +++ b/backend/app/api/common/crud/exceptions.py @@ -1,31 +1,31 @@ """Custom exceptions for CRUD operations.""" -from fastapi import status +from typing import TYPE_CHECKING -from app.api.common.exceptions import APIError +from app.api.common.exceptions import BadRequestError, ConflictError, InternalServerError, NotFoundError +from app.api.common.models.base import get_model_label, get_model_label_plural from app.api.common.models.custom_types import IDT, MT +if TYPE_CHECKING: + from collections.abc import Iterable -class ModelNotFoundError(APIError): - """Exception raised when a model is not found in the database.""" - http_status_code = status.HTTP_404_NOT_FOUND +class ModelNotFoundError(NotFoundError): + """Exception raised when a model is not found in the database.""" def __init__(self, model_type: type[MT] | None = None, model_id: IDT | None = None) -> None: self.model_type = model_type self.model_id = model_id - model_name = model_type.get_api_model_name().name_capital if model_type else "Model" + model_name = get_model_label(model_type) super().__init__( message=f"{model_name} {f'with id {model_id}' if model_id else ''} not found", ) -class DependentModelOwnershipError(APIError): +class DependentModelOwnershipError(BadRequestError): """Exception raised when a dependent model does not belong to the specified parent model.""" - http_status_code = status.HTTP_400_BAD_REQUEST - def __init__( self, dependent_model: type[MT], @@ -33,8 +33,8 @@ def __init__( parent_model: type[MT], parent_id: IDT, ) -> None: - dependent_model_name = dependent_model.get_api_model_name().name_capital - parent_model_name = parent_model.get_api_model_name().name_capital + dependent_model_name = get_model_label(dependent_model) + parent_model_name = get_model_label(parent_model) super().__init__( message=( @@ -42,3 +42,42 @@ def __init__( f"{parent_model_name} with ID {parent_id}." ) ) + + +class CRUDConfigurationError(InternalServerError): + """Exception raised when shared CRUD helpers are misconfigured for a model.""" + + def __init__(self, message: str) -> None: + super().__init__(message=message, log_message=message) + + +class ModelsNotFoundError(NotFoundError): + """Exception raised when one or more requested models do not exist.""" + + def __init__(self, model_type: type[MT], missing_ids: Iterable[IDT]) -> None: + model_name = get_model_label_plural(model_type) + formatted_ids = ", ".join(map(str, sorted(missing_ids))) + super().__init__(message=f"The following {model_name} do not exist: {formatted_ids}") + + +class NoLinkedItemsError(BadRequestError): + """Exception raised when a parent model has no linked items to operate on.""" + + def __init__(self, model_name_plural: str) -> None: + super().__init__(message=f"No {model_name_plural.lower()} are assigned") + + +class LinkedItemsAlreadyAssignedError(ConflictError): + """Exception raised when attempting to add already-linked items.""" + + def __init__(self, model_name_plural: str, duplicate_ids: Iterable[IDT]) -> None: + formatted_ids = ", ".join(map(str, sorted(duplicate_ids))) + super().__init__(message=f"{model_name_plural} with id {formatted_ids} are already assigned") + + +class LinkedItemsMissingError(NotFoundError): + """Exception raised when expected linked items are missing.""" + + def __init__(self, model_name_plural: str, missing_ids: Iterable[IDT]) -> None: + formatted_ids = ", ".join(map(str, sorted(missing_ids))) + super().__init__(message=f"{model_name_plural} with id {formatted_ids} not found") diff --git a/backend/app/api/common/crud/filtering.py b/backend/app/api/common/crud/filtering.py new file mode 100644 index 00000000..4e6dadc6 --- /dev/null +++ b/backend/app/api/common/crud/filtering.py @@ -0,0 +1,82 @@ +"""Filtering integration boundary for CRUD queries.""" +# spell-checker: ignore isouter + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi_filter.contrib.sqlalchemy import Filter +from sqlalchemy import Select + +from app.api.common.models.custom_types import MT + +if TYPE_CHECKING: + from typing import Any + + +def filter_has_values(filter_obj: Filter) -> bool: + """Return whether a fastapi-filter instance contains any active value.""" + for value in filter_obj.__dict__.values(): + if isinstance(value, Filter): + if filter_has_values(value): + return True + elif value is not None: + return True + return False + + +def apply_relationship_filter_joins( + statement: Select[tuple[MT]], + model: type[MT], + filter_obj: Filter, + path: list[str] | None = None, +) -> Select[tuple[MT]]: + """Add joins needed by nested relationship filters. + + fastapi-filter owns the field-level filtering. This helper only bridges its + nested filter objects into SQLAlchemy joins, keeping the fragile introspection + in one small module. + """ + path = path or [] + if not filter_has_values(filter_obj): + return statement + + relationship_filters = {name: value for name, value in filter_obj.__dict__.items() if isinstance(value, Filter)} + for rel_name, nested_filter in relationship_filters.items(): + if not filter_has_values(nested_filter): + continue + + current_model: Any = model + current_path: list[str] = [] + for ancestor in path: + current_model = getattr(current_model, ancestor).property.entity.entity + current_path.append(ancestor) + + relationship = getattr(current_model, rel_name) + prop = relationship.property + target = prop.entity.entity + + if getattr(prop, "secondary", None) is not None: + statement = statement.join(prop.secondary, isouter=bool(current_path)).join( + target, isouter=bool(current_path) + ) + else: + statement = statement.join(target, prop.primaryjoin, isouter=bool(current_path)) + + statement = apply_relationship_filter_joins(statement, model, nested_filter, path=[*path, rel_name]) + + return statement + + +def apply_filter(statement: Select[tuple[MT]], model: type[MT], model_filter: Filter | None) -> Select[tuple[MT]]: + """Apply fastapi-filter filtering, nested joins, and sorting to a select.""" + if model_filter is None: + return statement + + statement = apply_relationship_filter_joins(statement, model, model_filter) + statement = model_filter.filter(statement) + if getattr(model_filter, "order_by", None): + sort_func = getattr(model_filter, "sort", None) + if callable(sort_func): + statement = sort_func(statement) + return statement diff --git a/backend/app/api/common/crud/loading.py b/backend/app/api/common/crud/loading.py new file mode 100644 index 00000000..a803e62c --- /dev/null +++ b/backend/app/api/common/crud/loading.py @@ -0,0 +1,89 @@ +"""Relationship loading helpers for SQLAlchemy CRUD queries.""" +# spell-checker: ignore joinedload + +from enum import StrEnum +from typing import Any, Self, cast + +from pydantic import BaseModel +from sqlalchemy import Select, inspect +from sqlalchemy.orm import joinedload, noload, selectinload +from sqlalchemy.orm.attributes import QueryableAttribute + +from app.api.common.crud.exceptions import CRUDConfigurationError +from app.api.common.models.custom_types import MT + + +class RelationshipLoadStrategy(StrEnum): + """Loading strategies for relationships in SQLAlchemy queries.""" + + SELECTIN = "selectin" + JOINED = "joined" + + +class LoaderProfile(frozenset[str]): + """Named set of relationships to eagerly load for a response shape.""" + + def __new__(cls, relationships: set[str] | frozenset[str] = frozenset()) -> Self: + """Create a loader profile from relationship names.""" + return cast("Self", super().__new__(cls, relationships)) + + +def _get_model_relationships(model: type[MT]) -> dict[str, QueryableAttribute[Any]]: + """Return relationship attributes keyed by relationship name.""" + mapper = inspect(model) + if not mapper: + return {} + + return {rel.key: cast("QueryableAttribute[Any]", getattr(model, rel.key)) for rel in mapper.relationships} + + +def relationship_names(model: type[MT]) -> set[str]: + """Return valid relationship names for a model.""" + return set(_get_model_relationships(model)) + + +def relationship_attr(model: type[MT], name: str) -> QueryableAttribute[Any]: + """Return a typed relationship attribute by name.""" + relationships = _get_model_relationships(model) + try: + return relationships[name] + except KeyError as exc: + err_msg = f"{model.__name__} has no relationship named {name!r}" + raise CRUDConfigurationError(err_msg) from exc + + +def apply_loader_profile( + statement: Select, + model: type[MT], + loaders: LoaderProfile | frozenset[str] | set[str] | None = None, + *, + read_schema: type[BaseModel] | None = None, + load_strategy: RelationshipLoadStrategy = RelationshipLoadStrategy.SELECTIN, +) -> Select: + """Apply eager/noload options for relationships selected by a loader profile.""" + relationships = _get_model_relationships(model) + if not relationships: + return statement + + schema_relationships = ( + {name for name in relationships if name in read_schema.model_fields} + if read_schema is not None + else set(relationships) + ) + selected = (set(loaders) if loaders else set()) & schema_relationships + unknown = (set(loaders) if loaders else set()) - set(relationships) + if unknown: + formatted = ", ".join(sorted(unknown)) + err_msg = f"{model.__name__} has no relationship(s): {formatted}" + raise CRUDConfigurationError(err_msg) + + for rel_name in selected: + rel_attr = relationships[rel_name] + option = joinedload(rel_attr) if load_strategy == RelationshipLoadStrategy.JOINED else selectinload(rel_attr) + statement = statement.options(option) + + if read_schema is not None: + for rel_name in schema_relationships - selected: + statement = statement.options(noload(relationships[rel_name])) + + return statement diff --git a/backend/app/api/common/crud/pagination.py b/backend/app/api/common/crud/pagination.py new file mode 100644 index 00000000..7f7d20cc --- /dev/null +++ b/backend/app/api/common/crud/pagination.py @@ -0,0 +1,61 @@ +"""Pagination helpers for SQLAlchemy select statements.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from fastapi_pagination import create_page +from fastapi_pagination.api import resolve_params +from sqlalchemy import Select, func, inspect, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from app.api.common.models.custom_types import MT + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi_pagination import Page + from fastapi_pagination.bases import AbstractParams + + +def _primary_key_column(model: type[MT]) -> ColumnElement[object] | None: + mapper = inspect(model) + primary_keys = list(mapper.primary_key) if mapper else [] + return cast("ColumnElement[object]", primary_keys[0]) if len(primary_keys) == 1 else None + + +async def paginate_select( + db: AsyncSession, + statement: Select[Any], + *, + model: type[MT] | None = None, + params: AbstractParams | None = None, + mutate_items: Callable[[list[Any]], None] | None = None, +) -> Page[Any]: + """Paginate a select with distinct-safe counts for ORM entity queries.""" + resolved_params = resolve_params(params) + raw_params = resolved_params.to_raw_params() + + total = None + if raw_params.include_total: + if model is not None and (pk_col := _primary_key_column(model)) is not None: + subquery = statement.with_only_columns(pk_col).order_by(None).distinct().subquery() + total = (await db.execute(select(func.count()).select_from(subquery))).scalar_one() + else: + count_query = select(func.count()).select_from(statement.order_by(None).subquery()) + total = (await db.execute(count_query)).scalar_one() + + paginated_statement = statement.distinct() if model is not None else statement + limit = getattr(raw_params, "limit", None) + offset = getattr(raw_params, "offset", None) + if limit is not None: + paginated_statement = paginated_statement.limit(limit) + if offset is not None: + paginated_statement = paginated_statement.offset(offset) + + items = list((await db.execute(paginated_statement)).scalars().unique().all()) + if mutate_items is not None: + mutate_items(items) + + return cast("Page[Any]", create_page(items, total=total, params=resolved_params)) diff --git a/backend/app/api/common/crud/persistence.py b/backend/app/api/common/crud/persistence.py new file mode 100644 index 00000000..e5d45c72 --- /dev/null +++ b/backend/app/api/common/crud/persistence.py @@ -0,0 +1,49 @@ +"""Shared persistence helpers for CRUD operations.""" + +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncSession + + +class SupportsModelDump(Protocol): + """Schema protocol for update payloads.""" + + def model_dump( + self, + *, + exclude_unset: bool = False, + exclude: set[str] | None = None, + ) -> dict[str, object]: + """Return payload values for persistence.""" + ... + + +async def commit_and_refresh[ModelT]( + db: AsyncSession, + db_model: ModelT, + *, + add_before_commit: bool = True, +) -> ModelT: + """Commit the current transaction and refresh one model instance.""" + if add_before_commit: + db.add(db_model) + await db.commit() + await db.refresh(db_model) + return db_model + + +async def update_and_commit[ModelT]( + db: AsyncSession, + db_model: ModelT, + payload: SupportsModelDump, +) -> ModelT: + """Apply a partial update and persist the result.""" + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(db_model, key, value) + return await commit_and_refresh(db, db_model) + + +async def delete_and_commit(db: AsyncSession, db_model: object) -> None: + """Delete one model instance and commit the transaction.""" + await db.delete(db_model) + await db.commit() diff --git a/backend/app/api/common/crud/query.py b/backend/app/api/common/crud/query.py new file mode 100644 index 00000000..0be67c7a --- /dev/null +++ b/backend/app/api/common/crud/query.py @@ -0,0 +1,98 @@ +"""Small query helpers for common SQLAlchemy CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.exceptions import CRUDConfigurationError, ModelsNotFoundError +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import LoaderProfile, apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.utils import ensure_model_exists +from app.api.common.models.custom_types import IDT, MT + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from fastapi_filter.contrib.sqlalchemy import Filter + from fastapi_pagination import Page + from pydantic import BaseModel + + +async def page_models( + db: AsyncSession, + model: type[MT], + *, + loaders: LoaderProfile | frozenset[str] | set[str] | None = None, + filters: Filter | None = None, + statement: Select[tuple[Any]] | None = None, + read_schema: type[BaseModel] | None = None, + mutate_items: Callable[[list[Any]], None] | None = None, +) -> Page[Any]: + """Return a page of models matching a query.""" + statement = statement if statement is not None else select(model) + statement = apply_filter(statement, model, filters) + statement = apply_loader_profile(statement, model, loaders, read_schema=read_schema) + return await paginate_select(db, statement, model=model, mutate_items=mutate_items) + + +async def get_model( + db: AsyncSession, + model: type[MT], + model_id: IDT, + *, + loaders: LoaderProfile | frozenset[str] | set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> MT | None: + """Return a model by primary key, or None when missing.""" + if not hasattr(model, "id"): + err_msg = f"Model {model} does not have an id field." + raise CRUDConfigurationError(err_msg) + + statement: Select[tuple[MT]] = select(model).filter_by(id=model_id) + statement = apply_loader_profile(statement, model, loaders, read_schema=read_schema) + return (await db.execute(statement)).scalars().unique().one_or_none() + + +async def require_model( + db: AsyncSession, + model: type[MT], + model_id: IDT, + *, + loaders: LoaderProfile | frozenset[str] | set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> MT: + """Return a model by primary key or raise ModelNotFoundError.""" + return ensure_model_exists( + await get_model(db, model, model_id, loaders=loaders, read_schema=read_schema), + model, + model_id, + ) + + +async def require_models( + db: AsyncSession, + model: type[MT], + model_ids: set[int] | set[UUID], +) -> list[MT]: + """Return all requested models or raise when any ID is missing.""" + if not hasattr(model, "id"): + err_msg = f"{model} does not have an 'id' attribute" + raise CRUDConfigurationError(err_msg) + + statement = select(model).where(cast("Any", model).id.in_(model_ids)) + found_models = list((await db.execute(statement)).scalars().all()) + if len(found_models) != len(model_ids): + found_ids: set[int | UUID] = {cast("int | UUID", db_model.__dict__["id"]) for db_model in found_models} + missing_ids = cast("set[int | UUID]", model_ids) - found_ids + raise ModelsNotFoundError(model, missing_ids) + return found_models + + +async def exists(db: AsyncSession, model: type[MT], model_id: IDT) -> bool: + """Return whether a model exists.""" + return await get_model(db, model, model_id) is not None diff --git a/backend/app/api/common/crud/utils.py b/backend/app/api/common/crud/utils.py index 8d051bdb..9d1d9b59 100644 --- a/backend/app/api/common/crud/utils.py +++ b/backend/app/api/common/crud/utils.py @@ -1,271 +1,125 @@ -"""Common utility functions for CRUD operations.""" +"""Common non-query utility functions for CRUD operations.""" -from collections.abc import Sequence -from enum import Enum -from typing import TYPE_CHECKING, Any, overload -from uuid import UUID +from __future__ import annotations -from pydantic import BaseModel -from sqlalchemy import inspect -from sqlalchemy.orm import joinedload, selectinload -from sqlalchemy.orm.attributes import set_committed_value -from sqlmodel import col, select -from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql._expression_select_cls import SelectOfScalar +from typing import TYPE_CHECKING, Any, cast -from app.api.background_data.models import Material, ProductType -from app.api.common.crud.exceptions import ModelNotFoundError -from app.api.common.models.base import CustomBase +from app.api.common.crud.exceptions import ( + LinkedItemsAlreadyAssignedError, + LinkedItemsMissingError, + ModelNotFoundError, + NoLinkedItemsError, +) from app.api.common.models.custom_types import ET, IDT, MT -from app.api.data_collection.models import Product -from app.api.file_storage.models.models import FileParentType, ImageParentType if TYPE_CHECKING: - from sqlalchemy.orm.mapper import Mapper - - -### SQLALchemy Select Utilities ### -class RelationshipLoadStrategy(str, Enum): - """Loading strategies for relationships in SQLAlchemy queries.""" - - SELECTIN = "selectin" - JOINED = "joined" - - -def add_relationship_options( - statement: SelectOfScalar, - model: type[MT], - include: set[str] | None = None, - *, - read_schema: type[BaseModel] | None = None, - load_strategy: RelationshipLoadStrategy = RelationshipLoadStrategy.SELECTIN, -) -> tuple[SelectOfScalar, dict[str, bool]]: - """Add selectinload options and return info about relationships to exclude. - - Returns: - tuple: (modified statement, dict of {rel_name: is_collection} to exclude) - """ - # Get all relationships from the database model in one pass - inspector: Mapper[Any] = inspect(model, raiseerr=True) - all_db_rels = {rel.key: (getattr(model, rel.key), rel.uselist) for rel in inspector.relationships} - - # Determine which relationships are in scope (db ∩ schema) - in_scope_rels = ( - {name for name in all_db_rels if name in read_schema.model_fields} if read_schema else set(all_db_rels.keys()) - ) - - # Valid relationships to include (user_input ∩ in_scope) - to_include = set(include or []) & in_scope_rels - - # Add selectinload for included relationships - for rel_name in to_include: - rel_attr = all_db_rels[rel_name][0] - option = joinedload(rel_attr) if load_strategy == RelationshipLoadStrategy.JOINED else selectinload(rel_attr) - statement = statement.options(option) - - # Build exclusion dict (in_scope - included) - relationships_to_exclude = { - rel_name: all_db_rels[rel_name][1] # rel_name: is_collection - for rel_name in (in_scope_rels - to_include) - } - - return statement, relationships_to_exclude - - -# HACK: This is a quick way to set relationships to empty values in SQLAlchemy models. -# Ideally we make a clear distinction between database model and Pydantic models throughout the codebase via typing. -class AttributeSettingStrategy(str, Enum): - """Model type for relationship setting strategy.""" - - SQLALCHEMY = "sqlalchemy" # SQLAlchemy method (uses set_committed_value) - PYDANTIC = "pydantic" # Pydantic method (uses setattr) - - -@overload -def set_empty_relationships(results: MT, relationships_to_exclude: ..., setattr_strat: ...) -> MT: ... - - -@overload -def set_empty_relationships( - results: Sequence[MT], relationships_to_exclude: ..., setattr_strat: ... -) -> Sequence[MT]: ... - - -def set_empty_relationships( - results: MT | Sequence[MT], - relationships_to_exclude: dict[str, bool], - setattr_strat: AttributeSettingStrategy = AttributeSettingStrategy.SQLALCHEMY, -) -> MT | Sequence[MT]: - """Set relationships to empty values for SQLAlchemy models. - - Args: - results: Single model instance or sequence of instances - relationships_to_exclude: Dict of {rel_name: is_collection} to set to empty - setattr_strat: Strategy for setting attributes (SQLAlchemy or Pydantic) - - Returns: - MT | Sequence[MT]: Original result(s) with empty relationships set - """ - if not results or not relationships_to_exclude: - return results - - # Process single item or sequence - items = results if isinstance(results, Sequence) else [results] - - for item in items: - for rel_name, is_collection in relationships_to_exclude.items(): - if setattr_strat == AttributeSettingStrategy.PYDANTIC: - # Use setattr to set the attribute directly - setattr(item, rel_name, [] if is_collection else None) - elif setattr_strat == AttributeSettingStrategy.SQLALCHEMY: - # Settattr cannot be used directly on SQLAlchemy models as they are linked to the session - set_committed_value(item, rel_name, [] if is_collection else None) - else: - err_msg = f"Invalid setting strategy: {setattr_strat}" - raise ValueError(err_msg) - - return results + from collections.abc import Sequence + from uuid import UUID ### Error Handling Utilities ### -def validate_model_with_id_exists(db_get_response: MT | None, model_type: type[MT], model_id: IDT) -> MT: - """Validate that a model with a given id from a db.get() response exists. +def ensure_model_exists(db_result: MT | None, model_type: type[MT], model_id: IDT) -> MT: + """Ensure a model with a given ID exists, providing type-safe return. Args: - db_get_response: Model instance to check - model_type: Type of the model instance + db_result: Model instance from database query (may be None) + model_type: Type of the model class model_id: ID that was queried Returns: - MT: The model instance if it exists + MT: The model instance with guaranteed ID Raises: ModelNotFoundError: If model instance is None """ - if not db_get_response: + if not db_result: raise ModelNotFoundError(model_type, model_id) - return db_get_response - - -async def db_get_model_with_id_if_it_exists(db: AsyncSession, model_type: type[MT], model_id: IDT) -> MT: - """Get a model instance with a given id if it exists in the database. - - Args: - db: AsyncSession to use for the database query - model_type: Type of the model instance - model_id: ID that was queried - - Returns: - MT: The model instance if it exists - Raises: - ModelNotFoundError if the model is not found - - """ - return validate_model_with_id_exists(await db.get(model_type, model_id), model_type, model_id) - - -async def db_get_models_with_ids_if_they_exist( - db: AsyncSession, model_type: type[MT], model_ids: set[int] | set[UUID] -) -> Sequence[MT]: - """Get model instances with given ids, throwing error if any don't exist. - - Args: - db: AsyncSession to use for the database query - model_type: Type of the model instance - model_ids: IDs that must exist - - Returns: - Sequence[MT]: The model instances - - Raises: - ValueError: If any requested ID doesn't exist - """ - if not hasattr(model_type, "id"): - err_msg = f"{model_type} does not have an 'id' attribute" - raise ValueError(err_msg) - - # TODO: Fix typing issues by implementing databasemodel typevar in utils.typing - statement = select(model_type).where(col(model_type.id).in_(model_ids)) - found_models = (await db.exec(statement)).all() - - if len(found_models) != len(model_ids): - found_ids: set[int] | set[UUID] = {model.id for model in found_models} - missing_ids = model_ids - found_ids - err_msg = f"The following {model_type.get_api_model_name().plural_capital} do not exist: {missing_ids}" - raise ValueError(err_msg) - - return found_models + return cast("MT", db_result) -def validate_no_duplicate_linked_items( - new_ids: set[int] | set[UUID], existing_items: Sequence[MT] | None, model_name_plural: str, id_field: str = "id" +### Linked Item Validation ### +def validate_linked_items( + item_ids: set[int] | set[UUID], + existing_items: Sequence[Any] | None, + model_name_plural: str, + *, + id_attr: str = "id", + check_duplicates: bool = True, + check_existence: bool = True, ) -> None: - """Validate that no linked items are already assigned. + """Validate linked items for both duplicates and existence. Args: - new_ids: Set of new IDs to validate + item_ids: Set of IDs to validate existing_items: Sequence of existing items to check against model_name_plural: Name of the item model for error messages - id_field: Field name for the ID in the model (default: "id") + id_attr: Attribute name to read the ID from each item (default ``"id"``) + check_duplicates: Whether to check if items are already assigned + check_existence: Whether to check if items exist in the list Raises: - ValueError: If any items are duplicates + NoLinkedItemsError: If no items exist + LinkedItemsAlreadyAssignedError: If items are duplicates + LinkedItemsMissingError: If items don't exist """ if not existing_items: - err_msg = f"No {model_name_plural.lower()} are assigned" - raise ValueError() + raise NoLinkedItemsError(model_name_plural) - existing_ids = {getattr(item, id_field) for item in existing_items} - duplicates = new_ids & existing_ids - if duplicates: - err_msg = f"{model_name_plural} with id {set_to_str(duplicates)} are already assigned" - raise ValueError(err_msg) + existing_ids = {getattr(item, id_attr) for item in existing_items} + if check_duplicates: + duplicates = item_ids & existing_ids + if duplicates: + raise LinkedItemsAlreadyAssignedError(model_name_plural, duplicates) -def validate_linked_items_exist( - item_ids: set[int] | set[UUID], existing_items: Sequence[MT] | None, model_name_plural: str, id_field: str = "id" -) -> None: - """Validate that all item IDs exist in the given items. + if check_existence: + missing = item_ids - existing_ids + if missing: + raise LinkedItemsMissingError(model_name_plural, missing) - Args: - item_ids: IDs to validate - existing_items: Items to check against - model_name_plural: Name of the item model for error messages - id_field: Field name for the ID in the model (default: "id") - - Raises: - ValueError: If items don't exist or no items are assigned - """ - if not existing_items: - err_msg = f"No {model_name_plural.lower()} are assigned" - raise ValueError(err_msg) - existing_ids = {getattr(item, id_field) for item in existing_items} - missing = item_ids - existing_ids - if missing: - err_msg = f"{model_name_plural} with id {set_to_str(missing)} not found" - raise ValueError(err_msg) +### Formatting Utilities ### +def format_id_set(id_set: set[Any]) -> str: + """Format a set of IDs as a comma-separated string.""" + return ", ".join(map(str, sorted(id_set))) -### Printing Utilities ### -def set_to_str(set_: set[Any]) -> str: - """Convert a set of strings to a comma-separated string.""" - return ", ".join(map(str, set_)) +def enum_format_id_set(enum_set: set[ET]) -> str: + """Format a set of enum values as a comma-separated string.""" + return ", ".join(str(e.value) for e in sorted(enum_set, key=lambda x: x.value)) -def enum_set_to_str(set_: set[ET]) -> str: - """Convert a set of enum types to a comma-separated string.""" - return ", ".join(str(e.value) for e in set_) +def validate_no_duplicate_linked_items( + new_ids: set[int] | set[UUID], + existing_items: Sequence[Any] | None, + model_name_plural: str, + *, + id_attr: str = "id", +) -> None: + """Validate that new items are not already in the existing items list.""" + validate_linked_items( + new_ids, + existing_items, + model_name_plural, + id_attr=id_attr, + check_duplicates=True, + check_existence=False, + ) -### Parent Type Utilities ### -def get_file_parent_type_model(parent_type: FileParentType | ImageParentType) -> type[CustomBase]: - """Return the model for the given parent type. Utility function to avoid circular imports.""" - if parent_type == parent_type.PRODUCT: - return Product - if parent_type == parent_type.PRODUCT_TYPE: - return ProductType - if parent_type == parent_type.MATERIAL: - return Material - err_msg = f"Invalid parent type: {parent_type}" - raise ValueError(err_msg) +def validate_linked_items_exist( + item_ids: set[int] | set[UUID], + existing_items: Sequence[Any] | None, + model_name_plural: str, + *, + id_attr: str = "id", +) -> None: + """Validate that all item_ids are present in existing_items.""" + validate_linked_items( + item_ids, + existing_items, + model_name_plural, + id_attr=id_attr, + check_duplicates=False, + check_existence=True, + ) diff --git a/backend/app/api/common/exceptions.py b/backend/app/api/common/exceptions.py index 4810b020..2a5b78c7 100644 --- a/backend/app/api/common/exceptions.py +++ b/backend/app/api/common/exceptions.py @@ -1,4 +1,4 @@ -"""Base API exception.""" +"""Base API exception types.""" from fastapi import status @@ -9,7 +9,71 @@ class APIError(Exception): # Default status code for API errors. Can be overridden in subclasses. http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__(self, message: str, details: str | None = None): + def __init__(self, message: str, details: str | None = None, *, log_message: str | None = None): self.message = message self.details = details + self.log_message = log_message or message super().__init__(message) + + +class BadRequestError(APIError): + """Exception raised when the client supplied invalid data.""" + + http_status_code = status.HTTP_400_BAD_REQUEST + + +class UnauthorizedError(APIError): + """Exception raised when authentication is required or invalid.""" + + http_status_code = status.HTTP_401_UNAUTHORIZED + + +class ForbiddenError(APIError): + """Exception raised when the current user is not allowed to perform an action.""" + + http_status_code = status.HTTP_403_FORBIDDEN + + +class NotFoundError(APIError): + """Exception raised when a requested resource does not exist.""" + + http_status_code = status.HTTP_404_NOT_FOUND + + +class ConflictError(APIError): + """Exception raised when the requested change conflicts with current state.""" + + http_status_code = status.HTTP_409_CONFLICT + + +class FailedDependencyError(APIError): + """Exception raised when an upstream or dependent system returns unusable data.""" + + http_status_code = status.HTTP_424_FAILED_DEPENDENCY + + +class PayloadTooLargeError(APIError): + """Exception raised when a request payload exceeds configured limits.""" + + http_status_code = status.HTTP_413_CONTENT_TOO_LARGE + + +class ServiceUnavailableError(APIError): + """Exception raised when a required service is temporarily unavailable.""" + + http_status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + +class InternalServerError(APIError): + """Exception raised for unexpected internal application errors.""" + + http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + def __init__( + self, + message: str = "Internal server error", + details: str | None = None, + *, + log_message: str | None = None, + ) -> None: + super().__init__(message=message, details=details, log_message=log_message) diff --git a/backend/app/api/common/models/associations.py b/backend/app/api/common/models/associations.py index e0da3a43..3a5fe7eb 100644 --- a/backend/app/api/common/models/associations.py +++ b/backend/app/api/common/models/associations.py @@ -1,41 +1,30 @@ """Linking tables for cross-module many-to-many relationships.""" -from typing import TYPE_CHECKING +from pydantic import BaseModel, Field +from sqlalchemy import Enum +from sqlalchemy.orm import Mapped, mapped_column -from sqlmodel import Column, Enum, Field, Relationship - -from app.api.common.models.base import CustomLinkingModelBase, TimeStampMixinBare from app.api.common.models.enums import Unit -if TYPE_CHECKING: - from app.api.background_data.models import Material - from app.api.data_collection.models import Product - -### Material-Product Association Models ### -class MaterialProductLinkBase(CustomLinkingModelBase): - """Base model for Material-Product links.""" +### Pydantic base schema (shared with schemas/associations.py) ### +class MaterialProductLinkBaseSchema(BaseModel): + """Base schema for Material-Product links. Used by Pydantic schemas only, not ORM.""" quantity: float = Field(gt=0, description="Quantity of the material in the product") unit: Unit = Field( default=Unit.KILOGRAM, - sa_column=Column(Enum(Unit)), description=f"Unit of the quantity, e.g. {', '.join([u.value for u in Unit][:3])}", ) -class MaterialProductLink(MaterialProductLinkBase, TimeStampMixinBare, table=True): - """Association table to link Material with Product.""" +### ORM Mixin ### +class MaterialProductLinkBase: + """ORM mixin for Material-Product links.""" - material_id: int = Field( - foreign_key="material.id", primary_key=True, description="ID of the material in the product" - ) - product_id: int = Field( - foreign_key="product.id", primary_key=True, description="ID of the product with the material" + quantity: Mapped[float] = mapped_column(doc="Quantity of the material in the product") + unit: Mapped[Unit] = mapped_column( + Enum(Unit), + default=Unit.KILOGRAM, + doc=f"Unit of the quantity, e.g. {', '.join([u.value for u in Unit][:3])}", ) - - material: "Material" = Relationship(back_populates="product_links", sa_relationship_kwargs={"lazy": "selectin"}) - product: "Product" = Relationship(back_populates="bill_of_materials", sa_relationship_kwargs={"lazy": "selectin"}) - - def __str__(self) -> str: - return f"{self.quantity} {self.unit} of {self.material.name} in {self.product.name}" diff --git a/backend/app/api/common/models/base.py b/backend/app/api/common/models/base.py index 1a43b9c8..8bf3bedb 100644 --- a/backend/app/api/common/models/base.py +++ b/backend/app/api/common/models/base.py @@ -1,217 +1,88 @@ -"""Base model and generic mixins for SQLModel models.""" +"""Base model helpers and generic mixins for ORM models.""" import re -from datetime import datetime -from enum import Enum -from functools import cached_property -from typing import Any, ClassVar, Generic, Self, TypeVar +from datetime import datetime # noqa: TC003 # Used in runtime for ORM mapping, not just for type annotations +from typing import TYPE_CHECKING -from pydantic import BaseModel, ConfigDict, computed_field, model_validator -from sqlalchemy import TIMESTAMP, func +import inflect +from sqlalchemy import DateTime, func +from sqlalchemy import inspect as sa_inspect from sqlalchemy.dialects.postgresql import JSONB -from sqlmodel import Column, Field, SQLModel - - -### Base Model ### -class APIModelName(BaseModel): - """Mixin to add models names for naming in API routes and documentation.""" - - name_camel: str # The base name is expected to be in CamelCase - - @computed_field - @cached_property - def plural_camel(self) -> str: - """Get the plural form of the model name. - - Example: "Taxonomy" -> "Taxonomies" - """ - return self.pluralize(self.name_camel) - - @computed_field - @cached_property - def name_capital(self) -> str: - return self.camel_to_capital(self.name_camel) - - @computed_field - @cached_property - def plural_capital(self) -> str: - return self.camel_to_capital(self.plural_camel) - - @computed_field - @cached_property - def name_slug(self) -> str: - return self.camel_to_slug(self.name_camel) - - @computed_field - @cached_property - def plural_slug(self) -> str: - return self.camel_to_slug(self.plural_camel) - - @computed_field - @cached_property - def name_snake(self) -> str: - return self.camel_to_snake(self.name_camel) - - @computed_field - @cached_property - def plural_snake(self) -> str: - return self.camel_to_snake(self.plural_camel) - - @staticmethod - def pluralize(name: str) -> str: - """Convert a word to its plural form.""" - if name.endswith("y"): - return name[:-1] + "ies" - if name.endswith("s"): - return name + "es" - return name + "s" - - @staticmethod - def camel_to_capital(name: str) -> str: - """Convert CamelCase to Capital Case.""" - return re.sub(r"(? str: - """Convert CamelCase to slug-case.""" - return re.sub(r"(? str: - """Convert CamelCase to snake_case.""" - return re.sub(r"(? APIModelName: - """Initialize api_model_name for the class.""" - if cls.api_model_name is None: - cls.api_model_name = APIModelName(name_camel=cls.__name__) - return cls.api_model_name - - -# TODO: Base class should not inherit from SQLModel but from Pydantic's BaseModel -class CustomBase(CustomBaseBare, SQLModel): - """Base class for all models.""" - - api_model_name: ClassVar[APIModelName | None] = None # The name of the model used in API routes - - @classmethod - def get_api_model_name(cls) -> APIModelName: - """Initialize api_model_name for the class.""" - if cls.api_model_name is None: - cls.api_model_name = APIModelName(name_camel=cls.__name__) - return cls.api_model_name +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +if TYPE_CHECKING: + from typing import Any -class CustomLinkingModelBase(CustomBase): - """Base class for linking models.""" +class Base(DeclarativeBase): + """SQLAlchemy 2.0 declarative base for all ORM models.""" -# TODO: Separate schema and database model base classes. Schema models should inherit from Pydantic's BaseModel. -# Database models should inherit from SQLModel. -class CustomDatabaseModelBase(CustomBase, SQLModel): - """Base class for models with database tables.""" + def model_dump(self, *, exclude: set[str] | None = None, exclude_unset: bool = False) -> dict[str, Any]: + """Serialize ORM instance to a dict.""" + exclude = exclude or set() + if exclude_unset: + unmodified = sa_inspect(self).unmodified + return { + c.key: getattr(self, c.key) + for c in self.__table__.columns + if c.key not in exclude and c.key not in unmodified + } + return {c.key: getattr(self, c.key) for c in self.__table__.columns if c.key not in exclude} - id: int = Field( - default=None, - primary_key=True, - ) +_INFLECT_ENGINE = inflect.engine() -### Mixins ### -## Timestamps ## -# TODO: Improve typing. Mixins should not inherit from SQLModel. -class TimeStampMixinBare: - """Bare mixin to add created_at and updated_at columns to Pydantic BaseModel-based classes. - Can be used to mixin timestamp properties for classes which already have BaseModel as base class. - """ +def pluralize_camel_name(name: str) -> str: + """Pluralize the final word in a CamelCase name.""" + parts = re.split(r"(? str: + """Convert CamelCase to Capital Case.""" + return re.sub(r"(? str: + """Return a human-readable singular label for a model-like class.""" + if model_type is None: + return default -class SingleParentMixin[ParentTypeEnum](SQLModel): - """Mixin to ensure an object belongs to exactly one parent.""" + explicit_label = getattr(model_type, "model_label", None) + if isinstance(explicit_label, str): + return explicit_label - # TODO: Implement improved polymorphic associations in SQLModel after this issue is resolved: https://github.com/fastapi/sqlmodel/pull/1226 + return camel_to_capital(getattr(model_type, "__name__", default)) - parent_type: ParentTypeEnum # Type of the parent object. To be overridden by derived classes. - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 +def get_model_label_plural(model_type: type[object], *, default: str = "Models") -> str: + """Return a human-readable plural label for a model-like class.""" + explicit_label_plural = getattr(model_type, "model_label_plural", None) + if isinstance(explicit_label_plural, str): + return explicit_label_plural - @classmethod - def get_parent_type_description(cls, enum_class: type[Enum]) -> str: - """Generate description string for parent_type field using actual enum class.""" - return f"Type of the parent object, e.g. {', '.join(t.value for t in enum_class)}" + model_name = getattr(model_type, "__name__", default.removesuffix("s")) + return camel_to_capital(pluralize_camel_name(model_name)) - @cached_property - def possible_parent_fields(self) -> list[str]: - """Get all possible parent ID field names.""" - return [f"{t.value!s}_id" for t in type(self.parent_type)] - @cached_property - def set_parent_fields(self) -> list[str]: - """Get currently set parent ID field names.""" - return [field for field in self.possible_parent_fields if getattr(self, field, None) is not None] - - @model_validator(mode="after") - def validate_single_parent(self) -> Self: - """Ensure parent_type and ID are consistent.""" - if len(self.set_parent_fields) != 1: - err_msg = f"Exactly one parent ID must be set, found {self.set_parent_fields}" - raise ValueError(err_msg) - - expected_field = f"{self.parent_type!s}_id" - if expected_field not in self.set_parent_fields: - err_msg = f"Parent type {self.parent_type} doesn't match set parent ID" - raise ValueError(err_msg) - - return self - - @cached_property - def parent_id(self) -> int: - """Get the ID of the current parent object.""" - field = f"{self.parent_type.value!s}_id" - return getattr(self, field) - - def set_parent(self, parent_type: ParentTypeEnum, parent_id: int) -> None: - """Set the parent type and ID.""" - self.parent_type = parent_type - - # Clear existing parents - for field in self.set_parent_fields: - setattr(self, field, None) - - # Set new parent ID - field = f"{parent_type.value}_id" - if field not in self.possible_parent_fields: - err_msg = f"Parent field '{field}' not found. Available fields: {self.possible_parent_fields}" - raise AttributeError(err_msg) +### Mixins ### +## Timestamps ## +class TimeStampMixinBare: + """Mixin that adds created_at and updated_at columns with server-side defaults.""" - setattr(self, field, parent_id) + created_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), server_default=func.now(), default=None + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + default=None, # spell-checker: ignore onupdate + ) ## Metadata JSON field ## @@ -221,6 +92,4 @@ class MetadataMixin: Note: Validation of the metadata content should be done in the DTO schemas. """ - metadata_json: dict[str, Any] | None = Field( - default=None, alias="metadata", description="Object metadata as a JSON dict", sa_column=Column(JSONB) - ) + metadata_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None) diff --git a/backend/app/api/common/models/custom_fields.py b/backend/app/api/common/models/custom_fields.py deleted file mode 100644 index 343940e1..00000000 --- a/backend/app/api/common/models/custom_fields.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Custom Pydantic fields for database models.""" - -from typing import Annotated - -from pydantic import AnyUrl, HttpUrl, PlainSerializer - -# HTTP URL that is stored as string in the database. -HttpUrlInDB = Annotated[HttpUrl, PlainSerializer(lambda x: str(x), return_type=str)] -AnyUrlInDB = Annotated[AnyUrl, PlainSerializer(lambda x: str(x), return_type=str)] diff --git a/backend/app/api/common/models/custom_types.py b/backend/app/api/common/models/custom_types.py index 392c0098..4f7aa351 100644 --- a/backend/app/api/common/models/custom_types.py +++ b/backend/app/api/common/models/custom_types.py @@ -4,26 +4,20 @@ from typing import TypeVar from uuid import UUID -from fastapi_filter.contrib.sqlalchemy import Filter - -from app.api.common.models.base import CustomBaseBare, CustomLinkingModelBase - -### Type aliases ### -# Type alias for ID types -IDT = int | UUID +from app.api.common.models.base import Base ### TypeVars ### -# TypeVar for models -MT = TypeVar("MT", bound=CustomBaseBare) +# ID type: constrains parameters that accept either integer or UUID primary keys +IDT = TypeVar("IDT", bound=int | UUID) -# Typevar for dependent models -DT = TypeVar("DT", bound=CustomBaseBare) +# Any model (id may be None; not yet persisted) +MT = TypeVar("MT", bound=Base) -# Typevar for linking models -LMT = TypeVar("LMT", bound=CustomLinkingModelBase) +# Dependent model in a nested relationship +DT = TypeVar("DT", bound=Base) -# Typevar for Enum classes -ET = TypeVar("ET", bound=Enum) +# Linking / association model +LMT = TypeVar("LMT", bound=Base) -# Typevar for Filter classes -FT = TypeVar("FT", bound=Filter) +# Enum subclass +ET = TypeVar("ET", bound=Enum) diff --git a/backend/app/api/common/openapi_examples.py b/backend/app/api/common/openapi_examples.py new file mode 100644 index 00000000..ef75ebd5 --- /dev/null +++ b/backend/app/api/common/openapi_examples.py @@ -0,0 +1,36 @@ +"""Shared OpenAPI examples used across multiple API domains.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +def openapi_example( + value: object, + *, + summary: str | None = None, + description: str | None = None, +) -> dict[str, object]: + """Build a single named OpenAPI example payload.""" + example: dict[str, object] = {"value": value} + if summary is not None: + example["summary"] = summary + if description is not None: + example["description"] = description + return example + + +def openapi_examples(**examples: dict[str, object]) -> dict[str, Example]: + """Build a typed mapping for FastAPI `openapi_examples=` arguments.""" + return cast("dict[str, Example]", examples) + + +IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES = openapi_examples( + nested_metadata=openapi_example( + r'{"foo_key": "foo_value", "bar_key": {"nested_key": "nested_value"}}', + summary="Nested metadata JSON string", + ) +) diff --git a/backend/app/api/common/ownership.py b/backend/app/api/common/ownership.py new file mode 100644 index 00000000..0773534f --- /dev/null +++ b/backend/app/api/common/ownership.py @@ -0,0 +1,30 @@ +"""Ownership validation helpers shared across API modules.""" + +from typing import cast + +from pydantic import UUID4 +from sqlalchemy import inspect, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from app.api.auth.exceptions import UserOwnershipError +from app.api.common.crud.exceptions import ModelNotFoundError +from app.api.common.models.custom_types import IDT, MT + + +async def get_user_owned_object( + db: AsyncSession, + model: type[MT], + model_id: IDT, + owner_id: UUID4, + user_fk: str = "owner_id", +) -> MT: + """Validate user ownership of a model instance with a many-to-one relationship.""" + model_id_column = cast("InstrumentedAttribute[IDT]", inspect(model).primary_key[0]) + statement = select(model).where(model_id_column == model_id) + db_model = (await db.execute(statement)).scalars().unique().one_or_none() + if db_model is None: + raise ModelNotFoundError(model, model_id) + if getattr(db_model, user_fk) != owner_id: + raise UserOwnershipError(model_type=model, model_id=model_id, user_id=owner_id) + return db_model diff --git a/backend/app/api/common/routers/__init__.py b/backend/app/api/common/routers/__init__.py index e69de29b..8cf44283 100644 --- a/backend/app/api/common/routers/__init__.py +++ b/backend/app/api/common/routers/__init__.py @@ -0,0 +1 @@ +"""General routes and route-utilities for the API.""" diff --git a/backend/app/api/common/routers/dependencies.py b/backend/app/api/common/routers/dependencies.py index 6ab56aaa..d119914e 100644 --- a/backend/app/api/common/routers/dependencies.py +++ b/backend/app/api/common/routers/dependencies.py @@ -2,10 +2,25 @@ from typing import Annotated -from fastapi import Depends -from sqlmodel.ext.asyncio.session import AsyncSession +from fastapi import Depends, Request +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.common.exceptions import ServiceUnavailableError from app.core.database import get_async_session +from app.core.runtime import get_request_services # FastAPI dependency for getting an asynchronous database session AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)] + + +def get_external_http_client(request: Request) -> AsyncClient: + """Return the shared outbound HTTP client from application state.""" + http_client = get_request_services(request).http_client + if http_client is None: + msg = "Outbound HTTP client is not available." + raise ServiceUnavailableError(msg) + return http_client + + +ExternalHTTPClientDep = Annotated[AsyncClient, Depends(get_external_http_client)] diff --git a/backend/app/api/common/routers/exceptions.py b/backend/app/api/common/routers/exceptions.py index ca1a21e2..be001391 100644 --- a/backend/app/api/common/routers/exceptions.py +++ b/backend/app/api/common/routers/exceptions.py @@ -1,44 +1,59 @@ -"""FastAPI exception handlers to raise HTTP errors for common exceptions.""" +"""FastAPI exception handlers for API and framework exceptions.""" -import logging -from collections.abc import Awaitable, Callable +from __future__ import annotations + +from typing import TYPE_CHECKING from fastapi import FastAPI, Request, status -from fastapi.responses import JSONResponse +from loguru import logger from pydantic import ValidationError +from app.api.auth.services.rate_limiter import RateLimitExceededError, rate_limit_exceeded_handler from app.api.common.exceptions import APIError +from app.core.responses import build_problem_response -### Generic exception handlers ### +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from starlette.responses import Response -logger = logging.getLogger() +### Generic exception handlers ### def create_exception_handler( default_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, -) -> Callable[[Request, Exception], Awaitable[JSONResponse]]: +) -> Callable[[Request, Exception], Awaitable[Response]]: """Create a FastAPI exception handler. Can take in a default status code for built-in exceptions.""" - async def handler(_: Request, exc: Exception) -> JSONResponse: + async def handler(request: Request, exc: Exception) -> Response: if isinstance(exc, APIError): status_code = exc.http_status_code - detail = {"message": exc.message} + detail = exc.message + log_message = exc.log_message + extra = {"code": exc.__class__.__name__} if exc.details: - detail["details"] = exc.details + extra["errors"] = exc.details else: status_code = default_status_code - detail = {"message": str(exc)} + detail = "Internal server error" if status_code >= 500 else str(exc) + log_message = str(exc) + extra = {"code": exc.__class__.__name__} - # TODO: Add traceback location to log message (perhaps easier by just using loguru) # Log based on status code severity. Can be made more granular if needed. if status_code >= 500: - logger.error("%s: %s", exc.__class__.__name__, str(exc), exc_info=exc) + logger.opt(exception=True).error(f"{exc.__class__.__name__}: {log_message}") elif status_code >= 400 and status_code != 404: - logger.warning("%s: %s", exc.__class__.__name__, str(exc)) + logger.warning(f"{exc.__class__.__name__}: {log_message}") else: - logger.info("%s: %s", exc.__class__.__name__, str(exc)) + logger.info(f"{exc.__class__.__name__}: {log_message}") - return JSONResponse(status_code=status_code, content={"detail": detail}) + return build_problem_response( + request=request, + status_code=status_code, + detail=detail, + code=extra.pop("code"), + extra=extra, + ) return handler @@ -46,16 +61,15 @@ async def handler(_: Request, exc: Exception) -> JSONResponse: ### Exception handler registration ### def register_exception_handlers(app: FastAPI) -> None: """Register all exception handlers with the FastAPI app.""" - # TODO: When going public, any errors resulting from internal server logic - # should be logged and not exposed to the client, instead returning a 500 error with a generic message. - # Custom API exceptions app.add_exception_handler(APIError, create_exception_handler()) - # Standard Python exceptions - # TODO: These should be replaced with custom exceptions + # Rate limiting + app.add_exception_handler(RateLimitExceededError, rate_limit_exceeded_handler) + + # Temporary compatibility handler for legacy domain validation paths. + # Avoid catching RuntimeError broadly so programmer errors still surface normally. app.add_exception_handler(ValueError, create_exception_handler(status.HTTP_400_BAD_REQUEST)) - app.add_exception_handler(RuntimeError, create_exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)) # NOTE: This is a validation error for internal logic, not for user input app.add_exception_handler(ValidationError, create_exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)) diff --git a/backend/app/api/common/routers/file_mounts.py b/backend/app/api/common/routers/file_mounts.py new file mode 100644 index 00000000..efb5ea39 --- /dev/null +++ b/backend/app/api/common/routers/file_mounts.py @@ -0,0 +1,50 @@ +"""File mounts and static file routes for the application.""" + +from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from app.core.config import settings + +FAVICON_ROUTE = "/favicon.ico" + + +def mount_static_directories(app: FastAPI) -> None: + """Mount static file directories to the FastAPI application. + + Args: + app: FastAPI application instance + """ + # Mount the uploads directory if it exists. Note: if this is called + # from lifespan, the directory should have been ensured already. + if settings.uploads_path.exists(): + app.mount("/uploads", StaticFiles(directory=settings.uploads_path), name="uploads") + else: + err_msg = ( + f"Uploads path '{settings.uploads_path}' does not exist. Ensure storage directories are created at startup." + ) + raise RuntimeError(err_msg) + + # Static files directory is part of the repo and should exist; mount + # it if present, otherwise skip to avoid raising at import time. + if settings.static_files_path.exists(): + app.mount("/static", StaticFiles(directory=settings.static_files_path), name="static") + else: + err_msg = ( + f"Static files path '{settings.static_files_path}' does not exist." + " Ensure storage directories are created at startup." + ) + raise RuntimeError(err_msg) + + +def register_favicon_route(app: FastAPI) -> None: + """Register favicon redirect route. + + Args: + app: FastAPI application instance + """ + + @app.get(FAVICON_ROUTE, include_in_schema=False) + async def favicon() -> RedirectResponse: + """Redirect favicon requests to static files.""" + return RedirectResponse(url="/static/favicon.ico") diff --git a/backend/app/api/common/routers/health.py b/backend/app/api/common/routers/health.py new file mode 100644 index 00000000..a3fdde5f --- /dev/null +++ b/backend/app/api/common/routers/health.py @@ -0,0 +1,110 @@ +"""Health check and readiness probe endpoints.""" + +import asyncio +import logging +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.__version__ import version as app_version +from app.core.config import settings +from app.core.database import async_engine +from app.core.redis import ping_redis +from app.core.runtime import get_request_services + +if TYPE_CHECKING: + from typing import Any + +HEALTHY_STATUS = "healthy" +UNHEALTHY_STATUS = "unhealthy" + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["health"]) + + +def healthy_check(component: str, *, details: dict[str, Any] | None = None) -> dict[str, Any]: + """Return a healthy check payload.""" + payload: dict[str, Any] = {"component": component, "status": HEALTHY_STATUS} + if details: + payload["details"] = details + return payload + + +def unhealthy_check(component: str, error: str) -> dict[str, Any]: + """Return an unhealthy check payload with error details.""" + return {"component": component, "status": UNHEALTHY_STATUS, "error": error} + + +async def check_database() -> dict[str, Any]: + """Check PostgreSQL database connectivity.""" + try: + async with async_engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + if result.scalar_one() != 1: + return unhealthy_check("database", "Database SELECT 1 returned unexpected result") + return healthy_check("database", details={"driver": "postgresql"}) + except SQLAlchemyError, OSError, RuntimeError: + logger.exception("Database health check failed") + return unhealthy_check("database", "Database connection failed") + + +async def check_redis(request: Request) -> dict[str, Any]: + """Check Redis cache connectivity.""" + redis_client = get_request_services(request).redis + + if redis_client is None: + return unhealthy_check("redis", "Redis client not initialized") + + try: + ping = await ping_redis(redis_client) + if ping: + return healthy_check("redis") + return unhealthy_check("redis", "Redis ping returned False") + except OSError, RuntimeError, TimeoutError: + logger.exception("Redis health check failed") + return unhealthy_check("redis", "Redis connection failed") + + +async def perform_health_checks(request: Request) -> dict[str, dict[str, Any]]: + """Perform parallel health checks for all service dependencies.""" + database_check, redis_check = await asyncio.gather(check_database(), check_redis(request), return_exceptions=False) + + return { + "database": database_check, + "redis": redis_check, + } + + +@router.get("/live", include_in_schema=False) +async def liveness_probe() -> JSONResponse: + """Liveness probe: signals the container is running.""" + return JSONResponse(content={"status": "alive", "version": app_version}, status_code=200) + + +@router.get("/health", include_in_schema=False) +async def readiness_probe(request: Request) -> JSONResponse: + """Readiness probe: signals the application is ready to serve requests. + + Performs health checks on all dependencies (database, Redis). + Returns HTTP 200 only if all dependencies are healthy. + Returns HTTP 503 if any dependency is unhealthy. + """ + checks = await perform_health_checks(request) + + # Determine overall status + all_healthy = all(check.get("status") == HEALTHY_STATUS for check in checks.values()) + overall_status = HEALTHY_STATUS if all_healthy else UNHEALTHY_STATUS + status_code = 200 if all_healthy else 503 + + response_data = { + "status": overall_status, + "version": app_version, + "environment": settings.environment, + "checks": checks, + } + + return JSONResponse(content=response_data, status_code=status_code) diff --git a/backend/app/api/common/routers/main.py b/backend/app/api/common/routers/main.py index 98dec8e2..da2a89d4 100644 --- a/backend/app/api/common/routers/main.py +++ b/backend/app/api/common/routers/main.py @@ -6,8 +6,9 @@ from app.api.background_data.routers.admin import router as background_data_admin_router from app.api.background_data.routers.public import router as background_data_public_router from app.api.data_collection.routers import router as data_collection_router +from app.api.file_storage.routers import router as file_storage_router from app.api.newsletter.routers import router as newsletter_backend_router -from app.api.plugins.rpi_cam.routers.main import router as rpi_cam_router +from app.api.plugins.rpi_cam.routers import router as rpi_cam_router router = APIRouter() @@ -16,6 +17,7 @@ background_data_admin_router, background_data_public_router, data_collection_router, + file_storage_router, *auth_routers, rpi_cam_router, newsletter_backend_router, diff --git a/backend/app/api/common/routers/openapi.py b/backend/app/api/common/routers/openapi.py index 45e8b078..79c13961 100644 --- a/backend/app/api/common/routers/openapi.py +++ b/backend/app/api/common/routers/openapi.py @@ -1,57 +1,48 @@ """Utilities for including or excluding endpoints in the public OpenAPI schema and documentation.""" -from collections.abc import Callable -from typing import Any +from types import MethodType +from typing import TYPE_CHECKING, Any, cast -from asyncache import cached -from cachetools import LRUCache from fastapi import APIRouter, FastAPI, Security from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import HTMLResponse +from fastapi.requests import Request +from fastapi.responses import HTMLResponse, Response from fastapi.routing import APIRoute from fastapi.types import DecoratedCallable from app.api.auth.dependencies import current_active_superuser -from app.api.common.config import settings +from app.api.common.config import settings as api_settings +from app.api.common.routers.file_mounts import FAVICON_ROUTE +from app.core.config import Environment, settings +from app.core.responses import conditional_html_response, conditional_json_response + +if TYPE_CHECKING: + from collections.abc import Callable ### Constants ### OPENAPI_PUBLIC_INCLUSION_EXTENSION: str = "x-public" ### Route inclusion functions ### +def _html_body_text(response: HTMLResponse) -> str: + """Normalize Starlette HTML response bodies to plain text.""" + return bytes(response.body).decode("utf-8") + + class PublicAPIRouter(APIRouter): """A router that marks all routes as public in the OpenAPI schema. Example: public_router = PublicAPIRouter(prefix="/products", tags=["products"]) """ - def api_route( - self, path: str, *args: Any, **kwargs: Any - ) -> Callable[[DecoratedCallable], DecoratedCallable]: # Allow Any-typed (kw)args as this is an override + def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]: # noqa: ANN401 # Any-typed (kw)args are expected by the parent method signatures + """Override the default api_route method to add the public inclusion extension to the OpenAPI schema.""" existing_extra = kwargs.get("openapi_extra") or {} kwargs["openapi_extra"] = {**existing_extra, OPENAPI_PUBLIC_INCLUSION_EXTENSION: True} return super().api_route(path, *args, **kwargs) -def public_endpoint(router_method: Callable) -> Callable: - """Wrapper function to mark an endpoint method as public. - - Example: product_router = APIRouter() - get = public_endpoint(product_router.get) - post = public_endpoint(product_router.post) - """ - - def wrapper( - *args: Any, **kwargs: Any - ) -> Callable[[DecoratedCallable], DecoratedCallable]: # Allow Any-typed (kw)args as this is a wrapper - existing_extra = kwargs.get("openapi_extra") or {} - kwargs["openapi_extra"] = {**existing_extra, OPENAPI_PUBLIC_INCLUSION_EXTENSION: True} - return router_method(*args, **kwargs) - - return wrapper - - def mark_router_routes_public(router: APIRouter) -> None: """Mark all routes in a router as public.""" for route in router.routes: @@ -61,78 +52,99 @@ def mark_router_routes_public(router: APIRouter) -> None: ### OpenAPI schema generation ### -def get_filtered_openapi_schema(app: FastAPI) -> dict[str, Any]: - """Generate OpenAPI schema with only public endpoints.""" - openapi_schema: dict[str, Any] = get_openapi( - title=settings.public_docs.title, - version=settings.public_docs.version, - description=settings.public_docs.description, +def _build_public_openapi(app: FastAPI) -> dict[str, Any]: + """Generate the public OpenAPI schema, keeping only routes marked with x-public.""" + schema: dict[str, Any] = get_openapi( + title=api_settings.public_docs.title, + version=api_settings.public_docs.version, + description=api_settings.public_docs.description, routes=app.routes, - license_info=settings.public_docs.license_info, + license_info=api_settings.public_docs.license_info, ) - paths = openapi_schema["paths"] - filtered_paths = {} - - # Only include paths marked as public - for path, path_item in paths.items(): + filtered_paths: dict[str, Any] = {} + for path, path_item in schema["paths"].items(): for method, operation in path_item.items(): if operation.get(OPENAPI_PUBLIC_INCLUSION_EXTENSION, False): - if path not in filtered_paths: - filtered_paths[path] = {} - filtered_paths[path][method] = operation + filtered_paths.setdefault(path, {})[method] = operation + schema["paths"] = filtered_paths + schema["x-tagGroups"] = api_settings.public_docs.x_tag_groups + schema["info"]["x-api-version"] = api_settings.public_docs.version + schema["info"]["x-deprecation-policy"] = "Breaking changes are documented in release notes." + return schema - openapi_schema["paths"] = filtered_paths - # Add tag groups for better organization in Redoc - openapi_schema["x-tagGroups"] = settings.public_docs.x_tag_groups +def init_openapi_docs(app: FastAPI) -> FastAPI: + """Initialize OpenAPI documentation endpoints. - return openapi_schema + Overrides app.openapi() so the public filtered schema is the canonical schema + for the app (the standard FastAPI integration point for tooling and middleware). + The /openapi.json endpoint simply delegates to app.openapi(). + """ + def _public_openapi(_: FastAPI) -> dict[str, Any]: + return _build_public_openapi(app) + + openapi_app = cast("Any", app) + openapi_app.openapi = MethodType(_public_openapi, app) -def init_openapi_docs(app: FastAPI) -> FastAPI: - """Initialize OpenAPI documentation endpoints.""" public_docs_router = APIRouter(prefix="", include_in_schema=False) # Public documentation @public_docs_router.get("/openapi.json") - @cached(LRUCache(maxsize=1)) - async def get_openapi_schema() -> dict[str, Any]: - return get_filtered_openapi_schema(app) + async def get_openapi_schema(request: Request) -> Response: + return conditional_json_response(request, app.openapi()) - @cached(LRUCache(maxsize=1)) @public_docs_router.get("/docs") - async def get_swagger_docs() -> HTMLResponse: - return get_swagger_ui_html(openapi_url="/openapi.json", title="Public API Documentation") + async def get_swagger_docs(request: Request) -> Response: + html = get_swagger_ui_html( + openapi_url="/openapi.json", + title="Public API Documentation", + swagger_favicon_url=FAVICON_ROUTE, + ) + return conditional_html_response(request, _html_body_text(html)) - @cached(LRUCache(maxsize=1)) @public_docs_router.get("/redoc") - async def get_redoc_docs() -> HTMLResponse: - return get_redoc_html(openapi_url="/openapi.json", title="Public API Documentation - ReDoc") + async def get_redoc_docs(request: Request) -> Response: + html = get_redoc_html( + openapi_url="/openapi.json", title="Public API Documentation - ReDoc", redoc_favicon_url=FAVICON_ROUTE + ) + return conditional_html_response(request, _html_body_text(html)) app.include_router(public_docs_router) - # Full documentation (requires superuser) - full_docs_router = APIRouter(prefix="", dependencies=[Security(current_active_superuser)], include_in_schema=False) + # Full documentation — requires superuser in staging/prod, open in dev/testing + full_docs_deps = ( + [] if settings.environment in (Environment.DEV, Environment.TESTING) else [Security(current_active_superuser)] + ) + full_docs_router = APIRouter(prefix="", dependencies=full_docs_deps, include_in_schema=False) @full_docs_router.get("/openapi_full.json") - @cached(LRUCache(maxsize=1)) - async def get_full_openapi() -> dict[str, Any]: - return get_openapi( - title=settings.full_docs.title, - version=settings.full_docs.version, - description=settings.full_docs.description, + async def get_full_openapi(request: Request) -> Response: + payload = get_openapi( + title=api_settings.full_docs.title, + version=api_settings.full_docs.version, + description=api_settings.full_docs.description, routes=app.routes, - license_info=settings.full_docs.license_info, + license_info=api_settings.full_docs.license_info, ) + payload["info"]["x-api-version"] = api_settings.full_docs.version + payload["info"]["x-deprecation-policy"] = "Breaking changes are documented in release notes." + return conditional_json_response(request, payload) @full_docs_router.get("/docs/full") - async def get_full_swagger_docs() -> HTMLResponse: - return get_swagger_ui_html(openapi_url="/openapi_full.json", title="Full API Documentation") + async def get_full_swagger_docs(request: Request) -> Response: + html = get_swagger_ui_html( + openapi_url="/openapi_full.json", title="Full API Documentation", swagger_favicon_url=FAVICON_ROUTE + ) + return conditional_html_response(request, _html_body_text(html)) @full_docs_router.get("/redoc/full") - async def get_full_redoc_docs() -> HTMLResponse: - return get_redoc_html(openapi_url="/openapi_full.json", title="Full API Documentation") + async def get_full_redoc_docs(request: Request) -> Response: + html = get_redoc_html( + openapi_url="/openapi_full.json", title="Full API Documentation", redoc_favicon_url=FAVICON_ROUTE + ) + return conditional_html_response(request, _html_body_text(html)) app.include_router(full_docs_router) diff --git a/backend/app/api/common/schemas/associations.py b/backend/app/api/common/schemas/associations.py index a24bd4ec..04f93466 100644 --- a/backend/app/api/common/schemas/associations.py +++ b/backend/app/api/common/schemas/associations.py @@ -2,7 +2,7 @@ from pydantic import Field, PositiveInt -from app.api.common.models.associations import MaterialProductLinkBase +from app.api.common.models.associations import MaterialProductLinkBaseSchema as MaterialProductLinkBase from app.api.common.models.enums import Unit from app.api.common.schemas.base import ( AssociationModelReadSchemaWithTimeStamp, diff --git a/backend/app/api/common/schemas/base.py b/backend/app/api/common/schemas/base.py index 0fb4f95e..1ed09f1e 100644 --- a/backend/app/api/common/schemas/base.py +++ b/backend/app/api/common/schemas/base.py @@ -12,9 +12,12 @@ field_serializer, ) -from app.api.background_data.models import MaterialBase -from app.api.common.models.base import TimeStampMixinBare -from app.api.data_collection.models import ProductBase +from app.api.common.schemas.field_mixins import ( + CircularityPropertiesFields, + MaterialFields, + PhysicalPropertiesFields, + ProductFields, +) ### Common Validation ### @@ -24,23 +27,48 @@ def serialize_datetime_with_z(dt: datetime) -> str: ### Base Schemas ### -class BaseCreateSchema(BaseModel): - """Base schema for all create operations.""" +class BaseInputSchema(BaseModel): + """Shared base for request-body schemas.""" - model_config = ConfigDict( - extra="forbid", # Prevent additional fields not in schema - str_strip_whitespace=True, # Strip whitespace from strings - ) + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +class BaseCreateSchema(BaseInputSchema): + """Base schema for all create operations.""" class BaseReadSchema(BaseModel): - """Base schema for all read operations.""" + """Base schema for all read operations. + + Subclasses MUST narrow the ``id`` type to either ``PositiveInt`` or + ``UUID4`` so the OpenAPI spec emits the correct JSON-Schema type + (``integer`` vs ``string``). The union kept here is only a fallback. + """ + + model_config = ConfigDict(from_attributes=True) id: PositiveInt | UUID4 -class BaseReadSchemaWithTimeStampBare(TimeStampMixinBare): - """Bare Timestamp reading mixin.""" +class IntIdReadSchema(BaseReadSchema): + """Read schema for models with integer primary keys.""" + + id: PositiveInt + + +class UUIDIdReadSchema(BaseReadSchema): + """Read schema for models with UUID primary keys.""" + + id: UUID4 + + +class TimestampReadSchemaMixin(BaseModel): + """Shared timestamp fields for read schemas.""" + + model_config = ConfigDict(from_attributes=True) + + created_at: datetime | None = None + updated_at: datetime | None = None @field_serializer("created_at", "updated_at", when_used="unless-none") def serialize_timestamps(self, dt: datetime, _info: FieldSerializationInfo) -> str: @@ -48,43 +76,49 @@ def serialize_timestamps(self, dt: datetime, _info: FieldSerializationInfo) -> s return serialize_datetime_with_z(dt) -class BaseReadSchemaWithTimeStamp(BaseReadSchema, BaseReadSchemaWithTimeStampBare): +class BaseReadSchemaWithTimeStamp(BaseReadSchema, TimestampReadSchemaMixin): """Base schema for all read operations, including timestamps.""" -class AssociationModelReadSchemaWithTimeStamp(BaseModel, BaseReadSchemaWithTimeStampBare): +class IntIdReadSchemaWithTimeStamp(IntIdReadSchema, TimestampReadSchemaMixin): + """Read schema for integer-PK models with timestamps.""" + + +class UUIDIdReadSchemaWithTimeStamp(UUIDIdReadSchema, TimestampReadSchemaMixin): + """Read schema for UUID-PK models with timestamps.""" + + +class AssociationModelReadSchemaWithTimeStamp(TimestampReadSchemaMixin): """Base schema for all read operations on association models, including timestamps. Association models don't have a separate primary key, so the id field is excluded """ -class BaseUpdateSchema(BaseModel): +class BaseUpdateSchema(BaseInputSchema): """Base schema for all update operations.""" - model_config = ConfigDict( - extra="forbid", # Prevent additional fields not in schema - str_strip_whitespace=True, # Strip whitespace from strings - ) - ### Base Schemas to avoid Circular Dependencies ### # These are defined in the same file to avoid circular dependencies with other schemas ## Material Schemas ## -class MaterialRead(BaseReadSchema, MaterialBase): +class MaterialRead(IntIdReadSchema, MaterialFields): """Schema for reading material information.""" ## Product Schemas ## -class ProductRead(BaseReadSchemaWithTimeStamp, ProductBase): +class ProductRead(IntIdReadSchemaWithTimeStamp, ProductFields, PhysicalPropertiesFields, CircularityPropertiesFields): """Base schema for reading product information.""" product_type_id: PositiveInt | None = None - owner_id: UUID4 + owner_id: UUID4 | None = None + owner_username: str | None = None + + thumbnail_url: str | None = None - # HACK: Include parent id and mount_in_parent in base product read schema + # Include component metadata here because the same read schema serves both base products and components. # TODO: separate components and base products on the model level parent_id: PositiveInt | None = None amount_in_parent: int | None = Field(default=None, description="Quantity within parent product") @@ -97,6 +131,3 @@ def serialize_timestamps(self, dt: datetime, _info: FieldSerializationInfo) -> s class ComponentRead(ProductRead): """Base schema for reading component information.""" - - parent_id: PositiveInt | None = None - amount_in_parent: int | None = Field(default=None, description="Quantity within parent product") diff --git a/backend/app/api/common/schemas/custom_fields.py b/backend/app/api/common/schemas/custom_fields.py new file mode 100644 index 00000000..3aa7aa41 --- /dev/null +++ b/backend/app/api/common/schemas/custom_fields.py @@ -0,0 +1,10 @@ +"""Shared fields for DTO schemas.""" + +from typing import Annotated + +from pydantic import AnyUrl, HttpUrl, PlainSerializer +from pydantic.networks import UrlConstraints + +# HTTP URL that is stored as string in the database. +type HttpUrlToDB = Annotated[HttpUrl, PlainSerializer(str, return_type=str), UrlConstraints(max_length=250)] +type AnyUrlToDB = Annotated[AnyUrl, PlainSerializer(str, return_type=str), UrlConstraints(max_length=250)] diff --git a/backend/app/api/common/schemas/field_mixins.py b/backend/app/api/common/schemas/field_mixins.py new file mode 100644 index 00000000..2eabd4b2 --- /dev/null +++ b/backend/app/api/common/schemas/field_mixins.py @@ -0,0 +1,107 @@ +"""Pure Pydantic field mixins shared by API schemas. + +These mixins deliberately avoid ORM field configuration so read and +request schemas can evolve independently from persistence models. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.background_data.models import TaxonomyDomain + + +class PhysicalPropertiesFields(BaseModel): + """Shared physical property fields for read schemas. + + No gt=0 constraints here — validation belongs on write schemas / model base. + Read schemas must accept whatever the DB returns. + """ + + weight_g: float | None = None + height_cm: float | None = None + width_cm: float | None = None + depth_cm: float | None = None + volume_cm3: float | None = None + + +class CircularityPropertiesFields(BaseModel): + """Shared circularity property fields for read schemas. + + No max_length constraints here — validation belongs on write schemas / model base. + """ + + recyclability_observation: str | None = None + recyclability_comment: str | None = None + recyclability_reference: str | None = None + repairability_observation: str | None = None + repairability_comment: str | None = None + repairability_reference: str | None = None + remanufacturability_observation: str | None = None + remanufacturability_comment: str | None = None + remanufacturability_reference: str | None = None + + +class ProductFields(BaseModel): + """Shared product fields for API schemas.""" + + name: str = Field(min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) + brand: str | None = Field(default=None, max_length=100) + model: str | None = Field(default=None, max_length=100) + dismantling_notes: str | None = Field( + default=None, + max_length=500, + description="Notes on the dismantling process of the product.", + ) + dismantling_time_start: datetime = Field(default_factory=lambda: datetime.now(UTC)) + dismantling_time_end: datetime | None = None + + +class MaterialFields(BaseModel): + """Shared material fields for API schemas.""" + + name: str = Field(min_length=2, max_length=100, description="Name of the Material") + description: str | None = Field(default=None, max_length=500, description="Description of the Material") + source: str | None = Field( + default=None, + max_length=100, + description="Source of the material data, e.g. URL, IRI or citation key", + ) + density_kg_m3: float | None = Field(default=None, gt=0, description="Volumetric density (kg/m^3)") + is_crm: bool | None = Field(default=None, description="Is this material a Critical Raw Material (CRM)?") + + +class ProductTypeFields(BaseModel): + """Shared product-type fields for API schemas.""" + + name: str = Field(min_length=2, max_length=100, description="Name of the Product Type.") + description: str | None = Field(default=None, max_length=500, description="Description of the Product Type.") + + +class CategoryFields(BaseModel): + """Shared category fields for API schemas.""" + + name: str = Field(min_length=2, max_length=250, description="Name of the category") + description: str | None = Field(default=None, max_length=500, description="Description of the category") + external_id: str | None = Field(default=None, description="ID of the category in the external taxonomy") + + +class TaxonomyFields(BaseModel): + """Shared taxonomy fields for API schemas.""" + + model_config = ConfigDict(use_enum_values=True) + + name: str = Field(min_length=2, max_length=100) + version: str | None = Field(min_length=1, max_length=50) + description: str | None = Field(default=None, max_length=500) + domains: set[TaxonomyDomain] = Field( + description=f"Domains of the taxonomy, e.g. {{{', '.join([d.value for d in TaxonomyDomain][:3])}}}" + ) + source: str | None = Field( + default=None, + max_length=500, + description="Source of the taxonomy data, e.g. URL, IRI or citation key", + ) diff --git a/backend/app/api/common/search_utils.py b/backend/app/api/common/search_utils.py new file mode 100644 index 00000000..c5ea0e18 --- /dev/null +++ b/backend/app/api/common/search_utils.py @@ -0,0 +1,130 @@ +"""Shared utilities for PostgreSQL full-text (tsvector) and trigram search. + +Usage in a Filter subclass +-------------------------- +1. Add a ``search_vector`` computed column to the model (see + ``app.api.data_collection.models.Product`` for the pattern). +2. Subclass ``TSVectorSearchMixin`` *before* ``Filter`` in the MRO. +3. Implement ``_search_vector_col`` and ``_trigram_cols`` as classmethods. +4. Remove ``search_model_fields`` from the inner ``Constants`` class so + fastapi-filter does not generate its own ILIKE queries for ``search``. + +Example: + class MyFilter(TSVectorSearchMixin, Filter): + search: str | None = None + order_by: list[str] | None = None + + @classmethod + def _search_vector_col(cls): + return cast("ColumnElement[Any]", MyModel.search_vector) + + @classmethod + def _trigram_cols(cls): + return [cast("SearchableColumn", MyModel.name)] + + class Constants(Filter.Constants): + model = MyModel + # search_model_fields intentionally omitted +""" + +# spell-checker: ignore trgm + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import ColumnElement, Select, func, or_ +from sqlalchemy.orm import Query + +if TYPE_CHECKING: + from fastapi_filter.contrib.sqlalchemy import Filter as _FilterBase +else: + _FilterBase = object + +type SearchableColumn = Any # Column-like; typed loosely to avoid SA import coupling + + +# ─── Clause builders ────────────────────────────────────────────────────────── + + +def build_text_search_clause( + search: str, + search_vector_col: ColumnElement[Any], + *trigram_fields: SearchableColumn, +) -> ColumnElement[bool]: + """Return a WHERE clause combining tsvector @@ tsquery with optional trigram fuzzy matches. + + Args: + search: The raw search string from the user. + search_vector_col: The computed ``tsvector`` column on the model. + *trigram_fields: Zero or more text columns to fuzzy-match with ``%`` (gin_trgm_ops). + + Returns: + An OR-combined SQLAlchemy ``ColumnElement`` suitable for ``.where()``. + """ + ts_query = func.websearch_to_tsquery("english", search) + search_lower = search.lower() + conditions: list[ColumnElement[bool]] = [search_vector_col.op("@@")(ts_query)] + conditions.extend([func.lower(field).op("%")(search_lower) for field in trigram_fields]) + return or_(*conditions) + + +def apply_ts_rank_ordering(query: Select[Any], search_vector_col: ColumnElement[Any], search: str) -> Select[Any]: + """Order *query* by ``ts_rank`` DESC, safe for use with ``SELECT DISTINCT``. + + Postgres requires that every ORDER BY expression under ``SELECT DISTINCT`` + appears in the select list. We label the rank expression and add it to the + select, then order by the label so the resulting SQL satisfies that rule. + The extra column is computed per-row from the tsvector + search, so + duplicate rows share the same rank and ``DISTINCT`` still collapses them. + """ + rank = func.ts_rank(search_vector_col, func.websearch_to_tsquery("english", search)).label("ts_rank_score") + return query.add_columns(rank).order_by(rank.desc()) + + +# ─── Mixin ──────────────────────────────────────────────────────────────────── + + +class TSVectorSearchMixin(_FilterBase): + """Mixin that replaces fastapi-filter's default ILIKE ``search`` with tsvector + trigram. + + Must appear before ``Filter`` in the class MRO so that ``super().filter()`` + delegates to the real ``Filter.filter()`` after we have cleared ``self.search``. + + By default, ``ts_rank`` ordering is added whenever a search term is active. + Subclasses may override ``_apply_rank_ordering`` to change this behaviour + (e.g. ``ProductFilter`` only ranks by relevance when no explicit ``order_by`` + is requested, or when the caller passes ``order_by=rank``). + """ + + @classmethod + def _search_vector_col(cls) -> ColumnElement[Any]: + """Return the tsvector column for this model. Must be implemented by the subclass.""" + msg = f"{cls.__name__} must implement _search_vector_col()" + raise NotImplementedError(msg) + + @classmethod + def _trigram_cols(cls) -> list[SearchableColumn]: + """Return the list of text columns to fuzzy-match with trigram similarity. + + Override in the subclass to enable trigram fallback on specific fields. + """ + return [] + + def _apply_rank_ordering(self, query: Select[Any], search: str) -> Select[Any]: + """Append ``ts_rank`` ordering to *query*. Override to change the behaviour.""" + return apply_ts_rank_ordering(query, self._search_vector_col(), search) + + def filter(self, query: Query | Select[Any]) -> Query | Select[Any]: + """Apply tsvector + trigram search, replacing fastapi-filter's default ILIKE logic.""" + search: str | None = getattr(self, "search", None) + # Temporarily suppress self.search so fastapi-filter's super().filter() + # does not try to apply it (we have intentionally omitted search_model_fields). + object.__setattr__(self, "search", None) + query = super().filter(query) + object.__setattr__(self, "search", search) + + if search: + clause = build_text_search_clause(search, self._search_vector_col(), *self._trigram_cols()) + query = query.where(clause) + query = self._apply_rank_ordering(query, search) + + return query diff --git a/backend/app/api/common/utils/__init__.py b/backend/app/api/common/utils/__init__.py deleted file mode 100644 index 1290a9c6..00000000 --- a/backend/app/api/common/utils/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Common utility functions for the API.""" - -from .ownership import get_user_owned_object - -__all__ = [ - "get_user_owned_object", -] diff --git a/backend/app/api/common/utils/ownership.py b/backend/app/api/common/utils/ownership.py deleted file mode 100644 index 61406c12..00000000 --- a/backend/app/api/common/utils/ownership.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Utility functions for validating user ownership of model instances.""" - -from pydantic import UUID4 -from sqlmodel.ext.asyncio.session import AsyncSession - -from app.api.auth.exceptions import UserOwnershipError -from app.api.auth.models import User -from app.api.common.crud.base import get_nested_model_by_id -from app.api.common.crud.exceptions import DependentModelOwnershipError -from app.api.common.models.custom_types import IDT, MT - - -async def get_user_owned_object( - db: AsyncSession, - model: type[MT], - model_id: IDT, - owner_id: UUID4, - user_fk: str = "owner_id", -) -> MT: - """Validate user ownership of a model instance with a many-to-one relationship.""" - try: - return await get_nested_model_by_id( - db=db, - parent_model=User, - parent_id=owner_id, - dependent_model=model, - dependent_id=model_id, - parent_fk_name=user_fk, - ) - except DependentModelOwnershipError: - raise UserOwnershipError(model_type=model, model_id=model_id, user_id=owner_id) from None diff --git a/backend/app/api/data_collection/crud.py b/backend/app/api/data_collection/crud.py deleted file mode 100644 index 52d75708..00000000 --- a/backend/app/api/data_collection/crud.py +++ /dev/null @@ -1,488 +0,0 @@ -"""CRUD operations for the models related to data collection.""" - -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any - -from pydantic import UUID4 -from sqlalchemy import Delete, delete -from sqlalchemy.orm import selectinload -from sqlmodel import col, select -from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql._expression_select_cls import SelectOfScalar - -from app.api.auth.models import User -from app.api.background_data.models import ( - Material, - ProductType, -) -from app.api.common.crud.associations import get_linking_model_with_ids_if_it_exists -from app.api.common.crud.utils import ( - db_get_model_with_id_if_it_exists, - db_get_models_with_ids_if_they_exist, - validate_linked_items_exist, - validate_no_duplicate_linked_items, -) -from app.api.common.models.associations import MaterialProductLink -from app.api.common.schemas.associations import ( - MaterialProductLinkCreateWithinProduct, - MaterialProductLinkCreateWithinProductAndMaterial, - MaterialProductLinkUpdate, -) -from app.api.data_collection.filters import ProductFilterWithRelationships -from app.api.data_collection.models import PhysicalProperties, Product -from app.api.data_collection.schemas import ( - ComponentCreateWithComponents, - PhysicalPropertiesCreate, - PhysicalPropertiesUpdate, - ProductCreateWithComponents, - ProductUpdate, - ProductUpdateWithProperties, -) -from app.api.file_storage.crud import ParentStorageOperations, create_file, create_image, delete_file, delete_image -from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video -from app.api.file_storage.schemas import ( - FileCreate, - ImageCreateFromForm, -) - -if TYPE_CHECKING: - from pydantic import EmailStr - from sqlmodel.sql._expression_select_cls import SelectOfScalar - -# NOTE: GET operations are implemented in the crud.common.base module -# TODO: Implement ownership checks for products and files -# TODO: Consider wether or not this should be a simple ownership check -# or if users can do get operations on any objects owned by members of the same organization - - -### PhysicalProperty CRUD operations ### -async def get_physical_properties(db: AsyncSession, product_id: int) -> PhysicalProperties: - """Get physical properties for a product.""" - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - if not product.physical_properties: - err_msg: str = f"Physical properties for product with id {product_id} not found" - raise ValueError(err_msg) - - return product.physical_properties - - -async def create_physical_properties( - db: AsyncSession, - physical_properties: PhysicalPropertiesCreate, - product_id: int, -) -> PhysicalProperties: - """Create physical properties for a product.""" - # Validate that product exists and doesn't have physical properties - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - if product.physical_properties: - err_msg: str = f"Product with id {product_id} already has physical properties" - raise ValueError(err_msg) - - # Create physical properties - db_physical_property = PhysicalProperties( - **physical_properties.model_dump(), - product_id=product_id, - ) - db.add(db_physical_property) - await db.commit() - await db.refresh(db_physical_property) - - return db_physical_property - - -async def update_physical_properties( - db: AsyncSession, product_id: int, physical_properties: PhysicalPropertiesUpdate -) -> PhysicalProperties: - """Update physical properties for a product.""" - # Validate that product exists and has physical properties - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - if not (db_physical_properties := product.physical_properties): - err_msg: EmailStr = f"Physical properties for product with id {product_id} not found" - raise ValueError(err_msg) - - physical_properties_data: dict[str, Any] = physical_properties.model_dump(exclude_unset=True) - db_physical_properties.sqlmodel_update(physical_properties_data) - - db.add(db_physical_properties) - await db.commit() - await db.refresh(db_physical_properties) - return db_physical_properties - - -async def delete_physical_properties(db: AsyncSession, product: Product) -> None: - """Delete physical properties for a product.""" - # Validate that product exists and has physical properties - if not (db_physical_properties := product.physical_properties): - err_msg: EmailStr = f"Physical properties for product with id {product.id} not found" - raise ValueError(err_msg) - - await db.delete(db_physical_properties) - await db.commit() - - -### Product CRUD operations ### -## Basic CRUD operations ### -async def get_product_trees( - db: AsyncSession, - recursion_depth: int = 1, - *, - parent_id: int | None = None, - product_filter: ProductFilterWithRelationships | None = None, -) -> Sequence[Product]: - """Get product with their components up to specified depth. - - If parent_id is None, get top-level products. - """ - # Validate that parent product exists - if parent_id: - await db_get_model_with_id_if_it_exists(db, Product, parent_id) - - statement: SelectOfScalar[Product] = ( - select(Product) - .where(Product.parent_id == parent_id) - .options(selectinload(Product.components, recursion_depth=recursion_depth)) - ) - - if product_filter: - statement = product_filter.filter(statement) - - return (await db.exec(statement)).all() - - -# TODO: refactor this function and create_product to use a common function for creating components. -# See the category CRUD functions for an example. -async def create_component( - db: AsyncSession, - component: ComponentCreateWithComponents, - parent_product_id: int, - *, - _is_recursive_call: bool = False, # Flag to track recursive calls - owner_id: UUID4 | None = None, -) -> Product: - """Add a component to a product.""" - # Validate bill of materials - if not component.bill_of_materials and not component.components: - err_msg: str = "Product needs materials or components" - raise ValueError(err_msg) - - if not _is_recursive_call: - # Validate that parent product exists and fetch its owner ID - db_parent_product = await db_get_model_with_id_if_it_exists(db, Product, parent_product_id) - owner_id = db_parent_product.owner_id - - # Create component - component_data: dict[str, Any] = component.model_dump( - exclude={ - "components", - "owner_id", - "physical_properties", - "videos", - "bill_of_materials", - } - ) - db_component = Product( - **component_data, - parent_id=parent_product_id, - owner_id=owner_id, # pyright: ignore[reportArgumentType] # owner ID is guaranteed by database fetch above - ) - db.add(db_component) - await db.flush() # Assign component ID - - # Create properties - if component.physical_properties: - db_physical_property = PhysicalProperties( - **component.physical_properties.model_dump(), - product_id=db_component.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - ) - db.add(db_physical_property) - - # Create videos - if component.videos: - for video in component.videos: - db_video = Video( - **video.model_dump(), - product_id=db_component.id, - ) - db.add(db_video) - - # Create bill of materials - if component.bill_of_materials: - # Validate materials exist - material_ids = {material.material_id for material in component.bill_of_materials} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) - - # Create material-product links - db.add_all( - MaterialProductLink(**material.model_dump(), product_id=db_component.id) # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - for material in component.bill_of_materials - ) - - # Create subcomponents recursively - if component.components: - for subcomponent in component.components: - await create_component( - db, - subcomponent, - parent_product_id=db_component.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - owner_id=owner_id, - _is_recursive_call=True, - ) - - # Commit only when it's not a recursive call - if not _is_recursive_call: - await db.commit() - await db.refresh(db_component) - - return db_component - - -async def create_product( - db: AsyncSession, - product: ProductCreateWithComponents, - owner_id: UUID4, -) -> Product: - """Create a new product in the database.""" - # Validate that product type exists - if product.product_type_id: - await db_get_model_with_id_if_it_exists(db, ProductType, product.product_type_id) - - # Validate that owner exists - # TODO: Replace all these existence and auth checks with dependencies on the router level - await db_get_model_with_id_if_it_exists(db, User, owner_id) - - # Create product - product_data: dict[str, Any] = product.model_dump( - exclude={ - "components", - "physical_properties", - "videos", - "bill_of_materials", - } - ) - db_product = Product(**product_data, owner_id=owner_id) - - db.add(db_product) - await db.flush() # Assign product ID - - # Create properties - if product.physical_properties: - db_physical_properties = PhysicalProperties( - **product.physical_properties.model_dump(), - product_id=db_product.id, # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - ) - db.add(db_physical_properties) - - # Create videos - if product.videos: - for video in product.videos: - db_video = Video( - **video.model_dump(), - product_id=db_product.id, - ) - db.add(db_video) - - # Create bill of materials - if product.bill_of_materials: - # Validate materials exist - material_ids: set[int] = {material.material_id for material in product.bill_of_materials} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) - - # Create material-product links - db.add_all( - MaterialProductLink(**material.model_dump(), product_id=db_product.id) # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - for material in product.bill_of_materials - ) - - # TODO: Support creation of images and files within product creation - # Create components recursively - if product.components: - for component in product.components: - await create_component( - db, - component, - parent_product_id=db_product.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - owner_id=owner_id, - _is_recursive_call=True, - ) - - await db.commit() - await db.refresh(db_product) - return db_product - - -async def update_product( - db: AsyncSession, product_id: int, product: ProductUpdate | ProductUpdateWithProperties -) -> Product: - """Update an existing product in the database.""" - # TODO: Consider whether to have the CRUD layer take in model objects (like db_product) - # pre-fetched and pre-validated by dependencies at the router level, instead of fetching the - # product by id on the CRUD layer, to reduce the load on the DB, for all RUD operations in the app - - # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate that product type exists - if product.product_type_id: - await db_get_model_with_id_if_it_exists(db, ProductType, product.product_type_id) - - product_data: dict[str, Any] = product.model_dump(exclude_unset=True, exclude={"physical_properties"}) - db_product.sqlmodel_update(product_data) - - # Update properties - if isinstance(product, ProductUpdateWithProperties) and product.physical_properties: - await update_physical_properties(db, product_id, product.physical_properties) - - db.add(db_product) - await db.commit() - await db.refresh(db_product) - return db_product - - -async def delete_product(db: AsyncSession, product_id: int) -> None: - """Delete a product from the database.""" - # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Delete stored files - await product_files_crud.delete_all(db, product_id) - await product_images_crud.delete_all(db, product_id) - - await db.delete(db_product) - await db.commit() - - -## Product Storage operations ## -product_files_crud = ParentStorageOperations[Product, File, FileCreate, FileFilter]( - parent_model=Product, - storage_model=File, - parent_type=FileParentType.PRODUCT, - parent_field="product_id", - create_func=create_file, - delete_func=delete_file, -) - -product_images_crud = ParentStorageOperations[Product, Image, ImageCreateFromForm, ImageFilter]( - parent_model=Product, - storage_model=Image, - parent_type=ImageParentType.PRODUCT, - parent_field="product_id", - create_func=create_image, - delete_func=delete_image, -) - - -## Bill of Materials operations ## -async def add_materials_to_product( - db: AsyncSession, product_id: int, material_links: list[MaterialProductLinkCreateWithinProduct] -) -> list[MaterialProductLink]: - """Add materials to a product.""" - # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate materials exist - material_ids: set[int] = {material_link.material_id for material_link in material_links} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) - - # Validate no duplicate materials - if db_product.bill_of_materials: - validate_no_duplicate_linked_items(material_ids, db_product.bill_of_materials, "Materials", "material_id") - - # Create material-product links - db_material_product_links: list[MaterialProductLink] = [ - MaterialProductLink(**material_link.model_dump(), product_id=product_id) for material_link in material_links - ] - db.add_all(db_material_product_links) - await db.commit() - await db.refresh(db_material_product_links) - - return db_material_product_links - - -async def add_material_to_product( - db: AsyncSession, - product_id: int, - material_link: MaterialProductLinkCreateWithinProduct | MaterialProductLinkCreateWithinProductAndMaterial, - *, - material_id: int | None = None, -) -> MaterialProductLink: - """Add a material to a product.""" - if isinstance(material_link, MaterialProductLinkCreateWithinProductAndMaterial): - if material_id is None: - err_msg: str = "Material ID is required for this operation" - raise ValueError(err_msg) - - # Cast to MaterialProductLinkCreateWithinProduct - material_link = MaterialProductLinkCreateWithinProduct(material_id=material_id, **material_link.model_dump()) - - # Add material link to product - db_material_link_list: list[MaterialProductLink] = await add_materials_to_product(db, product_id, [material_link]) - - if len(db_material_link_list) != 1: - err_msg: str = ( - f"Database integrity error: Expected 1 material with id {material_link.material_id}," - f" got {len(db_material_link_list)}" - ) - raise RuntimeError(err_msg) - - return db_material_link_list[0] - - -async def update_material_within_product( - db: AsyncSession, product_id: int, material_id: int, material_link: MaterialProductLinkUpdate -) -> MaterialProductLink: - """Update material in a product bill of materials.""" - # Validate that product exists - await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate that material exists in the product - db_material_link: MaterialProductLink = await get_linking_model_with_ids_if_it_exists( - db, - MaterialProductLink, - product_id, - material_id, - "product_id", - "material_id", - ) - - # Update material link - db_material_link.sqlmodel_update(material_link.model_dump(exclude_unset=True)) - - db.add(db_material_link) - await db.commit() - await db.refresh(db_material_link) - return db_material_link - - -async def remove_materials_from_product(db: AsyncSession, product_id: int, material_ids: int | set[int]) -> None: - """Remove materials from a product.""" - # Convert single material ID to list - if isinstance(material_ids, int): - material_ids = {material_ids} - - # Validate that product exists - product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate materials exist - await db_get_models_with_ids_if_they_exist(db, MaterialProductLink, material_ids) - - # Validate materials are actually assigned to the product - validate_linked_items_exist(material_ids, product.bill_of_materials, "Materials", "material_id") - - statement: Delete = ( - delete(MaterialProductLink) - .where(col(MaterialProductLink.product_id) == product_id) - .where(col(MaterialProductLink.material_id).in_(material_ids)) - ) - await db.execute(statement) - await db.commit() - - -### Ancillary Search CRUD operations ### -async def get_unique_product_brands(db: AsyncSession) -> list[str]: - """Get all unique product brands.""" - statement = select(Product.brand).distinct().order_by(Product.brand).where(Product.brand.is_not(None)) - results = (await db.exec(statement)).all() - unique_brands = sorted({brand.strip().title() for brand in results if brand and brand.strip()}) - return unique_brands diff --git a/backend/app/api/data_collection/crud/__init__.py b/backend/app/api/data_collection/crud/__init__.py new file mode 100644 index 00000000..8890bc7f --- /dev/null +++ b/backend/app/api/data_collection/crud/__init__.py @@ -0,0 +1 @@ +"""Data-collection CRUD package.""" diff --git a/backend/app/api/data_collection/crud/material_links.py b/backend/app/api/data_collection/crud/material_links.py new file mode 100644 index 00000000..9146f730 --- /dev/null +++ b/backend/app/api/data_collection/crud/material_links.py @@ -0,0 +1,101 @@ +"""Bill-of-materials CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.crud.associations import require_link +from app.api.common.crud.persistence import update_and_commit +from app.api.common.crud.utils import validate_linked_items_exist, validate_no_duplicate_linked_items +from app.api.common.exceptions import InternalServerError +from app.api.common.schemas.associations import ( + MaterialProductLinkCreateWithinProduct, + MaterialProductLinkCreateWithinProductAndMaterial, + MaterialProductLinkUpdate, +) +from app.api.data_collection.exceptions import MaterialIDRequiredError +from app.api.data_collection.models.product import MaterialProductLink + +from .shared import get_material_links_for_product, get_product_with_bill_of_materials, validate_product_material_links + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def add_materials_to_product( + db: AsyncSession, product_id: int, material_links: list[MaterialProductLinkCreateWithinProduct] +) -> list[MaterialProductLink]: + """Add materials to a product.""" + material_ids: set[int] = {material_link.material_id for material_link in material_links} + db_product, normalized_material_ids = await validate_product_material_links(db, product_id, material_ids) + + if db_product.bill_of_materials: + validate_no_duplicate_linked_items( + normalized_material_ids, db_product.bill_of_materials, "Materials", id_attr="material_id" + ) + + db_material_product_links: list[MaterialProductLink] = [ + MaterialProductLink(**material_link.model_dump(), product_id=product_id) for material_link in material_links + ] + db.add_all(db_material_product_links) + await db.commit() + for link in db_material_product_links: + await db.refresh(link) + + return db_material_product_links + + +async def add_material_to_product( + db: AsyncSession, + product_id: int, + material_link: MaterialProductLinkCreateWithinProduct | MaterialProductLinkCreateWithinProductAndMaterial, + *, + material_id: int | None = None, +) -> MaterialProductLink: + """Add a material to a product.""" + if isinstance(material_link, MaterialProductLinkCreateWithinProductAndMaterial): + if material_id is None: + raise MaterialIDRequiredError + + material_link = MaterialProductLinkCreateWithinProduct(material_id=material_id, **material_link.model_dump()) + + db_material_link_list: list[MaterialProductLink] = await add_materials_to_product(db, product_id, [material_link]) + + if len(db_material_link_list) != 1: + err_msg = ( + f"Database integrity error: Expected 1 material with id {material_link.material_id}," + f" got {len(db_material_link_list)}" + ) + raise InternalServerError(log_message=err_msg) + + return db_material_link_list[0] + + +async def update_material_within_product( + db: AsyncSession, product_id: int, material_id: int, material_link: MaterialProductLinkUpdate +) -> MaterialProductLink: + """Update material in a product bill of materials.""" + await get_product_with_bill_of_materials(db, product_id) + + db_material_link: MaterialProductLink = await require_link( + db, + MaterialProductLink, + product_id, + material_id, + MaterialProductLink.product_id, + MaterialProductLink.material_id, + ) + + return await update_and_commit(db, db_material_link, material_link) + + +async def remove_materials_from_product(db: AsyncSession, product_id: int, material_ids: int | set[int]) -> None: + """Remove materials from a product.""" + product, normalized_material_ids = await validate_product_material_links(db, product_id, material_ids) + + validate_linked_items_exist(normalized_material_ids, product.bill_of_materials, "Materials", id_attr="material_id") + + for material_link in await get_material_links_for_product(db, product_id, normalized_material_ids): + await db.delete(material_link) + + await db.commit() diff --git a/backend/app/api/data_collection/crud/product_commands.py b/backend/app/api/data_collection/crud/product_commands.py new file mode 100644 index 00000000..7bd62a03 --- /dev/null +++ b/backend/app/api/data_collection/crud/product_commands.py @@ -0,0 +1,218 @@ +"""Command helpers for product creation, mutation, and deletion.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import UUID4 +from sqlalchemy import select + +from app.api.auth.services.stats import recompute_user_stats +from app.api.background_data.models import Material, ProductType +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.persistence import commit_and_refresh +from app.api.common.crud.query import require_model, require_models +from app.api.data_collection.crud.storage import delete_all_product_files, delete_all_product_images +from app.api.data_collection.exceptions import ProductOwnerRequiredError +from app.api.data_collection.models.product import MaterialProductLink, Product +from app.api.data_collection.schemas import ComponentCreateWithComponents, ProductCreateWithComponents, ProductUpdate +from app.api.file_storage.models import Video + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +def product_payload( + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, +) -> dict[str, Any]: + """Return the shared payload used to create a product or component.""" + return product_data.model_dump( + exclude={ + "components", + "owner_id", + "videos", + "bill_of_materials", + } + ) + + +async def create_product_record( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4, + parent_product: Product | None = None, +) -> Product: + """Create the base Product row and flush it so dependent rows can reference it.""" + db_product = Product( + **product_payload(product_data), + owner_id=owner_id, + parent=parent_product, + ) + db.add(db_product) + await db.flush() + return db_product + + +def create_product_videos( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + db_product: Product, +) -> None: + """Create video rows linked to the product.""" + if not product_data.videos: + return + + videos: list[Video] = db_product.videos if db_product.videos is not None else [] + db_product.videos = videos + for video in product_data.videos: + db_video = Video(**video.model_dump()) + videos.append(db_video) + db.add(db_video) + + +async def create_product_bill_of_materials( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + db_product: Product, +) -> None: + """Create bill-of-materials rows linked to the product.""" + if not product_data.bill_of_materials: + return + + material_ids = {material.material_id for material in product_data.bill_of_materials} + await require_models(db, Material, material_ids) + + db.add_all( + MaterialProductLink(**material.model_dump(), product=db_product) for material in product_data.bill_of_materials + ) + + +async def create_product_components( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4, + db_product: Product, +) -> None: + """Recursively create child components for a product.""" + for component in product_data.components: + await create_product_tree(db, component, owner_id=owner_id, parent_product=db_product) + + +async def create_product_tree( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4 | None = None, + parent_product: Product | None = None, +) -> Product: + """Create an in-memory product tree and flush rows for persistence.""" + if owner_id is None: + raise ProductOwnerRequiredError + + db_product = await create_product_record(db, product_data, owner_id=owner_id, parent_product=parent_product) + create_product_videos(db, product_data, db_product) + await create_product_bill_of_materials(db, product_data, db_product) + await create_product_components(db, product_data, owner_id=owner_id, db_product=db_product) + + return db_product + + +async def create_and_persist_product_tree( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4 | None, + parent_product: Product | None = None, +) -> Product: + """Create a product tree and persist the root row.""" + db_product = await create_product_tree(db, product_data, owner_id=owner_id, parent_product=parent_product) + await db.commit() + await db.refresh(db_product) + return db_product + + +async def create_component( + db: AsyncSession, + component: ComponentCreateWithComponents, + parent_product: Product, +) -> Product: + """Add a component to a product.""" + return await create_and_persist_product_tree( + db, + component, + owner_id=parent_product.owner_id, + parent_product=parent_product, + ) + + +async def create_product( + db: AsyncSession, + product: ProductCreateWithComponents, + owner_id: UUID4 | None, +) -> Product: + """Create a new product in the database.""" + db_product = await create_and_persist_product_tree(db, product, owner_id=owner_id) + if owner_id: + await recompute_user_stats(db, owner_id) + await db.commit() + return db_product + + +async def get_owned_component(db: AsyncSession, *, parent_product_id: int, component_id: int) -> Product: + """Load a component only when it belongs to the requested parent product.""" + component = await db.scalar( + select(Product).where( + Product.id == component_id, + Product.parent_id == parent_product_id, + ) + ) + if component is None: + raise DependentModelOwnershipError(Product, component_id, Product, parent_product_id) + return component + + +async def validate_product_type(db: AsyncSession, product_type_id: int | None) -> None: + """Validate the referenced product type when one was provided.""" + if product_type_id is not None: + await require_model(db, ProductType, product_type_id) + + +def apply_product_update(db_product: Product, product: ProductUpdate) -> None: + """Apply the provided mutable product fields to an existing row.""" + product_data: dict[str, Any] = product.model_dump(exclude_unset=True) + for key, value in product_data.items(): + setattr(db_product, key, value) + + +async def update_product(db: AsyncSession, product_id: int, product: ProductUpdate) -> Product: + """Update an existing product in the database.""" + db_product = await require_model(db, Product, product_id) + await validate_product_type(db, product.product_type_id) + apply_product_update(db_product, product) + + res = await commit_and_refresh(db, db_product) + if db_product.owner_id is not None: + await recompute_user_stats(db, db_product.owner_id) + await db.commit() + return res + + +async def delete_product_media(db: AsyncSession, product_id: int) -> None: + """Delete all stored files and images associated with a product.""" + await delete_all_product_files(db, product_id) + await delete_all_product_images(db, product_id) + + +async def delete_product(db: AsyncSession, product_id: int) -> None: + """Delete a product from the database.""" + db_product = await require_model(db, Product, product_id) + await delete_product_media(db, product_id) + + owner_id = db_product.owner_id + await db.delete(db_product) + await db.commit() + if owner_id is not None: + await recompute_user_stats(db, owner_id) + await db.commit() diff --git a/backend/app/api/data_collection/crud/product_tree_queries.py b/backend/app/api/data_collection/crud/product_tree_queries.py new file mode 100644 index 00000000..86ad81af --- /dev/null +++ b/backend/app/api/data_collection/crud/product_tree_queries.py @@ -0,0 +1,117 @@ +"""Query helpers for bounded product tree reads.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import QueryableAttribute + +from app.api.common.crud.query import require_model +from app.api.data_collection.filters import ProductFilterWithRelationships +from app.api.data_collection.models.product import Product + +PRODUCT_READ_SUMMARY_RELATIONSHIPS: frozenset[str] = frozenset({"owner"}) +PRODUCT_READ_DETAIL_RELATIONSHIPS: frozenset[str] = frozenset( + {"owner", "product_type", "videos", "files", "images", "bill_of_materials", "components"} +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import Select + from sqlalchemy.ext.asyncio import AsyncSession + + +@dataclass(slots=True) +class ProductTreeData: + """Loaded tree roots plus an explicit child adjacency map.""" + + roots: list[Product] + children_by_parent_id: dict[int, list[Product]] + + +async def get_product_trees( + db: AsyncSession, + recursion_depth: int = 1, + *, + parent_id: int | None = None, + product_filter: ProductFilterWithRelationships | None = None, +) -> Sequence[Product]: + """Get product with their components up to specified depth.""" + if parent_id: + await require_model(db, Product, parent_id) + + statement: Select[tuple[Product]] = ( + select(Product) + .where(Product.parent_id == parent_id) + .options( + selectinload(cast("QueryableAttribute[Any]", Product.components), recursion_depth=recursion_depth), + selectinload(cast("QueryableAttribute[Any]", Product.owner)), + selectinload(cast("QueryableAttribute[Any]", Product.product_type)), + selectinload(cast("QueryableAttribute[Any]", Product.videos)), + selectinload(cast("QueryableAttribute[Any]", Product.files)), + selectinload(cast("QueryableAttribute[Any]", Product.images)), + selectinload(cast("QueryableAttribute[Any]", Product.bill_of_materials)), + ) + ) + + if product_filter: + statement = cast("Select[tuple[Product]]", product_filter.filter(statement)) + + return list((await db.execute(statement)).scalars().all()) + + +async def load_product_tree_data( + db: AsyncSession, + recursion_depth: int = 1, + *, + parent_id: int | None = None, + product_filter: ProductFilterWithRelationships | None = None, +) -> ProductTreeData: + """Load bounded product-tree data without relying on ORM recursive traversal.""" + if parent_id is not None: + await require_model(db, Product, parent_id) + + root_statement: Select[tuple[Product]] = ( + select(Product) + .where(Product.parent_id == parent_id) + .options( + selectinload(cast("QueryableAttribute[Any]", Product.owner)), + selectinload(cast("QueryableAttribute[Any]", Product.product_type)), + selectinload(cast("QueryableAttribute[Any]", Product.videos)), + selectinload(cast("QueryableAttribute[Any]", Product.files)), + selectinload(cast("QueryableAttribute[Any]", Product.images)), + selectinload(cast("QueryableAttribute[Any]", Product.bill_of_materials)), + ) + ) + if product_filter is not None: + root_statement = cast("Select[tuple[Product]]", product_filter.filter(root_statement)) + + roots = list((await db.execute(root_statement)).scalars().unique().all()) + children_by_parent_id: dict[int, list[Product]] = {} + frontier = [product.id for product in roots if product.id is not None] + + for _ in range(max(recursion_depth - 1, 0)): + if not frontier: + break + + child_statement: Select[tuple[Product]] = select(Product).where(Product.parent_id.in_(frontier)) + children = list((await db.execute(child_statement)).scalars().unique().all()) + grouped_children: defaultdict[int, list[Product]] = defaultdict(list) + next_frontier: list[int] = [] + + for child in children: + if child.parent_id is None: + continue + grouped_children[child.parent_id].append(child) + if child.id is not None: + next_frontier.append(child.id) + + children_by_parent_id.update(grouped_children) + frontier = next_frontier + + return ProductTreeData(roots=roots, children_by_parent_id=children_by_parent_id) diff --git a/backend/app/api/data_collection/crud/products.py b/backend/app/api/data_collection/crud/products.py new file mode 100644 index 00000000..f294f013 --- /dev/null +++ b/backend/app/api/data_collection/crud/products.py @@ -0,0 +1,49 @@ +"""Explicit domain entrypoints for product reads and mutations.""" + +from app.api.data_collection.crud.product_commands import ( + apply_product_update, + create_and_persist_product_tree, + create_component, + create_product, + create_product_bill_of_materials, + create_product_components, + create_product_record, + create_product_tree, + create_product_videos, + delete_product, + delete_product_media, + get_owned_component, + product_payload, + update_product, + validate_product_type, +) +from app.api.data_collection.crud.product_tree_queries import ( + PRODUCT_READ_DETAIL_RELATIONSHIPS, + PRODUCT_READ_SUMMARY_RELATIONSHIPS, + ProductTreeData, + get_product_trees, + load_product_tree_data, +) + +__all__ = [ + "PRODUCT_READ_DETAIL_RELATIONSHIPS", + "PRODUCT_READ_SUMMARY_RELATIONSHIPS", + "ProductTreeData", + "apply_product_update", + "create_and_persist_product_tree", + "create_component", + "create_product", + "create_product_bill_of_materials", + "create_product_components", + "create_product_record", + "create_product_tree", + "create_product_videos", + "delete_product", + "delete_product_media", + "get_owned_component", + "get_product_trees", + "load_product_tree_data", + "product_payload", + "update_product", + "validate_product_type", +] diff --git a/backend/app/api/data_collection/crud/shared.py b/backend/app/api/data_collection/crud/shared.py new file mode 100644 index 00000000..2d885555 --- /dev/null +++ b/backend/app/api/data_collection/crud/shared.py @@ -0,0 +1,56 @@ +"""Shared helpers for data-collection CRUD operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from app.api.background_data.models import Material +from app.api.common.crud.query import require_model, require_models +from app.api.data_collection.models.product import ( + MaterialProductLink, + Product, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy.ext.asyncio import AsyncSession + + +def normalize_material_ids(material_ids: int | set[int]) -> set[int]: + """Normalize a single material ID into the set-based CRUD interface.""" + return {material_ids} if isinstance(material_ids, int) else material_ids + + +async def get_product_with_bill_of_materials(db: AsyncSession, product_id: int) -> Product: + """Fetch a product with its bill of materials loaded.""" + return await require_model(db, Product, product_id, loaders={"bill_of_materials"}) + + +async def validate_product_material_links( + db: AsyncSession, + product_id: int, + material_ids: int | set[int], +) -> tuple[Product, set[int]]: + """Validate that the product and referenced materials exist.""" + normalized_material_ids = normalize_material_ids(material_ids) + product = await get_product_with_bill_of_materials(db, product_id) + await require_models(db, Material, normalized_material_ids) + return product, normalized_material_ids + + +async def get_material_links_for_product( + db: AsyncSession, + product_id: int, + material_ids: set[int], +) -> Sequence[MaterialProductLink]: + """Fetch material-product links for a product and a set of material IDs.""" + statement = ( + select(MaterialProductLink) + .where(MaterialProductLink.product_id == product_id) + .where(MaterialProductLink.material_id.in_(material_ids)) + ) + results = await db.execute(statement) + return results.scalars().all() diff --git a/backend/app/api/data_collection/crud/storage.py b/backend/app/api/data_collection/crud/storage.py new file mode 100644 index 00000000..7bb32b3b --- /dev/null +++ b/backend/app/api/data_collection/crud/storage.py @@ -0,0 +1,139 @@ +"""Product storage helpers.""" + +from typing import TYPE_CHECKING + +from app.api.data_collection.models.product import Product +from app.api.file_storage.crud.parent_media import ( + create_parent_media, + delete_all_parent_media, + delete_parent_media, + get_parent_media, + list_parent_media, +) +from app.api.file_storage.crud.support_services import file_storage_service, image_storage_service +from app.api.file_storage.models import File, Image, MediaParentType + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pydantic import UUID4 + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.file_storage.filters import FileFilter, ImageFilter + from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm + + +async def list_product_files(db: AsyncSession, product_id: int, *, filter_params: FileFilter) -> Sequence[File]: + """List files attached to a product.""" + return await list_parent_media( + db, + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=File, + parent_id=product_id, + filter_params=filter_params, + ) + + +async def get_product_file(db: AsyncSession, product_id: int, file_id: UUID4) -> File: + """Load one file attached to a product.""" + return await get_parent_media( + db, + parent_model=Product, + storage_model=File, + parent_id=product_id, + item_id=file_id, + ) + + +async def create_product_file(db: AsyncSession, product_id: int, payload: FileCreate) -> File: + """Create a file attached to a product.""" + return await create_parent_media( + db, + parent_id=product_id, + parent_type=MediaParentType.PRODUCT, + storage_service=file_storage_service, + item_data=payload, + ) + + +async def delete_product_file(db: AsyncSession, product_id: int, file_id: UUID4) -> None: + """Delete a file attached to a product.""" + await delete_parent_media( + db, + parent_model=Product, + storage_model=File, + parent_id=product_id, + item_id=file_id, + storage_service=file_storage_service, + ) + + +async def delete_all_product_files(db: AsyncSession, product_id: int) -> None: + """Delete all files attached to a product.""" + await delete_all_parent_media( + db, + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=File, + parent_id=product_id, + storage_service=file_storage_service, + ) + + +async def list_product_images(db: AsyncSession, product_id: int, *, filter_params: ImageFilter) -> Sequence[Image]: + """List images attached to a product.""" + return await list_parent_media( + db, + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=Image, + parent_id=product_id, + filter_params=filter_params, + ) + + +async def get_product_image(db: AsyncSession, product_id: int, image_id: UUID4) -> Image: + """Load one image attached to a product.""" + return await get_parent_media( + db, + parent_model=Product, + storage_model=Image, + parent_id=product_id, + item_id=image_id, + ) + + +async def create_product_image(db: AsyncSession, product_id: int, payload: ImageCreateFromForm) -> Image: + """Create an image attached to a product.""" + return await create_parent_media( + db, + parent_id=product_id, + parent_type=MediaParentType.PRODUCT, + storage_service=image_storage_service, + item_data=payload, + ) + + +async def delete_product_image(db: AsyncSession, product_id: int, image_id: UUID4) -> None: + """Delete an image attached to a product.""" + await delete_parent_media( + db, + parent_model=Product, + storage_model=Image, + parent_id=product_id, + item_id=image_id, + storage_service=image_storage_service, + ) + + +async def delete_all_product_images(db: AsyncSession, product_id: int) -> None: + """Delete all images attached to a product.""" + await delete_all_parent_media( + db, + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=Image, + parent_id=product_id, + storage_service=image_storage_service, + ) diff --git a/backend/app/api/data_collection/dependencies.py b/backend/app/api/data_collection/dependencies.py index 666577a3..419ad9ce 100644 --- a/backend/app/api/data_collection/dependencies.py +++ b/backend/app/api/data_collection/dependencies.py @@ -7,12 +7,11 @@ from pydantic import PositiveInt from app.api.auth.dependencies import CurrentActiveVerifiedUserDep -from app.api.auth.exceptions import UserOwnershipError -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.models.custom_types import IDT +from app.api.common.crud.query import require_model +from app.api.common.ownership import get_user_owned_object from app.api.common.routers.dependencies import AsyncSessionDep from app.api.data_collection.filters import MaterialProductLinkFilter, ProductFilterWithRelationships -from app.api.data_collection.models import Product +from app.api.data_collection.models.product import Product ### FastAPI-Filters ### MaterialProductLinkFilterDep = Annotated[MaterialProductLinkFilter, FilterDepends(MaterialProductLinkFilter)] @@ -27,25 +26,26 @@ async def get_product_by_id( session: AsyncSessionDep, ) -> Product: """Verify that a product with a given ID exists.""" - return await db_get_model_with_id_if_it_exists(session, Product, product_id) + return await require_model(session, Product, product_id) ProductByIDDep = Annotated[Product, Depends(get_product_by_id)] async def get_user_owned_product( - product: ProductByIDDep, + product_id: Annotated[PositiveInt, Path()], + session: AsyncSessionDep, current_user: CurrentActiveVerifiedUserDep, ) -> Product: """Verify that the current user owns the specified product.""" - if product.owner_id == current_user.id: - return product - raise UserOwnershipError(model_type=Product, model_id=product.id, user_id=current_user.id) from None + if current_user.is_superuser: + return await require_model(session, Product, product_id) + return await get_user_owned_object(session, Product, product_id, current_user.id) UserOwnedProductDep = Annotated[Product, Depends(get_user_owned_product)] -async def get_user_owned_product_id(user_owned_product: UserOwnedProductDep) -> IDT | None: +async def get_user_owned_product_id(user_owned_product: UserOwnedProductDep) -> int | None: """Get the ID of a user owned product.""" return user_owned_product.id diff --git a/backend/app/api/data_collection/examples.py b/backend/app/api/data_collection/examples.py new file mode 100644 index 00000000..24026d8f --- /dev/null +++ b/backend/app/api/data_collection/examples.py @@ -0,0 +1,130 @@ +"""Centralized OpenAPI examples for data-collection schemas and routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +PRODUCT_CREATE_BASE_EXAMPLE = { + "name": "Office Chair", + "description": "Complete chair assembly", + "brand": "Brand 1", + "model": "Model 1", + "dismantling_time_start": "2025-09-22T14:30:45Z", + "dismantling_time_end": "2025-09-22T16:30:45Z", + "product_type_id": 1, + "weight_g": 20000, + "height_cm": 150, + "width_cm": 70, + "depth_cm": 50, + "videos": [{"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"}], + "bill_of_materials": [ + {"quantity": 0.3, "unit": "g", "material_id": 1}, + {"quantity": 0.1, "unit": "g", "material_id": 2}, + ], +} + +PRODUCT_CREATE_WITH_COMPONENTS_EXAMPLE = { + **PRODUCT_CREATE_BASE_EXAMPLE, + "components": [ + { + "name": "Office Chair Seat", + "description": "Seat assembly", + "brand": "Brand 2", + "model": "Model 2", + "dismantling_time_start": "2025-09-22T14:30:45Z", + "dismantling_time_end": "2025-09-22T16:30:45Z", + "amount_in_parent": 1, + "product_type_id": 2, + "weight_g": 5000, + "height_cm": 50, + "width_cm": 40, + "depth_cm": 30, + "components": [ + { + "name": "Seat Cushion", + "description": "Seat cushion assembly", + "amount_in_parent": 1, + "weight_g": 2000, + "height_cm": 10, + "width_cm": 40, + "depth_cm": 30, + "product_type_id": 3, + "bill_of_materials": [ + {"quantity": 1.5, "unit": "g", "material_id": 1}, + {"quantity": 0.5, "unit": "g", "material_id": 2}, + ], + } + ], + } + ], +} + +PRODUCT_CREATE_EXAMPLES = [PRODUCT_CREATE_BASE_EXAMPLE] + +PRODUCT_CREATE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + basic=openapi_example(PRODUCT_CREATE_BASE_EXAMPLE, summary="Basic product without components"), + with_components=openapi_example(PRODUCT_CREATE_WITH_COMPONENTS_EXAMPLE, summary="Product with components"), +) + +COMPONENT_CREATE_SIMPLE_EXAMPLE = { + "name": "Seat Assembly", + "description": "Chair seat component", + "amount_in_parent": 1, + "bill_of_materials": [{"material_id": 1, "quantity": 0.5, "unit": "g"}], +} + +COMPONENT_CREATE_NESTED_EXAMPLE = { + "name": "Seat Assembly", + "description": "Chair seat with cushion", + "amount_in_parent": 1, + "components": [ + { + "name": "Cushion", + "description": "Foam cushion", + "amount_in_parent": 1, + "bill_of_materials": [{"material_id": 2, "quantity": 0.3, "unit": "g"}], + } + ], +} + +COMPONENT_CREATE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + simple=openapi_example( + COMPONENT_CREATE_SIMPLE_EXAMPLE, + summary="Basic component", + description="Create a component without subcomponents", + ), + nested=openapi_example( + COMPONENT_CREATE_NESTED_EXAMPLE, + summary="Component with subcomponents", + description="Create a component with nested subcomponents", + ), +) + +PRODUCT_MATERIAL_LINKS_BULK_EXAMPLE = [ + {"material_id": 1, "quantity": 5, "unit": "g"}, + {"material_id": 2, "quantity": 10, "unit": "g"}, +] + +PRODUCT_MATERIAL_LINKS_BULK_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + multiple_materials=openapi_example(PRODUCT_MATERIAL_LINKS_BULK_EXAMPLE, summary="Add multiple materials"), +) + +PRODUCT_MATERIAL_ID_PATH_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + material_id=openapi_example(1, summary="Existing material ID"), +) + +PRODUCT_SINGLE_MATERIAL_LINK_EXAMPLE = {"quantity": 5, "unit": "g"} + +PRODUCT_SINGLE_MATERIAL_LINK_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + single_material=openapi_example(PRODUCT_SINGLE_MATERIAL_LINK_EXAMPLE, summary="Link details for one material"), +) + +PRODUCT_REMOVE_MATERIAL_IDS_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + multiple_material_ids=openapi_example([1, 2, 3], summary="Remove multiple material links"), +) diff --git a/backend/app/api/data_collection/exceptions.py b/backend/app/api/data_collection/exceptions.py new file mode 100644 index 00000000..c13e7377 --- /dev/null +++ b/backend/app/api/data_collection/exceptions.py @@ -0,0 +1,28 @@ +"""Custom exceptions for data collection CRUD and router flows.""" + +from app.api.common.exceptions import BadRequestError + + +class InvalidProductTreeError(BadRequestError): + """Raised when a product/component tree payload is structurally invalid.""" + + +class ProductTreeMissingContentError(InvalidProductTreeError): + """Raised when a product tree has neither materials nor components.""" + + def __init__(self) -> None: + super().__init__("Product needs materials or components") + + +class ProductOwnerRequiredError(InvalidProductTreeError): + """Raised when product tree creation is attempted without an owner.""" + + def __init__(self) -> None: + super().__init__("Product owner_id must be set before creating a product or component.") + + +class MaterialIDRequiredError(BadRequestError): + """Raised when a nested material operation requires an explicit material id.""" + + def __init__(self) -> None: + super().__init__("Material ID is required for this operation") diff --git a/backend/app/api/data_collection/filters.py b/backend/app/api/data_collection/filters.py index c01d8011..3a2b5d81 100644 --- a/backend/app/api/data_collection/filters.py +++ b/backend/app/api/data_collection/filters.py @@ -1,13 +1,25 @@ """FastAPI-Filter classes for filtering database queries.""" -from datetime import datetime +from __future__ import annotations + +from datetime import datetime # noqa: TC003 # Runtime import is required for FastAPI-Filter field definitions +from typing import TYPE_CHECKING, Any, Literal, cast from fastapi_filter import FilterDepends, with_prefix from fastapi_filter.contrib.sqlalchemy import Filter +from pydantic import model_validator +from sqlalchemy import ColumnElement, Select, asc, desc, func, select from app.api.background_data.filters import MaterialFilter, ProductTypeFilter -from app.api.common.models.associations import MaterialProductLink -from app.api.data_collection.models import PhysicalProperties, Product +from app.api.common.search_utils import ( + TSVectorSearchMixin, + apply_ts_rank_ordering, + build_text_search_clause, +) +from app.api.data_collection.models.product import MaterialProductLink, Product + +if TYPE_CHECKING: + from sqlalchemy import Select ### Association Model Filters ### @@ -27,56 +39,132 @@ class Constants(Filter.Constants): model = MaterialProductLink -## Physical Properties Filters ## -class PhysicalPropertiesFilter(Filter): - """FastAPI-filter class for Physical Properties filtering.""" +### Brand search helpers (kept here as they are product/brand-specific) ### - weight_kg__gte: float | None = None - weight_kg__lte: float | None = None - height_cm__gte: float | None = None - height_cm__lte: float | None = None - width_cm__gte: float | None = None - width_cm__lte: float | None = None - depth_cm__gte: float | None = None - depth_cm__lte: float | None = None +# Constants for ordering +ORDER_DESC: Literal["desc"] = "desc" - class Constants(Filter.Constants): - """FilterAPI class configuration.""" - model = PhysicalProperties +def get_brand_search_statement(search: str | None = None, order: Literal["asc", "desc"] = "asc") -> Select: + """Return a select statement for normalised, distinct brands with optional search and order.""" + brand_expr = func.trim(func.lower(Product.brand)).label("brand_norm") + statement = select(brand_expr).where(cast("ColumnElement[Any]", Product.brand).is_not(None)) + if search: + clause = build_text_search_clause( + search.strip(), + cast("ColumnElement[Any]", Product.search_vector), + cast("ColumnElement[Any]", Product.brand), + ) + statement = statement.where(clause) + return statement.distinct().order_by(desc(brand_expr) if order == ORDER_DESC else asc(brand_expr)) + +### Product Filters ### -## Product Filters ## -class ProductFilter(Filter): +# 'rank' / '-rank' are virtual sort values understood by ProductFilter +# but not real Product columns. They must be stripped before fastapi-filter's +# validate_order_by validator runs (which checks hasattr(model, field_name)). +_RANK_SORT_VALUES = frozenset(("rank", "-rank")) + + +class ProductFilter(TSVectorSearchMixin, Filter): """FastAPI-filter class for Product.""" name__ilike: str | None = None description__ilike: str | None = None brand__ilike: str | None = None + brand__in: list[str] | None = None model__ilike: str | None = None dismantling_time_start__gte: datetime | None = None dismantling_time_start__lte: datetime | None = None dismantling_time_end__gte: datetime | None = None dismantling_time_end__lte: datetime | None = None + created_at__gte: datetime | None = None + created_at__lte: datetime | None = None + updated_at__gte: datetime | None = None + updated_at__lte: datetime | None = None search: str | None = None + order_by: list[str] | None = None + + @model_validator(mode="before") + @classmethod + def _strip_rank_from_order_by(cls, data: object) -> object: + """Remove 'rank'/'-rank' from order_by before fastapi-filter validates it. + + 'rank' is a virtual sort token — it signals "order by ts_rank" but has no + corresponding column on Product. fastapi-filter's validate_order_by validator + rejects unknown field names, so we strip the token here (mode="before") before + that validator sees the value. + + FastAPI delivers query-param values as raw strings at this stage (before the + split_str field_validator has run), so we must handle both str and list forms. + + When 'rank' is the *only* value the result becomes empty/None, which + _apply_rank_ordering treats as "no explicit sort → apply ts_rank". + """ + if not isinstance(data, dict): + return data + fields = cast("dict[str, Any]", data) + raw = fields.get("order_by") + if isinstance(raw, str): + # Still a comma-separated string; split, strip rank, rejoin. + items = [v.strip() for v in raw.split(",") if v.strip()] + cleaned = [v for v in items if v not in _RANK_SORT_VALUES] + fields["order_by"] = ",".join(cleaned) if cleaned else None + elif isinstance(raw, list): + cleaned = [v for v in raw if v not in _RANK_SORT_VALUES] + fields["order_by"] = cleaned or None + return fields + + @classmethod + def _search_vector_col(cls) -> ColumnElement[Any]: + return cast("ColumnElement[Any]", Product.search_vector) + + @classmethod + def _trigram_cols(cls) -> list[Any]: + return [Product.brand, Product.name] + + def _apply_rank_ordering(self, query: Select[Any], search: str) -> Select[Any]: + """Apply ts_rank ordering when no explicit order_by is set. + + Because 'rank'/'- rank' is always stripped by _strip_rank_from_order_by before + the instance is constructed, the only signal we need here is whether order_by + is empty/None (user wants relevance) or has real fields (user chose an explicit sort). + """ + if not (self.order_by or []): + return apply_ts_rank_ordering(query, self._search_vector_col(), search) + return query + + def sort(self, query: Any) -> Any: # noqa: ANN401 # Any-type expected by fastapi-filter + """Override of fastapi-filter's sort method. + + 'rank' is already stripped at construction time, so this override is a no-op + safety net in case order_by ends up empty after stripping. + """ + if not self.order_by: + return query + return super().sort(query) + class Constants(Filter.Constants): """FilterAPI class configuration.""" model = Product - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - "brand", - "model", - ] + # search_model_fields intentionally omitted; search is handled by TSVectorSearchMixin + # using tsvector + trigram indexes. class ProductFilterWithRelationships(ProductFilter): """FastAPI-filter class for Product filtering with relationships.""" - physical_properties: PhysicalPropertiesFilter | None = FilterDepends( - with_prefix("physical_properties", PhysicalPropertiesFilter) - ) + weight_g__gte: float | None = None + weight_g__lte: float | None = None + height_cm__gte: float | None = None + height_cm__lte: float | None = None + width_cm__gte: float | None = None + width_cm__lte: float | None = None + depth_cm__gte: float | None = None + depth_cm__lte: float | None = None + product_type: ProductTypeFilter | None = FilterDepends(with_prefix("product_type", ProductTypeFilter)) diff --git a/backend/app/api/data_collection/models.py b/backend/app/api/data_collection/models.py deleted file mode 100644 index 9afe6fa3..00000000 --- a/backend/app/api/data_collection/models.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Database models for data collection on products.""" - -import logging -from datetime import UTC, datetime -from functools import cached_property -from typing import TYPE_CHECKING, Optional, Self - -from pydantic import UUID4, ConfigDict, computed_field, model_validator -from sqlalchemy import TIMESTAMP -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Column, Field, Relationship - -from app.api.common.models.associations import MaterialProductLink -from app.api.common.models.base import CustomBase, TimeStampMixinBare - -if TYPE_CHECKING: - from app.api.auth.models import User - from app.api.background_data.models import ProductType - from app.api.file_storage.models.models import File, Image, Video - - -# Initialize logger -logger = logging.getLogger(__name__) - - -### Validation Utilities ### -def validate_start_and_end_time(start_time: datetime, end_time: datetime | None) -> None: - """Validate that end time is after start time if both are set.""" - if start_time and end_time and end_time < start_time: - err_msg: str = f"End time {end_time:%Y-%m-%d %H:%M} must be after start time {start_time:%Y-%m-%d %H:%M}" - raise ValueError(err_msg) - - -### Properties Models ### -class PhysicalPropertiesBase(CustomBase): - """Base model to store physical properties of a product.""" - - weight_kg: float | None = Field(default=None, gt=0) - height_cm: float | None = Field(default=None, gt=0) - width_cm: float | None = Field(default=None, gt=0) - depth_cm: float | None = Field(default=None, gt=0) - - # Computed properties - @computed_field - @cached_property - def volume_cm3(self) -> float | None: - """Calculate the volume of the product.""" - if self.height_cm is None or self.width_cm is None or self.depth_cm is None: - logger.warning("All dimensions must be set to calculate the volume.") - return None - return self.height_cm * self.width_cm * self.depth_cm - - -class PhysicalProperties(PhysicalPropertiesBase, TimeStampMixinBare, table=True): - """Model to store physical properties of a product.""" - - id: int | None = Field(default=None, primary_key=True) - - # One-to-one relationships - product_id: int = Field(foreign_key="product.id") - product: "Product" = Relationship(back_populates="physical_properties") - - -### Product Model ### -class ProductBase(CustomBase): - """Basic model to store product information.""" - - name: str = Field(index=True, min_length=2, max_length=100) - description: str | None = Field(default=None, max_length=500) - brand: str | None = Field(default=None, max_length=100) - model: str | None = Field(default=None, max_length=100) - - # Dismantling information - dismantling_notes: str | None = Field( - default=None, max_length=500, description="Notes on the dismantling process of the product." - ) - - dismantling_time_start: datetime = Field( - sa_column=Column(TIMESTAMP(timezone=True), nullable=False), default_factory=lambda: datetime.now(UTC) - ) - dismantling_time_end: datetime | None = Field(default=None, sa_column=Column(TIMESTAMP(timezone=True))) - - # Time validation - @model_validator(mode="after") - def validate_times(self) -> Self: - """Ensure end time is after start time if both are set.""" - validate_start_and_end_time(self.dismantling_time_start, self.dismantling_time_end) - return self - - -class Product(ProductBase, TimeStampMixinBare, table=True): - """Database model for product information.""" - - id: int | None = Field(default=None, primary_key=True) - - # Self-referential relationship for hierarchy - parent_id: int | None = Field(default=None, foreign_key="product.id") - parent: Optional["Product"] = Relationship( - back_populates="components", - sa_relationship_kwargs={ - "uselist": False, - "remote_side": "Product.id", - "lazy": "selectin", # Eagerly load linked component products - "join_depth": 1, - }, - ) - amount_in_parent: int | None = Field(default=None, description="Quantity within parent product") - components: list["Product"] | None = Relationship( - back_populates="parent", - cascade_delete=True, - sa_relationship_kwargs={"lazy": "selectin", "join_depth": 1}, # Eagerly load linked parent product - ) - - # One-to-one relationships - physical_properties: PhysicalProperties | None = Relationship( - back_populates="product", cascade_delete=True, sa_relationship_kwargs={"uselist": False, "lazy": "selectin"} - ) - - # Many-to-one relationships - files: list["File"] | None = Relationship(back_populates="product", cascade_delete=True) - images: list["Image"] | None = Relationship( - back_populates="product", cascade_delete=True, sa_relationship_kwargs={"lazy": "subquery"} - ) - videos: list["Video"] | None = Relationship(back_populates="product", cascade_delete=True) - - # One-to-many relationships - owner_id: UUID4 = Field(foreign_key="user.id") - owner: "User" = Relationship( - back_populates="products", sa_relationship_kwargs={"uselist": False, "lazy": "selectin"} - ) - - product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="products", sa_relationship_kwargs={"uselist": False}) - - # Many-to-many relationships - bill_of_materials: list[MaterialProductLink] | None = Relationship( - back_populates="product", sa_relationship_kwargs={"lazy": "selectin"}, cascade_delete=True - ) - - # Helper methods - @computed_field - @cached_property - def is_leaf_node(self) -> bool: - """Check if the product is a leaf node (no components).""" - return self.components is None or len(self.components) == 0 - - @computed_field - @cached_property - def is_base_product(self) -> bool: - """Check if the product is a base product (no parent).""" - return self.parent_id is None - - # TODO: move this validation to the CRUD and schema layers - - def has_cycles(self) -> bool: - """Check if the product hierarchy contains cycles.""" - visited = set() - - def visit(node: "Product") -> bool: - if node.id in visited: - return True # Cycle detected - visited.add(node.id) - if node.components: - for component in node.components: - if visit(component): - return True - visited.remove(node.id) - return False - - return visit(self) - - def components_resolve_to_materials(self) -> bool: - """Ensure all leaf components have a non-empty bill of materials.""" - - def check(node: "Product") -> bool: - if not node.components: - # Leaf node - if not node.bill_of_materials: - return False - else: - for component in node.components: - if not check(component): - return False - return True - - return check(self) - - @model_validator(mode="after") - def validate_product(self) -> Self: - components: list[Product] | None = self.components - bill_of_materials: list[MaterialProductLink] | None = self.bill_of_materials - amount_in_parent: int | None = self.amount_in_parent - - if self.has_cycles(): - err_msg = "Cycle detected: a product cannot contain itself directly or indirectly." - raise ValueError(err_msg) - - if self.is_base_product: - if not components and not bill_of_materials: - err_msg = "A product must have at least one material or one component." - raise ValueError(err_msg) - if amount_in_parent is not None: - err_msg = "Base product must have amount_in_parent set to None." - raise ValueError(err_msg) - - else: - # Intermediate product - if amount_in_parent is None: - err_msg = "Intermediate product must have amount_in_parent set." - raise ValueError(err_msg) - if not components and not bill_of_materials: - err_msg = "Intermediate product must have at least one material or one component." - raise ValueError(err_msg) - - # Ensure all components ultimately resolve to materials - if not self.components_resolve_to_materials(): - err_msg = "All leaf components must have a non-empty bill of materials." - raise ValueError(err_msg) - return self - - async def get_total_bill_of_materials(self, session: AsyncSession) -> dict[int, float]: - """Traverse all components and calculate the total bill of materials for the product. - - Args: - session: The database session to use for loading relationships. - - Returns: - A dictionary mapping material IDs to total quantities. - """ - total_materials = {} - visited_products = set() - - async def traverse(product: Product, quantity_multiplier: float) -> None: - """Recursively traverse the product hierarchy and aggregate bill of materials.""" - if product.id in visited_products: - return - visited_products.add(product.id) - - # Ensure components and bill_of_materials are loaded - await session.refresh(product) - await session.refresh(product.components) - await session.refresh(product.bill_of_materials) - - # Collect materials from the current product's bill_of_materials - if product.bill_of_materials: - for link in product.bill_of_materials: - material_id = link.material_id - quantity = link.quantity * quantity_multiplier - # Aggregate quantities - if material_id in total_materials: - total_materials[material_id] += quantity - else: - total_materials[material_id] = quantity - - # Traverse components - if product.components: - for component in product.components: - component_quantity = component.amount_in_parent or 1.0 - await traverse(component, quantity_multiplier * component_quantity) - - await traverse(self, 1.0) - return total_materials - - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - - def __str__(self): - return f"{self.name} (id: {self.id})" diff --git a/backend/app/api/data_collection/models/__init__.py b/backend/app/api/data_collection/models/__init__.py new file mode 100644 index 00000000..41dcf997 --- /dev/null +++ b/backend/app/api/data_collection/models/__init__.py @@ -0,0 +1 @@ +"""Data collection models.""" diff --git a/backend/app/api/data_collection/models/base.py b/backend/app/api/data_collection/models/base.py new file mode 100644 index 00000000..069ae21d --- /dev/null +++ b/backend/app/api/data_collection/models/base.py @@ -0,0 +1,115 @@ +"""Base model classes for data collection; split out to avoid circular imports. + +These classes have no heavy ORM dependencies (no relationships, foreign keys, or +other model imports) and can therefore be imported by common/schemas/base.py +without triggering the full data_collection/models.py import chain. +""" + +from datetime import UTC, datetime + +from pydantic import BaseModel, computed_field +from pydantic import Field as PydanticField +from sqlalchemy import TIMESTAMP, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.api.common.schemas.field_mixins import CircularityPropertiesFields, PhysicalPropertiesFields + + +### Validation Utilities ### +def validate_start_and_end_time(start_time: datetime, end_time: datetime | None) -> None: + """Validate that end time is after start time if both are set.""" + if start_time and end_time and end_time < start_time: + err_msg: str = f"End time {end_time:%Y-%m-%d %H:%M} must be after start time {start_time:%Y-%m-%d %H:%M}" + raise ValueError(err_msg) + + +### Properties Mixins ### +class PhysicalPropertiesMixin: + """Mixin for physical properties of a product.""" + + weight_g: Mapped[float | None] = mapped_column(default=None) + height_cm: Mapped[float | None] = mapped_column(default=None) + width_cm: Mapped[float | None] = mapped_column(default=None) + depth_cm: Mapped[float | None] = mapped_column(default=None) + + @computed_field + @property + def volume_cm3(self) -> float | None: + """Calculate the volume of the product.""" + if self.height_cm is None or self.width_cm is None or self.depth_cm is None: + return None + return self.height_cm * self.width_cm * self.depth_cm + + +class CircularityPropertiesMixin: + """Mixin for circularity properties of a product.""" + + # Recyclability + recyclability_observation: Mapped[str | None] = mapped_column(String(500), default=None) + recyclability_comment: Mapped[str | None] = mapped_column(String(100), default=None) + recyclability_reference: Mapped[str | None] = mapped_column(String(100), default=None) + + # Repairability + repairability_observation: Mapped[str | None] = mapped_column(String(500), default=None) + repairability_comment: Mapped[str | None] = mapped_column(String(100), default=None) + repairability_reference: Mapped[str | None] = mapped_column(String(100), default=None) + + # Remanufacturability + remanufacturability_observation: Mapped[str | None] = mapped_column(String(500), default=None) + remanufacturability_comment: Mapped[str | None] = mapped_column(String(100), default=None) + remanufacturability_reference: Mapped[str | None] = mapped_column(String(100), default=None) + + +### Product Mixin ### +class ProductFieldsMixin(PhysicalPropertiesMixin, CircularityPropertiesMixin): + """Mixin for product fields shared between Product model and schemas.""" + + name: Mapped[str] = mapped_column(String(100), index=True) + description: Mapped[str | None] = mapped_column(String(500), default=None) + brand: Mapped[str | None] = mapped_column(String(100), default=None) + model: Mapped[str | None] = mapped_column(String(100), default=None) + + # Dismantling information + dismantling_notes: Mapped[str | None] = mapped_column(String(500), default=None) + dismantling_time_start: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + dismantling_time_end: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True), default=None) + + +# Backward compat aliases +PhysicalPropertiesBase = PhysicalPropertiesMixin +CircularityPropertiesBase = CircularityPropertiesMixin + + +### Pydantic base schema (shared with schemas.py) ### +class ProductBase(PhysicalPropertiesFields, CircularityPropertiesFields, BaseModel): + """Base schema for Product. Used by Pydantic CREATE schemas, not ORM. + + Includes validation constraints (max_length, gt, min_length) for write operations. + """ + + name: str = PydanticField(min_length=2, max_length=100) + description: str | None = PydanticField(default=None, max_length=500) + brand: str | None = PydanticField(default=None, max_length=100) + model: str | None = PydanticField(default=None, max_length=100) + dismantling_notes: str | None = PydanticField(default=None, max_length=500) + dismantling_time_start: datetime = PydanticField(default_factory=lambda: datetime.now(UTC)) + dismantling_time_end: datetime | None = None + + # Physical properties with write-side constraints + weight_g: float | None = PydanticField(default=None, gt=0) + height_cm: float | None = PydanticField(default=None, gt=0) + width_cm: float | None = PydanticField(default=None, gt=0) + depth_cm: float | None = PydanticField(default=None, gt=0) + + # Circularity properties with write-side constraints + recyclability_observation: str | None = PydanticField(default=None, max_length=500) + recyclability_comment: str | None = PydanticField(default=None, max_length=100) + recyclability_reference: str | None = PydanticField(default=None, max_length=100) + repairability_observation: str | None = PydanticField(default=None, max_length=500) + repairability_comment: str | None = PydanticField(default=None, max_length=100) + repairability_reference: str | None = PydanticField(default=None, max_length=100) + remanufacturability_observation: str | None = PydanticField(default=None, max_length=500) + remanufacturability_comment: str | None = PydanticField(default=None, max_length=100) + remanufacturability_reference: str | None = PydanticField(default=None, max_length=100) diff --git a/backend/app/api/data_collection/models/product.py b/backend/app/api/data_collection/models/product.py new file mode 100644 index 00000000..1190ec40 --- /dev/null +++ b/backend/app/api/data_collection/models/product.py @@ -0,0 +1,220 @@ +"""Database models for data collection on products.""" +# spell-checker: ignore trgm + +from pydantic import UUID4, computed_field +from sqlalchemy import Computed, ForeignKey, Index, and_, asc, select +from sqlalchemy.dialects.postgresql import TSVECTOR +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import ( + Mapped, + MappedSQLExpression, + column_property, + declared_attr, + foreign, + mapped_column, + relationship, +) + +from app.api.auth.models import User +from app.api.background_data.models import Material, ProductType +from app.api.common.models.associations import MaterialProductLinkBase +from app.api.common.models.base import Base, TimeStampMixinBare +from app.api.data_collection.models.base import ProductFieldsMixin +from app.api.file_storage.models import File, Image, MediaParentType, Video + + +class Product(ProductFieldsMixin, TimeStampMixinBare, Base): + """Database model for product information.""" + + __tablename__ = "product" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + __table_args__ = ( + Index("product_search_vector_idx", "search_vector", postgresql_using="gin"), + Index("product_name_trgm_idx", "name", postgresql_using="gin", postgresql_ops={"name": "gin_trgm_ops"}), + Index("product_brand_trgm_idx", "brand", postgresql_using="gin", postgresql_ops={"brand": "gin_trgm_ops"}), + ) + + search_vector: Mapped[str | None] = mapped_column( + TSVECTOR(), + Computed( + "to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || " + "coalesce(brand, '') || ' ' || coalesce(model, ''))", + persisted=True, + ), + default=None, + ) + + @declared_attr + def first_image_id(self) -> MappedSQLExpression[UUID4 | None]: + """Column property that exposes the first image ID for thumbnails.""" + return column_property( + select(Image.id) + .where(Image.parent_type == MediaParentType.PRODUCT) + .where(Image.parent_id == self.id) + .correlate_except(Image) + .order_by(asc(Image.created_at)) + .limit(1) + .scalar_subquery() + ) + + # Self-referential relationship for hierarchy + parent_id: Mapped[int | None] = mapped_column(ForeignKey("product.id"), default=None) + parent: Mapped[Product | None] = relationship( + back_populates="components", + uselist=False, + remote_side="Product.id", + lazy="selectin", + join_depth=1, + ) + amount_in_parent: Mapped[int | None] = mapped_column(default=None) + components: Mapped[list[Product] | None] = relationship( + back_populates="parent", + cascade="all, delete-orphan", + lazy="selectin", + join_depth=1, + ) + + # One-to-many relationships (file storage) — generic FK, no DB-level constraint + files: Mapped[list[File] | None] = relationship( + primaryjoin=lambda: and_( + Product.id == foreign(File.parent_id), + File.parent_type == MediaParentType.PRODUCT, + ), + cascade="all, delete-orphan", + overlaps="files,images", + ) + images: Mapped[list[Image] | None] = relationship( + primaryjoin=lambda: and_( + Product.id == foreign(Image.parent_id), + Image.parent_type == MediaParentType.PRODUCT, + ), + cascade="all, delete-orphan", + lazy="selectin", + overlaps="files,images", + ) + videos: Mapped[list[Video] | None] = relationship(cascade="all, delete-orphan") + + # Many-to-one: owner + # nullable=False preserves the NOT NULL DB constraint; the Python type allows None + # so that pre-serialisation privacy redaction can null out the owner without a cast. + owner_id: Mapped[UUID4 | None] = mapped_column(ForeignKey("user.id"), nullable=False) + owner: Mapped[User | None] = relationship( + uselist=False, + lazy="selectin", + foreign_keys="[Product.owner_id]", + ) + + # Many-to-one: product type + product_type_id: Mapped[int | None] = mapped_column(ForeignKey("producttype.id"), default=None) + product_type: Mapped[ProductType] = relationship(uselist=False) + + # Many-to-many: bill of materials + bill_of_materials: Mapped[list[MaterialProductLink] | None] = relationship( + back_populates="product", lazy="selectin", cascade="all, delete-orphan" + ) + + @property + def thumbnail_url(self) -> str | None: + """Return thumbnail URL from the first image.""" + if first_image_id := self.first_image_id: + return f"/images/{first_image_id}/resized?width=200" + return None + + @computed_field + @property + def is_leaf_node(self) -> bool: + """Check if the product is a leaf node (no components).""" + return self.components is None or len(self.components) == 0 + + @computed_field + @property + def is_base_product(self) -> bool: + """Check if the product is a base product (no parent).""" + return self.parent_id is None + + def has_cycles(self) -> bool: + """Check if the product hierarchy contains cycles.""" + visited: set[int | None] = set() + + def visit(node: Product) -> bool: + if node.id in visited: + return True + visited.add(node.id) + if node.components: + for component in node.components: + if visit(component): + return True + visited.remove(node.id) + return False + + return visit(self) + + def components_resolve_to_materials(self) -> bool: + """Ensure all leaf components have a non-empty bill of materials.""" + + def check(node: Product) -> bool: + if not node.components: + if not node.bill_of_materials: + return False + else: + for component in node.components: + if not check(component): + return False + return True + + return check(self) + + async def get_total_bill_of_materials(self, session: AsyncSession) -> dict[int, float]: + """Traverse all components and calculate the total bill of materials.""" + total_materials: dict[int, float] = {} + visited_products: set[int | None] = set() + + async def traverse(product: Product, quantity_multiplier: float) -> None: + if product.id in visited_products: + return + visited_products.add(product.id) + + await session.refresh(product) + + if product.bill_of_materials: + for link in product.bill_of_materials: + material_id = link.material_id + quantity = link.quantity * quantity_multiplier + if material_id in total_materials: + total_materials[material_id] += quantity + else: + total_materials[material_id] = quantity + + if product.components: + for component in product.components: + component_quantity = component.amount_in_parent or 1.0 + await traverse(component, quantity_multiplier * component_quantity) + + await traverse(self, 1.0) + return total_materials + + @property + def owner_username(self) -> str | None: + """Return the owner's username.""" + return self.owner.username if self.owner else None + + def __str__(self) -> str: + return f"{self.name} (id: {self.id})" + + +### MaterialProductLink; lives here so Product and Material are both in scope ### +class MaterialProductLink(MaterialProductLinkBase, TimeStampMixinBare, Base): + """Association table to link Material with Product.""" + + __tablename__ = "materialproductlink" + + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("product.id"), primary_key=True) + + material: Mapped[Material] = relationship(lazy="selectin") + product: Mapped[Product] = relationship(back_populates="bill_of_materials", lazy="selectin") + + def __str__(self) -> str: + return f"{self.quantity} {self.unit} of {self.material.name} in {self.product.name}" diff --git a/backend/app/api/data_collection/routers.py b/backend/app/api/data_collection/routers.py deleted file mode 100644 index c70f2ddf..00000000 --- a/backend/app/api/data_collection/routers.py +++ /dev/null @@ -1,1070 +0,0 @@ -"""Routers for data collection models.""" - -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated - -from asyncache import cached -from cachetools import LRUCache, TTLCache -from fastapi import APIRouter, Body, HTTPException, Path, Query, Request -from fastapi.responses import RedirectResponse -from fastapi_filter import FilterDepends -from fastapi_pagination.links import Page -from pydantic import UUID4, PositiveInt -from sqlmodel import select - -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep -from app.api.background_data.models import Material -from app.api.background_data.routers.public import RecursionDepthQueryParam -from app.api.common.crud.associations import ( - get_linking_model_with_ids_if_it_exists, -) -from app.api.common.crud.base import ( - get_model_by_id, - get_models, - get_nested_model_by_id, - get_paginated_models, -) -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.models.associations import MaterialProductLink -from app.api.common.models.enums import Unit -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.schemas.associations import ( - MaterialProductLinkCreateWithinProduct, - MaterialProductLinkCreateWithinProductAndMaterial, - MaterialProductLinkReadWithinProduct, - MaterialProductLinkUpdate, -) -from app.api.common.schemas.base import ProductRead -from app.api.data_collection import crud -from app.api.data_collection.dependencies import ( - MaterialProductLinkFilterDep, - ProductByIDDep, - ProductFilterWithRelationshipsDep, - UserOwnedProductDep, - get_user_owned_product_id, -) -from app.api.data_collection.models import ( - PhysicalProperties, - Product, -) -from app.api.data_collection.schemas import ( - ComponentCreateWithComponents, - ComponentReadWithRecursiveComponents, - PhysicalPropertiesCreate, - PhysicalPropertiesRead, - PhysicalPropertiesUpdate, - ProductCreateWithComponents, - ProductReadWithProperties, - ProductReadWithRecursiveComponents, - ProductReadWithRelationshipsAndFlatComponents, - ProductUpdate, - ProductUpdateWithProperties, -) -from app.api.file_storage.crud import create_video, delete_video -from app.api.file_storage.filters import VideoFilter -from app.api.file_storage.models.models import Video -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes -from app.api.file_storage.schemas import VideoCreateWithinProduct, VideoReadWithinProduct - -if TYPE_CHECKING: - from sqlmodel.sql._expression_select_cls import SelectOfScalar - -# Initialize API router -router = APIRouter() - - -## User Product routers ## -user_product_redirect_router = PublicAPIRouter(prefix="/users/me/products", tags=["products"]) - - -@user_product_redirect_router.get( - "", - response_class=RedirectResponse, - status_code=307, # Temporary redirect that preserves method and body - summary="Redirect to user's products", -) -async def redirect_to_current_user_products( - current_user: CurrentActiveVerifiedUserDep, - request: Request, -) -> RedirectResponse: - """Redirect /users/me/products to /users/{id}/products for better caching.""" - # Preserve query parameters - query_string = str(request.url.query) - redirect_url = f"/users/{current_user.id}/products" - if query_string: - redirect_url += f"?{query_string}" - return RedirectResponse(url=redirect_url, status_code=307) - - -user_product_router = PublicAPIRouter(prefix="/users/{user_id}/products", tags=["products"]) - - -@user_product_router.get( - "", - response_model=list[ProductReadWithRelationshipsAndFlatComponents], - summary="Get products collected by a user", -) -async def get_user_products( - user_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveVerifiedUserDep, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": {}}, - "properties": {"value": {"physical_properties"}}, - "materials": {"value": {"bill_of_materials"}}, - "components": {"value": {"components"}}, - "media": {"value": {"images", "videos", "files"}}, - "all": { - "value": { - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - } - }, - }, - ), - ] = None, -) -> Sequence[Product]: - """Get products collected by a specific user.""" - # NOTE: If needed, we can open up this endpoint to any user by removing this ownership check - if user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=403, detail="Not authorized to view this user's products") - - return await get_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=(select(Product).where(Product.owner_id == user_id)), - ) - - -### Product Routers ### -product_router = PublicAPIRouter(prefix="/products", tags=["products"]) - - -## Utility functions ## -def convert_components_to_read_model( - components: list[Product], max_depth: int = 1, current_depth: int = 0 -) -> list[ComponentReadWithRecursiveComponents]: - """Convert components to read model recursively.""" - if current_depth >= max_depth: - return [] - - return [ - ComponentReadWithRecursiveComponents.model_validate( - component, - update={ - "components": convert_components_to_read_model(component.components or [], max_depth, current_depth + 1) - }, - ) - for component in components - ] - - -## GET routers ## -@product_router.get( - "", - response_model=Page[ProductReadWithRelationshipsAndFlatComponents], - summary="Get all products with optional relationships", -) -async def get_products( - session: AsyncSessionDep, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, - *, - include_components_as_base_products: Annotated[ - bool | None, - Query(description="Whether to include components as base products in the response"), - ] = None, -) -> Page[Sequence[ProductReadWithRelationshipsAndFlatComponents]]: - """Get all products with specified relationships. - - Relationships that can be included: - - physical_properties: Physical measurements and attributes - - images: Product images - - videos: Product videos - - files: Related documents - - product_type: Type classification - - bill_of_materials: Material composition - """ - # TODO: Instead of this hacky parameter, distinguish between base products and components on the model level - # For now, only return base products (those without a parent) - if include_components_as_base_products: - statement: SelectOfScalar[Product] = select(Product) - else: - statement: SelectOfScalar[Product] = select(Product).where(Product.parent_id == None) - - if product_filter: - statement = product_filter.filter(statement) - - return await get_paginated_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=statement, - read_schema=ProductReadWithRelationshipsAndFlatComponents, - ) - - -@product_router.get( - "/tree", - response_model=list[ProductReadWithRecursiveComponents], - summary="Get products tree", - responses={ - 200: { - "description": "Product tree with components", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple product tree", - "value": [ - { - "id": 1, - "name": "Office Chair", - "description": "Complete chair assembly", - "components": [], - } - ], - }, - "nested_tree": { - "summary": "Nested product tree", - "value": [ - { - "id": 1, - "name": "Office Chair", - "description": "Complete chair assembly", - "components": [ - { - "id": 2, - "name": "Seat Assembly", - "description": "Chair seat", - "components": [ - { - "id": 3, - "name": "Cushion", - "description": "Foam cushion", - "components": [], - } - ], - } - ], - } - ], - }, - } - } - }, - } - }, -) -async def get_products_tree( - session: AsyncSessionDep, - product_filter: ProductFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[ProductReadWithRecursiveComponents]: - """Get all base products and their components in a tree structure.""" - products: Sequence[Product] = await crud.get_product_trees( - session, recursion_depth=recursion_depth, product_filter=product_filter - ) - return [ - ProductReadWithRecursiveComponents.model_validate( - product, - update={ - "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) - }, - ) - for product in products - ] - - -@product_router.get( - "/{product_id}", - response_model=ProductReadWithRelationshipsAndFlatComponents, - summary="Get product by ID", -) -async def get_product( - session: AsyncSessionDep, - product_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, -) -> Product: - """Get product by ID with specified relationships. - - Relationships that can be included: - - physical_properties: Physical measurements and attributes - - images: Product images - - videos: Product videos - - files: Related documents - - product_type: Type classification - - bill_of_materials: Material composition - """ - return await get_model_by_id(session, Product, product_id, include_relationships=include) - - -## POST routers ## -@product_router.post( - "", - response_model=ProductRead, - summary="Create a new product, optionally with components", - status_code=201, -) -async def create_product( - product: Annotated[ - ProductCreateWithComponents, - Body( - description="Product to create", - openapi_examples={ - "basic": { - "summary": "Basic product without components", - "value": { - "name": "Office Chair", - "description": "Complete chair assembly", - "brand": "Brand 1", - "model": "Model 1", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "product_type_id": 1, - "physical_properties": { - "weight_kg": 20, - "height_cm": 150, - "width_cm": 70, - "depth_cm": 50, - }, - "videos": [ - {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} - ], - "bill_of_materials": [ - {"quantity": 15, "unit": "kg", "material_id": 1}, - {"quantity": 5, "unit": "kg", "material_id": 2}, - ], - }, - }, - "with_components": { - "summary": "Product with components", - "value": { - "name": "Office Chair", - "description": "Complete chair assembly", - "brand": "Brand 1", - "model": "Model 1", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "product_type_id": 1, - "physical_properties": { - "weight_kg": 20, - "height_cm": 150, - "width_cm": 70, - "depth_cm": 50, - }, - "videos": [ - {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} - ], - "o": 1, - "components": [ - { - "name": "Office Chair Seat", - "description": "Seat assembly", - "brand": "Brand 2", - "model": "Model 2", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "amount_in_parent": 1, - "product_type_id": 2, - "physical_properties": { - "weight_kg": 5, - "height_cm": 50, - "width_cm": 40, - "depth_cm": 30, - }, - "components": [ - { - "name": "Seat Cushion", - "description": "Seat cushion assembly", - "amount_in_parent": 1, - "physical_properties": { - "weight_kg": 2, - "height_cm": 10, - "width_cm": 40, - "depth_cm": 30, - }, - "product_type_id": 3, - "bill_of_materials": [ - {"quantity": 1.5, "unit": "kg", "material_id": 1}, - {"quantity": 0.5, "unit": "kg", "material_id": 2}, - ], - } - ], - } - ], - }, - }, - }, - ), - ], - current_user: CurrentActiveVerifiedUserDep, - session: AsyncSessionDep, -) -> Product: - """Create a new product.""" - return await crud.create_product(session, product, current_user.id) - - -## PATCH routers ## -@product_router.patch("/{product_id}", response_model=ProductReadWithProperties, summary="Update product") -async def update_product( - product_update: ProductUpdate | ProductUpdateWithProperties, - db_product: UserOwnedProductDep, - session: AsyncSessionDep, -) -> Product: - """Update an existing product.""" - return await crud.update_product(session, db_product.id, product_update) - - -## DELETE routers ## -@product_router.delete( - "/{product_id}", - status_code=204, - summary="Delete product", -) -async def delete_product(db_product: UserOwnedProductDep, session: AsyncSessionDep) -> None: - """Delete a product, including components.""" - await crud.delete_product(session, db_product.id) - - -## Product Component routers ## -@product_router.get( - "/{product_id}/components/tree", - summary="Get product component subtree", - response_model=list[ComponentReadWithRecursiveComponents], - responses={ - 200: { - "description": "Product tree with components", - "content": { - "application/json": { - "examples": { - "stub_tree": { - "summary": "Product without components", - "value": [], - }, - "nested_tree": { - "summary": "Nested component tree", - "value": [ - { - "id": 2, - "name": "Seat Assembly", - "description": "Chair seat", - "components": [ - { - "id": 3, - "name": "Cushion", - "description": "Foam cushion", - "components": [], - } - ], - } - ], - }, - } - }, - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def get_product_subtree( - session: AsyncSessionDep, - product_id: PositiveInt, - product_filter: ProductFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[ComponentReadWithRecursiveComponents]: - """Get a product's components in a tree structure, up to a specified depth.""" - products: Sequence[Product] = await crud.get_product_trees( - session, recursion_depth=recursion_depth, parent_id=product_id, product_filter=product_filter - ) - - return [ - ComponentReadWithRecursiveComponents.model_validate( - product, - update={ - "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) - }, - ) - for product in products - ] - - -@product_router.get( - "/{product_id}/components", - response_model=list[ProductReadWithRelationshipsAndFlatComponents], - summary="Get product components", -) -async def get_product_components( - session: AsyncSessionDep, - product_id: PositiveInt, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, -) -> Sequence[Product]: - """Get all components of a product.""" - # Validate existence of product - await get_model_by_id(session, Product, product_id) - - # Get components - return await get_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=(select(Product).where(Product.parent_id == product_id)), - ) - - -@product_router.get( - "/{product_id}/components/{component_id}", - response_model=ProductReadWithRelationshipsAndFlatComponents, - summary="Get product component by ID", -) -async def get_product_component( - product_id: PositiveInt, - component_id: PositiveInt, - *, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, - session: AsyncSessionDep, -) -> Product: - """Get component by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Product, product_id, Product, component_id, "parent_id", include_relationships=include - ) - - -@product_router.post( - "/{product_id}/components", - response_model=ComponentReadWithRecursiveComponents, - status_code=201, - summary="Create a new component in a product", -) -async def add_component_to_product( - db_product: UserOwnedProductDep, - component: Annotated[ - ComponentCreateWithComponents, - Body( - openapi_examples={ - "simple": { - "summary": "Basic component", - "description": "Create a component without subcomponents", - "value": { - "name": "Seat Assembly", - "description": "Chair seat component", - "amount_in_parent": 1, - "bill_of_materials": [{"material_id": 1, "quantity": 0.5, "unit": "kg"}], - }, - }, - "nested": { - "summary": "Component with subcomponents", - "description": "Create a component with nested subcomponents", - "value": { - "name": "Seat Assembly", - "description": "Chair seat with cushion", - "amount_in_parent": 1, - "components": [ - { - "name": "Cushion", - "description": "Foam cushion", - "amount_in_parent": 1, - "bill_of_materials": [{"material_id": 2, "quantity": 0.3, "unit": "kg"}], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Product: - """Create a new component in an existing product.""" - return await crud.create_component( - db=session, - component=component, - parent_product_id=db_product.id, - owner_id=None, - ) - - -@product_router.delete( - "/{product_id}/components/{component_id}", - status_code=204, - summary="Delete product component", -) -async def delete_product_component( - db_product: UserOwnedProductDep, component_id: PositiveInt, session: AsyncSessionDep -) -> None: - """Delete a component in a product, including subcomponents.""" - # Validate existence of product and component - await get_nested_model_by_id(session, Product, db_product.id, Product, component_id, "parent_id") - - # Delete category - await crud.delete_product(session, component_id) - - -## Product Storage routers ## -add_storage_routes( - router=product_router, - parent_api_model_name=Product.get_api_model_name(), - files_crud=crud.product_files_crud, - images_crud=crud.product_images_crud, - include_methods={StorageRouteMethod.GET, StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - read_parent_auth_dep=None, - # TODO: Build ownership check for modification operations - modify_parent_auth_dep=get_user_owned_product_id, -) - - -## Product Property routers ## -@product_router.get( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - summary="Get product physical properties", -) -async def get_product_physical_properties(product_id: PositiveInt, session: AsyncSessionDep) -> PhysicalProperties: - """Get physical properties for a product.""" - return await crud.get_physical_properties(session, product_id) - - -@product_router.post( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - status_code=201, - summary="Create product physical properties", -) -async def create_product_physical_properties( - product: UserOwnedProductDep, - properties: PhysicalPropertiesCreate, - session: AsyncSessionDep, -) -> PhysicalProperties: - """Create physical properties for a product.""" - return await crud.create_physical_properties(session, properties, product.id) - - -@product_router.patch( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - summary="Update product physical properties", -) -async def update_product_physical_properties( - product: UserOwnedProductDep, - properties: PhysicalPropertiesUpdate, - session: AsyncSessionDep, -) -> PhysicalProperties: - """Update physical properties for a product.""" - return await crud.update_physical_properties(session, product.id, properties) - - -@product_router.delete( - "/{product_id}/physical_properties", - status_code=204, - summary="Delete product physical properties", -) -async def delete_product_physical_properties( - product: UserOwnedProductDep, - session: AsyncSessionDep, -) -> None: - """Delete physical properties for a product.""" - await crud.delete_physical_properties(session, product) - - -## Product Video routers ## -@product_router.get( - "/{product_id}/videos", - response_model=list[VideoReadWithinProduct], - summary="Get all videos for a product", - responses={ - 200: { - "description": "List of videos", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Videos for a product", - "value": [ - { - "id": 1, - "url": "https://example.com/video1", - "description": "Product disassembly video", - } - ], - } - } - } - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def get_product_videos( - session: AsyncSessionDep, - product: ProductByIDDep, - video_filter: VideoFilter = FilterDepends(VideoFilter), # noqa: B008 # FilterDepends is a valid Depends wrapper -) -> Sequence[Video]: - """Get all videos associated with a specific product.""" - # Create statement to filter by product_id - statement: SelectOfScalar[Video] = select(Video).where(Video.product_id == product.id) - - return await get_models( - session, - Video, - model_filter=video_filter, - statement=statement, - ) - - -@product_router.get( - "/{product_id}/videos/{video_id}", - response_model=VideoReadWithinProduct, - summary="Get video by ID", -) -async def get_product_video( - product_id: PositiveInt, - video_id: PositiveInt, - session: AsyncSessionDep, -) -> Video: - """Get a video associated with a specific product.""" - return await get_nested_model_by_id(session, Product, product_id, Video, video_id, "product_id") - - -@product_router.post( - "/{product_id}/videos", - response_model=VideoReadWithinProduct, - status_code=201, - summary="Create a new video for a product", - responses={ - 201: { - "description": "Video created successfully", - "content": { - "application/json": { - "example": { - "id": 1, - "url": "https://example.com/video1", - "description": "Product disassembly video", - } - } - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def create_product_video( - product: UserOwnedProductDep, - video: VideoCreateWithinProduct, - session: AsyncSessionDep, -) -> Video: - """Create a new video associated with a specific product.""" - return await create_video(session, video, product_id=product.id) - - -@product_router.delete( - "/{product_id}/videos/{video_id}", - status_code=204, - summary="Delete video by ID", -) -async def delete_product_video(product: UserOwnedProductDep, video_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a video associated with a specific product.""" - # Validate existence of product and video - await get_nested_model_by_id(session, Product, product.id, Video, video_id, "product_id") - - # Delete video - await delete_video(session, video_id) - - -## Product Bill of Material routers ## -@product_router.get( - "/{product_id}/materials", - response_model=list[MaterialProductLinkReadWithinProduct], - summary="Get product bill of materials", -) -async def get_product_bill_of_materials( - session: AsyncSessionDep, - product_id: PositiveInt, - material_filter: MaterialProductLinkFilterDep, -) -> Sequence[MaterialProductLink]: - """Get bill of materials for a product.""" - # Validate existence of product - await db_get_model_with_id_if_it_exists(session, Product, product_id) - - statement: SelectOfScalar[MaterialProductLink] = ( - select(MaterialProductLink).join(Material).where(MaterialProductLink.product_id == product_id) - ) - - return await get_models( - session, - MaterialProductLink, - model_filter=material_filter, - statement=statement, - ) - - -@product_router.get( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - summary="Get material in product bill of materials", -) -async def get_material_in_product_bill_of_materials( - product_id: PositiveInt, - material_id: PositiveInt, - session: AsyncSessionDep, -) -> MaterialProductLink: - """Get a material in a product's bill of materials.""" - return await get_linking_model_with_ids_if_it_exists( - session, - MaterialProductLink, - product_id, - material_id, - "product_id", - "material_id", - ) - - -@product_router.post( - "/{product_id}/materials", - response_model=list[MaterialProductLinkReadWithinProduct], - status_code=201, - summary="Add multiple materials to product bill of materials", -) -async def add_materials_to_product( - product: UserOwnedProductDep, - materials: Annotated[ - list[MaterialProductLinkCreateWithinProduct], - Body( - description="List of materials-product links to add to the product", - examples=[ - [ - {"material_id": 1, "quantity": 5, "unit": "kg"}, - {"material_id": 2, "quantity": 10, "unit": "kg"}, - ] - ], - ), - ], - session: AsyncSessionDep, -) -> list[MaterialProductLink]: - """Add multiple materials to a product's bill of materials.""" - return await crud.add_materials_to_product(session, product.id, materials) - - -@product_router.post( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - status_code=201, - summary="Add single material to product bill of materials", -) -async def add_material_to_product( - product: UserOwnedProductDep, - material_id: Annotated[ - PositiveInt, - Path(description="ID of material to add to the product", examples=[1]), - ], - material_link: Annotated[ - MaterialProductLinkCreateWithinProductAndMaterial, - Body( - description="Material-product link details", - examples=[[{"quantity": 5, "unit": "kg"}]], - ), - ], - session: AsyncSessionDep, -) -> MaterialProductLink: - """Add a single material to a product's bill of materials.""" - return await crud.add_material_to_product(session, product.id, material_link, material_id=material_id) - - -@product_router.patch( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - summary="Update material in product bill of materials", -) -async def update_product_bill_of_materials( - product: UserOwnedProductDep, - material_id: PositiveInt, - material: MaterialProductLinkUpdate, - session: AsyncSessionDep, -) -> MaterialProductLink: - """Update material in bill of materials for a product.""" - return await crud.update_material_within_product(session, product.id, material_id, material) - - -@product_router.delete( - "/{product_id}/materials/{material_id}", - status_code=204, - summary="Remove single material from product bill of materials", -) -async def remove_material_from_product( - product: UserOwnedProductDep, - material_id: Annotated[ - PositiveInt, - Path(description="ID of material to remove from the product"), - ], - session: AsyncSessionDep, -) -> None: - """Remove a single material from a product's bill of materials.""" - await crud.remove_materials_from_product(session, product.id, {material_id}) - - -@product_router.delete( - "/{product_id}/materials", - status_code=204, - summary="Remove multiple materials from product bill of materials", -) -async def remove_materials_from_product_bulk( - product: UserOwnedProductDep, - material_ids: Annotated[ - set[PositiveInt], - Body( - description="Material IDs to remove from the product", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple materials from a product's bill of materials.""" - await crud.remove_materials_from_product(session, product.id, material_ids) - - -### Ancillary Search Routers ### - -search_router = PublicAPIRouter(prefix="", include_in_schema=True) - - -@search_router.get("/brands") -@cached(cache=TTLCache(maxsize=1, ttl=60)) -async def get_brands( - session: AsyncSessionDep, -) -> Sequence[str]: - """Get a list of unique product brands.""" - return await crud.get_unique_product_brands(session) - - -### Unit Routers ### -unit_router = PublicAPIRouter(prefix="/units", tags=["units"], include_in_schema=True) - - -@unit_router.get("") -@cached(LRUCache(maxsize=1)) # Cache units, as they are defined on app startup and do not change -async def get_units() -> list[str]: - """Get a list of available units.""" - return [unit.value for unit in Unit] - - -### Router inclusion ### -router.include_router(user_product_redirect_router) -router.include_router(user_product_router) -router.include_router(product_router) -router.include_router(search_router) -router.include_router(unit_router) diff --git a/backend/app/api/data_collection/routers/__init__.py b/backend/app/api/data_collection/routers/__init__.py new file mode 100644 index 00000000..d2e09690 --- /dev/null +++ b/backend/app/api/data_collection/routers/__init__.py @@ -0,0 +1,57 @@ +"""Routers for data collection models.""" + +from typing import TYPE_CHECKING, Annotated, Literal, cast + +from fastapi import APIRouter, Query +from fastapi_pagination.links import Page + +from app.api.common.crud.pagination import paginate_select +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.data_collection.filters import get_brand_search_statement +from app.api.data_collection.routers.product_mutation_routers import product_mutation_router +from app.api.data_collection.routers.product_read_routers import ( + product_read_router, + user_product_redirect_router, + user_product_router, +) +from app.api.data_collection.routers.product_related_routers import product_related_router +from app.core.cache import cache + +if TYPE_CHECKING: + from sqlalchemy import Select + +# Initialize API router +router = APIRouter() + + +### Ancillary Search Routers ### + +search_router = PublicAPIRouter(prefix="", include_in_schema=True) + + +@search_router.get( + "/brands", + response_model=Page[str], + summary="Get paginated list of unique product brands", +) +@cache(expire=60) +async def get_brands( + session: AsyncSessionDep, + search: Annotated[str | None, Query(description="Search brand (case-insensitive)")] = None, + order: Annotated[Literal["asc", "desc"], Query(description="Sort order: 'asc' or 'desc'")] = "asc", +) -> Page[str]: + """Get a paginated, searchable and orderable list of unique product brands.""" + statement = get_brand_search_statement(search=search, order=order) + page = await paginate_select(session, cast("Select[tuple[str]]", statement)) + page.items = [brand.title() for brand in page.items if brand] + return cast("Page[str]", page) + + +### Router inclusion ### +router.include_router(user_product_redirect_router) +router.include_router(user_product_router) +router.include_router(product_read_router) +router.include_router(product_mutation_router) +router.include_router(product_related_router) +router.include_router(search_router) diff --git a/backend/app/api/data_collection/routers/product_mutation_routers.py b/backend/app/api/data_collection/routers/product_mutation_routers.py new file mode 100644 index 00000000..2e30de58 --- /dev/null +++ b/backend/app/api/data_collection/routers/product_mutation_routers.py @@ -0,0 +1,321 @@ +"""Mutation-focused routers for product endpoints.""" + +from __future__ import annotations + +import json +from typing import Annotated + +from fastapi import Body, Depends, Form, Path, UploadFile +from fastapi import File as FastAPIFile +from fastapi_filter import FilterDepends +from pydantic import UUID4, BeforeValidator, PositiveInt + +from app.api.auth.dependencies import CurrentActiveVerifiedUserDep +from app.api.auth.services.stats import recompute_user_stats +from app.api.common.openapi_examples import IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.schemas.base import ProductRead +from app.api.data_collection.crud.products import create_component, get_owned_component +from app.api.data_collection.crud.products import create_product as create_product_record +from app.api.data_collection.crud.products import delete_product as delete_product_record +from app.api.data_collection.crud.products import update_product as update_product_record +from app.api.data_collection.crud.storage import ( + create_product_file, + create_product_image, + list_product_files, + list_product_images, +) +from app.api.data_collection.crud.storage import ( + delete_product_file as delete_product_file_record, +) +from app.api.data_collection.crud.storage import ( + delete_product_image as delete_product_image_record, +) +from app.api.data_collection.crud.storage import ( + get_product_file as load_product_file, +) +from app.api.data_collection.crud.storage import ( + get_product_image as load_product_image, +) +from app.api.data_collection.dependencies import UserOwnedProductDep, get_user_owned_product_id +from app.api.data_collection.examples import ( + COMPONENT_CREATE_OPENAPI_EXAMPLES, + PRODUCT_CREATE_OPENAPI_EXAMPLES, +) +from app.api.data_collection.models.product import Product +from app.api.data_collection.schemas import ( + ComponentCreateWithComponents, + ComponentReadWithRecursiveComponents, + ProductCreateWithComponents, + ProductUpdate, +) +from app.api.file_storage.filters import FileFilter, ImageFilter +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.schemas import ( + FileCreate, + FileReadWithinParent, + ImageCreateFromForm, + ImageReadWithinParent, + empty_str_to_none, +) + +product_mutation_router = PublicAPIRouter(prefix="/products", tags=["products"]) + + +def _parse_optional_json(value: str | None) -> dict[str, object] | None: + """Parse optional JSON form payloads only when provided.""" + if value is None: + return None + return dict(json.loads(value)) + + +def _product_file_create(parent_id: int, *, file: UploadFile, description: str | None) -> FileCreate: + """Build the canonical product file create payload.""" + return FileCreate( + file=file, + description=description, + parent_id=parent_id, + parent_type=MediaParentType.PRODUCT, + ) + + +def _product_image_create( + parent_id: int, + *, + file: UploadFile, + description: str | None, + image_metadata: str | None, +) -> ImageCreateFromForm: + """Build the canonical product image create payload.""" + return ImageCreateFromForm.model_validate( + { + "file": file, + "description": description, + "image_metadata": _parse_optional_json(image_metadata), + "parent_id": parent_id, + "parent_type": MediaParentType.PRODUCT, + } + ) + + +@product_mutation_router.post( + "", + response_model=ProductRead, + summary="Create a new product, optionally with components", + status_code=201, +) +async def create_product( + product: Annotated[ + ProductCreateWithComponents, + Body( + description="Product to create", + openapi_examples=PRODUCT_CREATE_OPENAPI_EXAMPLES, + ), + ], + current_user: CurrentActiveVerifiedUserDep, + session: AsyncSessionDep, +) -> Product: + """Create a new product.""" + return await create_product_record(session, product, current_user.id) + + +@product_mutation_router.patch("/{product_id}", response_model=ProductRead, summary="Update product") +async def update_product( + product_update: ProductUpdate, + db_product: UserOwnedProductDep, + session: AsyncSessionDep, +) -> Product: + """Update an existing product.""" + return await update_product_record(session, db_product.id, product_update) + + +@product_mutation_router.delete( + "/{product_id}", + status_code=204, + summary="Delete product", +) +async def delete_product(db_product: UserOwnedProductDep, session: AsyncSessionDep) -> None: + """Delete a product, including components.""" + await delete_product_record(session, db_product.id) + + +@product_mutation_router.post( + "/{product_id}/components", + response_model=ComponentReadWithRecursiveComponents, + status_code=201, + summary="Create a new component in a product", +) +async def add_component_to_product( + db_product: UserOwnedProductDep, + component: Annotated[ + ComponentCreateWithComponents, + Body(openapi_examples=COMPONENT_CREATE_OPENAPI_EXAMPLES), + ], + session: AsyncSessionDep, +) -> Product: + """Create a new component in an existing product.""" + return await create_component( + db=session, + component=component, + parent_product=db_product, + ) + + +@product_mutation_router.delete( + "/{product_id}/components/{component_id}", + status_code=204, + summary="Delete product component", +) +async def delete_product_component( + db_product: UserOwnedProductDep, component_id: PositiveInt, session: AsyncSessionDep +) -> None: + """Delete a component in a product, including subcomponents.""" + component = await get_owned_component(session, parent_product_id=db_product.id, component_id=component_id) + await delete_product_record(session, component.id) + + +@product_mutation_router.get( + "/{product_id}/files", + response_model=list[FileReadWithinParent], + summary="Get Product Files", +) +async def get_product_files( + product_id: Annotated[PositiveInt, Path(description="ID of the Product")], + session: AsyncSessionDep, + item_filter: FileFilter = FilterDepends(FileFilter), +) -> list[FileReadWithinParent]: + """Get all files associated with a product.""" + items = await list_product_files(session, product_id, filter_params=item_filter) + return [FileReadWithinParent.model_validate(item) for item in items] + + +@product_mutation_router.get( + "/{product_id}/files/{file_id}", + response_model=FileReadWithinParent, + summary="Get specific Product File", +) +async def get_product_file( + product_id: Annotated[PositiveInt, Path(description="ID of the Product")], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> FileReadWithinParent: + """Get a specific file associated with a product.""" + item = await load_product_file(session, product_id, file_id) + return FileReadWithinParent.model_validate(item) + + +@product_mutation_router.post( + "/{product_id}/files", + response_model=FileReadWithinParent, + status_code=201, + summary="Add File to Product", +) +async def upload_product_file( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(get_user_owned_product_id)], + file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], + description: Annotated[str | None, Form()] = None, +) -> FileReadWithinParent: + """Upload a new file for the product.""" + item = await create_product_file( + session, + parent_id, + _product_file_create(parent_id, file=file, description=description), + ) + return FileReadWithinParent.model_validate(item) + + +@product_mutation_router.delete( + "/{product_id}/files/{file_id}", + summary="Remove File from Product", + status_code=204, +) +async def delete_product_file( + parent_id: Annotated[int, Depends(get_user_owned_product_id)], + file_id: Annotated[UUID4, Path(description="ID of the file")], + session: AsyncSessionDep, +) -> None: + """Remove a file from the product.""" + await delete_product_file_record(session, parent_id, file_id) + + +@product_mutation_router.get( + "/{product_id}/images", + response_model=list[ImageReadWithinParent], + summary="Get Product Images", +) +async def get_product_images( + product_id: Annotated[PositiveInt, Path(description="ID of the Product")], + session: AsyncSessionDep, + item_filter: ImageFilter = FilterDepends(ImageFilter), +) -> list[ImageReadWithinParent]: + """Get all images associated with a product.""" + items = await list_product_images(session, product_id, filter_params=item_filter) + return [ImageReadWithinParent.model_validate(item) for item in items] + + +@product_mutation_router.get( + "/{product_id}/images/{image_id}", + response_model=ImageReadWithinParent, + summary="Get specific Product Image", +) +async def get_product_image( + product_id: Annotated[PositiveInt, Path(description="ID of the Product")], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> ImageReadWithinParent: + """Get a specific image associated with a product.""" + item = await load_product_image(session, product_id, image_id) + return ImageReadWithinParent.model_validate(item) + + +@product_mutation_router.post( + "/{product_id}/images", + response_model=ImageReadWithinParent, + status_code=201, + summary="Add Image to Product", +) +async def upload_product_image( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(get_user_owned_product_id)], + file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], + current_user: CurrentActiveVerifiedUserDep, + description: Annotated[str | None, Form()] = None, + image_metadata: Annotated[ + str | None, + Form( + description="Image metadata in JSON string format", + openapi_examples=IMAGE_METADATA_JSON_STRING_OPENAPI_EXAMPLES, + ), + BeforeValidator(empty_str_to_none), + ] = None, +) -> ImageReadWithinParent: + """Upload a new image for the product.""" + item = await create_product_image( + session, + parent_id, + _product_image_create(parent_id, file=file, description=description, image_metadata=image_metadata), + ) + await recompute_user_stats(session, current_user.id) + await session.commit() + return ImageReadWithinParent.model_validate(item) + + +@product_mutation_router.delete( + "/{product_id}/images/{image_id}", + summary="Remove Image from Product", + status_code=204, +) +async def delete_product_image( + parent_id: Annotated[int, Depends(get_user_owned_product_id)], + image_id: Annotated[UUID4, Path(description="ID of the image")], + session: AsyncSessionDep, +) -> None: + """Remove an image from the product.""" + # Need owner ID for stats update + product = await session.get(Product, parent_id) + await delete_product_image_record(session, parent_id, image_id) + if product and product.owner_id is not None: + await recompute_user_stats(session, product.owner_id) + await session.commit() diff --git a/backend/app/api/data_collection/routers/product_read_routers.py b/backend/app/api/data_collection/routers/product_read_routers.py new file mode 100644 index 00000000..a5e591c5 --- /dev/null +++ b/backend/app/api/data_collection/routers/product_read_routers.py @@ -0,0 +1,489 @@ +"""Read-focused routers for product and component endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import HTTPException, Query, Request +from fastapi.responses import RedirectResponse +from fastapi_pagination.links import Page +from pydantic import UUID4, PositiveInt +from sqlalchemy import select + +from app.api.auth.dependencies import CurrentActiveUserDep, OptionalCurrentActiveUserDep +from app.api.auth.models import User +from app.api.auth.services.privacy import redact_product_owner, should_redact_owner +from app.api.background_data.routers.public import RecursionDepthQueryParam +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.filtering import apply_filter +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.schemas.base import ProductRead +from app.api.data_collection.crud.products import ( + PRODUCT_READ_DETAIL_RELATIONSHIPS, + PRODUCT_READ_SUMMARY_RELATIONSHIPS, + load_product_tree_data, +) +from app.api.data_collection.dependencies import ProductFilterWithRelationshipsDep +from app.api.data_collection.models.product import Product +from app.api.data_collection.schemas import ( + ComponentReadWithRecursiveComponents, + ProductReadWithRecursiveComponents, + ProductReadWithRelationships, + ProductReadWithRelationshipsAndFlatComponents, +) +from app.api.data_collection.validators import ProductValidationError, validate_product +from app.core.responses import conditional_json_response + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import Select + from starlette.responses import Response + +user_product_redirect_router = PublicAPIRouter(prefix="/users/me/products", tags=["products"]) +user_product_router = PublicAPIRouter(prefix="/users/{user_id}/products", tags=["products"]) +product_read_router = PublicAPIRouter(prefix="/products", tags=["products"]) + +type IncludeComponentsAsBaseProductsQueryParam = Annotated[ + bool | None, + Query(description="Whether to include components as base products in the response"), +] + + +def redact_product_owners(products: list[Product], current_user: User | None) -> None: + """Apply owner privacy redaction to paginated products in place.""" + for product in products: + redact_product_owner(product, current_user) + + +def assign_shared_owner(product: Product, owner: User | None) -> None: + """Assign the same owner to a single product row.""" + product.owner = owner + product.owner_id = owner.id if owner else None + + +def assign_owner_to_components(components: list[Product], owner: User | None) -> None: + """Assign the shared owner to direct component rows only.""" + for component in components: + assign_shared_owner(component, owner) + + +def _visible_owner(owner: User | None, viewer: User | None) -> User | None: + """Return the owner when privacy rules allow it, otherwise ``None``.""" + if owner is None: + return None + if should_redact_owner(owner, viewer): + return None + return owner + + +def _product_owner_fields(owner: User | None) -> dict[str, str | UUID4 | None]: + """Build public owner fields for product responses.""" + return { + "owner_id": owner.id if owner else None, + "owner_username": owner.username if owner else None, + } + + +def _product_scalar_payload( + product: Product, + *, + owner: User | None, +) -> dict[str, object]: + """Build the scalar payload shared by product and component read schemas.""" + payload: dict[str, object] = { + "id": product.id, + "created_at": product.created_at, + "updated_at": product.updated_at, + "name": product.name, + "description": product.description, + "brand": product.brand, + "model": product.model, + "dismantling_notes": product.dismantling_notes, + "dismantling_time_start": product.dismantling_time_start, + "dismantling_time_end": product.dismantling_time_end, + "product_type_id": product.product_type_id, + "thumbnail_url": product.thumbnail_url, + "parent_id": product.parent_id, + "amount_in_parent": product.amount_in_parent, + "weight_g": product.weight_g, + "height_cm": product.height_cm, + "width_cm": product.width_cm, + "depth_cm": product.depth_cm, + "volume_cm3": product.volume_cm3, + "recyclability_observation": product.recyclability_observation, + "recyclability_comment": product.recyclability_comment, + "recyclability_reference": product.recyclability_reference, + "repairability_observation": product.repairability_observation, + "repairability_comment": product.repairability_comment, + "repairability_reference": product.repairability_reference, + "remanufacturability_observation": product.remanufacturability_observation, + "remanufacturability_comment": product.remanufacturability_comment, + "remanufacturability_reference": product.remanufacturability_reference, + } + payload.update(_product_owner_fields(owner)) + return payload + + +def _serialize_component_tree( + product: Product, + *, + owner: User | None, + children_by_parent_id: dict[int, list[Product]], + max_depth: int, + current_depth: int = 0, + visited: set[int] | None = None, +) -> ComponentReadWithRecursiveComponents: + """Serialize a component subtree from preloaded nodes without touching ORM relationships.""" + visited = visited or set() + if product.id is None: + child_components: list[ComponentReadWithRecursiveComponents] = [] + elif product.id in visited or current_depth >= max_depth: + child_components = [] + else: + next_visited = visited | {product.id} + child_components = [ + _serialize_component_tree( + child, + owner=owner, + children_by_parent_id=children_by_parent_id, + max_depth=max_depth, + current_depth=current_depth + 1, + visited=next_visited, + ) + for child in children_by_parent_id.get(product.id, []) + ] + + return ComponentReadWithRecursiveComponents.model_validate( + { + **_product_scalar_payload(product, owner=owner), + "components": child_components, + } + ) + + +def _serialize_product_tree( + product: Product, + *, + viewer: User | None, + children_by_parent_id: dict[int, list[Product]], + recursion_depth: int, +) -> ProductReadWithRecursiveComponents: + """Serialize a root product plus its bounded child tree.""" + visible_owner = _visible_owner(product.owner, viewer) + base = ProductReadWithRelationships.model_validate(product).model_dump() + base.update(_product_owner_fields(visible_owner)) + components = [ + _serialize_component_tree( + child, + owner=visible_owner, + children_by_parent_id=children_by_parent_id, + max_depth=recursion_depth - 1, + visited={product.id} if product.id is not None else None, + ) + for child in ([] if product.id is None else children_by_parent_id.get(product.id, [])) + ] + return ProductReadWithRecursiveComponents.model_validate({**base, "components": components}) + + +async def load_product_tree_for_validation( + session: AsyncSessionDep, + product: Product, + *, + visited: set[int] | None = None, +) -> None: + """Explicitly load the product tree needed for validation.""" + visited = visited or set() + if product.id in visited: + return + visited.add(product.id) + + await session.refresh(product, attribute_names=["components", "bill_of_materials"]) + for component in product.components or []: + await load_product_tree_for_validation(session, component, visited=visited) + + +async def _require_product_summary(session: AsyncSessionDep, product_id: PositiveInt) -> Product: + """Load one product with the summary relationships used on collection reads.""" + return await require_model(session, Product, product_id, loaders=PRODUCT_READ_SUMMARY_RELATIONSHIPS) + + +async def _require_product_detail(session: AsyncSessionDep, product_id: PositiveInt) -> Product: + """Load one product with the detail relationships used on detail reads.""" + return await require_model(session, Product, product_id, loaders=PRODUCT_READ_DETAIL_RELATIONSHIPS) + + +async def _list_direct_components( + session: AsyncSessionDep, + *, + product_id: PositiveInt, + product_filter: ProductFilterWithRelationshipsDep, +) -> Sequence[Product]: + """List direct child components for a product.""" + statement = select(Product).where(Product.parent_id == product_id) + statement = apply_loader_profile(statement, Product, PRODUCT_READ_SUMMARY_RELATIONSHIPS) + statement = product_filter.filter(statement) + return list((await session.execute(statement)).scalars().unique().all()) + + +async def _page_products( + session: AsyncSessionDep, + *, + statement: Select[tuple[Product]], + product_filter: ProductFilterWithRelationshipsDep, + current_user: User | None, +) -> Page[Product]: + """Page products from an explicit product read query.""" + statement = apply_filter(statement, Product, product_filter) + statement = apply_loader_profile(statement, Product, PRODUCT_READ_SUMMARY_RELATIONSHIPS) + return await paginate_select( + session, + statement, + model=Product, + mutate_items=lambda items: redact_product_owners(items, current_user), + ) + + +async def _load_product_component( + session: AsyncSessionDep, + *, + product_id: PositiveInt, + component_id: PositiveInt, +) -> Product: + """Load one component scoped to a parent product.""" + await _require_product_summary(session, product_id) + statement = select(Product).where(Product.id == component_id, Product.parent_id == product_id) + statement = apply_loader_profile(statement, Product, PRODUCT_READ_DETAIL_RELATIONSHIPS) + product = (await session.execute(statement)).scalars().unique().one_or_none() + if product is not None: + return product + + existing = await _require_product_detail(session, component_id) + if existing.parent_id != product_id: + raise DependentModelOwnershipError(Product, component_id, Product, product_id) + return existing + + +@user_product_redirect_router.get( + "", + response_class=RedirectResponse, + status_code=307, + summary="Redirect to user's products", +) +async def redirect_to_current_user_products( + current_user: CurrentActiveUserDep, + request: Request, +) -> RedirectResponse: + """Redirect /users/me/products to /users/{id}/products for better caching.""" + query_string = str(request.url.query) + redirect_url = f"/users/{current_user.id}/products" + if query_string: + redirect_url += f"?{query_string}" + return RedirectResponse(url=redirect_url, status_code=307) + + +@user_product_router.get( + "", + response_model=Page[ProductRead], + summary="Get products collected by a user", +) +async def get_user_products( + request: Request, + user_id: UUID4, + session: AsyncSessionDep, + current_user: CurrentActiveUserDep, + product_filter: ProductFilterWithRelationshipsDep, + *, + include_components_as_base_products: IncludeComponentsAsBaseProductsQueryParam = None, +) -> Page[Product] | Response: + """Get products collected by a specific user.""" + if user_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not authorized to view this user's products") + + statement = select(Product).where(Product.owner_id == user_id) + if not include_components_as_base_products: + statement = statement.where(Product.parent_id.is_(None)) + + payload = await _page_products( + session, + statement=statement, + product_filter=product_filter, + current_user=current_user, + ) + return conditional_json_response(request, payload) + + +@product_read_router.get( + "", + response_model=Page[ProductRead], + summary="Get all products", +) +async def get_products( + request: Request, + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, + product_filter: ProductFilterWithRelationshipsDep, + *, + include_components_as_base_products: IncludeComponentsAsBaseProductsQueryParam = None, +) -> Page[Product] | Response: + """Get all products.""" + if include_components_as_base_products: + statement: Select[tuple[Product]] = select(Product) + else: + statement = select(Product).where(Product.parent_id.is_(None)) + + payload = await _page_products( + session, + statement=statement, + product_filter=product_filter, + current_user=current_user, + ) + return conditional_json_response(request, payload) + + +@product_read_router.get( + "/tree", + response_model=list[ProductReadWithRecursiveComponents], + summary="Get products tree", +) +async def get_products_tree( + request: Request, + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, + product_filter: ProductFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[ProductReadWithRecursiveComponents] | Response: + """Get all base products and their components as a bounded hierarchical view.""" + tree_data = await load_product_tree_data(session, recursion_depth=recursion_depth, product_filter=product_filter) + payload = [ + _serialize_product_tree( + product, + viewer=current_user, + children_by_parent_id=tree_data.children_by_parent_id, + recursion_depth=recursion_depth, + ) + for product in tree_data.roots + ] + return conditional_json_response(request, payload) + + +@product_read_router.get( + "/{product_id}", + response_model=ProductReadWithRelationshipsAndFlatComponents, + summary="Get product by ID", +) +async def get_product( + request: Request, + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, + product_id: PositiveInt, +) -> ProductReadWithRelationshipsAndFlatComponents | Response: + """Get product by ID.""" + product = await _require_product_detail(session, product_id) + redact_product_owner(product, current_user) + payload = ProductReadWithRelationshipsAndFlatComponents.model_validate(product) + return conditional_json_response(request, payload) + + +@product_read_router.get( + "/{product_id}/components/tree", + summary="Get product component subtree", + response_model=list[ComponentReadWithRecursiveComponents], +) +async def get_product_subtree( + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, + product_id: PositiveInt, + product_filter: ProductFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[ComponentReadWithRecursiveComponents]: + """Get a product's component subtree as a bounded hierarchical view.""" + parent_product = await _require_product_summary(session, product_id) + visible_owner = _visible_owner(parent_product.owner, current_user) + tree_data = await load_product_tree_data( + session, + recursion_depth=recursion_depth, + parent_id=product_id, + product_filter=product_filter, + ) + return [ + _serialize_component_tree( + product, + owner=visible_owner, + children_by_parent_id=tree_data.children_by_parent_id, + max_depth=recursion_depth - 1, + visited={product_id}, + ) + for product in tree_data.roots + ] + + +@product_read_router.get( + "/{product_id}/components", + response_model=list[ProductRead], + summary="Get product components", +) +async def get_product_components( + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, + product_id: PositiveInt, + product_filter: ProductFilterWithRelationshipsDep, +) -> list[ProductRead]: + """Get all components of a product.""" + parent_product = await _require_product_summary(session, product_id) + products = await _list_direct_components(session, product_id=product_id, product_filter=product_filter) + redact_product_owner(parent_product, current_user) + for p in products: + assign_shared_owner(p, parent_product.owner) + + return [ProductRead.model_validate(p) for p in products] + + +@product_read_router.get( + "/{product_id}/components/{component_id}", + response_model=ProductReadWithRelationshipsAndFlatComponents, + summary="Get product component by ID", +) +async def get_product_component( + product_id: PositiveInt, + component_id: PositiveInt, + *, + session: AsyncSessionDep, + current_user: OptionalCurrentActiveUserDep, +) -> ProductReadWithRelationshipsAndFlatComponents: + """Get component by ID.""" + product = await _load_product_component(session, product_id=product_id, component_id=component_id) + parent_product = await _require_product_summary(session, product_id) + redact_product_owner(parent_product, current_user) + assign_shared_owner(product, parent_product.owner) + assign_owner_to_components(product.components or [], parent_product.owner) + return ProductReadWithRelationshipsAndFlatComponents.model_validate(product) + + +@product_read_router.post( + "/{product_id}/validate", + summary="Validate product tree", + response_model=dict[str, bool | list[str]], +) +async def validate_product_tree( + session: AsyncSessionDep, + product_id: PositiveInt, +) -> dict[str, bool | list[str]]: + """Validate the product hierarchy and bill-of-materials constraints. + + Returns ``{"valid": true, "errors": []}`` when the tree passes all checks, + or ``{"valid": false, "errors": [...]}`` with human-readable messages otherwise. + """ + product = await require_model(session, Product, product_id) + await load_product_tree_for_validation(session, product) + try: + validate_product(product) + except ProductValidationError as exc: + return {"valid": False, "errors": [exc.public_message]} + except ValueError: + return {"valid": False, "errors": ["Product failed validation."]} + return {"valid": True, "errors": []} diff --git a/backend/app/api/data_collection/routers/product_related_routers.py b/backend/app/api/data_collection/routers/product_related_routers.py new file mode 100644 index 00000000..3456ab8e --- /dev/null +++ b/backend/app/api/data_collection/routers/product_related_routers.py @@ -0,0 +1,299 @@ +"""Routers for product-related resources like properties, videos, and materials.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import Body, Path +from fastapi_filter import FilterDepends +from pydantic import PositiveInt +from sqlalchemy import select + +from app.api.background_data.models import Material +from app.api.common.crud.associations import require_link +from app.api.common.crud.exceptions import DependentModelOwnershipError +from app.api.common.crud.query import require_model +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.schemas.associations import ( + MaterialProductLinkCreateWithinProduct, + MaterialProductLinkCreateWithinProductAndMaterial, + MaterialProductLinkReadWithinProduct, + MaterialProductLinkUpdate, +) +from app.api.data_collection.crud.material_links import ( + add_material_to_product as add_material_to_product_link, +) +from app.api.data_collection.crud.material_links import ( + add_materials_to_product as add_materials_to_product_links, +) +from app.api.data_collection.crud.material_links import ( + remove_materials_from_product as remove_materials_from_product_links, +) +from app.api.data_collection.crud.material_links import ( + update_material_within_product, +) +from app.api.data_collection.dependencies import MaterialProductLinkFilterDep, ProductByIDDep, UserOwnedProductDep +from app.api.data_collection.examples import ( + PRODUCT_MATERIAL_ID_PATH_OPENAPI_EXAMPLES, + PRODUCT_MATERIAL_LINKS_BULK_OPENAPI_EXAMPLES, + PRODUCT_REMOVE_MATERIAL_IDS_OPENAPI_EXAMPLES, + PRODUCT_SINGLE_MATERIAL_LINK_OPENAPI_EXAMPLES, +) +from app.api.data_collection.models.product import ( + MaterialProductLink, + Product, +) +from app.api.file_storage.crud.video import create_video, delete_video, update_video +from app.api.file_storage.filters import VideoFilter +from app.api.file_storage.models import Video +from app.api.file_storage.schemas import VideoCreateWithinProduct, VideoReadWithinProduct, VideoUpdateWithinProduct + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlalchemy import Select + +product_related_router = PublicAPIRouter(prefix="/products", tags=["products"]) + + +async def _load_product_video(session: AsyncSessionDep, *, product_id: PositiveInt, video_id: PositiveInt) -> Video: + """Load one video scoped to a product.""" + video = await require_model(session, Video, video_id) + if video.product_id != product_id: + raise DependentModelOwnershipError(Video, video_id, Product, product_id) + return video + + +async def _list_product_videos( + session: AsyncSessionDep, + *, + product_id: PositiveInt, + video_filter: VideoFilter, +) -> Sequence[Video]: + """List videos scoped to one product.""" + statement: Select[tuple[Video]] = select(Video).where(Video.product_id == product_id) + statement = video_filter.filter(statement) + return list((await session.execute(statement)).scalars().unique().all()) + + +async def _list_product_material_links( + session: AsyncSessionDep, + *, + product_id: PositiveInt, + material_filter: MaterialProductLinkFilterDep, +) -> Sequence[MaterialProductLink]: + """List bill-of-material rows scoped to one product.""" + statement: Select[tuple[MaterialProductLink]] = ( + select(MaterialProductLink).join(Material).where(MaterialProductLink.product_id == product_id) + ) + statement = material_filter.filter(statement) + return list((await session.execute(statement)).scalars().unique().all()) + + +@product_related_router.get( + "/{product_id}/videos", + response_model=list[VideoReadWithinProduct], + summary="Get all videos for a product", +) +async def get_product_videos( + session: AsyncSessionDep, + product: ProductByIDDep, + video_filter: VideoFilter = FilterDepends(VideoFilter), +) -> Sequence[Video]: + """Get all videos associated with a specific product.""" + return await _list_product_videos(session, product_id=product.id, video_filter=video_filter) + + +@product_related_router.get( + "/{product_id}/videos/{video_id}", + response_model=VideoReadWithinProduct, + summary="Get video by ID", +) +async def get_product_video( + product_id: PositiveInt, + video_id: PositiveInt, + session: AsyncSessionDep, +) -> Video: + """Get a video associated with a specific product.""" + return await _load_product_video(session, product_id=product_id, video_id=video_id) + + +@product_related_router.post( + "/{product_id}/videos", + response_model=VideoReadWithinProduct, + status_code=201, + summary="Create a new video for a product", +) +async def create_product_video( + product: UserOwnedProductDep, + video: VideoCreateWithinProduct, + session: AsyncSessionDep, +) -> Video: + """Create a new video associated with a specific product.""" + return await create_video(session, video, product_id=product.id) + + +@product_related_router.patch( + "/{product_id}/videos/{video_id}", + response_model=VideoReadWithinProduct, + summary="Update video by ID", +) +async def update_product_video( + product: UserOwnedProductDep, + video_id: PositiveInt, + video_update: VideoUpdateWithinProduct, + session: AsyncSessionDep, +) -> Video: + """Update a video associated with a specific product.""" + await _load_product_video(session, product_id=product.id, video_id=video_id) + return await update_video(session, video_id, video_update) + + +@product_related_router.delete( + "/{product_id}/videos/{video_id}", + status_code=204, + summary="Delete video by ID", +) +async def delete_product_video(product: UserOwnedProductDep, video_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a video associated with a specific product.""" + await _load_product_video(session, product_id=product.id, video_id=video_id) + await delete_video(session, video_id) + + +@product_related_router.get( + "/{product_id}/materials", + response_model=list[MaterialProductLinkReadWithinProduct], + summary="Get product bill of materials", +) +async def get_product_bill_of_materials( + session: AsyncSessionDep, + product_id: PositiveInt, + material_filter: MaterialProductLinkFilterDep, +) -> Sequence[MaterialProductLink]: + """Get bill of materials for a product.""" + await require_model(session, Product, product_id) + return await _list_product_material_links(session, product_id=product_id, material_filter=material_filter) + + +@product_related_router.get( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + summary="Get material in product bill of materials", +) +async def get_material_in_product_bill_of_materials( + product_id: PositiveInt, + material_id: PositiveInt, + session: AsyncSessionDep, +) -> MaterialProductLink: + """Get a material in a product's bill of materials.""" + return await require_link( + session, + MaterialProductLink, + product_id, + material_id, + MaterialProductLink.product_id, + MaterialProductLink.material_id, + ) + + +@product_related_router.post( + "/{product_id}/materials", + response_model=list[MaterialProductLinkReadWithinProduct], + status_code=201, + summary="Add multiple materials to product bill of materials", +) +async def add_materials_to_product( + product: UserOwnedProductDep, + materials: Annotated[ + list[MaterialProductLinkCreateWithinProduct], + Body( + description="List of materials-product links to add to the product", + openapi_examples=PRODUCT_MATERIAL_LINKS_BULK_OPENAPI_EXAMPLES, + ), + ], + session: AsyncSessionDep, +) -> list[MaterialProductLink]: + """Add multiple materials to a product's bill of materials.""" + return await add_materials_to_product_links(session, product.id, materials) + + +@product_related_router.post( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + status_code=201, + summary="Add single material to product bill of materials", +) +async def add_material_to_product( + product: UserOwnedProductDep, + material_id: Annotated[ + PositiveInt, + Path( + description="ID of material to add to the product", + openapi_examples=PRODUCT_MATERIAL_ID_PATH_OPENAPI_EXAMPLES, + ), + ], + material_link: Annotated[ + MaterialProductLinkCreateWithinProductAndMaterial, + Body( + description="Material-product link details", + openapi_examples=PRODUCT_SINGLE_MATERIAL_LINK_OPENAPI_EXAMPLES, + ), + ], + session: AsyncSessionDep, +) -> MaterialProductLink: + """Add a single material to a product's bill of materials.""" + return await add_material_to_product_link(session, product.id, material_link, material_id=material_id) + + +@product_related_router.patch( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + summary="Update material in product bill of materials", +) +async def update_product_bill_of_materials( + product: UserOwnedProductDep, + material_id: PositiveInt, + material: MaterialProductLinkUpdate, + session: AsyncSessionDep, +) -> MaterialProductLink: + """Update material in bill of materials for a product.""" + return await update_material_within_product(session, product.id, material_id, material) + + +@product_related_router.delete( + "/{product_id}/materials/{material_id}", + status_code=204, + summary="Remove single material from product bill of materials", +) +async def remove_material_from_product( + product: UserOwnedProductDep, + material_id: Annotated[ + PositiveInt, + Path(description="ID of material to remove from the product"), + ], + session: AsyncSessionDep, +) -> None: + """Remove a single material from a product's bill of materials.""" + await remove_materials_from_product_links(session, product.id, {material_id}) + + +@product_related_router.delete( + "/{product_id}/materials", + status_code=204, + summary="Remove multiple materials from product bill of materials", +) +async def remove_materials_from_product_bulk( + product: UserOwnedProductDep, + material_ids: Annotated[ + set[PositiveInt], + Body( + description="Material IDs to remove from the product", + default_factory=set, + openapi_examples=PRODUCT_REMOVE_MATERIAL_IDS_OPENAPI_EXAMPLES, + ), + ], + session: AsyncSessionDep, +) -> None: + """Remove multiple materials from a product's bill of materials.""" + await remove_materials_from_product_links(session, product.id, material_ids) diff --git a/backend/app/api/data_collection/schemas.py b/backend/app/api/data_collection/schemas.py index 6306902f..3e5c8c6c 100644 --- a/backend/app/api/data_collection/schemas.py +++ b/backend/app/api/data_collection/schemas.py @@ -1,12 +1,13 @@ """Pydantic models used to validate CRUD operations for data collection data.""" -from collections.abc import Collection +import logging from datetime import UTC, datetime, timedelta -from typing import Annotated, Self +from typing import TYPE_CHECKING, Annotated, Self from pydantic import ( AfterValidator, AwareDatetime, + BeforeValidator, ConfigDict, Field, PastDatetime, @@ -21,14 +22,14 @@ ) from app.api.common.schemas.base import ( BaseCreateSchema, - BaseReadSchemaWithTimeStamp, BaseUpdateSchema, ComponentRead, ProductRead, ) -from app.api.data_collection.models import ( - PhysicalPropertiesBase, +from app.api.data_collection.examples import PRODUCT_CREATE_EXAMPLES +from app.api.data_collection.models.base import ( ProductBase, + validate_start_and_end_time, ) from app.api.file_storage.schemas import ( FileRead, @@ -37,11 +38,21 @@ VideoReadWithinProduct, ) -### Constants ### +if TYPE_CHECKING: + from collections.abc import Collection + +logger = logging.getLogger(__name__) +### Constants ### MAX_TIMESTAMP_AGE: timedelta = timedelta(days=365) +# Normalizes brand strings: strips whitespace and lowercases; empty string becomes None +NormalizedBrand = Annotated[ + str | None, + BeforeValidator(lambda v: v.strip().lower() or None if isinstance(v, str) else v), +] + ### Common Validators ### def not_too_old(dt: datetime, time_delta: timedelta = MAX_TIMESTAMP_AGE) -> datetime: @@ -68,31 +79,6 @@ def ensure_timezone(dt: datetime) -> AwareDatetime: ] -### Properties Schemas ### - - -class PhysicalPropertiesCreate(BaseCreateSchema, PhysicalPropertiesBase): - """Schema for creating physical properties.""" - - model_config: ConfigDict = ConfigDict( - json_schema_extra={"examples": [{"weight_kg": 20, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} - ) - - -class PhysicalPropertiesRead(BaseReadSchemaWithTimeStamp, PhysicalPropertiesBase): - """Schema for reading physical properties.""" - - model_config: ConfigDict = ConfigDict( - json_schema_extra={"examples": [{"id": 1, "weight_kg": 20, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} - ) - - -class PhysicalPropertiesUpdate(BaseUpdateSchema, PhysicalPropertiesBase): - """Schema for updating physical properties.""" - - model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": [{"weight_kg": 15, "height_cm": 120}]}) - - ### Product Schemas ### @@ -101,15 +87,17 @@ def validate_material_or_components(bill_of_materials: Collection, components: C """Validation logic to ensure either materials or components are provided.""" if len(bill_of_materials) == 0 and len(components) == 0: err_msg = "Product must have at least one material or component" - raise ValueError(err_msg) + # TODO: raise error again once we implement mBill of materials UI + # that allows users to add materials at product creation instead of only components + logger.warning("Validation warning: %s. This will become an error in the future.", err_msg) ## Create Schemas ## - - class ProductCreateBase(BaseCreateSchema, ProductBase): """Base schema for product and component creation.""" + brand: NormalizedBrand = Field(default=None, max_length=100) + # Override base model start and end time to for validation purposes dismantling_time_start: ValidDateTime = Field( default_factory=lambda: datetime.now(UTC), @@ -119,16 +107,18 @@ class ProductCreateBase(BaseCreateSchema, ProductBase): default=None, description="End of the dismantling time, in ISO 8601 format with timezone info" ) + @model_validator(mode="after") + def validate_times(self) -> Self: + """Ensure end time is after start time if both are set.""" + validate_start_and_end_time(self.dismantling_time_start, self.dismantling_time_end) + return self + class ProductCreateWithRelationships(ProductCreateBase): """Schema for creating a product or component with relationships to other models.""" product_type_id: PositiveInt | None = None - physical_properties: PhysicalPropertiesCreate | None = Field( - default=None, description="Physical properties of the product" - ) - videos: list[VideoCreateWithinProduct] = Field(default_factory=list, description="Disassembly videos") bill_of_materials: list[MaterialProductLinkCreateWithinProduct] = Field( default_factory=list, description="Bill of materials with quantities and units" @@ -138,34 +128,7 @@ class ProductCreateWithRelationships(ProductCreateBase): class ProductCreateBaseProduct(ProductCreateWithRelationships): """Schema for creating a base product.""" - model_config: ConfigDict = ConfigDict( - json_schema_extra={ - "examples": [ - { - "name": "Office Chair", - "description": "Complete chair assembly", - "brand": "Brand 1", - "model": "Model 1", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "product_type_id": 1, - "physical_properties": { - "weight_kg": 20, - "height_cm": 150, - "width_cm": 70, - "depth_cm": 50, - }, - "videos": [ - {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} - ], - "bill_of_materials": [ - {"quantity": 0.3, "unit": "kg", "material_id": 1}, - {"quantity": 0.1, "unit": "kg", "material_id": 2}, - ], - } - ] - } - ) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": PRODUCT_CREATE_EXAMPLES}) class ComponentCreate(ProductCreateWithRelationships): @@ -187,12 +150,13 @@ class ComponentCreateWithComponents(ComponentCreate): """ # Recursive components - components: list["ComponentCreateWithComponents"] = Field( + components: list[ComponentCreateWithComponents] = Field( default_factory=list, description="Set of component products" ) @model_validator(mode="after") def has_material_or_components(self) -> Self: + """Validation to ensure product has either materials or components.""" validate_material_or_components(self.bill_of_materials, self.components) return self @@ -210,6 +174,7 @@ class ProductCreateWithComponents(ProductCreateBaseProduct): @model_validator(mode="after") def has_material_or_components(self) -> Self: + """Validation to ensure product has either materials or components.""" validate_material_or_components(self.bill_of_materials, self.components) return self @@ -218,13 +183,7 @@ def has_material_or_components(self) -> Self: # Note that the base ProductRead schema is imported from app.api.common.schemas.base to avoid circular dependencies -class ProductReadWithProperties(ProductRead): - """Schema for reading product information with all properties.""" - - physical_properties: PhysicalPropertiesRead | None = None - - -class ProductReadWithRelationships(ProductReadWithProperties): +class ProductReadWithRelationships(ProductRead): """Schema for reading product information with all relationships.""" product_type: ProductTypeRead | None = None @@ -235,17 +194,24 @@ class ProductReadWithRelationships(ProductReadWithProperties): default_factory=list, description="Bill of materials with quantities and units" ) + @model_validator(mode="after") + def populate_thumbnail_url_from_images(self) -> Self: + """Fill thumbnail_url from the first image when the field is otherwise unset.""" + if self.thumbnail_url is None and self.images: + self.thumbnail_url = self.images[0].image_url + return self + class ProductReadWithRelationshipsAndFlatComponents(ProductReadWithRelationships): """Schema for reading product information with one level of components.""" - components: list["ComponentRead"] = Field(default_factory=list, description="List of component products") + components: list[ComponentRead] = Field(default_factory=list, description="List of component products") class ComponentReadWithRecursiveComponents(ComponentRead): """Schema for reading product information with recursive components.""" - components: list["ComponentReadWithRecursiveComponents"] = Field( + components: list[ComponentReadWithRecursiveComponents] = Field( default_factory=list, description="List of component products" ) @@ -264,11 +230,11 @@ class ProductReadWithRecursiveComponents(ProductReadWithRelationships): ### Update Schemas ### class ProductUpdate(BaseUpdateSchema): - """Schema for updating basic product information.""" + """Schema for updating product information including physical and circularity properties.""" name: str | None = Field(default=None, min_length=2, max_length=100) description: str | None = Field(default=None, max_length=500) - brand: str | None = Field(default=None, max_length=100) + brand: NormalizedBrand = Field(default=None, max_length=100) model: str | None = Field(default=None, max_length=100) dismantling_notes: str | None = Field(default=None, max_length=500, description="Notes on the dismantling process") @@ -285,8 +251,25 @@ class ProductUpdate(BaseUpdateSchema): default=None, gt=0, description="Quantity within parent product. Required for component products." ) + # Physical properties + weight_g: float | None = Field(default=None, gt=0) + height_cm: float | None = Field(default=None, gt=0) + width_cm: float | None = Field(default=None, gt=0) + depth_cm: float | None = Field(default=None, gt=0) + + # Circularity properties + recyclability_observation: str | None = Field(default=None, max_length=500) + recyclability_comment: str | None = Field(default=None, max_length=100) + recyclability_reference: str | None = Field(default=None, max_length=100) + repairability_observation: str | None = Field(default=None, max_length=500) + repairability_comment: str | None = Field(default=None, max_length=100) + repairability_reference: str | None = Field(default=None, max_length=100) + remanufacturability_observation: str | None = Field(default=None, max_length=500) + remanufacturability_comment: str | None = Field(default=None, max_length=100) + remanufacturability_reference: str | None = Field(default=None, max_length=100) -class ProductUpdateWithProperties(ProductUpdate): - """Schema for a partial update of a product with properties.""" - - physical_properties: PhysicalPropertiesUpdate | None = None + @model_validator(mode="after") + def validate_times(self) -> Self: + """Ensure end time is after start time if both are set.""" + validate_start_and_end_time(self.dismantling_time_start, self.dismantling_time_end) + return self diff --git a/backend/app/api/data_collection/validators.py b/backend/app/api/data_collection/validators.py new file mode 100644 index 00000000..28d1c782 --- /dev/null +++ b/backend/app/api/data_collection/validators.py @@ -0,0 +1,61 @@ +"""Business validation for product hierarchy and bill of materials. + +Extracted from Product.validate_product model_validator per ADR-013. +These validators should be called from the CRUD layer during create/update, +not from the ORM model itself. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.api.data_collection.models.product import Product + +ERR_PRODUCT_CYCLE = "Cycle detected: a product cannot contain itself directly or indirectly." +ERR_BASE_PRODUCT_EMPTY = "A product must have at least one material or one component." +ERR_BASE_PRODUCT_AMOUNT = "Base product must have amount_in_parent set to None." +ERR_INTERMEDIATE_PRODUCT_AMOUNT = "Intermediate product must have amount_in_parent set." +ERR_INTERMEDIATE_PRODUCT_EMPTY = "Intermediate product must have at least one material or one component." +ERR_LEAF_COMPONENTS_WITHOUT_MATERIALS = "All leaf components must have a non-empty bill of materials." + + +class ProductValidationError(ValueError): + """Business-rule validation failure safe to return to API clients.""" + + def __init__(self, public_message: str) -> None: + self.public_message = public_message + super().__init__(public_message) + + +def validate_product(product: Product) -> Product: + """Validate the product hierarchy and bill of materials constraints. + + Raises: + ValueError: If the product fails any business rule. + """ + components = product.components + bill_of_materials = product.bill_of_materials + amount_in_parent = product.amount_in_parent + + if product.has_cycles(): + raise ProductValidationError(ERR_PRODUCT_CYCLE) + + if product.is_base_product: + if not components and not bill_of_materials: + raise ProductValidationError(ERR_BASE_PRODUCT_EMPTY) + if amount_in_parent is not None: + raise ProductValidationError(ERR_BASE_PRODUCT_AMOUNT) + + else: + # Intermediate product + if amount_in_parent is None: + raise ProductValidationError(ERR_INTERMEDIATE_PRODUCT_AMOUNT) + if not components and not bill_of_materials: + raise ProductValidationError(ERR_INTERMEDIATE_PRODUCT_EMPTY) + + # Ensure all components ultimately resolve to materials + if not product.components_resolve_to_materials(): + raise ProductValidationError(ERR_LEAF_COMPONENTS_WITHOUT_MATERIALS) + + return product diff --git a/backend/app/api/exceptions.py b/backend/app/api/exceptions.py new file mode 100644 index 00000000..5f66fd62 --- /dev/null +++ b/backend/app/api/exceptions.py @@ -0,0 +1,171 @@ +"""Centralized exception exports for API modules. + +Import from this module in new code when you want a single, discoverable +exception surface without changing the underlying exception implementations. +""" + +from app.api.auth.exceptions import ( + AlreadyMemberError, + AuthCRUDError, + DisposableEmailError, + InvalidOAuthProviderError, + OAuthAccountAlreadyLinkedError, + OAuthAccountNotLinkedError, + OAuthEmailUnavailableError, + OAuthHTTPError, + OAuthInactiveUserHTTPError, + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, + OAuthStateDecodeError, + OAuthStateExpiredError, + OAuthUserAlreadyExistsHTTPError, + OrganizationHasMembersError, + OrganizationNameExistsError, + RefreshTokenError, + RefreshTokenInvalidError, + RefreshTokenNotFoundError, + RefreshTokenRevokedError, + RefreshTokenUserInactiveError, + RegistrationHTTPError, + RegistrationInvalidPasswordHTTPError, + RegistrationUnexpectedHTTPError, + RegistrationUserAlreadyExistsHTTPError, + UserDoesNotOwnOrgError, + UserHasNoOrgError, + UserIsNotMemberError, + UserNameAlreadyExistsError, + UserOwnershipError, + UserOwnsOrgError, +) +from app.api.common.crud.exceptions import ( + CRUDConfigurationError, + DependentModelOwnershipError, + LinkedItemsAlreadyAssignedError, + LinkedItemsMissingError, + ModelNotFoundError, + ModelsNotFoundError, + NoLinkedItemsError, +) +from app.api.common.exceptions import ( + APIError, + BadRequestError, + ConflictError, + FailedDependencyError, + ForbiddenError, + InternalServerError, + NotFoundError, + PayloadTooLargeError, + ServiceUnavailableError, + UnauthorizedError, +) +from app.api.data_collection.exceptions import ( + InvalidProductTreeError, + MaterialIDRequiredError, + ProductOwnerRequiredError, + ProductTreeMissingContentError, +) +from app.api.file_storage.exceptions import ( + FastAPIStorageFileNotFoundError, + ModelFileNotFoundError, + ParentStorageOwnershipError, + UploadTooLargeError, +) +from app.api.newsletter.exceptions import ( + NewsletterAlreadyConfirmedError, + NewsletterAlreadySubscribedError, + NewsletterConfirmationResentError, + NewsletterInvalidConfirmationTokenError, + NewsletterInvalidUnsubscribeTokenError, + NewsletterSubscriberNotFoundError, +) +from app.api.plugins.rpi_cam.exceptions import ( + CameraProxyRequestError, + GoogleOAuthAssociationRequiredError, + InvalidCameraOwnershipTransferError, + InvalidCameraResponseError, + InvalidRecordingSessionDataError, + NoActiveYouTubeRecordingError, + PairingCodeAlreadyClaimedError, + PairingCodeCollisionError, + PairingCodeNotFoundError, + PairingFingerprintMismatchError, + RecordingSessionNotFoundError, + RecordingSessionStoreError, +) + +__all__ = [ + "APIError", + "AlreadyMemberError", + "AuthCRUDError", + "BadRequestError", + "CRUDConfigurationError", + "CameraProxyRequestError", + "ConflictError", + "DependentModelOwnershipError", + "DisposableEmailError", + "FailedDependencyError", + "FastAPIStorageFileNotFoundError", + "ForbiddenError", + "GoogleOAuthAssociationRequiredError", + "InternalServerError", + "InvalidCameraOwnershipTransferError", + "InvalidCameraResponseError", + "InvalidOAuthProviderError", + "InvalidProductTreeError", + "InvalidRecordingSessionDataError", + "LinkedItemsAlreadyAssignedError", + "LinkedItemsMissingError", + "MaterialIDRequiredError", + "ModelFileNotFoundError", + "ModelNotFoundError", + "ModelsNotFoundError", + "NewsletterAlreadyConfirmedError", + "NewsletterAlreadySubscribedError", + "NewsletterConfirmationResentError", + "NewsletterInvalidConfirmationTokenError", + "NewsletterInvalidUnsubscribeTokenError", + "NewsletterSubscriberNotFoundError", + "NoActiveYouTubeRecordingError", + "NoLinkedItemsError", + "NotFoundError", + "OAuthAccountAlreadyLinkedError", + "OAuthAccountNotLinkedError", + "OAuthEmailUnavailableError", + "OAuthHTTPError", + "OAuthInactiveUserHTTPError", + "OAuthInvalidRedirectURIError", + "OAuthInvalidStateError", + "OAuthStateDecodeError", + "OAuthStateExpiredError", + "OAuthUserAlreadyExistsHTTPError", + "OrganizationHasMembersError", + "OrganizationNameExistsError", + "PairingCodeAlreadyClaimedError", + "PairingCodeCollisionError", + "PairingCodeNotFoundError", + "PairingFingerprintMismatchError", + "ParentStorageOwnershipError", + "PayloadTooLargeError", + "ProductOwnerRequiredError", + "ProductTreeMissingContentError", + "RecordingSessionNotFoundError", + "RecordingSessionStoreError", + "RefreshTokenError", + "RefreshTokenInvalidError", + "RefreshTokenNotFoundError", + "RefreshTokenRevokedError", + "RefreshTokenUserInactiveError", + "RegistrationHTTPError", + "RegistrationInvalidPasswordHTTPError", + "RegistrationUnexpectedHTTPError", + "RegistrationUserAlreadyExistsHTTPError", + "ServiceUnavailableError", + "UnauthorizedError", + "UploadTooLargeError", + "UserDoesNotOwnOrgError", + "UserHasNoOrgError", + "UserIsNotMemberError", + "UserNameAlreadyExistsError", + "UserOwnershipError", + "UserOwnsOrgError", +] diff --git a/backend/app/api/file_storage/crud.py b/backend/app/api/file_storage/crud.py deleted file mode 100644 index d07bcdbb..00000000 --- a/backend/app/api/file_storage/crud.py +++ /dev/null @@ -1,400 +0,0 @@ -"""CRUD operations for file storage models.""" - -import logging -import uuid -from collections.abc import Callable, Sequence -from pathlib import Path -from typing import Any, Generic, TypeVar - -from anyio import to_thread -from fastapi import UploadFile -from fastapi_filter.contrib.sqlalchemy import Filter -from pydantic import UUID4 -from slugify import slugify -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession - -from app.api.common.crud.base import get_models -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists, get_file_parent_type_model -from app.api.common.models.custom_types import MT -from app.api.data_collection.models import Product -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError, ModelFileNotFoundError -from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video -from app.api.file_storage.schemas import ( - FileCreate, - FileUpdate, - ImageCreateFromForm, - ImageCreateInternal, - ImageUpdate, - VideoCreate, - VideoCreateWithinProduct, - VideoUpdate, -) - -logger = logging.getLogger(__name__) - - -### Common utilities ### -def sanitize_filename(filename: str, max_length: int = 42) -> str: - """Preserve all suffixes while sanitizing base name.""" - path = Path(filename) - name = path.name - - # Reverse order to remove last suffix first - for suffix in path.suffixes[::-1]: - name = name.removesuffix(suffix) - - sanitized_filename = slugify( - name[:-1] + "_" if len(name) > max_length else name, lowercase=False, max_length=max_length, word_boundary=True - ) - - return f"{sanitized_filename}{''.join(path.suffixes)}" - - -def process_uploadfile_name( - file: UploadFile, -) -> tuple[UploadFile, UUID4, str]: - """Process an UploadFile for storing in the database.""" - if file.filename is None: - err_msg = "File name is empty." - raise ValueError(err_msg) - - # Extract and truncate original filename - original_filename: str = sanitize_filename(file.filename) - - file_id = uuid.uuid4() - file.filename = f"{file_id.hex}_{original_filename}" - return file, file_id, original_filename - - -async def delete_file_from_storage(file_path: Path) -> None: - """Delete a file from the filesystem.""" - if file_path.exists(): - await to_thread.run_sync(file_path.unlink) - - -### File CRUD operations ### -## Basic CRUD operations ## -async def get_files(db: AsyncSession, *, file_filter: FileFilter | None = None) -> Sequence[File]: - """Get all files from the database.""" - # TODO: Handle missing files in storage - return await get_models(db, File, model_filter=file_filter) - - -async def get_file(db: AsyncSession, file_id: UUID4) -> File: - """Get a file from the database.""" - try: - return await db_get_model_with_id_if_it_exists(db, File, file_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(File, file_id, details=e.message) from e - - -async def create_file(db: AsyncSession, file_data: FileCreate) -> File: - """Create a new file in the database and save it.""" - if file_data.file.filename is None: - err_msg = "File name is empty" - raise ValueError(err_msg) - - # Generate ID before creating File - file_data.file, file_id, original_filename = process_uploadfile_name(file_data.file) - - # Verify parent exists (will raise ModelNotFoundError if not) - parent_model = get_file_parent_type_model(file_data.parent_type) - await db_get_model_with_id_if_it_exists(db, parent_model, file_data.parent_id) - - db_file = File( - id=file_id, - description=file_data.description, - filename=original_filename, - file=file_data.file, # pyright: ignore [reportArgumentType] # Incoming UploadFile cannot be preemptively cast to FileType because of how FastAPI-storages works. - parent_type=file_data.parent_type, - ) - - # Set parent id - db_file.set_parent(file_data.parent_type, file_data.parent_id) - - db.add(db_file) - await db.commit() - await db.refresh(db_file) - - return db_file - - -async def update_file(db: AsyncSession, file_id: UUID4, file: FileUpdate) -> File: - """Update an existing file in the database.""" - try: - db_file = await db_get_model_with_id_if_it_exists(db, File, file_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(File, file_id, details=e.message) from e - file_data: dict[str, Any] = file.model_dump(exclude_unset=True) - db_file.sqlmodel_update(file_data) - - db.add(db_file) - - await db.commit() - await db.refresh(db_file) - - return db_file - - -async def delete_file(db: AsyncSession, file_id: UUID4) -> None: - """Delete a file from the database and remove it from storage.""" - try: - db_file = await db_get_model_with_id_if_it_exists(db, File, file_id) - file_path = Path(db_file.file.path) if db_file.file else None - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: - # File missing from storage but exists in DB - proceed with DB cleanup - # TODO: Test this scenario - db_file = await db.get(File, file_id) - file_path = None - logger.warning("File %s not found in storage: %s. File instance will be deleted from the database.", file_id, e) - - await db.delete(db_file) - await db.commit() - - if file_path: - await delete_file_from_storage(file_path) - - -### Image CRUD operations ### -## Basic CRUD operations ## -async def get_images(db: AsyncSession, *, image_filter: ImageFilter | None = None) -> Sequence[Image]: - """Get all images from the database.""" - # TODO: Handle missing files in storage - return await get_models(db, Image, model_filter=image_filter) - - -async def get_image(db: AsyncSession, image_id: UUID4) -> Image: - """Get an image from the database.""" - try: - return await db_get_model_with_id_if_it_exists(db, Image, image_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(Image, image_id, details=e.message) from e - - -async def create_image(db: AsyncSession, image_data: ImageCreateFromForm | ImageCreateInternal) -> Image: - """Create a new image in the database and save it.""" - if image_data.file.filename is None: - err_msg = "File name is empty" - raise ValueError(err_msg) - - # Generate ID before creating File to store in local filesystem - image_data.file, image_id, original_filename = process_uploadfile_name(image_data.file) - - # Verify parent exists (will raise ModelNotFoundError if not) - parent_model = get_file_parent_type_model(image_data.parent_type) - await db_get_model_with_id_if_it_exists(db, parent_model, image_data.parent_id) - - db_image = Image( - id=image_id, - description=image_data.description, - image_metadata=image_data.image_metadata, - filename=original_filename, - file=image_data.file, # pyright: ignore [reportArgumentType] # Incoming UploadFile cannot be preemptively cast to FileType because of how FastAPI-storages works. - parent_type=image_data.parent_type, - ) - - # Set parent id - db_image.set_parent(image_data.parent_type, image_data.parent_id) - - db.add(db_image) - await db.commit() - await db.refresh(db_image) - - return db_image - - -async def update_image(db: AsyncSession, image_id: UUID4, image: ImageUpdate) -> Image: - """Update an existing image in the database.""" - try: - db_image: Image = await db_get_model_with_id_if_it_exists(db, Image, image_id) - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: - raise ModelFileNotFoundError(Image, image_id, details=e.message) from e - - image_data: dict[str, Any] = image.model_dump(exclude_unset=True) - db_image.sqlmodel_update(image_data) - - db.add(db_image) - await db.commit() - await db.refresh(db_image) - - return db_image - - -async def delete_image(db: AsyncSession, image_id: UUID4) -> None: - """Delete an image from the database and remove it from storage.""" - try: - db_image = await db_get_model_with_id_if_it_exists(db, Image, image_id) - file_path = Path(db_image.file.path) if db_image.file else None - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError): - # TODO: test this scenario - # File missing from storage but exists in DB - proceed with DB cleanup - db_image = await db.get(Image, image_id) - file_path = None - - await db.delete(db_image) - await db.commit() - - if file_path: - await delete_file_from_storage(file_path) - - -### Video CRUD operations ### -async def create_video( - db: AsyncSession, - video: VideoCreate | VideoCreateWithinProduct, - product_id: int | None = None, - *, - commit: bool = True, -) -> Video: - """Create a new video in the database, optionally linked to a product.""" - if isinstance(video, VideoCreate): - product_id = video.product_id - if product_id: - await db_get_model_with_id_if_it_exists(db, Product, product_id) - - db_video = Video( - **video.model_dump(exclude={"product_id"}), - product_id=product_id, - ) - db.add(db_video) - - if commit: - await db.commit() - await db.refresh(db_video) - else: - await db.flush() - - return db_video - - -async def update_video(db: AsyncSession, video_id: int, video: VideoUpdate) -> Video: - """Update an existing video in the database.""" - db_video: Video = await db_get_model_with_id_if_it_exists(db, Video, video_id) - - db_video.sqlmodel_update(video.model_dump(exclude_unset=True)) - db.add(db_video) - await db.commit() - await db.refresh(db_video) - return db_video - - -async def delete_video(db: AsyncSession, video_id: int) -> None: - """Delete a video from the database.""" - db_video: Video = await db_get_model_with_id_if_it_exists(db, Video, video_id) - - await db.delete(db_video) - await db.commit() - - -### Parent CRUD operations ### -StorageModel = TypeVar("StorageModel", File, Image) -CreateSchema = TypeVar("CreateSchema", FileCreate, ImageCreateFromForm) -FilterType = TypeVar("FilterType", bound=Filter) - - -class ParentStorageOperations[MT, StorageModel, CreateSchema, FilterType]: - """Generic Create, Read, and Delete operations for managing files/images attached to a parent model.""" - - def __init__( - self, - parent_model: type[MT], - storage_model: type[StorageModel], - parent_type: FileParentType | ImageParentType, - parent_field: str, - create_func: Callable, - delete_func: Callable, - ): - self.parent_model = parent_model - self.storage_model = storage_model - self.parent_type = parent_type - self.parent_field = parent_field - self._create = create_func - self._delete = delete_func - - async def get_all( - self, - db: AsyncSession, - parent_id: int, - *, - filter_params: FilterType | None = None, - ) -> Sequence[StorageModel]: - """Get all storage items for a parent.""" - # TODO: Handle missing files in storage - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) - - statement = select(self.storage_model).where( - getattr(self.storage_model, self.parent_field) == parent_id, - self.storage_model.parent_type == self.parent_type, - ) - - if filter_params: - statement = filter_params.filter(statement) - - return (await db.exec(statement)).all() - - async def get_by_id(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> StorageModel: - """Get a specific storage item for a parent.""" - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) - - storage_model_name: str = self.storage_model.get_api_model_name().name_capital - parent_model_name: str = self.parent_model.get_api_model_name().name_capital - - # Get item and verify ownership - try: - db_item = await db.get(self.storage_model, item_id) - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: - raise ModelFileNotFoundError(self.storage_model, item_id, details=str(e)) from e - if not db_item: - err_msg = f"{storage_model_name} with id {item_id} not found" - raise ValueError(err_msg) - - if getattr(db_item, self.parent_field) != parent_id: - err_msg: str = f"{storage_model_name} {item_id} does not belong to {parent_model_name} {parent_id}" - raise ValueError(err_msg) - - return db_item - - async def create( - self, - db: AsyncSession, - parent_id: int, - item_data: CreateSchema, - ) -> StorageModel: - """Create a new storage item for a parent.""" - # Set parent data - item_data.parent_type = self.parent_type - item_data.parent_id = parent_id - - return await self._create(db, item_data) - - async def delete(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> None: - """Delete a storage item from a parent.""" - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) - - # First verify the item exists and belongs to the parent - await self.get_by_id(db, parent_id, item_id) - - # Then delete it - await self._delete(db, item_id) - - async def delete_all(self, db: AsyncSession, parent_id: int) -> None: - """Delete all storage items associated with a parent. - - Args: - db: Database session - parent_id: ID of parent to delete items from - - Returns: - List of deleted items - """ - # Get all items for this parent - items: Sequence[StorageModel] = await self.get_all(db, parent_id) - - # Delete each item - for item in items: - await self._delete(db, item.id) diff --git a/backend/app/api/file_storage/crud/__init__.py b/backend/app/api/file_storage/crud/__init__.py new file mode 100644 index 00000000..616af43a --- /dev/null +++ b/backend/app/api/file_storage/crud/__init__.py @@ -0,0 +1 @@ +"""File storage CRUD package.""" diff --git a/backend/app/api/file_storage/crud/media_queries.py b/backend/app/api/file_storage/crud/media_queries.py new file mode 100644 index 00000000..0e5f042e --- /dev/null +++ b/backend/app/api/file_storage/crud/media_queries.py @@ -0,0 +1,80 @@ +"""CRUD entrypoints for file and image rows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import UUID4 +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.filtering import apply_filter +from app.api.file_storage.filters import FileFilter, ImageFilter +from app.api.file_storage.models import File, Image +from app.api.file_storage.schemas import FileCreate, FileUpdate, ImageCreateFromForm, ImageCreateInternal, ImageUpdate + +from .support_queries import ( + get_storage_item_or_raise, + update_storage_item, +) +from .support_services import ( + file_storage_service, + image_storage_service, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +async def get_files(db: AsyncSession, *, file_filter: FileFilter | None = None) -> Sequence[File]: + """Get all files from the database.""" + statement: Select[tuple[File]] = select(File) + statement = apply_filter(statement, File, file_filter) + return list((await db.execute(statement)).scalars().unique().all()) + + +async def get_file(db: AsyncSession, file_id: UUID4) -> File: + """Get a file from the database.""" + return await get_storage_item_or_raise(db, File, file_id) + + +async def create_file(db: AsyncSession, file_data: FileCreate) -> File: + """Create a new file in the database and save it.""" + return await file_storage_service.create(db, file_data) + + +async def update_file(db: AsyncSession, file_id: UUID4, file: FileUpdate) -> File: + """Update an existing file in the database.""" + return await update_storage_item(db, File, file_id, file) + + +async def delete_file(db: AsyncSession, file_id: UUID4) -> None: + """Delete a file from the database and remove it from storage.""" + await file_storage_service.delete(db, file_id) + + +async def get_images(db: AsyncSession, *, image_filter: ImageFilter | None = None) -> Sequence[Image]: + """Get all images from the database.""" + statement: Select[tuple[Image]] = select(Image) + statement = apply_filter(statement, Image, image_filter) + return list((await db.execute(statement)).scalars().unique().all()) + + +async def get_image(db: AsyncSession, image_id: UUID4) -> Image: + """Get an image from the database.""" + return await get_storage_item_or_raise(db, Image, image_id) + + +async def create_image(db: AsyncSession, image_data: ImageCreateFromForm | ImageCreateInternal) -> Image: + """Create a new image in the database and save it.""" + return await image_storage_service.create(db, image_data) + + +async def update_image(db: AsyncSession, image_id: UUID4, image: ImageUpdate) -> Image: + """Update an existing image in the database.""" + return await update_storage_item(db, Image, image_id, image) + + +async def delete_image(db: AsyncSession, image_id: UUID4) -> None: + """Delete an image from the database and remove it from storage.""" + await image_storage_service.delete(db, image_id) diff --git a/backend/app/api/file_storage/crud/parent_media.py b/backend/app/api/file_storage/crud/parent_media.py new file mode 100644 index 00000000..0139d0d0 --- /dev/null +++ b/backend/app/api/file_storage/crud/parent_media.py @@ -0,0 +1,227 @@ +"""Parent-scoped CRUD operations for stored media.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pydantic import UUID4 +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.models.custom_types import MT +from app.api.file_storage.exceptions import ( + FastAPIStorageFileNotFoundError, +) +from app.api.file_storage.models import MediaParentType +from app.core.logging import sanitize_log_value + +from .support_paths import storage_item_exists +from .support_queries import get_parent_owned_storage_item, list_parent_storage_items +from .support_types import StorageCreateSchema, StorageModel + +if TYPE_CHECKING: + from fastapi_filter.contrib.sqlalchemy import Filter + + from .support_services import StoredMediaService + + +logger = logging.getLogger(__name__) + + +def validate_parent_media_scope[CreateSchemaT: StorageCreateSchema]( + *, + parent_id: int, + parent_type: MediaParentType, + item_data: CreateSchemaT, +) -> None: + """Ensure the payload is already scoped to this parent.""" + if item_data.parent_id != parent_id: + msg = f"Parent ID mismatch: expected {parent_id}, got {item_data.parent_id}" + raise ValueError(msg) + if item_data.parent_type != parent_type: + msg = f"Parent type mismatch: expected {parent_type}, got {item_data.parent_type}" + raise ValueError(msg) + + +async def list_parent_media[StorageModelT: StorageModel]( + db: AsyncSession, + *, + parent_model: type[object], + parent_type: MediaParentType, + storage_model: type[StorageModelT], + parent_id: int, + filter_params: Filter | None = None, +) -> list[StorageModelT]: + """Get all storage items for a parent, excluding items with missing files.""" + items = await list_parent_storage_items( + db, + model=storage_model, + parent_type=parent_type, + parent_id=parent_id, + filter_params=filter_params, + ) + valid_items = [item for item in items if storage_item_exists(item)] + if len(valid_items) < len(items): + missing = len(items) - len(valid_items) + logger.warning( + "%d %s(s) for %s %s have missing files in storage and will be excluded from the response.", + missing, + sanitize_log_value(storage_model.__name__), + sanitize_log_value(parent_model.__name__), + sanitize_log_value(parent_id), + ) + return valid_items + + +async def get_parent_media[StorageModelT: StorageModel]( + db: AsyncSession, + *, + parent_model: type[object], + storage_model: type[StorageModelT], + parent_id: int, + item_id: UUID4, +) -> StorageModelT: + """Get one storage item for a parent, raising when the file is missing.""" + db_item = await get_parent_owned_storage_item( + db, + parent_model=cast("type[MT]", parent_model), + model=storage_model, + parent_id=parent_id, + item_id=item_id, + ) + + if not storage_item_exists(db_item): + raise FastAPIStorageFileNotFoundError(filename=getattr(db_item, "filename", str(item_id))) + + return db_item + + +async def create_parent_media[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]( + db: AsyncSession, + *, + parent_id: int, + parent_type: MediaParentType, + storage_service: StoredMediaService[StorageModelT, CreateSchemaT], + item_data: CreateSchemaT, +) -> StorageModelT: + """Create a new parent-scoped storage item.""" + validate_parent_media_scope(parent_id=parent_id, parent_type=parent_type, item_data=item_data) + return await storage_service.create(db, item_data) + + +async def delete_parent_media[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]( + db: AsyncSession, + *, + parent_model: type[object], + storage_model: type[StorageModelT], + parent_id: int, + item_id: UUID4, + storage_service: StoredMediaService[StorageModelT, CreateSchemaT], +) -> None: + """Delete one storage item from a parent.""" + await get_parent_owned_storage_item( + db, + parent_model=cast("type[MT]", parent_model), + model=storage_model, + parent_id=parent_id, + item_id=item_id, + ) + await storage_service.delete(db, item_id) + + +async def delete_all_parent_media[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]( + db: AsyncSession, + *, + parent_model: type[object], + parent_type: MediaParentType, + storage_model: type[StorageModelT], + parent_id: int, + storage_service: StoredMediaService[StorageModelT, CreateSchemaT], +) -> None: + """Delete all storage items associated with a parent.""" + items = await list_parent_media( + db, + parent_model=parent_model, + parent_type=parent_type, + storage_model=storage_model, + parent_id=parent_id, + ) + for item in items: + if item.id is not None: + await storage_service.delete(db, item.id) + + +class ParentMediaCrud[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]: + """Parent-scoped operations for stored media.""" + + def __init__( + self, + *, + parent_model: type[object], + parent_type: MediaParentType, + storage_model: type[StorageModelT], + storage_service: StoredMediaService[StorageModelT, CreateSchemaT], + ) -> None: + self.parent_model = parent_model + self.storage_model = storage_model + self.parent_type = parent_type + self.storage_service = storage_service + + async def get_all( + self, + db: AsyncSession, + parent_id: int, + *, + filter_params: Filter | None = None, + ) -> list[StorageModelT]: + """Get all storage items for a parent, excluding items with missing files.""" + return await list_parent_media( + db, + parent_model=self.parent_model, + parent_type=self.parent_type, + storage_model=self.storage_model, + parent_id=parent_id, + filter_params=filter_params, + ) + + async def get_by_id(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> StorageModelT: + """Get a specific storage item for a parent, raising an error if the file is missing.""" + return await get_parent_media( + db, + parent_model=cast("type[MT]", self.parent_model), + storage_model=self.storage_model, + parent_id=parent_id, + item_id=item_id, + ) + + async def create(self, db: AsyncSession, parent_id: int, item_data: CreateSchemaT) -> StorageModelT: + """Create a new storage item for a parent.""" + return await create_parent_media( + db, + parent_id=parent_id, + parent_type=self.parent_type, + storage_service=self.storage_service, + item_data=item_data, + ) + + async def delete(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> None: + """Delete a storage item from a parent.""" + await delete_parent_media( + db, + parent_model=cast("type[MT]", self.parent_model), + storage_model=self.storage_model, + parent_id=parent_id, + item_id=item_id, + storage_service=cast("StoredMediaService[StorageModelT, StorageCreateSchema]", self.storage_service), + ) + + async def delete_all(self, db: AsyncSession, parent_id: int) -> None: + """Delete all storage items associated with a parent.""" + await delete_all_parent_media( + db, + parent_model=self.parent_model, + parent_type=self.parent_type, + storage_model=self.storage_model, + parent_id=parent_id, + storage_service=cast("StoredMediaService[StorageModelT, StorageCreateSchema]", self.storage_service), + ) diff --git a/backend/app/api/file_storage/crud/support_paths.py b/backend/app/api/file_storage/crud/support_paths.py new file mode 100644 index 00000000..e34ce582 --- /dev/null +++ b/backend/app/api/file_storage/crud/support_paths.py @@ -0,0 +1,37 @@ +"""Path and deletion helpers for stored media.""" + +from __future__ import annotations + +from pathlib import Path + +from anyio import Path as AnyIOPath +from anyio import to_thread + +from app.api.file_storage.models import File, Image +from app.core.images import delete_thumbnails + + +def stored_file_path(item: File | Image) -> Path | None: + """Return the storage path for a stored file-backed model.""" + file_field = getattr(item, "file", None) + path = getattr(file_field, "path", None) + return Path(path) if path else None + + +def storage_item_exists(item: File | Image) -> bool: + """Return whether the backing file exists on disk.""" + file_path = stored_file_path(item) + return file_path is not None and file_path.exists() + + +async def delete_file_from_storage(file_path: Path) -> None: + """Delete a file from the filesystem.""" + async_path = AnyIOPath(str(file_path)) + if await async_path.exists(): + await async_path.unlink() + + +async def delete_image_from_storage(image_path: Path) -> None: + """Delete an image and any generated thumbnails from the filesystem.""" + await to_thread.run_sync(delete_thumbnails, image_path) + await delete_file_from_storage(image_path) diff --git a/backend/app/api/file_storage/crud/support_queries.py b/backend/app/api/file_storage/crud/support_queries.py new file mode 100644 index 00000000..a0ecd6bb --- /dev/null +++ b/backend/app/api/file_storage/crud/support_queries.py @@ -0,0 +1,114 @@ +"""Query/update helpers for stored media rows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pydantic import UUID4 +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.exceptions import ModelNotFoundError +from app.api.common.crud.persistence import SupportsModelDump, update_and_commit +from app.api.common.crud.query import require_model +from app.api.common.models.base import Base +from app.api.file_storage.exceptions import ( + FastAPIStorageFileNotFoundError, + ModelFileNotFoundError, + ParentStorageOwnershipError, +) +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.parents import parent_model_for_type + +from .support_types import StorageModel + +if TYPE_CHECKING: + from fastapi_filter.contrib.sqlalchemy import Filter + + +async def ensure_parent_exists(db: AsyncSession, parent_type: MediaParentType, parent_id: int) -> None: + """Validate that the target parent record exists.""" + parent_model = parent_model_for_type(parent_type) + await require_model(db, parent_model, parent_id) + + +async def get_storage_item_or_raise[StorageModelT: StorageModel]( + db: AsyncSession, + model: type[StorageModelT], + item_id: UUID4, +) -> StorageModelT: + """Fetch a storage item and normalize storage-related lookup errors.""" + try: + return await require_model(db, model, item_id) + except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: + raise ModelFileNotFoundError(model, item_id, details=str(e)) from e + + +async def update_storage_item[StorageModelT: StorageModel, UpdateSchemaT: SupportsModelDump]( + db: AsyncSession, + model: type[StorageModelT], + item_id: UUID4, + update_payload: UpdateSchemaT, +) -> StorageModelT: + """Update a storage item after resolving storage-specific lookup failures.""" + db_item = await get_storage_item_or_raise(db, model, item_id) + return await update_and_commit(db, db_item, update_payload) + + +async def get_optional_storage_item[StorageModelT: StorageModel]( + db: AsyncSession, + model: type[StorageModelT], + item_id: UUID4, +) -> StorageModelT | None: + """Return a storage item directly from SQLAlchemy or None when missing.""" + return await db.get(model, item_id) + + +def ensure_storage_item_found[StorageModelT: StorageModel]( + model: type[StorageModelT], + item_id: UUID4, + db_item: StorageModelT | None, +) -> StorageModelT: + """Raise the standard not-found error when a storage item is missing.""" + if db_item is None: + raise ModelNotFoundError(model, item_id) + return db_item + + +async def get_parent_owned_storage_item[StorageModelT: StorageModel]( + db: AsyncSession, + *, + parent_model: type[Base], + model: type[StorageModelT], + parent_id: int, + item_id: UUID4, +) -> StorageModelT: + """Fetch a storage item and verify that it belongs to the scoped parent.""" + await require_model(db, parent_model, parent_id) + try: + db_item = await db.get(model, item_id) + except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: + raise ModelFileNotFoundError(model, item_id, details=str(e)) from e + + db_item = ensure_storage_item_found(model, item_id, db_item) + if db_item.parent_id != parent_id: + raise ParentStorageOwnershipError(model, item_id, parent_model, parent_id) + return db_item + + +async def list_parent_storage_items[StorageModelT: StorageModel]( + db: AsyncSession, + *, + model: type[StorageModelT], + parent_type: MediaParentType, + parent_id: int, + filter_params: Filter | None = None, +) -> list[StorageModelT]: + """List storage items owned by one parent/type scope.""" + statement: Select[tuple[StorageModelT]] = select(model).where( + model.parent_type == parent_type, + model.parent_id == parent_id, + ) + if filter_params is not None: + statement = cast("Select[tuple[StorageModelT]]", filter_params.filter(statement)) + return list((await db.execute(statement)).scalars().all()) diff --git a/backend/app/api/file_storage/crud/support_services.py b/backend/app/api/file_storage/crud/support_services.py new file mode 100644 index 00000000..8c954353 --- /dev/null +++ b/backend/app/api/file_storage/crud/support_services.py @@ -0,0 +1,166 @@ +"""Service classes for file-backed media CRUD.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from anyio import to_thread +from fastapi import UploadFile +from pydantic import UUID4 +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.query import require_model +from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError, ModelFileNotFoundError +from app.api.file_storage.models import File, Image +from app.api.file_storage.models.storage_resolver import _get_file_storage, _get_image_storage +from app.api.file_storage.schemas import ( + MAX_FILE_SIZE_MB, + MAX_IMAGE_SIZE_MB, + FileCreate, + ImageCreateFromForm, + ImageCreateInternal, +) +from app.core.images import generate_thumbnails, process_image_for_storage + +from .support_paths import delete_file_from_storage, delete_image_from_storage, stored_file_path +from .support_queries import ensure_parent_exists, ensure_storage_item_found, get_optional_storage_item +from .support_types import StorageCreateSchema, StorageModel +from .support_uploads import build_storage_instance, process_uploadfile_name, validate_upload_size + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +async def _process_created_image(db: AsyncSession, db_image: Image) -> Image: + """Post-process a stored image and roll back the record on processing failures.""" + image_path = stored_file_path(db_image) + if image_path is None: + return db_image + + try: + await require_model(db, Image, db_image.id) + await to_thread.run_sync(process_image_for_storage, image_path) + except (ValueError, OSError) as e: + logger.warning("Image processing failed for image %s, rolling back: %s", db_image.id, e) + await delete_image_record(db, db_image.id) + raise ValueError(str(e)) from e + + try: + await to_thread.run_sync(generate_thumbnails, image_path) + except ValueError, OSError: + logger.warning("Thumbnail generation failed for image %s, skipping", db_image.id, exc_info=True) + + return db_image + + +class StoredMediaService[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]: + """Explicit service for create/delete operations on stored media.""" + + def __init__( + self, + *, + model: type[StorageModelT], + max_size_mb: int, + ) -> None: + self.model = model + self.max_size_mb = max_size_mb + + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist an uploaded file to storage.""" + msg = "Subclasses must implement write_upload()." + raise NotImplementedError(msg) + + async def after_create(self, db: AsyncSession, item: StorageModelT) -> StorageModelT: + """Hook for post-create processing.""" + del db + return item + + async def create(self, db: AsyncSession, payload: CreateSchemaT) -> StorageModelT: + """Create a file-backed model, store the upload, and persist the DB row.""" + if payload.file.filename is None: + msg = "File name is empty" + raise ValueError(msg) + + await validate_upload_size(payload.file, self.max_size_mb) + payload.file, file_id, original_filename = process_uploadfile_name(payload.file) + await ensure_parent_exists(db, payload.parent_type, payload.parent_id) + + stored_name = await self.write_upload(payload.file, cast("str", payload.file.filename)) + db_item = build_storage_instance( + model=self.model, + file_id=file_id, + original_filename=original_filename, + stored_name=stored_name, + payload=payload, + ) + + db.add(db_item) + await db.commit() + await db.refresh(db_item) + return await self.after_create(db, db_item) + + async def delete(self, db: AsyncSession, item_id: UUID4) -> None: + """Delete a file-backed model and best-effort clean up its storage file.""" + cleanup_path: Path | None = None + file_path: Path | None = None + try: + db_item = await require_model(db, self.model, item_id) + file_path = stored_file_path(db_item) + cleanup_path = file_path + except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: + maybe_item = await get_optional_storage_item(db, self.model, item_id) + db_item = ensure_storage_item_found(self.model, item_id, maybe_item) + if self.model is Image: + cleanup_path = stored_file_path(db_item) + logger.warning( + "%s %s not found in storage: %s. Deleting database row only.", + self.model.__name__, + item_id, + e, + ) + + await db.delete(db_item) + await db.commit() + + if self.model is Image and cleanup_path: + await delete_image_from_storage(cleanup_path) + elif file_path: + await delete_file_from_storage(file_path) + + +class FileStorageService(StoredMediaService[File, FileCreate]): + """Service for generic file storage.""" + + def __init__(self) -> None: + super().__init__(model=File, max_size_mb=MAX_FILE_SIZE_MB) + + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist a generic file upload.""" + return await _get_file_storage().write_upload(upload_file, filename) + + +class ImageStorageService(StoredMediaService[Image, ImageCreateFromForm | ImageCreateInternal]): + """Service for image storage and post-processing.""" + + def __init__(self) -> None: + super().__init__(model=Image, max_size_mb=MAX_IMAGE_SIZE_MB) + + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist an image upload.""" + return await _get_image_storage().write_image_upload(upload_file, filename) + + async def after_create(self, db: AsyncSession, item: Image) -> Image: + """Process the saved image after it has been persisted.""" + return await _process_created_image(db, item) + + +file_storage_service = FileStorageService() +image_storage_service = ImageStorageService() + + +async def delete_image_record(db: AsyncSession, image_id: UUID4) -> None: + """Delete an image row and remove it from storage.""" + await image_storage_service.delete(db, image_id) diff --git a/backend/app/api/file_storage/crud/support_types.py b/backend/app/api/file_storage/crud/support_types.py new file mode 100644 index 00000000..2d4cf545 --- /dev/null +++ b/backend/app/api/file_storage/crud/support_types.py @@ -0,0 +1,7 @@ +"""Shared type aliases for file-storage CRUD helpers.""" + +from app.api.file_storage.models import File, Image +from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm, ImageCreateInternal + +type StorageModel = File | Image +type StorageCreateSchema = FileCreate | ImageCreateFromForm | ImageCreateInternal diff --git a/backend/app/api/file_storage/crud/support_uploads.py b/backend/app/api/file_storage/crud/support_uploads.py new file mode 100644 index 00000000..715121bd --- /dev/null +++ b/backend/app/api/file_storage/crud/support_uploads.py @@ -0,0 +1,95 @@ +"""Upload validation and filename helpers for stored media.""" + +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from anyio import to_thread +from fastapi import UploadFile +from pydantic import UUID4 +from slugify import slugify + +from app.api.file_storage.exceptions import UploadTooLargeError +from app.api.file_storage.schemas import ImageCreateFromForm, ImageCreateInternal + +from .support_types import StorageCreateSchema, StorageModel + +if TYPE_CHECKING: + from typing import BinaryIO + + +def sanitize_filename(filename: str, max_length: int = 42) -> str: + """Preserve all suffixes while sanitizing the base name.""" + path = Path(filename) + name = path.name + + for suffix in path.suffixes[::-1]: + name = name.removesuffix(suffix) + + sanitized_filename = slugify( + name[:-1] + "_" if len(name) > max_length else name, + lowercase=False, + max_length=max_length, + word_boundary=True, + ) + + return f"{sanitized_filename}{''.join(path.suffixes)}" + + +def process_uploadfile_name(file: UploadFile) -> tuple[UploadFile, UUID4, str]: + """Process an UploadFile for storing in the database.""" + if file.filename is None: + msg = "File name is empty." + raise ValueError(msg) + + original_filename = sanitize_filename(file.filename) + file_id = uuid.uuid4() + file.filename = f"{file_id.hex}_{original_filename}" + return file, file_id, original_filename + + +def _measure_file_size(file: BinaryIO) -> int: + """Measure a binary file object without changing its current position.""" + current_position = file.tell() + file.seek(0, 2) + file_size = file.tell() + file.seek(current_position) + return file_size + + +async def validate_upload_size(upload_file: UploadFile, max_size_mb: int) -> None: + """Validate upload size, even when UploadFile.size is unavailable.""" + file_size = upload_file.size + if file_size is None: + file_size = await to_thread.run_sync(_measure_file_size, upload_file.file) + + if file_size == 0: + msg = "File size is zero." + raise ValueError(msg) + if file_size > max_size_mb * 1024 * 1024: + raise UploadTooLargeError(file_size_bytes=file_size, max_size_mb=max_size_mb) + + +def build_storage_instance[StorageModelT: StorageModel]( + *, + model: type[StorageModelT], + file_id: UUID4, + original_filename: str, + stored_name: str, + payload: StorageCreateSchema, +) -> StorageModelT: + """Create a storage model instance from an upload payload.""" + item_kwargs: dict[str, Any] = { + "id": file_id, + "description": payload.description, + "filename": original_filename, + "file": stored_name, + "parent_type": payload.parent_type, + "parent_id": payload.parent_id, + } + if isinstance(payload, ImageCreateFromForm | ImageCreateInternal): + item_kwargs["image_metadata"] = payload.image_metadata + + return model(**item_kwargs) diff --git a/backend/app/api/file_storage/crud/video.py b/backend/app/api/file_storage/crud/video.py new file mode 100644 index 00000000..9b739b5a --- /dev/null +++ b/backend/app/api/file_storage/crud/video.py @@ -0,0 +1,48 @@ +"""CRUD operations for video models.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit, update_and_commit +from app.api.common.crud.query import require_model +from app.api.data_collection.models.product import Product +from app.api.file_storage.models import Video +from app.api.file_storage.schemas import VideoCreate, VideoCreateWithinProduct, VideoUpdate, VideoUpdateWithinProduct + + +async def create_video( + db: AsyncSession, + video: VideoCreate | VideoCreateWithinProduct, + product_id: int | None = None, + *, + commit: bool = True, +) -> Video: + """Create a new video in the database.""" + if isinstance(video, VideoCreate): + product_id = video.product_id + if product_id is None: + err_msg = "Product ID is required." + raise ValueError(err_msg) + await require_model(db, Product, product_id) + + db_video = Video( + **video.model_dump(exclude={"product_id"}), + product_id=product_id, + ) + db.add(db_video) + + if commit: + return await commit_and_refresh(db, db_video, add_before_commit=False) + await db.flush() + return db_video + + +async def update_video(db: AsyncSession, video_id: int, video: VideoUpdate | VideoUpdateWithinProduct) -> Video: + """Update an existing video in the database.""" + db_video = await require_model(db, Video, video_id) + return await update_and_commit(db, db_video, video) + + +async def delete_video(db: AsyncSession, video_id: int) -> None: + """Delete a video from the database.""" + db_video = await require_model(db, Video, video_id) + await delete_and_commit(db, db_video) diff --git a/backend/app/api/file_storage/examples.py b/backend/app/api/file_storage/examples.py new file mode 100644 index 00000000..ee78fe6a --- /dev/null +++ b/backend/app/api/file_storage/examples.py @@ -0,0 +1,68 @@ +"""Centralized OpenAPI examples for file-storage schemas and routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +FILE_READ_WITHIN_PARENT_EXAMPLES = [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "description": "Assembly manual PDF", + "filename": "manual.pdf", + "file_url": "/uploads/files/manuals/manual.pdf", + } +] + +IMAGE_READ_WITHIN_PARENT_EXAMPLES = [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "description": "Front view of the product", + "image_metadata": {"camera_make": "Raspberry Pi", "orientation": "landscape"}, + "filename": "front-view.webp", + "image_url": "/uploads/images/products/front-view.webp", + "thumbnail_url": "/images/12345678-cc4e-405c-8553-7806424de2a1/resized?width=200", + } +] + +VIDEO_CREATE_WITHIN_PRODUCT_EXAMPLES = [ + { + "url": "https://www.youtube.com/watch?v=abcdefghijk", + "title": "Full disassembly", + "description": "Recorded teardown of the product", + "video_metadata": {"duration_seconds": 420, "source": "youtube"}, + } +] + +VIDEO_READ_WITHIN_PRODUCT_EXAMPLES = [ + { + "id": 1, + "url": "https://www.youtube.com/watch?v=abcdefghijk", + "title": "Full disassembly", + "description": "Recorded teardown of the product", + "video_metadata": {"duration_seconds": 420, "source": "youtube"}, + } +] + +VIDEO_UPDATE_WITHIN_PRODUCT_EXAMPLES = [ + { + "title": "Updated disassembly title", + "description": "Shortened version for publication", + "video_metadata": {"edited": True}, + } +] + +IMAGE_RESIZE_WIDTH_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + thumbnail=openapi_example(200, summary="Default thumbnail width"), + gallery=openapi_example(800, summary="Larger gallery preview"), +) + +IMAGE_RESIZE_HEIGHT_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + unconstrained=openapi_example(None, summary="Preserve aspect ratio"), + portrait=openapi_example(1200, summary="Constrain height for portrait images"), +) diff --git a/backend/app/api/file_storage/exceptions.py b/backend/app/api/file_storage/exceptions.py index f46613aa..9aba1692 100644 --- a/backend/app/api/file_storage/exceptions.py +++ b/backend/app/api/file_storage/exceptions.py @@ -1,30 +1,44 @@ """Custom exceptions for file storage database models.""" -from fastapi import status - -from app.api.common.exceptions import APIError +from app.api.common.exceptions import NotFoundError, PayloadTooLargeError +from app.api.common.models.base import get_model_label from app.api.common.models.custom_types import IDT, MT -class FastAPIStorageFileNotFoundError(APIError): +class FastAPIStorageFileNotFoundError(NotFoundError): """Custom error for file not found in storage.""" - http_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__(self, filename: str, details: str | None = None) -> None: super().__init__(message=f"File not found in storage: {filename}.", details=details) -class ModelFileNotFoundError(APIError): +class ModelFileNotFoundError(NotFoundError): """Exception raised when a file of a database model is not found in the local storage.""" - http_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__( self, model_type: type[MT] | None = None, model_id: IDT | None = None, details: str | None = None ) -> None: super().__init__( - message=f"File for {model_type.get_api_model_name().name_capital if model_type else 'Model'}" - f"{f'with id {model_id}'} not found.", + message=f"File for {get_model_label(model_type)}{f'with id {model_id}'} not found.", details=details, ) + + +class ParentStorageOwnershipError(NotFoundError): + """Raised when a stored item does not belong to the requested parent resource.""" + + def __init__(self, storage_model: type[MT], storage_id: IDT, parent_model: type[MT], parent_id: IDT) -> None: + storage_model_name = get_model_label(storage_model) + parent_model_name = get_model_label(parent_model) + super().__init__( + message=f"{storage_model_name} with id {storage_id} not found for {parent_model_name} {parent_id}" + ) + + +class UploadTooLargeError(PayloadTooLargeError): + """Raised when an uploaded file exceeds the configured size limit.""" + + def __init__(self, *, file_size_bytes: int, max_size_mb: int) -> None: + super().__init__( + message=f"File size too large: {file_size_bytes / 1024 / 1024:.2f} MB. Maximum size: {max_size_mb} MB" + ) diff --git a/backend/app/api/file_storage/filters.py b/backend/app/api/file_storage/filters.py index 30292e3d..37068780 100644 --- a/backend/app/api/file_storage/filters.py +++ b/backend/app/api/file_storage/filters.py @@ -2,7 +2,7 @@ from fastapi_filter.contrib.sqlalchemy import Filter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video +from app.api.file_storage.models import File, Image, MediaParentType, Video class FileFilter(Filter): @@ -10,7 +10,7 @@ class FileFilter(Filter): filename__ilike: str | None = None description__ilike: str | None = None - parent_type: FileParentType | None = None + parent_type: MediaParentType | None = None search: str | None = None @@ -18,7 +18,7 @@ class Constants(Filter.Constants): # FilterAPI class configuration """FilterAPI class configuration.""" model = File - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override + search_model_fields: list[str] = [ # noqa: RUF012 # fastapi-filter excepts this syntax "filename", "description", ] @@ -29,7 +29,7 @@ class ImageFilter(Filter): filename__ilike: str | None = None description__ilike: str | None = None - parent_type: ImageParentType | None = None + parent_type: MediaParentType | None = None search: str | None = None @@ -37,7 +37,7 @@ class Constants(Filter.Constants): # FilterAPI class configuration """FilterAPI class configuration.""" model = Image - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override + search_model_fields: list[str] = [ # noqa: RUF012 # fastapi-filter excepts this syntax "filename", "description", ] @@ -55,7 +55,7 @@ class Constants(Filter.Constants): # FilterAPI class configuration """FilterAPI class configuration.""" model = Video - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override + search_model_fields: list[str] = [ # noqa: RUF012 # fastapi-filter excepts this syntax "url", "description", ] diff --git a/backend/app/api/file_storage/models/__init__.py b/backend/app/api/file_storage/models/__init__.py index 510c44ff..d5f4e4f2 100644 --- a/backend/app/api/file_storage/models/__init__.py +++ b/backend/app/api/file_storage/models/__init__.py @@ -1 +1,92 @@ """Database models for file storage.""" + +import uuid +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel +from sqlalchemy import Enum as SAEnum +from sqlalchemy import ForeignKey, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.api.common.models.base import Base, TimeStampMixinBare +from app.api.file_storage.models.storage_types import FileType, ImageType + + +### Pydantic base schemas (shared with schemas.py) ### +class FileBase(BaseModel): + """Base schema for File. Used by Pydantic schemas only, not ORM.""" + + description: str | None = None + + +class ImageBase(BaseModel): + """Base schema for Image. Used by Pydantic schemas only, not ORM.""" + + description: str | None = None + image_metadata: dict[str, Any] | None = None + + +class VideoBase(BaseModel): + """Base schema for Video. Used by Pydantic schemas only, not ORM.""" + + url: str + title: str | None = None + description: str | None = None + video_metadata: dict[str, Any] | None = None + + +class MediaParentType(StrEnum): + """Parent entity types that can own files and images.""" + + PRODUCT = "product" + PRODUCT_TYPE = "product_type" + MATERIAL = "material" + + +class File(TimeStampMixinBare, Base): + """Database model for generic files stored in the local file system.""" + + __tablename__ = "file" + __table_args__ = (Index("ix_file_parent_type_parent_id", "parent_type", "parent_id"),) + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + filename: Mapped[str] = mapped_column(doc="Original file name of the file.") + file: Mapped[Any] = mapped_column(FileType, nullable=False, doc="Local file path to the file") + description: Mapped[str | None] = mapped_column(default=None) + + parent_type: Mapped[MediaParentType] = mapped_column(SAEnum(MediaParentType, name="fileparenttype"), nullable=False) + parent_id: Mapped[int] = mapped_column(nullable=False) + + +class Image(TimeStampMixinBare, Base): + """Database model for images stored in the local file system.""" + + __tablename__ = "image" + __table_args__ = (Index("ix_image_parent_type_parent_id", "parent_type", "parent_id"),) + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + filename: Mapped[str] = mapped_column(nullable=False, doc="Original file name of the image.") + file: Mapped[Any] = mapped_column(ImageType, nullable=False, doc="Local file path to the image") + description: Mapped[str | None] = mapped_column(default=None) + image_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None) + + parent_type: Mapped[MediaParentType] = mapped_column( + SAEnum(MediaParentType, name="imageparenttype"), nullable=False + ) + parent_id: Mapped[int] = mapped_column(nullable=False) + + +class Video(TimeStampMixinBare, Base): + """Database model for videos stored online.""" + + __tablename__ = "video" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + url: Mapped[str] = mapped_column(nullable=False, doc="URL linking to the video") + title: Mapped[str | None] = mapped_column(default=None) + description: Mapped[str | None] = mapped_column(default=None) + video_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None) + + product_id: Mapped[int] = mapped_column(ForeignKey("product.id"), nullable=False) diff --git a/backend/app/api/file_storage/models/custom_types.py b/backend/app/api/file_storage/models/custom_types.py deleted file mode 100644 index 602e7e29..00000000 --- a/backend/app/api/file_storage/models/custom_types.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Custom types for FastAPI Storages models.""" - -from typing import Any, BinaryIO - -from fastapi_storages import FileSystemStorage, StorageImage -from fastapi_storages.integrations.sqlalchemy import FileType as _FileType -from fastapi_storages.integrations.sqlalchemy import ImageType as _ImageType -from sqlalchemy import Dialect - -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError -from app.core.config import settings - - -## Custom error handling for file not found in storage -class CustomFileSystemStorage(FileSystemStorage): - """File system storage with custom error handling.""" - - def open(self, name: str) -> BinaryIO: - """Override of base class 'open' method for custom error handling.""" - try: - return super().open(name) - except FileNotFoundError as e: - details = str(e) if settings.debug else None - raise FastAPIStorageFileNotFoundError(name, details=details) from e - - -## File and Image types with custom storage paths -class FileType(_FileType): - """Custom file type with a default FileSystemStorage path. - - This supports alembic migrations on FastAPI Storages models. - """ - - def __init__( - self, *args: Any, **kwargs: Any - ) -> None: # Any-type args and kwargs are expected by the parent class signature - storage = CustomFileSystemStorage(path=str(settings.file_storage_path)) - super().__init__(*args, storage=storage, **kwargs) - - -class ImageType(_ImageType): - """Custom image type with a default FileSystemStorage path. - - This supports alembic migrations on FastAPI Storages models. - """ - - def __init__( - self, *args: Any, **kwargs: Any - ) -> None: # Any-type args and kwargs are expected by the parent class signature - storage = CustomFileSystemStorage(path=str(settings.image_storage_path)) - super().__init__(*args, storage=storage, **kwargs) - - def process_result_value(self, value: Any, dialect: Dialect) -> StorageImage | None: - """Override the default process_result_value method to raise a custom error if the file is not found.""" - try: - return super().process_result_value(value, dialect) - except FileNotFoundError as e: - raise FastAPIStorageFileNotFoundError(value, str(e)) from e diff --git a/backend/app/api/file_storage/models/models.py b/backend/app/api/file_storage/models/models.py deleted file mode 100644 index ae78b785..00000000 --- a/backend/app/api/file_storage/models/models.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Database models for files, images and videos.""" - -import uuid -from enum import Enum -from functools import cached_property -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar -from urllib.parse import quote - -from markupsafe import Markup -from pydantic import UUID4, ConfigDict -from sqlalchemy.dialects.postgresql import JSONB -from sqlmodel import AutoString, Column, Field, Relationship -from sqlmodel import Enum as SAEnum - -from app.api.common.models.base import APIModelName, CustomBase, SingleParentMixin, TimeStampMixinBare -from app.api.common.models.custom_fields import AnyUrlInDB -from app.api.data_collection.models import Product -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError -from app.api.file_storage.models.custom_types import FileType, ImageType -from app.core.config import settings - -if TYPE_CHECKING: - from app.api.background_data.models import Material, ProductType - - -### Constants ### -PLACEHOLDER_IMAGE_PATH: Path = settings.static_files_path / "images " / "placeholder.png" - - -### File Model ### -class FileParentType(Enum): - """Enumeration of types that can have files.""" - - PRODUCT = "product" - PRODUCT_TYPE = "product_type" - MATERIAL = "material" - - -class FileBase(CustomBase): - """Base model for generic files stored in the local file system.""" - - description: str | None = Field(default=None, max_length=500, description="Description of the file") - - # Class variables - api_model_name: ClassVar[APIModelName | None] = APIModelName(name_camel="File") - - -class File(FileBase, TimeStampMixinBare, SingleParentMixin[FileParentType], table=True): - """Database model for generic files stored in the local file system, using FastAPI-Storages.""" - - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) - filename: str = Field(description="Original file name of the file. Automatically generated.") - - # TODO: Add custom file paths based on parent object (Product, year, etc.) - file: FileType = Field(sa_column=Column(FileType, nullable=False), description="Local file path to the file") - - # Many-to-one relationships. This is ugly but SQLModel does not play well with polymorphic associations. - # TODO: Implement improved polymorphic associations in SQLModel after this issue is resolved: https://github.com/fastapi/sqlmodel/pull/1226 - - parent_type: FileParentType = Field( - sa_column=Column(SAEnum(FileParentType), nullable=False), - description=SingleParentMixin.get_parent_type_description(FileParentType), - ) - - product_id: int | None = Field(default=None, foreign_key="product.id") - product: "Product" = Relationship(back_populates="files") - - material_id: int | None = Field(default=None, foreign_key="material.id") - material: "Material" = Relationship(back_populates="files") - - product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="files") - - # Model configuration - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True, use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - - @cached_property - def file_url(self) -> str: - """Return the URL to the file.""" - if self.file and Path(self.file.path).exists(): - relative_path: Path = Path(self.file.path).relative_to(settings.file_storage_path) - return f"/uploads/files/{quote(str(relative_path))}" - - raise FastAPIStorageFileNotFoundError(filename=self.filename) - - -### Image Model ### - - -class ImageParentType(str, Enum): - """Enumeration of types that can have images.""" - - PRODUCT = "product" - PRODUCT_TYPE = "product_type" - MATERIAL = "material" - - -class ImageBase(CustomBase): - """Base model for images stored in the local file system.""" - - description: str | None = Field(default=None, max_length=500, description="Description of the image") - image_metadata: dict[str, Any] | None = Field( - default=None, description="Image metadata as a JSON dict", sa_column=Column(JSONB) - ) - - # Class variables - api_model_name: ClassVar[APIModelName | None] = APIModelName(name_camel="Image") - - -class Image(ImageBase, TimeStampMixinBare, SingleParentMixin, table=True): - """Database model for images stored in the local file system, using FastAPI-Storages.""" - - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) - filename: str = Field(description="Original file name of the image. Automatically generated.", nullable=False) - file: ImageType = Field( - sa_column=Column(ImageType, nullable=False), - description="Local file path to the image", - ) - - # Many-to-one relationships. This is ugly but SQLModel does not play well with polymorphic associations. - parent_type: ImageParentType = Field( - sa_column=Column(SAEnum(ImageParentType), nullable=False), - description=SingleParentMixin.get_parent_type_description(ImageParentType), - ) - - product_id: int | None = Field(default=None, foreign_key="product.id") - product: "Product" = Relationship(back_populates="images") - - material_id: int | None = Field(default=None, foreign_key="material.id") - material: "Material" = Relationship(back_populates="images") - - product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="images") - - # Model configuration - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - - @cached_property - def image_url(self) -> str: - """Return the URL to the image file or a placeholder if missing.""" - if self.file and Path(self.file.path).exists(): - relative_path = Path(self.file.path).relative_to(settings.image_storage_path) - return f"/uploads/images/{quote(str(relative_path))}" - return str(PLACEHOLDER_IMAGE_PATH) - - def image_preview(self, size: int = 100) -> str: - """HTML preview of the image with a specified size.""" - return Markup('').format(self.image_url, size) - - -### Video Model ### -class VideoBase(CustomBase): - """Base model for videos stored online.""" - - url: AnyUrlInDB = Field(description="URL linking to the video", sa_type=AutoString, nullable=False) - title: str | None = Field(default=None, max_length=100, description="Title of the video") - description: str | None = Field(default=None, max_length=500, description="Description of the video") - video_metadata: dict[str, Any] | None = Field( - default=None, description="Video metadata as a JSON dict", sa_column=Column(JSONB) - ) - - -class Video(VideoBase, TimeStampMixinBare, table=True): - """Database model for videos stored online.""" - - id: int | None = Field(default=None, primary_key=True) - - # Many-to-one relationships - product_id: int = Field(foreign_key="product.id", nullable=False) - product: Product = Relationship(back_populates="videos") diff --git a/backend/app/api/file_storage/models/storage.py b/backend/app/api/file_storage/models/storage.py new file mode 100644 index 00000000..1ef6b2e9 --- /dev/null +++ b/backend/app/api/file_storage/models/storage.py @@ -0,0 +1,30 @@ +"""Migration-compat shim for historical imports. + +Alembic revisions import this module directly, so it remains as a thin +compatibility layer even though app code now imports the explicit storage +modules directly. +""" + +from importlib import import_module + +from app.api.file_storage.models.storage_core import BaseStorage, StorageFile, StorageImage, secure_filename +from app.api.file_storage.models.storage_filesystem import FileSystemStorage +from app.api.file_storage.models.storage_resolver import _get_file_storage, _get_image_storage +from app.api.file_storage.models.storage_s3 import S3Storage +from app.api.file_storage.models.storage_types import FileType, ImageType +from app.core.images import validate_image_file + +__all__ = [ + "BaseStorage", + "FileSystemStorage", + "FileType", + "ImageType", + "S3Storage", + "StorageFile", + "StorageImage", + "_get_file_storage", + "_get_image_storage", + "import_module", + "secure_filename", + "validate_image_file", +] diff --git a/backend/app/api/file_storage/models/storage_core.py b/backend/app/api/file_storage/models/storage_core.py new file mode 100644 index 00000000..27054a7f --- /dev/null +++ b/backend/app/api/file_storage/models/storage_core.py @@ -0,0 +1,123 @@ +"""Core storage abstractions shared by concrete backends and SQLAlchemy types.""" + +from __future__ import annotations + +import os +import re +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import BinaryIO, Protocol, Self + + from fastapi import UploadFile + + class UploadValue(Protocol): + """Minimal protocol for uploaded files passed from FastAPI.""" + + file: BinaryIO + filename: str + + +_FILENAME_ASCII_STRIP_RE = re.compile(r"[^A-Za-z0-9_.-]") + + +def secure_filename(filename: str) -> str: + """Normalize a filename to a safe ASCII representation.""" + for sep in os.path.sep, os.path.altsep: + if sep: + filename = filename.replace(sep, " ") + + normalized_filename = _FILENAME_ASCII_STRIP_RE.sub("", "_".join(filename.split())) + return str(normalized_filename).strip("._") + + +class BaseStorage(ABC): + """Abstract interface for storage backends.""" + + OVERWRITE_EXISTING_FILES = True + + @abstractmethod + def get_name(self, name: str) -> str: + """Return the normalized storage name.""" + + @abstractmethod + def get_path(self, name: str) -> str: + """Return the absolute path or URL for a stored file.""" + + @abstractmethod + def get_size(self, name: str) -> int: + """Return the file size in bytes.""" + + @abstractmethod + def open(self, name: str) -> BinaryIO: + """Open a stored file for reading.""" + + @abstractmethod + def write(self, file: BinaryIO, name: str) -> str: + """Persist a binary file and return the stored name.""" + + @abstractmethod + def generate_new_filename(self, filename: str) -> str: + """Generate a collision-free file name.""" + + @abstractmethod + async def write_upload(self, upload_file: UploadFile, name: str) -> str: + """Persist an uploaded file asynchronously and return the stored name.""" + + @abstractmethod + async def write_image_upload(self, upload_file: UploadFile, name: str) -> str: + """Validate and persist an uploaded image asynchronously.""" + + +class StorageFile(str): + """String-like file wrapper returned from storage-backed columns.""" + + __slots__ = ("_name", "_storage") + + def __new__(cls, *, name: str, storage: BaseStorage) -> Self: + """Create the string value from the resolved storage path.""" + return str.__new__(cls, storage.get_path(name)) + + def __init__(self, *, name: str, storage: BaseStorage) -> None: + self._name = name + self._storage = storage + + @property + def name(self) -> str: + """File name including extension.""" + return self._storage.get_name(self._name) + + @property + def path(self) -> str: + """Absolute file path.""" + return self._storage.get_path(self._name) + + @property + def size(self) -> int: + """File size in bytes.""" + return self._storage.get_size(self._name) + + def open(self) -> BinaryIO: + """Open a binary file handle to the stored file.""" + return self._storage.open(self._name) + + def write(self, file: BinaryIO) -> str: + """Write binary file contents to storage.""" + if not self._storage.OVERWRITE_EXISTING_FILES: + self._name = self._storage.generate_new_filename(self._name) + + return self._storage.write(file=file, name=self._name) + + def __str__(self) -> str: + return self.path + + +class StorageImage(StorageFile): + """Storage file wrapper for image files.""" + + __slots__ = () + + def __new__(cls, *, name: str, storage: BaseStorage) -> Self: + """Create the string value from the resolved storage path.""" + return str.__new__(cls, storage.get_path(name)) diff --git a/backend/app/api/file_storage/models/storage_filesystem.py b/backend/app/api/file_storage/models/storage_filesystem.py new file mode 100644 index 00000000..4c0625a1 --- /dev/null +++ b/backend/app/api/file_storage/models/storage_filesystem.py @@ -0,0 +1,98 @@ +"""Filesystem-backed storage backend.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from anyio import open_file, to_thread + +from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError +from app.api.file_storage.models.storage_core import BaseStorage, secure_filename +from app.core.config import settings +from app.core.images import validate_image_file + +if TYPE_CHECKING: + from typing import BinaryIO + + from fastapi import UploadFile + + +class FileSystemStorage(BaseStorage): + """Filesystem-backed local storage.""" + + default_chunk_size = 64 * 1024 + + def __init__(self, path: str, *, create_path: bool = False) -> None: + self._path = Path(path) + if create_path: + self._ensure_path() + + def _ensure_path(self) -> None: + """Create the storage directory if needed.""" + self._path.mkdir(parents=True, exist_ok=True) + + def get_name(self, name: str) -> str: + """Normalize a file name for storage.""" + return secure_filename(Path(name).name) + + def get_path(self, name: str) -> str: + """Return the absolute path for a stored file.""" + return str(self._path / Path(name)) + + def get_size(self, name: str) -> int: + """Return the file size in bytes.""" + return (self._path / name).stat().st_size + + def open(self, name: str) -> BinaryIO: + """Open a stored file in binary mode, mapping missing files to the API error.""" + try: + return (self._path / Path(name)).open("rb") + except FileNotFoundError as e: + details = str(e) if settings.debug else None + raise FastAPIStorageFileNotFoundError(name, details=details) from e + + def write(self, file: BinaryIO, name: str) -> str: + """Write a binary file to local storage.""" + self._ensure_path() + filename = secure_filename(name) + path = self._path / Path(filename) + + file.seek(0) + with path.open("wb") as output: + while chunk := file.read(self.default_chunk_size): + output.write(chunk) + + return str(path) + + def generate_new_filename(self, filename: str) -> str: + """Generate a unique filename if collisions are not allowed.""" + counter = 0 + path = self._path / filename + stem, extension = Path(filename).stem, Path(filename).suffix + + while path.exists(): + counter += 1 + path = self._path / f"{stem}_{counter}{extension}" + + return path.name + + async def write_upload(self, upload_file: UploadFile, name: str) -> str: + """Write an uploaded file using async file I/O.""" + self._ensure_path() + filename = self.get_name(name) + path = self._path / filename + + await upload_file.seek(0) + async with await open_file(path, "wb") as output: + while chunk := await upload_file.read(self.default_chunk_size): + await output.write(chunk) + + await upload_file.close() + return filename + + async def write_image_upload(self, upload_file: UploadFile, name: str) -> str: + """Validate and write an uploaded image using async file I/O.""" + self._ensure_path() + await to_thread.run_sync(validate_image_file, upload_file.file) + return await self.write_upload(upload_file, name) diff --git a/backend/app/api/file_storage/models/storage_resolver.py b/backend/app/api/file_storage/models/storage_resolver.py new file mode 100644 index 00000000..acaf151c --- /dev/null +++ b/backend/app/api/file_storage/models/storage_resolver.py @@ -0,0 +1,38 @@ +"""Storage backend resolution helpers.""" + +from __future__ import annotations + +from app.api.file_storage.models.storage_core import BaseStorage +from app.api.file_storage.models.storage_filesystem import FileSystemStorage +from app.api.file_storage.models.storage_s3 import S3Storage +from app.core.config import StorageBackend, settings + + +def _get_file_storage() -> BaseStorage: + """Return the configured storage backend for generic files.""" + if settings.storage_backend == StorageBackend.S3: + return S3Storage( + bucket=settings.s3_bucket, + prefix=settings.s3_file_prefix, + region=settings.s3_region, + access_key_id=settings.s3_access_key_id.get_secret_value() or None, + secret_access_key=settings.s3_secret_access_key.get_secret_value() or None, + endpoint_url=settings.s3_endpoint_url, + base_url=settings.s3_base_url, + ) + return FileSystemStorage(path=str(settings.file_storage_path)) + + +def _get_image_storage() -> BaseStorage: + """Return the configured storage backend for image files.""" + if settings.storage_backend == StorageBackend.S3: + return S3Storage( + bucket=settings.s3_bucket, + prefix=settings.s3_image_prefix, + region=settings.s3_region, + access_key_id=settings.s3_access_key_id.get_secret_value() or None, + secret_access_key=settings.s3_secret_access_key.get_secret_value() or None, + endpoint_url=settings.s3_endpoint_url, + base_url=settings.s3_base_url, + ) + return FileSystemStorage(path=str(settings.image_storage_path)) diff --git a/backend/app/api/file_storage/models/storage_s3.py b/backend/app/api/file_storage/models/storage_s3.py new file mode 100644 index 00000000..f6130cdf --- /dev/null +++ b/backend/app/api/file_storage/models/storage_s3.py @@ -0,0 +1,167 @@ +"""S3-compatible storage backend.""" + +from __future__ import annotations + +import io +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from anyio import to_thread + +from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError +from app.api.file_storage.models.storage_core import BaseStorage, secure_filename +from app.core.config import settings +from app.core.images import validate_image_file + +if TYPE_CHECKING: + from typing import BinaryIO, Protocol + + from fastapi import UploadFile + + class _S3Client(Protocol): + """Narrow protocol for the boto3 S3 client methods used by S3Storage.""" + + def head_object(self, *, bucket: str, key: str) -> dict: ... + def get_object(self, *, bucket: str, key: str) -> dict: ... + def upload_fileobj(self, fileobj: BinaryIO, *, bucket: str, key: str) -> None: ... + + +def _import_boto3() -> object: + """Import boto3 lazily so the optional dependency stays optional.""" + return import_module("boto3") + + +def _client_error_type() -> type[Exception]: + """Return botocore's ClientError type, or a local fallback when unavailable.""" + try: + return import_module("botocore.exceptions").ClientError + except ImportError: + + class ClientError(Exception): + """Fallback exception used when botocore is not installed.""" + + return ClientError + + +class S3Storage(BaseStorage): + """S3-compatible storage backend.""" + + def __init__( + self, + bucket: str, + prefix: str, + *, + region: str = "us-east-1", + access_key_id: str | None = None, + secret_access_key: str | None = None, + endpoint_url: str | None = None, + base_url: str | None = None, + ) -> None: + self._bucket = bucket + self._prefix = prefix.strip("/") + self._region = region + self._access_key_id = access_key_id or None + self._secret_access_key = secret_access_key or None + self._endpoint_url = endpoint_url + self._base_url = base_url.rstrip("/") if base_url else None + self._client: _S3Client | None = None + + def _get_client(self) -> _S3Client: + """Return a cached boto3 S3 client, importing boto3 lazily.""" + if self._client is None: + try: + boto3 = cast("Any", _import_boto3()) + except ImportError: + msg = "boto3 is required for S3 storage. Install it with: uv sync --group s3" + raise ImportError(msg) from None + kwargs: dict[str, object] = {"region_name": self._region} + if self._access_key_id: + kwargs["aws_access_key_id"] = self._access_key_id + if self._secret_access_key: + kwargs["aws_secret_access_key"] = self._secret_access_key + if self._endpoint_url: + kwargs["endpoint_url"] = self._endpoint_url + self._client = boto3.client("s3", **kwargs) + return self._client + + def _s3_key(self, name: str) -> str: + filename = secure_filename(Path(name).name) + return f"{self._prefix}/{filename}" if self._prefix else filename + + def get_name(self, name: str) -> str: + """Normalize a file name for storage.""" + return secure_filename(Path(name).name) + + def get_path(self, name: str) -> str: + """Return the public URL for a stored object.""" + filename = secure_filename(Path(name).name) + key = f"{self._prefix}/{filename}" if self._prefix else filename + if self._base_url: + return f"{self._base_url}/{key}" + if self._endpoint_url: + return f"{self._endpoint_url.rstrip('/')}/{self._bucket}/{key}" + return f"https://{self._bucket}.s3.{self._region}.amazonaws.com/{key}" + + def get_size(self, name: str) -> int: + """Return the object size in bytes via a HEAD request.""" + client = cast("Any", self._get_client()) + response = client.head_object(Bucket=self._bucket, Key=self._s3_key(name)) + return response["ContentLength"] + + def open(self, name: str) -> BinaryIO: + """Download and return the object body as a BytesIO buffer.""" + client_error = _client_error_type() + client = cast("Any", self._get_client()) + try: + response = client.get_object(Bucket=self._bucket, Key=self._s3_key(name)) + return io.BytesIO(response["Body"].read()) + except client_error as e: + error_response = cast("dict[str, Any]", getattr(e, "response", {})) + if error_response.get("Error", {}).get("Code") in ("404", "NoSuchKey"): + details = str(e) if settings.debug else None + raise FastAPIStorageFileNotFoundError(name, details=details) from e + raise + + def write(self, file: BinaryIO, name: str) -> str: + """Upload a binary file to S3 and return the stored name.""" + filename = self.get_name(name) + file.seek(0) + client = cast("Any", self._get_client()) + client.upload_fileobj(file, Bucket=self._bucket, Key=self._s3_key(name)) + return filename + + def generate_new_filename(self, filename: str) -> str: + """Return a collision-free key name by probing S3 with HEAD requests.""" + client_error = _client_error_type() + client = cast("Any", self._get_client()) + counter = 0 + stem, extension = Path(filename).stem, Path(filename).suffix + name = filename + while True: + try: + client.head_object(Bucket=self._bucket, Key=self._s3_key(name)) + except client_error as e: + error_response = cast("dict[str, Any]", getattr(e, "response", {})) + if error_response.get("Error", {}).get("Code") in ("404", "NoSuchKey"): + break + raise + counter += 1 + name = f"{stem}_{counter}{extension}" + return name + + async def write_upload(self, upload_file: UploadFile, name: str) -> str: + """Upload a file to S3 using a background thread and return the stored name.""" + filename = self.get_name(name) + await upload_file.seek(0) + client = cast("Any", self._get_client()) + bucket, key = self._bucket, self._s3_key(name) + file_obj = upload_file.file + await to_thread.run_sync(lambda: client.upload_fileobj(file_obj, Bucket=bucket, Key=key)) + await upload_file.close() + return filename + + async def write_image_upload(self, upload_file: UploadFile, name: str) -> str: + """Validate and upload an image to S3.""" + await to_thread.run_sync(validate_image_file, upload_file.file) + return await self.write_upload(upload_file, name) diff --git a/backend/app/api/file_storage/models/storage_types.py b/backend/app/api/file_storage/models/storage_types.py new file mode 100644 index 00000000..6c4b2481 --- /dev/null +++ b/backend/app/api/file_storage/models/storage_types.py @@ -0,0 +1,94 @@ +"""SQLAlchemy column types backed by configured storage backends.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.types import TypeDecorator, Unicode + +from app.api.file_storage.models.storage_core import BaseStorage, StorageFile, StorageImage +from app.api.file_storage.models.storage_resolver import _get_file_storage, _get_image_storage +from app.core.images import validate_image_file + +if TYPE_CHECKING: + from typing import BinaryIO + + from app.api.file_storage.models.storage_core import UploadValue + + +class _BaseStorageType(TypeDecorator): + """Shared SQLAlchemy type behavior for storage-backed columns.""" + + impl = Unicode + cache_ok = True + + def __init__(self, storage: BaseStorage, *args: object, **kwargs: object) -> None: + self.storage = storage + super().__init__(*args, **kwargs) + + def process_bind_param(self, value: UploadValue | None, dialect: Dialect) -> str | None: + """Persist an uploaded value and return the stored file name.""" + del dialect + if value is None: + return value + if isinstance(value, str): + return self.storage.get_name(value) + + file_obj = value.file + if len(file_obj.read(1)) != 1: + return None + + file_obj.seek(0) + try: + return self._process_upload_value(value, file_obj) + finally: + file_obj.close() + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + """Persist an uploaded file-like value and return the stored name.""" + raise NotImplementedError + + +class FileType(_BaseStorageType): + """SQLAlchemy column type that stores files on the configured storage backend.""" + + cache_ok = True + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(_get_file_storage(), *args, **kwargs) + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + file = StorageFile(name=value.filename, storage=_get_file_storage()) + file.write(file=file_obj) + return file.name + + def process_result_value(self, value: str | None, dialect: Dialect) -> StorageFile | None: + """Hydrate a database value as a storage-backed file object.""" + del dialect + if value is None: + return value + return StorageFile(name=value, storage=_get_file_storage()) + + +class ImageType(_BaseStorageType): + """SQLAlchemy column type that stores images on the configured storage backend.""" + + cache_ok = True + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(_get_image_storage(), *args, **kwargs) + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + validate_image_file(file_obj) + file_obj.seek(0) + image = StorageImage(name=value.filename, storage=_get_image_storage()) + image.write(file=file_obj) + return image.name + + def process_result_value(self, value: str | None, dialect: Dialect) -> StorageImage | None: + """Hydrate a database value as a storage-backed image object. No file IO performed here.""" + del dialect + if value is None: + return value + return StorageImage(name=value, storage=_get_image_storage()) diff --git a/backend/app/api/file_storage/parents.py b/backend/app/api/file_storage/parents.py new file mode 100644 index 00000000..2a688ab9 --- /dev/null +++ b/backend/app/api/file_storage/parents.py @@ -0,0 +1,19 @@ +"""File-storage parent model registry.""" + +from app.api.background_data.models import Material, ProductType +from app.api.common.exceptions import BadRequestError +from app.api.common.models.base import Base +from app.api.data_collection.models.product import Product +from app.api.file_storage.models import MediaParentType + + +def parent_model_for_type(parent_type: MediaParentType) -> type[Base]: + """Return the ORM model for a storage parent type.""" + if parent_type == parent_type.PRODUCT: + return Product + if parent_type == parent_type.PRODUCT_TYPE: + return ProductType + if parent_type == parent_type.MATERIAL: + return Material + err_msg = f"Invalid parent type: {parent_type}" + raise BadRequestError(err_msg) diff --git a/backend/app/api/file_storage/router_factories.py b/backend/app/api/file_storage/router_factories.py deleted file mode 100644 index 55ceda86..00000000 --- a/backend/app/api/file_storage/router_factories.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Common generator functions for routers.""" - -from collections.abc import Callable, Sequence -from enum import Enum -from typing import Annotated, Any, TypeVar - -from fastapi import APIRouter, Depends, Form, Path, Security, UploadFile -from fastapi import File as FastAPIFile -from fastapi_filter import FilterDepends -from pydantic import UUID4, BeforeValidator - -from app.api.common.models.base import APIModelName -from app.api.common.models.custom_types import IDT -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.file_storage.crud import ParentStorageOperations -from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, Image -from app.api.file_storage.schemas import ( - FileCreate, - FileReadWithinParent, - ImageCreateFromForm, - ImageReadWithinParent, - empty_str_to_none, -) - -StorageModel = TypeVar("StorageModel", File, Image) -ReadSchema = TypeVar("ReadSchema", FileReadWithinParent, ImageReadWithinParent) -CreateSchema = TypeVar("CreateSchema", FileCreate, ImageCreateFromForm) -FilterSchema = TypeVar("FilterSchema", FileFilter, ImageFilter) - -BaseDep = Callable[[], Any] # Base auth dependency -ParentIdDep = Callable[[IDT], Any] # Dependency with parent_id parameter - -# Map of example extension for each storage type -STORAGE_EXTENSION_MAP: dict = {"image": "jpg", "file": "csv"} - - -class StorageRouteMethod(str, Enum): - """Enum for storage route methods.""" - - GET = "get" - POST = "post" - DELETE = "delete" - - -# TODO: Simplify, or split it up in read and modify factories, or just create the routes manually for clarity -def add_storage_type_routes( - router: APIRouter, - *, - parent_api_model_name: APIModelName, - storage_crud: ParentStorageOperations, - read_schema: type[ReadSchema], - create_schema: type[CreateSchema], - filter_schema: type[FilterSchema], - include_methods: set[StorageRouteMethod], - read_auth_dep: BaseDep | None = None, - read_parent_auth_dep: ParentIdDep | None = None, - modify_auth_dep: BaseDep | None = None, - modify_parent_auth_dep: ParentIdDep | None = None, -) -> None: - """Add storage routes for a specific storage type (files or images) to a router. - - Args: - router (APIRouter): The router to add the routes to. - parent_api_model_name (APIModelName): The parent model name. - storage_crud (ParentStorageOperations): The CRUD operations for the storage type. - read_schema (type[ReadSchema]): The schema to use for reading storage items. - create_schema (type[CreateSchema]): The schema to use for creating storage items. - filter_schema (type[FilterSchema]): The schema to use for filtering storage items. - include_methods (set[StorageRouteMethods] | None, optional): The methods to include in the routes. - read_auth_dep (Callable[[], Any] | None, optional): The authentication dependency for reading storage items. - Defaults to None. - read_parent_auth_dep (Callable[[IDT], Any] | None, optional): The authentication dependency for reading - storage items with a given parent_id. Defaults to None. - modify_auth_dep (Callable[[], Any] | None, optional): The authentication dependency for modifying storage items. - Defaults to None. - modify_parent_auth_dep (Callable[[IDT], Any] | None, optional): The authentication dependency for modifying - storage items with a given parent_id. Defaults to None. - """ - parent_slug_plural: str = parent_api_model_name.plural_slug - parent_title: str = parent_api_model_name.name_capital - parent_id_param: str = parent_api_model_name.name_snake + "_id" - - storage_type_title: str = read_schema.get_api_model_name().name_capital - storage_type_title_plural: str = read_schema.get_api_model_name().plural_capital - storage_type_slug: str = read_schema.get_api_model_name().name_slug - storage_type_slug_plural = read_schema.get_api_model_name().plural_slug - - storage_type = storage_type_slug - storage_ext: str = STORAGE_EXTENSION_MAP[storage_type] - - # HACK: Define null parent auth dependencies if none are provided - # TODO: Simplify storage crud and router factories - if read_parent_auth_dep is None: - - async def read_parent_auth_dep( - parent_id: Annotated[int, Path(alias=parent_id_param, description=f"ID of the {parent_title}")], - ) -> int: - return parent_id - - if modify_parent_auth_dep is None: - - async def modify_parent_auth_dep( - parent_id: Annotated[int, Path(alias=parent_id_param, description=f"ID of the {parent_title}")], - ) -> int: - return parent_id - - if StorageRouteMethod.GET in include_methods: - - @router.get( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}", - description=f"Get all {storage_type_title_plural} associated with the {parent_title}", - dependencies=[Security(read_auth_dep)] if read_auth_dep else None, - response_model=list[read_schema], - responses={ - 200: { - "description": f"List of {storage_type_title_plural} associated with the {parent_title}", - "content": { - "application/json": { - "example": [ - { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - ] - } - }, - }, - 404: {"description": f"{parent_title} not found"}, - }, - summary=f"Get {parent_title} {storage_type_title_plural}", - ) - async def get_items( - session: AsyncSessionDep, - parent_id: Annotated[int, Depends(read_parent_auth_dep)], - item_filter: FilterSchema = FilterDepends(filter_schema), - ) -> Sequence[StorageModel]: - """Get all storage items associated with the parent.""" - return await storage_crud.get_all(session, parent_id, filter_params=item_filter) - - @router.get( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}/{{{storage_type_slug}_id}}", - dependencies=[Security(read_auth_dep)] if read_auth_dep else None, - description=f"Get specific {parent_title} {storage_type_title} by ID", - response_model=read_schema, - responses={ - 200: { - "description": f"{storage_type.title()} found", - "content": { - "application/json": { - "example": { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - } - }, - }, - 404: {"description": f"{parent_title} or {storage_type} not found"}, - }, - summary=f"Get specific {parent_title} {storage_type_title}", - ) - async def get_item( - parent_id: Annotated[int, Depends(read_parent_auth_dep)], - item_id: Annotated[UUID4, Path(alias=f"{storage_type_slug}_id", description=f"ID of the {storage_type}")], - session: AsyncSessionDep, - ) -> StorageModel: - """Get a specific storage item associated with the parent.""" - return await storage_crud.get_by_id(session, parent_id, item_id) - - if StorageRouteMethod.POST in include_methods: - # HACK: This is an ugly way to differentiate between file and image uploads - common_upload_route_params = { - "path": f"/{{{parent_id_param}}}/{storage_type_slug_plural}", - "dependencies": [Security(modify_auth_dep)] if modify_auth_dep else None, - "description": f"Upload a new {storage_type_title} for the {parent_title}", - "response_model": read_schema, - "responses": { - 200: { - "description": f"{storage_type_title} successfully uploaded", - "content": { - "application/json": { - "example": { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - } - }, - }, - 400: {"description": f"Invalid {storage_type} data"}, - 404: {"description": f"{parent_title} not found"}, - }, - "summary": f"Add {storage_type_title} to {parent_title}", - } - - if create_schema is ImageCreateFromForm: - - @router.post(**common_upload_route_params) - async def upload_image( - session: AsyncSessionDep, - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], - description: Annotated[str | None, Form()] = None, - image_metadata: Annotated[ - str | None, - Form( - description="Image metadata in JSON string format", - examples=[r'{"foo_key": "foo_value", "bar_key": {"nested_key": "nested_value"}}'], - ), - BeforeValidator(empty_str_to_none), - ] = None, - ) -> StorageModel: - """Upload a new image for the parent. - - Note that the parent id and type setting is handled in the crud operation. - """ - item_data = ImageCreateFromForm(file=file, description=description, image_metadata=image_metadata) - return await storage_crud.create(session, parent_id, item_data) - - elif create_schema is FileCreate: - - @router.post(**common_upload_route_params) - async def upload_file( - session: AsyncSessionDep, - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], - description: Annotated[str | None, Form()] = None, - ) -> StorageModel: - """Upload a new file for the parent. - - Note that the parent id and type setting is handled in the crud operation. - """ - item_data = FileCreate(file=file, description=description) - return await storage_crud.create(session, parent_id, item_data) - - else: - err_msg = "Invalid create schema" - raise ValueError(err_msg) - - if StorageRouteMethod.DELETE in include_methods: - - @router.delete( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}/{{{storage_type_slug}_id}}", - dependencies=[Security(modify_auth_dep)] if modify_auth_dep else None, - description=f"Remove {storage_type_title} from the {parent_title} and delete it from the storage.", - responses={ - 204: {"description": f"{storage_type.title()} successfully removed"}, - 404: {"description": f"{parent_title} or {storage_type} not found"}, - }, - summary=f"Remove {storage_type_title} from {parent_title}", - status_code=204, - ) - async def delete_item( - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - item_id: Annotated[UUID4, Path(alias=f"{storage_type_slug}_id", description=f"ID of the {storage_type}")], - session: AsyncSessionDep, - ) -> None: - """Remove a storage item from the parent.""" - await storage_crud.delete(session, parent_id, item_id) - - -def add_storage_routes( - router: APIRouter, - *, - parent_api_model_name: APIModelName, - files_crud: ParentStorageOperations, - images_crud: ParentStorageOperations, - include_methods: set[StorageRouteMethod], - read_auth_dep: BaseDep | None = None, - read_parent_auth_dep: ParentIdDep | None = None, - modify_auth_dep: BaseDep | None = None, - modify_parent_auth_dep: ParentIdDep | None = None, -) -> None: - """Add both file and image storage routes to a router.""" - # Add file routes - add_storage_type_routes( - router=router, - parent_api_model_name=parent_api_model_name, - storage_crud=files_crud, - read_schema=FileReadWithinParent, - create_schema=FileCreate, - filter_schema=FileFilter, - include_methods=include_methods, - read_auth_dep=read_auth_dep, - read_parent_auth_dep=read_parent_auth_dep, - modify_auth_dep=modify_auth_dep, - modify_parent_auth_dep=modify_parent_auth_dep, - ) - - # Add image routes - add_storage_type_routes( - router=router, - parent_api_model_name=parent_api_model_name, - storage_crud=images_crud, - read_schema=ImageReadWithinParent, - create_schema=ImageCreateFromForm, - filter_schema=ImageFilter, - include_methods=include_methods, - read_auth_dep=read_auth_dep, - read_parent_auth_dep=read_parent_auth_dep, - modify_auth_dep=modify_auth_dep, - modify_parent_auth_dep=modify_parent_auth_dep, - ) diff --git a/backend/app/api/file_storage/routers/__init__.py b/backend/app/api/file_storage/routers/__init__.py new file mode 100644 index 00000000..4345f3fb --- /dev/null +++ b/backend/app/api/file_storage/routers/__init__.py @@ -0,0 +1,106 @@ +"""Routers for file storage models, including image resizing.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +from anyio import Path as AsyncPath +from anyio import to_thread +from fastapi import APIRouter, HTTPException, Query, Request, Response +from pydantic import UUID4 + +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import mark_router_routes_public +from app.api.file_storage.crud.media_queries import get_image +from app.api.file_storage.examples import ( + IMAGE_RESIZE_HEIGHT_OPENAPI_EXAMPLES, + IMAGE_RESIZE_WIDTH_OPENAPI_EXAMPLES, +) +from app.core.constants import HOUR +from app.core.images import THUMBNAIL_WIDTHS, resize_image, thumbnail_path_for +from app.core.logging import sanitize_log_value +from app.core.runtime import get_connection_image_resize_limiter + +if TYPE_CHECKING: + from typing import NoReturn + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/images", tags=["images"]) + +MEDIA_TYPE_WEBP = "image/webp" + + +def _trusted_thumbnail_width(width: int | None) -> int | None: + """Return a configured thumbnail width rather than a raw query value.""" + return next((thumbnail_width for thumbnail_width in THUMBNAIL_WIDTHS if width == thumbnail_width), None) + + +@router.get("/{image_id}/resized", summary="Get a resized version of an image") +async def get_resized_image( + request: Request, + image_id: UUID4, + session: AsyncSessionDep, + width: Annotated[ + int | None, + Query(gt=0, le=2000, openapi_examples=IMAGE_RESIZE_WIDTH_OPENAPI_EXAMPLES), + ] = 200, + height: Annotated[ + int | None, + Query(gt=0, le=2000, openapi_examples=IMAGE_RESIZE_HEIGHT_OPENAPI_EXAMPLES), + ] = None, +) -> Response: + """Get a resized version of an image as WebP. + + The image is resized while maintaining its aspect ratio. + Resizing is performed in a background thread to avoid blocking the event loop. + Results are cached via HTTP Cache-Control headers for 1 hour. + """ + + def _raise_not_found(detail: str) -> NoReturn: + raise HTTPException(status_code=404, detail=detail) + + def _raise_error(detail: str, exc: Exception | None = None) -> NoReturn: + if exc: + raise HTTPException(status_code=500, detail=detail) from exc + raise HTTPException(status_code=500, detail=detail) + + cache_headers = {"Cache-Control": f"public, max-age={HOUR}, immutable"} + + try: + db_image = await get_image(session, image_id) + if not db_image.file or not db_image.file.path: + _raise_not_found("Image file not found in storage") + + image_path = AsyncPath(db_image.file.path) + if not await image_path.exists(): + _raise_not_found("Image file not found on disk") + + # Serve pre-computed thumbnail when the request matches a standard width + thumbnail_width = _trusted_thumbnail_width(width) + if thumbnail_width is not None and not height: + thumb = AsyncPath(thumbnail_path_for(Path(image_path), thumbnail_width)) + if await thumb.exists(): + content = await thumb.read_bytes() + return Response(content=content, media_type=MEDIA_TYPE_WEBP, headers=cache_headers) + + # Fall back to on-demand resize for non-standard sizes + limiter = get_connection_image_resize_limiter(request) + resized_bytes = await to_thread.run_sync(resize_image, image_path, width, height, limiter=limiter) + + return Response( + content=resized_bytes, + media_type=MEDIA_TYPE_WEBP, + headers=cache_headers, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception("Error resizing image %s", sanitize_log_value(image_id)) + _raise_error("Error resizing image", exc=e) + + +mark_router_routes_public(router) diff --git a/backend/app/api/file_storage/schemas.py b/backend/app/api/file_storage/schemas.py index ab4bf4b5..6ad0c5bd 100644 --- a/backend/app/api/file_storage/schemas.py +++ b/backend/app/api/file_storage/schemas.py @@ -1,44 +1,40 @@ -"""Pydantic models used to validate CRUD operations for file data.""" +"""Pydantic models used to validate file storage CRUD operations.""" -from typing import Annotated, Any +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, cast +from urllib.parse import quote from fastapi import UploadFile -from pydantic import AfterValidator, Field, HttpUrl, Json, PositiveInt +from pydantic import AfterValidator, ConfigDict, Field, PositiveInt, model_validator + +from app.api.common.schemas.base import ( + BaseCreateSchema, + BaseUpdateSchema, + IntIdReadSchemaWithTimeStamp, + UUIDIdReadSchemaWithTimeStamp, +) +from app.api.common.schemas.custom_fields import AnyUrlToDB +from app.api.file_storage.examples import ( + FILE_READ_WITHIN_PARENT_EXAMPLES, + IMAGE_READ_WITHIN_PARENT_EXAMPLES, + VIDEO_CREATE_WITHIN_PRODUCT_EXAMPLES, + VIDEO_READ_WITHIN_PRODUCT_EXAMPLES, + VIDEO_UPDATE_WITHIN_PRODUCT_EXAMPLES, +) +from app.api.file_storage.models import FileBase, ImageBase, MediaParentType, VideoBase +from app.core.config import settings +from app.core.images import validate_image_mime_type + +if TYPE_CHECKING: + from os import PathLike -from app.api.common.models.custom_types import IDT -from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp, BaseUpdateSchema -from app.api.file_storage.models.models import FileBase, FileParentType, ImageBase, ImageParentType, VideoBase - -### Constants ### MAX_FILE_SIZE_MB = 50 - -ALLOWED_IMAGE_MIME_TYPES: set[str] = { - "image/bmp", - "image/gif", - "image/jpeg", - "image/png", - "image/tiff", - "image/webp", -} MAX_IMAGE_SIZE_MB = 10 - - -### Common Validation ### -def validate_file_size(file: UploadFile | None, max_size_mb: int) -> UploadFile | None: - """Validate the file size against a maximum size limit.""" - if file is None: - return file - if file.size is None or file.size == 0: - err_msg: str = "File size is None or zero." - raise ValueError(err_msg) - if file.size > max_size_mb * 1024 * 1024: - err_msg: str = f"File size too large: {file.size / 1024 / 1024:.2f} MB. Maximum size: {max_size_mb} MB" - raise ValueError(err_msg) - return file +PARENT_TYPE_DESCRIPTION = f"Type of the parent object, e.g. {', '.join(parent.value for parent in MediaParentType)}" def validate_filename(file: UploadFile | None) -> UploadFile | None: - """Validate the image file name.""" + """Validate that the uploaded file has a filename.""" if file is None: return file if not file.filename: @@ -47,152 +43,220 @@ def validate_filename(file: UploadFile | None) -> UploadFile | None: return file -AT = Any # HACK: To avoid type issues +def empty_str_to_none(value: object) -> object | None: + """Convert empty strings in request form to None.""" + if value == "": + return None + return value -def empty_str_to_none(v: AT) -> AT | None: - """Convert empty strings in request form to None.""" - if v == "": +def _build_storage_url(path: str | PathLike[str] | None, storage_root: Path, url_prefix: str) -> str | None: + """Build a public URL for a stored file-backed object from its filesystem path.""" + if path is None: return None - return v + + file_path = Path(path) + if not file_path.exists(): + return None + + relative_path = file_path.relative_to(storage_root) + return f"{url_prefix}/{quote(str(relative_path))}" + + +def _build_image_urls( + file_path: str | None, + image_id: int | None, + storage_root: Path, +) -> tuple[str | None, str | None]: + """Build image_url and thumbnail_url with a single filesystem existence check. + + Returns (image_url, thumbnail_url) — both None if the file does not exist. + """ + if file_path is None: + return None, None + path = Path(file_path) + if not path.exists(): + return None, None + relative_path = path.relative_to(storage_root) + return f"/uploads/images/{quote(str(relative_path))}", f"/images/{image_id}/resized?width=200" + + +FileUpload = Annotated[ + UploadFile, + AfterValidator(validate_filename), +] + +ImageUpload = Annotated[ + UploadFile, + AfterValidator(validate_filename), + AfterValidator(validate_image_mime_type), +] -### File Schemas ### class FileCreateWithinParent(BaseCreateSchema, FileBase): """Schema for creating a file within a parent object.""" - file: Annotated[ - UploadFile, - AfterValidator(validate_filename), - AfterValidator(lambda f: validate_file_size(f, MAX_FILE_SIZE_MB)), - ] + file: FileUpload class FileCreate(FileCreateWithinParent): """Schema for creating a file.""" - # HACK: Even though the parent_id is optional, it should be required in the request. - # It is optional to allow for the currently messy storage crud and router factories to work - parent_id: IDT | None = None - parent_type: FileParentType | None = Field( - default=None, description=f"Type of the parent object, e.g. {', '.join(t.value for t in FileParentType)}" - ) + parent_id: int = Field(description="ID of the parent object") + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) -class FileReadWithinParent(BaseReadSchemaWithTimeStamp, FileBase): +class FileReadWithinParent(UUIDIdReadSchemaWithTimeStamp, FileBase): """Schema for reading file information within a parent object.""" + model_config = ConfigDict(json_schema_extra={"examples": FILE_READ_WITHIN_PARENT_EXAMPLES}) + filename: str - file_url: str + file_url: str | None + + @model_validator(mode="before") + @classmethod + def populate_file_url(cls, data: object) -> object: + """Populate ``file_url`` when validating directly from an ORM row.""" + if isinstance(data, dict): + payload = cast("dict[str, Any]", data) + if payload.get("file_url") is not None: + return payload + file_path = getattr(payload.get("file"), "path", None) + return { + **payload, + "file_url": _build_storage_url(file_path, settings.file_storage_path, "/uploads/files"), + } + + file_path = getattr(getattr(data, "file", None), "path", None) + return { + "id": getattr(data, "id", None), + "description": getattr(data, "description", None), + "filename": getattr(data, "filename", None), + "file_url": _build_storage_url(file_path, settings.file_storage_path, "/uploads/files"), + "created_at": getattr(data, "created_at", None), + "updated_at": getattr(data, "updated_at", None), + "parent_id": getattr(data, "parent_id", None), + "parent_type": getattr(data, "parent_type", None), + } class FileRead(FileReadWithinParent): """Schema for reading file information.""" parent_id: PositiveInt = Field(description="ID of the parent object") - parent_type: FileParentType = Field( - description=f"Type of the parent object, e.g. {', '.join(t.value for t in FileParentType)}" - ) + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class FileUpdate(BaseUpdateSchema, FileBase): """Schema for updating a file description.""" - # Only includes fields from FileBase (description) - # If the user wants to update the file or reassign to a new parent object, - # they should delete the old file and create a new one. - - -### Image Schemas ### -def validate_image_type(file: UploadFile | None) -> UploadFile | None: - """Validate the image file mime type.""" - if file is None: - return file - allowed_mime_types: set[str] = ALLOWED_IMAGE_MIME_TYPES - if file.content_type not in allowed_mime_types: - err_msg: str = f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_mime_types)}" - raise ValueError(err_msg) - return file - class ImageCreateInternal(BaseCreateSchema, ImageBase): """Schema for creating a new image internally, without a form upload.""" - file: Annotated[ - UploadFile, - AfterValidator(validate_filename), - AfterValidator(validate_image_type), - AfterValidator(lambda f: validate_file_size(f, MAX_IMAGE_SIZE_MB)), - ] - # HACK: Even though the parent_id is optional, it should be required in the request. - # It is optional to allow for the currently messy storage crud and router factories to work - parent_id: IDT | None = None - parent_type: ImageParentType | None = Field( - default=None, description=f"Type of the parent object, e.g. {', '.join(t.value for t in ImageParentType)}" - ) + file: ImageUpload + parent_id: int = Field(description="ID of the parent object") + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class ImageCreateFromForm(ImageCreateInternal): - """Schema for creating a new image from Form data. - - Parses image metadata from a JSON string in a form field, allowing file and metadata upload in one request. - """ + """Schema for creating a new image from multipart form data.""" - # Overriding the ImageBase field to allow for JSON validation - image_metadata: Json | None = Field(default=None, description="Image metadata in JSON string format") + image_metadata: dict[str, Any] | None = Field( + default=None, + description="Image metadata in JSON string format", + ) -class ImageReadWithinParent(BaseReadSchemaWithTimeStamp, ImageBase): +class ImageReadWithinParent(UUIDIdReadSchemaWithTimeStamp, ImageBase): """Schema for reading image information within a parent object.""" + model_config = ConfigDict(json_schema_extra={"examples": IMAGE_READ_WITHIN_PARENT_EXAMPLES}) + filename: str - image_url: str + image_url: str | None + thumbnail_url: str | None = None + + @model_validator(mode="before") + @classmethod + def populate_image_urls(cls, data: object) -> object: + """Populate image URLs when validating directly from an ORM row.""" + if isinstance(data, dict): + payload = cast("dict[str, Any]", data) + if payload.get("image_url") is not None: + return payload + file_path = getattr(payload.get("file"), "path", None) + image_url, thumbnail_url = _build_image_urls(file_path, payload.get("id"), settings.image_storage_path) + return {**payload, "image_url": image_url, "thumbnail_url": thumbnail_url} + + item_id = getattr(data, "id", None) + file_path = getattr(getattr(data, "file", None), "path", None) + image_url, thumbnail_url = _build_image_urls(file_path, item_id, settings.image_storage_path) + return { + "id": item_id, + "description": getattr(data, "description", None), + "image_metadata": getattr(data, "image_metadata", None), + "filename": getattr(data, "filename", None), + "image_url": image_url, + "thumbnail_url": thumbnail_url, + "created_at": getattr(data, "created_at", None), + "updated_at": getattr(data, "updated_at", None), + "parent_id": getattr(data, "parent_id", None), + "parent_type": getattr(data, "parent_type", None), + } class ImageRead(ImageReadWithinParent): """Schema for reading image information.""" parent_id: PositiveInt - parent_type: ImageParentType = Field( - description=f"Type of the object that the image belongs to, e.g. {', '.join(t.value for t in ImageParentType)}", - ) + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class ImageUpdate(BaseUpdateSchema, ImageBase): """Schema for updating an image description.""" - # Only includes fields from ImageBase. - # If the user wants to update the image file or reassign to a new parent object, - # they should delete the old image and create a new one. - # TODO: Add logic to reassign to new parent object - -### Video Schemas ### class VideoCreateWithinProduct(BaseCreateSchema, VideoBase): """Schema for creating a video.""" + model_config = ConfigDict(json_schema_extra={"examples": VIDEO_CREATE_WITHIN_PRODUCT_EXAMPLES}) + + url: AnyUrlToDB + class VideoCreate(BaseCreateSchema, VideoBase): """Schema for creating a video.""" + url: AnyUrlToDB product_id: PositiveInt -class VideoReadWithinProduct(BaseReadSchemaWithTimeStamp, VideoBase): - """Schema for reading video information.""" +class VideoReadWithinProduct(IntIdReadSchemaWithTimeStamp, VideoBase): + """Schema for reading video information within a product.""" + + model_config = ConfigDict(json_schema_extra={"examples": VIDEO_READ_WITHIN_PRODUCT_EXAMPLES}) -class VideoRead(BaseReadSchemaWithTimeStamp, VideoBase): +class VideoRead(IntIdReadSchemaWithTimeStamp, VideoBase): """Schema for reading video information.""" product_id: PositiveInt -class VideoUpdate(BaseUpdateSchema): - """Schema for updating a video.""" +class VideoUpdateWithinProduct(BaseUpdateSchema): + """Schema for updating a video within a product.""" + + model_config = ConfigDict(json_schema_extra={"examples": VIDEO_UPDATE_WITHIN_PRODUCT_EXAMPLES}) - url: HttpUrl | None = Field(default=None, max_length=250, description="HTTP(S) URL linking to the video") + url: AnyUrlToDB | None = Field(default=None, description="URL linking to the video") title: str | None = Field(default=None, max_length=100, description="Title of the video") description: str | None = Field(default=None, max_length=500, description="Description of the video") video_metadata: dict[str, Any] | None = Field(default=None, description="Video metadata as a JSON dict") + + +class VideoUpdate(VideoUpdateWithinProduct): + """Schema for updating a video.""" + product_id: PositiveInt diff --git a/backend/app/api/file_storage/services/__init__.py b/backend/app/api/file_storage/services/__init__.py new file mode 100644 index 00000000..8e6d34f4 --- /dev/null +++ b/backend/app/api/file_storage/services/__init__.py @@ -0,0 +1 @@ +"""File storage services.""" diff --git a/backend/app/api/file_storage/services/cleanup.py b/backend/app/api/file_storage/services/cleanup.py new file mode 100644 index 00000000..05c5400a --- /dev/null +++ b/backend/app/api/file_storage/services/cleanup.py @@ -0,0 +1,141 @@ +"""Core logic for cleaning up unreferenced files in storage.""" + +import logging +import time +from pathlib import Path +from typing import cast + +from anyio import Path as AnyIOPath +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.file_storage.models import File, Image +from app.core.config import settings +from app.core.images import THUMBNAIL_WIDTHS, thumbnail_path_for + +logger = logging.getLogger(__name__) + + +async def _resolve_storage_path(path_like: object, *, storage_dir: Path | str | None = None) -> AnyIOPath | None: + """Resolve a storage field value to an absolute path when possible.""" + name_attr = getattr(path_like, "name", None) + if isinstance(name_attr, str) and storage_dir is not None: + candidate = Path(storage_dir) / name_attr + return await AnyIOPath(str(candidate)).resolve() + + if isinstance(path_like, str): + candidate = Path(path_like) + if not candidate.is_absolute() and storage_dir is not None: + candidate = Path(storage_dir) / candidate + return await AnyIOPath(str(candidate)).resolve() + + path_attr = getattr(path_like, "path", None) + if isinstance(path_attr, str): + return await AnyIOPath(path_attr).resolve() + + file_attr = getattr(path_like, "file", None) + if file_attr is not None: + return await _resolve_storage_path(file_attr, storage_dir=storage_dir) + + return None + + +def _get_thumbnail_paths(image_path: str) -> set[AnyIOPath]: + """Return the expected thumbnail paths for a stored image.""" + path = Path(image_path) + return {AnyIOPath(str(thumbnail_path_for(path, width))) for width in THUMBNAIL_WIDTHS} + + +async def get_referenced_files(session: AsyncSession) -> set[AnyIOPath]: + """Get all file paths referenced in the database. + + Returns: + Set of absolute Paths to referenced files. + """ + referenced_paths: set[AnyIOPath] = set() + + file_stmt = select(File) + files = (await session.execute(file_stmt)).scalars().all() + for f in files: + resolved_path = await _resolve_storage_path(getattr(f, "file", None), storage_dir=settings.file_storage_path) + if resolved_path is not None: + referenced_paths.add(resolved_path) + + image_stmt = select(Image) + images = (await session.execute(image_stmt)).scalars().all() + for img in images: + resolved_path = await _resolve_storage_path(getattr(img, "file", None), storage_dir=settings.image_storage_path) + if resolved_path is not None: + referenced_paths.add(resolved_path) + referenced_paths.update(_get_thumbnail_paths(str(resolved_path))) + + return referenced_paths + + +async def get_files_on_disk() -> set[AnyIOPath]: + """Get all file paths on disk in the upload directories that are old enough to delete. + + Only files older than ``settings.file_cleanup_min_file_age_minutes`` are + included. This grace period prevents a Time-of-Check to Time-of-Use race + where a file has been written to disk but whose database record has not yet committed. + + Returns: + Set of absolute Paths to eligible files on disk. + """ + files_on_disk: set[AnyIOPath] = set() + min_age_seconds = settings.file_cleanup_min_file_age_minutes * 60 + now = time.time() + + for storage_dir in [settings.file_storage_path, settings.image_storage_path]: + dir_path = AnyIOPath(storage_dir) + if await dir_path.exists(): + async for path in dir_path.rglob("*"): + if await path.is_file(): + stat = await path.stat() + if now - stat.st_mtime >= min_age_seconds: + files_on_disk.add(await path.resolve()) + + return files_on_disk + + +async def get_unreferenced_files(session: AsyncSession) -> list[AnyIOPath]: + """Identify files on disk that are not referenced in the database. + + Returns: + Sorted list of absolute Paths to unreferenced files. + """ + referenced = await get_referenced_files(session) + on_disk = await get_files_on_disk() + return cast("list[AnyIOPath]", sorted(on_disk - referenced, key=str)) + + +async def cleanup_unreferenced_files(session: AsyncSession, *, dry_run: bool = True) -> list[AnyIOPath]: + """Delete files from disk that are not referenced in the database. + + Args: + session: AsyncSession to use for database queries. + dry_run: If True, only log what would be deleted without actually deleting. + + Returns: + List of Paths that were (or would have been) deleted. + """ + unreferenced = await get_unreferenced_files(session) + + if not unreferenced: + logger.info("No unreferenced files found.") + return [] + + if dry_run: + logger.info("Dry run: Found %d unreferenced files to delete:", len(unreferenced)) + for path in unreferenced: + logger.info(" [DRY RUN] Would delete: %s", path) + else: + logger.info("Cleaning up %d unreferenced files...", len(unreferenced)) + for path in unreferenced: + try: + await AnyIOPath(str(path)).unlink() + logger.info(" Deleted: %s", path) + except OSError: + logger.exception(" Failed to delete %s", path) + + return unreferenced diff --git a/backend/app/api/file_storage/services/manager.py b/backend/app/api/file_storage/services/manager.py new file mode 100644 index 00000000..7ec319e1 --- /dev/null +++ b/backend/app/api/file_storage/services/manager.py @@ -0,0 +1,38 @@ +"""Periodic background task for cleaning up unreferenced files.""" + +import logging +from typing import TYPE_CHECKING + +from app.api.file_storage.services.cleanup import cleanup_unreferenced_files +from app.core.background_tasks import PeriodicBackgroundTask +from app.core.config import settings + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class FileCleanupManager(PeriodicBackgroundTask): + """Periodic background task that deletes unreferenced files from storage.""" + + def __init__(self, session_factory: Callable[[], AsyncSession]) -> None: + super().__init__(interval_seconds=settings.file_cleanup_interval_hours * 3600) + self.session_factory = session_factory + + async def initialize(self) -> None: + """Start the periodic cleanup task, unless cleanup is disabled in settings.""" + if not settings.file_cleanup_enabled: + logger.info("File cleanup is disabled (FILE_CLEANUP_ENABLED=false), skipping.") + return + logger.info("Initializing FileCleanupManager background task...") + await super().initialize() + + async def run_once(self) -> None: + """Run one cleanup pass, deleting unreferenced files from storage.""" + logger.info("Starting scheduled background file cleanup...") + async with self.session_factory() as session: + await cleanup_unreferenced_files(session, dry_run=settings.file_cleanup_dry_run) + logger.info("Finished scheduled background file cleanup.") diff --git a/backend/app/api/newsletter/examples.py b/backend/app/api/newsletter/examples.py new file mode 100644 index 00000000..d3019635 --- /dev/null +++ b/backend/app/api/newsletter/examples.py @@ -0,0 +1,45 @@ +"""Centralized OpenAPI examples for newsletter schemas and routers.""" +# spell-checker: ignore eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +NEWSLETTER_SUBSCRIBER_READ_EXAMPLES = [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "email": "subscriber@example.com", + "is_confirmed": True, + } +] + +NEWSLETTER_PREFERENCE_READ_EXAMPLES = [ + { + "email": "subscriber@example.com", + "subscribed": True, + "is_confirmed": True, + } +] + +NEWSLETTER_PREFERENCE_UPDATE_EXAMPLES = [ + { + "subscribed": True, + } +] + +NEWSLETTER_EMAIL_BODY_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + subscriber_email=openapi_example("subscriber@example.com", summary="Newsletter subscriber email") +) + +NEWSLETTER_TOKEN_BODY_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + confirmation_token=openapi_example( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.signature", + summary="JWT-style confirmation or unsubscribe token", + ) +) diff --git a/backend/app/api/newsletter/exceptions.py b/backend/app/api/newsletter/exceptions.py new file mode 100644 index 00000000..2314212c --- /dev/null +++ b/backend/app/api/newsletter/exceptions.py @@ -0,0 +1,45 @@ +"""Custom exceptions for newsletter subscription flows.""" + +from app.api.common.exceptions import BadRequestError, NotFoundError + + +class NewsletterAlreadySubscribedError(BadRequestError): + """Raised when a confirmed subscriber tries to subscribe again.""" + + def __init__(self) -> None: + super().__init__("Already subscribed.") + + +class NewsletterConfirmationResentError(BadRequestError): + """Raised when a subscriber exists but still needs to confirm their email.""" + + def __init__(self) -> None: + super().__init__("Already subscribed, but not confirmed. A new confirmation email has been sent.") + + +class NewsletterInvalidConfirmationTokenError(BadRequestError): + """Raised when a confirmation token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired confirmation link.") + + +class NewsletterInvalidUnsubscribeTokenError(BadRequestError): + """Raised when an unsubscribe token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired unsubscribe link.") + + +class NewsletterSubscriberNotFoundError(NotFoundError): + """Raised when the subscriber referenced by a token no longer exists.""" + + def __init__(self) -> None: + super().__init__("Subscriber not found.") + + +class NewsletterAlreadyConfirmedError(BadRequestError): + """Raised when a subscription is already confirmed.""" + + def __init__(self) -> None: + super().__init__("Already confirmed.") diff --git a/backend/app/api/newsletter/models.py b/backend/app/api/newsletter/models.py index 1bbdc231..c9e26758 100644 --- a/backend/app/api/newsletter/models.py +++ b/backend/app/api/newsletter/models.py @@ -1,22 +1,26 @@ """Database models related to newsletter subscribers.""" import uuid -from typing import Annotated -from pydantic import UUID4, EmailStr, StringConstraints -from sqlmodel import Field +from pydantic import BaseModel, EmailStr +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column -from app.api.common.models.base import CustomBase, TimeStampMixinBare +from app.api.common.models.base import Base, TimeStampMixinBare -class NewsletterSubscriberBase(CustomBase): - """Base schema for newsletter subscribers.""" +### Pydantic base schema (shared with schemas.py) ### +class NewsletterSubscriberBase(BaseModel): + """Base schema for newsletter subscribers. Used by Pydantic schemas only, not ORM.""" - email: Annotated[EmailStr, StringConstraints(strip_whitespace=True)] = Field(index=True, unique=True) + email: EmailStr -class NewsletterSubscriber(NewsletterSubscriberBase, TimeStampMixinBare, table=True): +class NewsletterSubscriber(TimeStampMixinBare, Base): """Database model for newsletter subscribers.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) - is_confirmed: bool = Field(default=False) + __tablename__ = "newslettersubscriber" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String, index=True, unique=True) + is_confirmed: Mapped[bool] = mapped_column(default=False) diff --git a/backend/app/api/newsletter/routers.py b/backend/app/api/newsletter/routers.py index 4483832c..ef086a37 100644 --- a/backend/app/api/newsletter/routers.py +++ b/backend/app/api/newsletter/routers.py @@ -1,17 +1,31 @@ -"""Basic newsletter subscription endpoint.""" +"""Newsletter subscription endpoints.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, HTTPException, Security +from fastapi import APIRouter, BackgroundTasks, Security from fastapi.params import Body +from fastapi_pagination import Page from pydantic import EmailStr -from sqlmodel import select +from sqlalchemy import Select, select -from app.api.auth.dependencies import current_active_superuser +from app.api.auth.dependencies import CurrentActiveUserDep, current_active_superuser, current_active_user +from app.api.common.crud.pagination import paginate_select +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.newsletter.examples import ( + NEWSLETTER_EMAIL_BODY_OPENAPI_EXAMPLES, + NEWSLETTER_TOKEN_BODY_OPENAPI_EXAMPLES, +) +from app.api.newsletter.exceptions import ( + NewsletterAlreadyConfirmedError, + NewsletterAlreadySubscribedError, + NewsletterConfirmationResentError, + NewsletterInvalidConfirmationTokenError, + NewsletterInvalidUnsubscribeTokenError, + NewsletterSubscriberNotFoundError, +) from app.api.newsletter.models import NewsletterSubscriber -from app.api.newsletter.schemas import NewsletterSubscriberRead +from app.api.newsletter.schemas import NewsletterPreferenceRead, NewsletterPreferenceUpdate, NewsletterSubscriberRead from app.api.newsletter.utils.emails import ( send_newsletter_subscription_email, send_newsletter_unsubscription_request_email, @@ -22,121 +36,227 @@ backend_router = APIRouter(prefix="/newsletter") -@backend_router.post("/subscribe", status_code=201, response_model=NewsletterSubscriberRead) -async def subscribe_to_newsletter(email: Annotated[EmailStr, Body()], db: AsyncSessionDep) -> NewsletterSubscriber: - """Subscribe to the newsletter to receive updates about the app launch.""" - # Check if the email already exists - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() +async def _get_subscriber_by_email(db: AsyncSessionDep, email: str) -> NewsletterSubscriber | None: + """Return the subscriber row for one email address.""" + statement = select(NewsletterSubscriber).where(NewsletterSubscriber.email == email) + return (await db.execute(statement)).scalars().unique().one_or_none() + + +def _newsletter_preference_read( + *, + email: str, + subscriber: NewsletterSubscriber | None, +) -> NewsletterPreferenceRead: + """Build the newsletter preference response for one email address.""" + return NewsletterPreferenceRead( + email=email, + subscribed=subscriber is not None, + is_confirmed=subscriber.is_confirmed if subscriber else False, ) - if existing_subscriber: - if existing_subscriber.is_confirmed: - raise HTTPException(status_code=400, detail="Already subscribed.") - - # If not confirmed, generate new token and send email - token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) - await send_newsletter_subscription_email(email, token) - raise HTTPException( - status_code=400, - detail="Already subscribed, but not confirmed. A new confirmation email has been sent.", - ) - - # Create new subscriber - new_subscriber = NewsletterSubscriber(email=email) - db.add(new_subscriber) - await db.commit() - await db.refresh(new_subscriber) - - # Send confirmation email - token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) - await send_newsletter_subscription_email(email, token) - return new_subscriber +def _safe_unsubscribe_message() -> dict[str, str]: + """Return the privacy-preserving unsubscribe response.""" + return {"message": "If you are subscribed, we've sent an unsubscribe link to your email."} -@backend_router.post("/confirm", status_code=200, response_model=NewsletterSubscriberRead) -async def confirm_newsletter_subscription(token: Annotated[str, Body()], db: AsyncSessionDep) -> NewsletterSubscriber: - """Confirm the newsletter subscription.""" - # Verify the token +async def _send_confirmation_email(email: str, background_tasks: BackgroundTasks) -> None: + """Send a newsletter confirmation email.""" + token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) + await send_newsletter_subscription_email(email, token, background_tasks=background_tasks) + + +async def _create_or_resend_subscriber( + db: AsyncSessionDep, + *, + email: str, + background_tasks: BackgroundTasks, +) -> NewsletterSubscriber: + """Create a new subscriber or resend confirmation for an existing unconfirmed one.""" + existing_subscriber = await _get_subscriber_by_email(db, email) + if existing_subscriber is None: + new_subscriber = NewsletterSubscriber(email=email) + await commit_and_refresh(db, new_subscriber) + await _send_confirmation_email(email, background_tasks) + return new_subscriber + if existing_subscriber.is_confirmed: + raise NewsletterAlreadySubscribedError + await _send_confirmation_email(email, background_tasks) + raise NewsletterConfirmationResentError + + +async def _confirm_subscriber(db: AsyncSessionDep, *, token: str) -> NewsletterSubscriber: + """Confirm a subscriber from a valid confirmation token.""" email = verify_jwt_token(token, JWTType.NEWSLETTER_CONFIRMATION) if not email: - raise HTTPException(status_code=400, detail="Invalid or expired confirmation link.") + raise NewsletterInvalidConfirmationTokenError + existing_subscriber = await _get_subscriber_by_email(db, email) + if not existing_subscriber: + raise NewsletterSubscriberNotFoundError + if existing_subscriber.is_confirmed: + raise NewsletterAlreadyConfirmedError + existing_subscriber.is_confirmed = True + return await commit_and_refresh(db, existing_subscriber, add_before_commit=False) - # Check if the email is already confirmed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) +async def _unsubscribe_subscriber(db: AsyncSessionDep, *, token: str) -> None: + """Delete a subscriber using a valid unsubscribe token.""" + email = verify_jwt_token(token, JWTType.NEWSLETTER_UNSUBSCRIBE) + if not email: + raise NewsletterInvalidUnsubscribeTokenError + existing_subscriber = await _get_subscriber_by_email(db, email) if not existing_subscriber: - raise HTTPException(status_code=404, detail="Subscriber not found.") + raise NewsletterSubscriberNotFoundError + await delete_and_commit(db, existing_subscriber) + + +async def _set_newsletter_preference( + db: AsyncSessionDep, + *, + email: str, + subscribed: bool, +) -> NewsletterPreferenceRead: + """Create/update/delete the subscriber row for a user's preference.""" + existing_subscriber = await _get_subscriber_by_email(db, email) + if subscribed: + if existing_subscriber is None: + existing_subscriber = NewsletterSubscriber(email=email, is_confirmed=True) + else: + existing_subscriber.is_confirmed = True + await commit_and_refresh(db, existing_subscriber) + return _newsletter_preference_read(email=email, subscriber=existing_subscriber) + if existing_subscriber is not None: + await delete_and_commit(db, existing_subscriber) + return _newsletter_preference_read(email=email, subscriber=None) + + +async def _request_unsubscribe( + db: AsyncSessionDep, + *, + email: str, + background_tasks: BackgroundTasks, +) -> dict[str, str]: + """Send an unsubscribe email when the subscriber exists, otherwise return the safe message.""" + existing_subscriber = await _get_subscriber_by_email(db, email) + if existing_subscriber is None: + return _safe_unsubscribe_message() - if existing_subscriber.is_confirmed: - raise HTTPException(status_code=400, detail="Already confirmed.") + token = create_jwt_token(email, JWTType.NEWSLETTER_UNSUBSCRIBE) + await send_newsletter_unsubscription_request_email(email, token, background_tasks=background_tasks) + return _safe_unsubscribe_message() - # Update subscriber status to confirmed - existing_subscriber.is_confirmed = True - await db.commit() - await db.refresh(existing_subscriber) - return existing_subscriber +async def _load_newsletter_preference( + db: AsyncSessionDep, + *, + email: str, +) -> NewsletterPreferenceRead: + """Load the preference state for one email address.""" + existing_subscriber = await _get_subscriber_by_email(db, email) + return _newsletter_preference_read(email=email, subscriber=existing_subscriber) -@backend_router.post("/request-unsubscribe", status_code=200) -async def request_unsubscribe(email: Annotated[EmailStr, Body()], db: AsyncSessionDep) -> dict: - """Request to unsubscribe by sending an email with unsubscribe link.""" - # Check if the email is subscribed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) +def _subscribers_statement() -> Select[tuple[NewsletterSubscriber]]: + """Build the admin subscriber listing query.""" + return select(NewsletterSubscriber).order_by(NewsletterSubscriber.created_at.desc()) - if not existing_subscriber: - # Don't reveal if someone is subscribed or not for privacy reasons - return {"message": "If you are subscribed, we've sent an unsubscribe link to your email."} - # Generate unsubscribe token - token = create_jwt_token(email, JWTType.NEWSLETTER_UNSUBSCRIBE) +async def _page_subscribers(db: AsyncSessionDep) -> Page[NewsletterSubscriber]: + """Page newsletter subscribers for the admin view.""" + return await paginate_select(db, _subscribers_statement(), model=NewsletterSubscriber) - # Send unsubscription email with the link - await send_newsletter_unsubscription_request_email(email, token) - return {"message": "If you are subscribed, we've sent an unsubscribe link to your email."} +@backend_router.post("/subscribe", status_code=201, response_model=NewsletterSubscriberRead) +async def subscribe_to_newsletter( + email: Annotated[ + EmailStr, + Body(description="Email address to subscribe", openapi_examples=NEWSLETTER_EMAIL_BODY_OPENAPI_EXAMPLES), + ], + db: AsyncSessionDep, + background_tasks: BackgroundTasks, +) -> NewsletterSubscriber: + """Subscribe to the newsletter to receive updates about the app launch.""" + return await _create_or_resend_subscriber(db, email=email, background_tasks=background_tasks) + + +@backend_router.post("/confirm", status_code=200, response_model=NewsletterSubscriberRead) +async def confirm_newsletter_subscription( + token: Annotated[ + str, + Body( + description="Confirmation token from the subscription email", + openapi_examples=NEWSLETTER_TOKEN_BODY_OPENAPI_EXAMPLES, + ), + ], + db: AsyncSessionDep, +) -> NewsletterSubscriber: + """Confirm the newsletter subscription.""" + return await _confirm_subscriber(db, token=token) + + +@backend_router.post("/request-unsubscribe", status_code=200) +async def request_unsubscribe( + email: Annotated[ + EmailStr, + Body(description="Email address to unsubscribe", openapi_examples=NEWSLETTER_EMAIL_BODY_OPENAPI_EXAMPLES), + ], + db: AsyncSessionDep, + background_tasks: BackgroundTasks, +) -> dict: + """Request to unsubscribe by sending an email with unsubscribe link.""" + return await _request_unsubscribe(db, email=email, background_tasks=background_tasks) @backend_router.post("/unsubscribe", status_code=204) -async def unsubscribe_with_token(token: Annotated[str, Body()], db: AsyncSessionDep) -> None: +async def unsubscribe_with_token( + token: Annotated[ + str, + Body( + description="Unsubscribe token from the email link", + openapi_examples=NEWSLETTER_TOKEN_BODY_OPENAPI_EXAMPLES, + ), + ], + db: AsyncSessionDep, +) -> None: """One-click unsubscribe from newsletter using a token.""" - # Verify the token - email = verify_jwt_token(token, JWTType.NEWSLETTER_UNSUBSCRIBE) - if not email: - raise HTTPException(status_code=400, detail="Invalid or expired unsubscribe link.") + await _unsubscribe_subscriber(db, token=token) - # Check if the email is subscribed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) - if not existing_subscriber: - raise HTTPException(status_code=404, detail="Subscriber not found.") +### Private router for user-specific newsletter preferences ## +private_router = APIRouter(prefix="/newsletter", dependencies=[Security(current_active_user)]) + + +@private_router.get("/me", response_model=NewsletterPreferenceRead) +async def get_newsletter_preference( + current_user: CurrentActiveUserDep, db: AsyncSessionDep +) -> NewsletterPreferenceRead: + """Return the logged-in user's newsletter preference.""" + return await _load_newsletter_preference(db, email=current_user.email) + - # Remove subscriber - await db.delete(existing_subscriber) - await db.commit() +@private_router.put("/me", response_model=NewsletterPreferenceRead) +async def update_newsletter_preference( + preference: NewsletterPreferenceUpdate, + current_user: CurrentActiveUserDep, + db: AsyncSessionDep, +) -> NewsletterPreferenceRead: + """Update the logged-in user's newsletter preference without email verification.""" + return await _set_newsletter_preference(db, email=current_user.email, subscribed=preference.subscribed) ### Admin router ### admin_router = APIRouter(prefix="/admin/newsletter", dependencies=[Security(current_active_superuser)]) -@admin_router.get("/subscribers", response_model=Sequence[NewsletterSubscriberRead]) -async def get_subscribers(db: AsyncSessionDep) -> Sequence[NewsletterSubscriber]: +@admin_router.get("/subscribers", response_model=Page[NewsletterSubscriberRead]) +async def get_subscribers(db: AsyncSessionDep) -> Page[NewsletterSubscriber]: """Get all newsletter subscribers. Only accessible by superusers.""" - subscribers = await db.exec(select(NewsletterSubscriber)) - return subscribers.all() + return await _page_subscribers(db) ### Router registration ### router = APIRouter() router.include_router(backend_router) +router.include_router(private_router) router.include_router(admin_router) diff --git a/backend/app/api/newsletter/schemas.py b/backend/app/api/newsletter/schemas.py index cacc5bd9..7754ee23 100644 --- a/backend/app/api/newsletter/schemas.py +++ b/backend/app/api/newsletter/schemas.py @@ -1,16 +1,45 @@ """DTO schemas for newsletter subscribers.""" -from pydantic import Field +from typing import Annotated -from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp +from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints + +from app.api.common.schemas.base import BaseCreateSchema, BaseUpdateSchema, UUIDIdReadSchemaWithTimeStamp +from app.api.newsletter.examples import ( + NEWSLETTER_PREFERENCE_READ_EXAMPLES, + NEWSLETTER_PREFERENCE_UPDATE_EXAMPLES, + NEWSLETTER_SUBSCRIBER_READ_EXAMPLES, +) from app.api.newsletter.models import NewsletterSubscriberBase class NewsletterSubscriberCreate(BaseCreateSchema, NewsletterSubscriberBase): """Create schema for newsletter subscribers.""" + email: Annotated[EmailStr, StringConstraints(strip_whitespace=True)] = Field() + -class NewsletterSubscriberRead(BaseReadSchemaWithTimeStamp, NewsletterSubscriberBase): +class NewsletterSubscriberRead(UUIDIdReadSchemaWithTimeStamp, NewsletterSubscriberBase): """Read schema for newsletter subscribers.""" + model_config = ConfigDict(json_schema_extra={"examples": NEWSLETTER_SUBSCRIBER_READ_EXAMPLES}) + + is_confirmed: bool = Field() + + +class NewsletterPreferenceRead(BaseModel): + """Read schema for a logged-in user's newsletter preference.""" + + model_config = ConfigDict(json_schema_extra={"examples": NEWSLETTER_PREFERENCE_READ_EXAMPLES}) + + email: EmailStr = Field() + subscribed: bool = Field() is_confirmed: bool = Field() + + +class NewsletterPreferenceUpdate(BaseUpdateSchema): + """Update schema for a logged-in user's newsletter preference.""" + + model_config = ConfigDict(json_schema_extra={"examples": NEWSLETTER_PREFERENCE_UPDATE_EXAMPLES}) + + subscribed: bool = Field() diff --git a/backend/app/api/newsletter/utils/emails.py b/backend/app/api/newsletter/utils/emails.py index 32b44597..3f0eeddb 100644 --- a/backend/app/api/newsletter/utils/emails.py +++ b/backend/app/api/newsletter/utils/emails.py @@ -1,71 +1,72 @@ """Email sending utilities for the newsletter service.""" -from app.api.auth.utils.programmatic_emails import TextContentType, generate_token_link, send_email +from fastapi import BackgroundTasks +from pydantic import EmailStr + +from app.api.auth.services.emails import generate_token_link, send_email_with_template from app.api.newsletter.utils.tokens import JWTType, create_jwt_token +from app.core.config import settings as core_settings -async def send_newsletter_subscription_email(to_email: str, token: str) -> None: +async def send_newsletter_subscription_email( + to_email: EmailStr, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a newsletter subscription email.""" subject = "Reverse Engineering Lab: Confirm Your Newsletter Subscription" - # TODO: Dynamically generate the confirmation link based on the frontend URL tree - # Alternatively, send the frontend-side link to the backend as a parameter - confirmation_link = generate_token_link(token, "newsletter/confirm") - - body = f""" -Hello, - -Thank you for subscribing to the Reverse Engineering Lab newsletter! - -Please confirm your subscription by clicking [here]({confirmation_link}). - -This link will expire in 24 hours. - -We'll keep you updated with our progress and let you know when the full application is launched. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN) - - -async def send_newsletter(to_email: str, subject: str, content: str) -> None: - """Send newsletter with proper unsubscribe headers.""" + confirmation_link = generate_token_link(token, "newsletter/confirm", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter_subscription.html", + template_body={ + "confirmation_link": confirmation_link, + }, + background_tasks=background_tasks, + ) + + +async def send_newsletter( + to_email: EmailStr, + subject: str, + content: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send newsletter with proper unsubscribe link.""" # Create unsubscribe token and link token = create_jwt_token(to_email, JWTType.NEWSLETTER_UNSUBSCRIBE) - unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe") - - # Add footer with unsubscribe link - body = f""" - {content} - ---- -You're receiving this email because you subscribed to the Reverse Engineering Lab newsletter. -To unsubscribe, click [here]({unsubscribe_link}) - """ - - # Add List-Unsubscribe header for email clients that support it - headers = {"List-Unsubscribe": f"<{unsubscribe_link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"} - - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN, headers=headers) - - -async def send_newsletter_unsubscription_request_email(to_email: str, token: str) -> None: + unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter.html", + template_body={ + "subject": subject, + "content": content, + "unsubscribe_link": unsubscribe_link, + }, + background_tasks=background_tasks, + ) + + +async def send_newsletter_unsubscription_request_email( + to_email: EmailStr, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send an email with unsubscribe link.""" subject = "Reverse Engineering Lab: Unsubscribe Request" - unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe") - - body = f""" -Hello, - -We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter. - -If you made this request, please click [here]({unsubscribe_link}) to unsubscribe. - -If you did not request to unsubscribe, you can safely ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN) + unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter_unsubscribe.html", + template_body={ + "unsubscribe_link": unsubscribe_link, + }, + background_tasks=background_tasks, + ) diff --git a/backend/app/api/newsletter/utils/tokens.py b/backend/app/api/newsletter/utils/tokens.py index 04ba19cd..be57b9ec 100644 --- a/backend/app/api/newsletter/utils/tokens.py +++ b/backend/app/api/newsletter/utils/tokens.py @@ -1,16 +1,18 @@ """Service for creating and verifying JWT tokens for newsletter confirmation.""" from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import StrEnum import jwt +from pydantic import SecretStr from app.api.auth.config import settings ALGORITHM = "HS256" # Algorithm used for JWT encoding/decoding +SECRET: SecretStr = settings.newsletter_secret -class JWTType(str, Enum): +class JWTType(StrEnum): """Enum for different newsletter-related JWT types.""" NEWSLETTER_CONFIRMATION = "newsletter_confirmation" @@ -33,15 +35,15 @@ def create_jwt_token(email: str, token_type: JWTType) -> str: """Create a JWT token for newsletter confirmation.""" expiration = datetime.now(UTC) + timedelta(seconds=token_type.expiration_seconds) payload = {"sub": email, "exp": expiration, "type": token_type.value} - return jwt.encode(payload, settings.newsletter_secret, algorithm=ALGORITHM) + return jwt.encode(payload, SECRET.get_secret_value(), algorithm=ALGORITHM) def verify_jwt_token(token: str, expected_token_type: JWTType) -> str | None: """Verify the JWT token and return the email if valid.""" try: - payload = jwt.decode(token, settings.newsletter_secret, algorithms=[ALGORITHM]) + payload = jwt.decode(token, SECRET.get_secret_value(), algorithms=[ALGORITHM]) if payload["type"] != expected_token_type.value: return None return payload["sub"] # Returns the email address from the token - except (jwt.PyJWTError, KeyError): + except jwt.PyJWTError, KeyError: return None diff --git a/backend/app/api/plugins/rpi_cam/config.py b/backend/app/api/plugins/rpi_cam/config.py index 4d407232..242acec0 100644 --- a/backend/app/api/plugins/rpi_cam/config.py +++ b/backend/app/api/plugins/rpi_cam/config.py @@ -1,24 +1,30 @@ """Configuration for the Raspberry Pi Camera plugin.""" -from pathlib import Path +from cryptography.fernet import Fernet +from pydantic import model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.config.models import Environment +from app.core.env import RelabBaseSettings -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[4]).resolve() - -class RPiCamSettings(BaseSettings): +class RPiCamSettings(RelabBaseSettings): """Settings class to store settings related to the Raspberry Pi Camera plugin.""" - # Authentication settings + environment: Environment = Environment.DEV rpi_cam_plugin_secret: str = "" - # Initialize the settings configuration from the .env file - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") + @model_validator(mode="after") + def validate_plugin_secret(self) -> RPiCamSettings: + """Require a valid Fernet key whenever the plugin may be active.""" + if self.rpi_cam_plugin_secret: + Fernet(self.rpi_cam_plugin_secret.encode()) + return self + + if self.environment in (Environment.STAGING, Environment.PROD): + msg = "RPI_CAM_PLUGIN_SECRET must not be empty in production/staging" + raise ValueError(msg) - api_key_header_name: str = "X-API-Key" + return self -# Create a settings instance that can be imported throughout the app settings = RPiCamSettings() diff --git a/backend/app/api/plugins/rpi_cam/constants.py b/backend/app/api/plugins/rpi_cam/constants.py new file mode 100644 index 00000000..4ab1d1b6 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/constants.py @@ -0,0 +1,19 @@ +"""Shared constants for backend interaction with the device-side RPi camera plugin.""" + +from enum import StrEnum + +PLUGIN_CAMERA_STATUS_ENDPOINT = "/camera" +PLUGIN_STREAM_ENDPOINT = "/streams/youtube" +PLUGIN_IMAGES_ENDPOINT = "/captures" + + +class HttpMethod(StrEnum): + """HTTP method type used by camera interaction helpers.""" + + GET = "GET" + OPTIONS = "OPTIONS" + HEAD = "HEAD" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" diff --git a/backend/app/api/plugins/rpi_cam/crud.py b/backend/app/api/plugins/rpi_cam/crud.py index f682c058..d3094f2b 100644 --- a/backend/app/api/plugins/rpi_cam/crud.py +++ b/backend/app/api/plugins/rpi_cam/crud.py @@ -1,74 +1,38 @@ """CRUD operations for the Raspberry Pi Camera plugin.""" from pydantic import UUID4 -from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession -from app.api.common.utils import get_user_owned_object +from app.api.common.crud.persistence import commit_and_refresh, update_and_commit from app.api.plugins.rpi_cam.models import Camera from app.api.plugins.rpi_cam.schemas import CameraCreate, CameraUpdate -from app.api.plugins.rpi_cam.utils.encryption import encrypt_str, generate_api_key -### CRUD Operations ### async def create_camera(db: AsyncSession, camera: CameraCreate, owner_id: UUID4) -> Camera: - """Create a new camera in the database.""" - # Generate api key - api_key = generate_api_key() - - # Extract camera data and auth headers + """Create a new WebSocket-relayed camera in the database.""" camera_data = camera.model_dump(exclude_unset=True) - auth_header_dict = camera_data.pop("auth_headers", None) - - # Create camera + public_key = camera_data.pop("relay_public_key_jwk") db_camera = Camera( **camera_data, owner_id=owner_id, - encrypted_api_key=encrypt_str(api_key), + relay_public_key_jwk=public_key, ) + return await commit_and_refresh(db, db_camera) - # Add additional auth headers if provided - if auth_header_dict: - db_camera.set_auth_headers(auth_header_dict) - - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) - return db_camera - - -async def update_camera(db: AsyncSession, db_camera: Camera, camera_in: CameraUpdate) -> Camera: - """Update an existing camera in the database.""" - # Extract camera data and auth headers +async def update_camera( + db: AsyncSession, + db_camera: Camera, + camera_in: CameraUpdate, + *, + new_owner_id: UUID4 | None = None, +) -> Camera: + """Update an existing camera.""" camera_data = camera_in.model_dump(exclude_unset=True) - auth_header_dict = camera_data.pop("auth_headers", None) - - db_camera.sqlmodel_update(camera_data) - - # Update auth headers if provided - if auth_header_dict: - db_camera.set_auth_headers(auth_header_dict) - - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) - return db_camera - - -async def regenerate_camera_api_key(db: AsyncSession, camera_id: UUID4, owner_id: UUID4) -> Camera: - """Regenerate API key for an existing camera.""" - # Validate ownership - db_camera = await get_user_owned_object(db, Camera, camera_id, owner_id) - - # Generate and encrypt new API key - new_api_key = generate_api_key() - db_camera.encrypted_api_key = encrypt_str(new_api_key) + camera_data.pop("owner_id", None) - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) + if new_owner_id is not None: + db_camera.owner_id = new_owner_id - return db_camera + camera_in_without_owner = CameraUpdate.model_validate(camera_data) + return await update_and_commit(db, db_camera, camera_in_without_owner) diff --git a/backend/app/api/plugins/rpi_cam/dependencies.py b/backend/app/api/plugins/rpi_cam/dependencies.py index 39d05d71..1d206cb6 100644 --- a/backend/app/api/plugins/rpi_cam/dependencies.py +++ b/backend/app/api/plugins/rpi_cam/dependencies.py @@ -7,14 +7,28 @@ from pydantic import UUID4 from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.auth.exceptions import UserHasNoOrgError, UserIsNotMemberError +from app.api.auth.models import User +from app.api.common.crud.query import require_model +from app.api.common.ownership import get_user_owned_object from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.utils import get_user_owned_object +from app.api.plugins.rpi_cam.exceptions import InvalidCameraOwnershipTransferError from app.api.plugins.rpi_cam.models import Camera -from app.api.plugins.rpi_cam.schemas import CameraFilter, CameraFilterWithOwner +from app.api.plugins.rpi_cam.schemas import CameraFilter, CameraFilterWithOwner, CameraUpdate ### FastAPI-Filters ### CameraFilterDep = Annotated[CameraFilter, FilterDepends(CameraFilter)] CameraFilterWithOwnerDep = Annotated[CameraFilterWithOwner, FilterDepends(CameraFilterWithOwner)] +OWNER_ID_FIELD = "owner_id" + + +### Camera Lookup Dependencies ### +async def get_camera_by_id(camera_id: UUID4, session: AsyncSessionDep) -> Camera: + """Retrieve a camera by ID.""" + return await require_model(session, Camera, camera_id) + + +CameraByIDDep = Annotated[Camera, Depends(get_camera_by_id)] ### Ownership Dependencies ### @@ -24,8 +38,42 @@ async def get_user_owned_camera( current_user: CurrentActiveUserDep, ) -> Camera: """Dependency function to retrieve a camera by ID and ensure it's owned by the current user.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - return db_camera + return await get_user_owned_object(session, Camera, camera_id, current_user.id) UserOwnedCameraDep = Annotated[Camera, Depends(get_user_owned_camera)] + + +async def get_camera_transfer_owner_id( + camera_in: CameraUpdate, + db_camera: UserOwnedCameraDep, + session: AsyncSessionDep, +) -> UUID4 | None: + """Validate ownership transfer requests and return the resolved owner ID.""" + if OWNER_ID_FIELD not in camera_in.model_fields_set: + return None + + new_owner_id = camera_in.owner_id + if new_owner_id is None: + raise InvalidCameraOwnershipTransferError + + current_owner = await require_model(session, User, db_camera.owner_id) + new_owner = await require_model(session, User, new_owner_id) + + if current_owner.id != new_owner.id: + if current_owner.organization_id is None: + raise UserHasNoOrgError( + user_id=current_owner.id, + details="Camera ownership can only be transferred within the same organization.", + ) + if new_owner.organization_id != current_owner.organization_id: + raise UserIsNotMemberError( + user_id=new_owner.id, + organization_id=current_owner.organization_id, + details="Camera ownership can only be transferred within the same organization.", + ) + + return new_owner_id + + +CameraTransferOwnerIDDep = Annotated[UUID4 | None, Depends(get_camera_transfer_owner_id)] diff --git a/backend/app/api/plugins/rpi_cam/device_assertion.py b/backend/app/api/plugins/rpi_cam/device_assertion.py new file mode 100644 index 00000000..4eb034ec --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/device_assertion.py @@ -0,0 +1,140 @@ +"""Shared device-assertion verification used by the WebSocket relay and HTTP endpoints. + +Both transports accept the same ES256 JWT minted by the Pi (``build_device_assertion`` +in ``relab-rpi-cam-plugin/app/utils/device_jwt.py``): the audience is shared, the +replay-protection namespace in Redis is shared, and the verification logic is +therefore identical. Keeping it in one place prevents drift between the two +code paths. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Annotated, Any + +import jwt +from fastapi import Depends, HTTPException, Request, status +from jwt import InvalidTokenError, PyJWK +from pydantic import UUID4 + +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.plugins.rpi_cam.models import Camera +from app.core.logging import sanitize_log_value +from app.core.runtime import get_connection_redis + +if TYPE_CHECKING: + from collections.abc import Mapping + + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +ASSERTION_AUDIENCE = "relab-rpi-cam-relay" +ASSERTION_ALGORITHMS = ("ES256",) +REPLAY_KEY_PREFIX = "rpi_cam:relay_assertion_jti:" +MAX_ASSERTION_TTL_SECONDS = 5 * 60 + + +async def verify_device_assertion(assertion: str, camera: Camera, redis: Redis) -> dict[str, Any]: + """Validate a device assertion against a camera's stored credential. + + Raises ``InvalidTokenError`` on any check failure. Returns the decoded JWT + payload (with the ``kid`` header appended) on success. Replay protection is + enforced via a one-shot Redis ``SET NX`` keyed by ``{camera.id}:{jti}``. + """ + header = jwt.get_unverified_header(assertion) + if header.get("alg") not in ASSERTION_ALGORITHMS: + msg = "Unsupported assertion algorithm" + raise InvalidTokenError(msg) + if header.get("kid") != camera.relay_key_id: + msg_0 = "Assertion key id does not match camera credential" + raise InvalidTokenError(msg_0) + + public_key = PyJWK.from_dict(camera.relay_public_key_jwk).key + payload = jwt.decode( + assertion, + key=public_key, + algorithms=list(ASSERTION_ALGORITHMS), + audience=ASSERTION_AUDIENCE, + options={"require": ["exp", "iat", "nbf", "jti", "sub"]}, + ) + expected_subject = f"camera:{camera.id}" + if payload.get("sub") != expected_subject: + msg_1 = "Assertion subject does not match camera" + raise InvalidTokenError(msg_1) + + jti = str(payload.get("jti") or "") + if not jti: + msg_2 = "Missing assertion id" + raise InvalidTokenError(msg_2) + ttl = _assertion_replay_ttl(payload) + was_set = await redis.set(f"{REPLAY_KEY_PREFIX}{camera.id}:{jti}", "1", ex=ttl, nx=True) + if not was_set: + msg_3 = "Assertion replay detected" + raise InvalidTokenError(msg_3) + payload["kid"] = header.get("kid") + return payload + + +def _assertion_replay_ttl(payload: Mapping[str, Any]) -> int: + exp = int(payload["exp"]) + now = int(datetime.now(UTC).timestamp()) + return max(1, min(exp - now, MAX_ASSERTION_TTL_SECONDS)) + + +async def _extract_bearer(request: Request) -> str: + raw_auth = request.headers.get("Authorization", "") + token = raw_auth.removeprefix("Bearer ").strip() + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token + + +async def _authenticated_camera( + request: Request, + camera_id: UUID4, + session: AsyncSessionDep, +) -> Camera: + """FastAPI dependency: resolve a Camera from the path param + validate its bearer token.""" + assertion = await _extract_bearer(request) + camera: Camera | None = await session.get(Camera, camera_id) + if camera is None or not camera.credential_is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed.") + + redis = get_connection_redis(request) + if redis is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Authentication service unavailable.", + ) + + try: + await verify_device_assertion(assertion, camera, redis) + except InvalidTokenError as exc: + logger.warning( + "Camera %s HTTP assertion rejected: %s", + sanitize_log_value(camera_id), + sanitize_log_value(str(exc)), + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed.") from exc + return camera + + +AuthenticatedCameraDep = Annotated[Camera, Depends(_authenticated_camera)] +"""FastAPI dep that yields the Camera row matching ``camera_id`` after verifying +the Bearer device assertion. Use on any endpoint the Pi itself calls.""" + + +__all__ = [ + "ASSERTION_ALGORITHMS", + "ASSERTION_AUDIENCE", + "MAX_ASSERTION_TTL_SECONDS", + "REPLAY_KEY_PREFIX", + "AuthenticatedCameraDep", + "verify_device_assertion", +] diff --git a/backend/app/api/plugins/rpi_cam/examples.py b/backend/app/api/plugins/rpi_cam/examples.py new file mode 100644 index 00000000..c986cefe --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/examples.py @@ -0,0 +1,93 @@ +"""Centralized OpenAPI examples for the Raspberry Pi Camera plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.common.openapi_examples import openapi_example, openapi_examples + +if TYPE_CHECKING: + from fastapi.openapi.models import Example + + +INCLUDE_STATUS_DISABLED = False +INCLUDE_STATUS_ENABLED = True +FORCE_REFRESH_DISABLED = False +FORCE_REFRESH_ENABLED = True + + +CAMERA_CREATE_EXAMPLES = [ + { + "name": "Workbench Camera", + "description": "Ceiling-mounted camera above the teardown bench", + "relay_public_key_jwk": { + "kty": "EC", + "crv": "P-256", + "x": "base64url-x-coordinate", + "y": "base64url-y-coordinate", + "kid": "cam-key-1", + }, + "relay_key_id": "cam-key-1", + } +] + +CAMERA_READ_EXAMPLES = [ + { + "id": "12345678-cc4e-405c-8553-7806424de2a1", + "name": "Workbench Camera", + "description": "Ceiling-mounted camera above the teardown bench", + "owner_id": "87654321-cc4e-405c-8553-7806424de2a1", + "relay_key_id": "cam-key-1", + "relay_credential_status": "active", + "relay_last_seen_at": None, + } +] + +CAMERA_UPDATE_EXAMPLES = [ + { + "description": "Camera assigned to the repairability bench", + } +] + +CAMERA_INCLUDE_STATUS_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + disabled=openapi_example(INCLUDE_STATUS_DISABLED, summary="Return camera metadata only"), + enabled=openapi_example(INCLUDE_STATUS_ENABLED, summary="Include current online status"), +) + +CAMERA_FORCE_REFRESH_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + cached=openapi_example(FORCE_REFRESH_DISABLED, summary="Use cached status when available"), + refresh=openapi_example(FORCE_REFRESH_ENABLED, summary="Bypass cache and query the camera directly"), +) + +CAMERA_MODE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + photo=openapi_example("photo", summary="Initialize the camera for still capture"), + video=openapi_example("video", summary="Initialize the camera for streaming or recording"), +) + +CAMERA_CAPTURE_IMAGE_PRODUCT_ID_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + product_id=openapi_example(42, summary="Associate the captured image with a product") +) + +CAMERA_CAPTURE_IMAGE_DESCRIPTION_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + image_description=openapi_example("Top-down capture from the first dismantling step", summary="Custom caption") +) + +CAMERA_START_RECORDING_PRODUCT_ID_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + product_id=openapi_example(42, summary="Associate the recording with a product") +) + +CAMERA_START_RECORDING_TITLE_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + custom_title=openapi_example("Vacuum cleaner teardown recording", summary="Custom YouTube title") +) + +CAMERA_START_RECORDING_DESCRIPTION_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + custom_description=openapi_example( + "Full teardown recording for the repairability workflow.", + summary="Custom YouTube description", + ) +) + +CAMERA_START_RECORDING_PRIVACY_OPENAPI_EXAMPLES: dict[str, Example] = openapi_examples( + private=openapi_example("private", summary="Only visible to the authenticated account"), + unlisted=openapi_example("unlisted", summary="Anyone with the link can watch"), +) diff --git a/backend/app/api/plugins/rpi_cam/exceptions.py b/backend/app/api/plugins/rpi_cam/exceptions.py new file mode 100644 index 00000000..42f51323 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/exceptions.py @@ -0,0 +1,106 @@ +"""Custom exceptions for the Raspberry Pi camera plugin.""" + +from app.api.common.exceptions import ( + BadRequestError, + ConflictError, + FailedDependencyError, + ForbiddenError, + NotFoundError, + ServiceUnavailableError, +) + + +class RecordingSessionStoreError(ServiceUnavailableError): + """Raised when a YouTube recording session cannot be persisted.""" + + def __init__(self) -> None: + super().__init__( + "Failed to store YouTube recording session in Redis.", + ) + + +class RecordingSessionNotFoundError(ConflictError): + """Raised when no cached YouTube recording session exists for a camera.""" + + def __init__(self) -> None: + super().__init__("No cached YouTube recording session found for this camera.") + + +class InvalidRecordingSessionDataError(BadRequestError): + """Raised when cached recording session data cannot be validated.""" + + def __init__(self, details: str) -> None: + super().__init__("Invalid recording session data.", details=details) + + +class GoogleOAuthAssociationRequiredError(ForbiddenError): + """Raised when a user tries to use YouTube features without linking Google OAuth first.""" + + def __init__(self) -> None: + super().__init__( + "Google OAuth account association required for YouTube streaming. " + "Use /api/auth/oauth/google/associate/authorize." + ) + + +class InvalidCameraResponseError(FailedDependencyError): + """Raised when the camera returns a payload that does not match the expected schema.""" + + def __init__(self, details: str) -> None: + super().__init__( + "Invalid response from camera.", + details=details, + log_message=f"Invalid response from camera: {details}", + ) + + +class NoActiveYouTubeRecordingError(ConflictError): + """Raised when monitor/stop actions require an active YouTube recording.""" + + def __init__(self) -> None: + super().__init__("No active YouTube recording found for this camera.") + + +class CameraProxyRequestError(ServiceUnavailableError): + """Raised when the backend cannot reach the camera over HTTP.""" + + def __init__(self, endpoint: str, details: str) -> None: + super().__init__(f"Network error contacting camera: {endpoint}", details=details) + + +class InvalidCameraOwnershipTransferError(BadRequestError): + """Raised when a camera ownership transfer payload is invalid.""" + + def __init__(self) -> None: + super().__init__("owner_id must reference an existing user in the same organization.") + + +# ── Pairing exceptions ─────────────────────────────────────────────────────── + + +class PairingCodeCollisionError(ConflictError): + """Raised when a pairing code is already registered.""" + + def __init__(self) -> None: + super().__init__("Pairing code already in use. Generate a new one.") + + +class PairingCodeNotFoundError(NotFoundError): + """Raised when a pairing code does not exist or has expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired pairing code.") + + +class PairingCodeAlreadyClaimedError(ConflictError): + """Raised when a pairing code has already been claimed by a user.""" + + def __init__(self) -> None: + super().__init__("This pairing code has already been claimed.") + + +class PairingFingerprintMismatchError(ForbiddenError): + """Raised when the fingerprint does not match the registered pairing code.""" + + def __init__(self) -> None: + super().__init__("Fingerprint mismatch.") diff --git a/backend/app/api/plugins/rpi_cam/models.py b/backend/app/api/plugins/rpi_cam/models.py index 974fab6f..0f1c10eb 100644 --- a/backend/app/api/plugins/rpi_cam/models.py +++ b/backend/app/api/plugins/rpi_cam/models.py @@ -1,36 +1,41 @@ """Database models for the Raspberry Pi Camera plugin.""" +# spell-checker: ignore ondelete + +from __future__ import annotations import uuid -from enum import Enum -from functools import cached_property -from typing import TYPE_CHECKING -from urllib.parse import urljoin - -import httpx -from asyncache import cached -from cachetools import TTLCache -from pydantic import UUID4, BaseModel, HttpUrl, computed_field +from datetime import datetime +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from pydantic import UUID4, BaseModel from relab_rpi_cam_models.camera import CameraStatusView as CameraStatusDetails -from sqlmodel import AutoString, Field, Relationship +from sqlalchemy import Enum as SAEnum +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.api.common.models.base import CustomBase, TimeStampMixinBare -from app.api.common.models.custom_fields import HttpUrlInDB -from app.api.plugins.rpi_cam.config import settings -from app.api.plugins.rpi_cam.utils.encryption import decrypt_dict, decrypt_str, encrypt_dict +from app.api.common.models.base import Base, TimeStampMixinBare if TYPE_CHECKING: from app.api.auth.models import User -### Utility models ### -class CameraConnectionStatus(str, Enum): +class CameraCredentialStatus(StrEnum): + """Status of the camera's relay device credential.""" + + ACTIVE = "active" + REVOKED = "revoked" + + +class CameraConnectionStatus(StrEnum): """Camera connection status.""" ONLINE = "online" OFFLINE = "offline" - UNAUTHORIZED = "unauthorized" # Camera is online but user is unauthorized - FORBIDDEN = "forbidden" # Camera is online but user is forbidden - ERROR = "error" # Camera is online but there is another error + UNAUTHORIZED = "unauthorized" + FORBIDDEN = "forbidden" + ERROR = "error" def to_http_error(self) -> tuple[int, str]: """Get appropriate HTTP status code and message for non-online status.""" @@ -50,99 +55,69 @@ def to_http_error(self) -> tuple[int, str]: class CameraStatus(BaseModel): """Camera connection status and details.""" - connection: CameraConnectionStatus = Field(description="Connection status of the camera") - - # TODO: Publish the plugin as a separate package and import the status details schema from there - details: CameraStatusDetails | None = Field( - default=None, description="Additional status details from the Raspberry Pi camera API" - ) - - -### RpiCam Model ### -class CameraBase(CustomBase): - """Base model for Camera with common fields.""" - - name: str = Field(index=True, min_length=2, max_length=100) - description: str | None = Field(default=None, max_length=500) + connection: CameraConnectionStatus + last_seen_at: datetime | None = None + details: CameraStatusDetails | None = None - # NOTE: Local addresses only work when they are on the local network of this API - # TODO: Add support for server communication to local network cameras for users via websocket or similar - # NOTE: Database models will have url as string type. This is likely because of how sa_type=Autostring works - # This means HttpUrl methods are not available in database model instances. - # TODO: Only validate the URL format in Pydantic schemas and store as plain string in the database model. - url: HttpUrlInDB = Field(description="HTTP(S) URL where the camera API is hosted", sa_type=AutoString) +class CameraBase(BaseModel): + """Base schema for Camera. Used by Pydantic schemas only, not ORM.""" + name: str + description: str | None = None -class Camera(CameraBase, TimeStampMixinBare, table=True): - """Database model for Camera.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) - encrypted_api_key: str = Field(nullable=False) - # TODO: Consider merging encrypted_auth_headers and encrypted_api_key into a single encrypted_credentials field - encrypted_auth_headers: str | None = Field(default=None) +class Camera(TimeStampMixinBare, Base): + """Database model for a WebSocket-relayed Raspberry Pi camera.""" - # Many-to-one relationship with User - owner_id: UUID4 = Field(foreign_key="user.id") - owner: "User" = Relationship() # One-way relationship to maintain plugin isolation + __tablename__ = "camera" - @computed_field - @cached_property - def auth_headers(self) -> dict[str, str]: - """Get all authentication headers including server-generated x-api-key.""" - headers = {settings.api_key_header_name: decrypt_str(self.encrypted_api_key)} - if self.encrypted_auth_headers: - headers.update(self._decrypt_auth_headers()) - return headers + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(100), index=True) + description: Mapped[str | None] = mapped_column(String(500), default=None) - def _decrypt_auth_headers(self) -> dict[str, str]: - """Decrypt additional auth headers.""" - return {} if not self.encrypted_auth_headers else decrypt_dict(self.encrypted_auth_headers) + relay_public_key_jwk: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + relay_key_id: Mapped[str] = mapped_column(String(64), nullable=False) + relay_credential_status: Mapped[CameraCredentialStatus] = mapped_column( + SAEnum(CameraCredentialStatus), + nullable=False, + default=CameraCredentialStatus.ACTIVE, + server_default=CameraCredentialStatus.ACTIVE.name, + ) - def set_auth_headers(self, headers: dict[str, str]) -> None: - """Encrypt and store additional auth headers.""" - self.encrypted_auth_headers = encrypt_dict(headers) + owner_id: Mapped[UUID4] = mapped_column(ForeignKey("user.id")) + owner: Mapped[User] = relationship( + primaryjoin="Camera.owner_id == User.id", + foreign_keys="[Camera.owner_id]", + ) - @computed_field - @cached_property - def verify_ssl(self) -> bool: - """Whether to verify SSL certificates based on URL scheme.""" - return HttpUrl(self.url).scheme == "https" + @property + def credential_is_active(self) -> bool: + """Return whether this camera can authenticate to the relay.""" + return self.relay_credential_status == CameraCredentialStatus.ACTIVE def __hash__(self) -> int: """Make Camera instances hashable using their id. Used for caching.""" return hash(self.id) - async def get_status(self, *, force_refresh: bool = False) -> CameraStatus: - if force_refresh: - return await self._fetch_status() - - return await self._get_cached_status() - - @cached(cache=TTLCache(maxsize=1, ttl=15)) - async def _get_cached_status(self) -> CameraStatus: - """Cached version of status fetch.""" - return await self._fetch_status() - - async def _fetch_status(self) -> CameraStatus: - status_url = urljoin(str(self.url), "/camera/status") - - async with httpx.AsyncClient(timeout=2.0, verify=self.verify_ssl) as client: - try: - response = await client.get(status_url, headers=self.auth_headers) - match response.status_code: - case 200: - return CameraStatus( - connection=CameraConnectionStatus.ONLINE, details=CameraStatusDetails(**response.json()) - ) - case 401: - return CameraStatus(connection=CameraConnectionStatus.UNAUTHORIZED, details=None) - case 403: - return CameraStatus(connection=CameraConnectionStatus.FORBIDDEN, details=None) - except httpx.RequestError: - return CameraStatus(connection=CameraConnectionStatus.OFFLINE, details=None) - else: - return CameraStatus(connection=CameraConnectionStatus.ERROR, details=None) - def __str__(self) -> str: return f"{self.name} (id: {self.id})" + + +class RecordingSession(TimeStampMixinBare, Base): + """Durable backstop for an in-progress YouTube recording.""" + + __tablename__ = "recording_session" + + camera_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("camera.id", ondelete="CASCADE"), primary_key=True) + product_id: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str] = mapped_column(String, nullable=False) + stream_url: Mapped[str] = mapped_column(String, nullable=False) + broadcast_key: Mapped[str] = mapped_column(String, nullable=False) + video_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None) + + camera: Mapped[Camera] = relationship( + primaryjoin="RecordingSession.camera_id == Camera.id", + foreign_keys="[RecordingSession.camera_id]", + ) diff --git a/backend/app/api/plugins/rpi_cam/routers/__init__.py b/backend/app/api/plugins/rpi_cam/routers/__init__.py index e69de29b..26a52748 100644 --- a/backend/app/api/plugins/rpi_cam/routers/__init__.py +++ b/backend/app/api/plugins/rpi_cam/routers/__init__.py @@ -0,0 +1,17 @@ +"""Routers for the Raspberry Pi Camera plugin.""" + +from fastapi import APIRouter + +from app.api.plugins.rpi_cam.routers.admin import router as admin_router +from app.api.plugins.rpi_cam.routers.camera_crud import router as public_crud_router +from app.api.plugins.rpi_cam.routers.camera_interaction import router as user_interact_router +from app.api.plugins.rpi_cam.routers.pairing import router as pairing_router +from app.api.plugins.rpi_cam.websocket.router import router as ws_router + +router = APIRouter() + +router.include_router(public_crud_router) +router.include_router(user_interact_router) +router.include_router(pairing_router) +router.include_router(admin_router) +router.include_router(ws_router) diff --git a/backend/app/api/plugins/rpi_cam/routers/admin.py b/backend/app/api/plugins/rpi_cam/routers/admin.py index dc69d431..d88345a5 100644 --- a/backend/app/api/plugins/rpi_cam/routers/admin.py +++ b/backend/app/api/plugins/rpi_cam/routers/admin.py @@ -1,22 +1,26 @@ """Routers for the Raspberry Pi Camera plugin.""" -from collections.abc import Sequence +import logging +from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, Path +from fastapi_pagination import Page from pydantic import UUID4 from app.api.auth.dependencies import current_active_superuser -from app.api.common.crud.base import get_model_by_id, get_models +from app.api.common.crud.query import page_models from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.plugins.rpi_cam import crud -from app.api.plugins.rpi_cam.dependencies import CameraFilterWithOwnerDep +from app.api.plugins.rpi_cam.dependencies import CameraByIDDep, CameraFilterWithOwnerDep from app.api.plugins.rpi_cam.models import Camera, CameraStatus +from app.api.plugins.rpi_cam.routers.camera_crud import _notify_camera_unpair from app.api.plugins.rpi_cam.schemas import CameraRead +from app.api.plugins.rpi_cam.services import get_camera_status as fetch_camera_status +from app.core.redis import OptionalRedisDep + +logger = logging.getLogger(__name__) ### Camera admin router ### -# TODO: Also make file and data-collection routers user-dependent and add admin routers for superusers -# TODO: write and implement generic get user_owned model dependency classes router = APIRouter( prefix="/admin/plugins/rpi-cam/cameras", @@ -28,39 +32,43 @@ ## GET ## @router.get( "", - response_model=list[CameraRead], + response_model=Page[CameraRead], summary="Get all Raspberry Pi cameras", ) async def get_all_cameras( session: AsyncSessionDep, camera_filter: CameraFilterWithOwnerDep, -) -> Sequence[Camera]: +) -> Page[Camera]: """Get all Raspberry Pi cameras.""" - return await get_models(session, Camera, model_filter=camera_filter) + return await page_models(session, Camera, filters=camera_filter, read_schema=CameraRead) @router.get("/{camera_id}", summary="Get Raspberry Pi camera by ID", response_model=CameraRead) -async def get_camera(camera_id: UUID4, session: AsyncSessionDep) -> Camera: +async def get_camera(_camera_id: Annotated[UUID4, Path(alias="camera_id")], camera: CameraByIDDep) -> Camera: """Get single Raspberry Pi camera by ID.""" - db_camera = await get_model_by_id(session, Camera, camera_id) - # TODO: Can we deduplicate these standard translations of exceptions to HTTP exceptions across the codebase? - - return db_camera + return camera @router.get("/{camera_id}/status", summary="Get Raspberry Pi camera online status") -async def get_camera_status(camera_id: UUID4, session: AsyncSessionDep) -> CameraStatus: +async def get_camera_status( + _camera_id: Annotated[UUID4, Path(alias="camera_id")], + camera: CameraByIDDep, + redis: OptionalRedisDep, +) -> CameraStatus: """Get Raspberry Pi camera online status.""" - db_camera = await get_model_by_id(session, Camera, camera_id) - - return await db_camera.get_status() + return await fetch_camera_status(redis, camera.id) ## DELETE @router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) async def delete_camera( - camera_id: UUID4, + _camera_id: Annotated[UUID4, Path(alias="camera_id")], + background_tasks: BackgroundTasks, session: AsyncSessionDep, + camera: CameraByIDDep, + redis: OptionalRedisDep, ) -> None: """Delete Raspberry Pi camera.""" - await crud.force_delete_camera(session, camera_id) + await session.delete(camera) + await session.commit() + background_tasks.add_task(_notify_camera_unpair, camera.id, redis) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_crud.py b/backend/app/api/plugins/rpi_cam/routers/camera_crud.py index bc42148f..09817724 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_crud.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_crud.py @@ -1,37 +1,48 @@ """Camera CRUD operations for Raspberry Pi Camera plugin.""" -from collections.abc import Sequence +import logging +from typing import TYPE_CHECKING -from fastapi import Query +from fastapi import BackgroundTasks, HTTPException, Query from pydantic import UUID4 -from sqlmodel import select +from relab_rpi_cam_models import LocalAccessInfo +from sqlalchemy import select from app.api.auth.dependencies import CurrentActiveUserDep -from app.api.common.crud.base import get_models +from app.api.common.crud.filtering import apply_filter from app.api.common.routers.dependencies import AsyncSessionDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.utils import get_user_owned_object from app.api.plugins.rpi_cam import crud -from app.api.plugins.rpi_cam.dependencies import CameraFilterDep, UserOwnedCameraDep -from app.api.plugins.rpi_cam.models import Camera, CameraStatus -from app.api.plugins.rpi_cam.schemas import ( - CameraCreate, - CameraRead, - CameraReadWithCredentials, - CameraReadWithStatus, - CameraUpdate, +from app.api.plugins.rpi_cam.dependencies import CameraFilterDep, CameraTransferOwnerIDDep, UserOwnedCameraDep +from app.api.plugins.rpi_cam.device_assertion import AuthenticatedCameraDep +from app.api.plugins.rpi_cam.examples import ( + CAMERA_INCLUDE_STATUS_OPENAPI_EXAMPLES, ) +from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus, CameraStatus +from app.api.plugins.rpi_cam.schemas import CameraCreate, CameraRead, CameraReadWithStatus, CameraUpdate +from app.api.plugins.rpi_cam.service_runtime import get_preview_thumbnail_path +from app.api.plugins.rpi_cam.services import ( + get_camera_status as fetch_camera_status, +) +from app.api.plugins.rpi_cam.services import ( + get_preview_thumbnail_urls_per_camera, +) +from app.api.plugins.rpi_cam.websocket.relay import relay_via_websocket +from app.core.redis import OptionalRedisDep + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) + from redis.asyncio import Redis -# TODO: Decide on proper path for user-dependent operations (e.g. cameras, organizations, etc.) -router = PublicAPIRouter(prefix="/plugins/rpi-cam/cameras", tags=["rpi-cam-management"]) +logger = logging.getLogger(__name__) +camera_router = PublicAPIRouter(tags=["rpi-cam-management"]) +router = PublicAPIRouter() -## GET ## -# TODO: Consider expanding get routes to cameras owned by any members of the organization of the user -@router.get( + +@camera_router.get( "", response_model=list[CameraRead] | list[CameraReadWithStatus], summary="Get Raspberry Pi cameras of the current user", @@ -40,96 +51,211 @@ async def get_user_cameras( session: AsyncSessionDep, current_user: CurrentActiveUserDep, camera_filter: CameraFilterDep, + redis: OptionalRedisDep, *, - include_status: bool = Query(default=False, description="Include camera online status"), + include_status: bool = Query( + default=False, + description="Include camera online status", + openapi_examples=CAMERA_INCLUDE_STATUS_OPENAPI_EXAMPLES, + ), + include_telemetry: bool = Query( + default=False, + description=( + "Include the last-known telemetry snapshot from the Redis cache. Implies " + "``include_status=true``. No relay round-trips — cameras without cached telemetry " + "come back with ``telemetry: null``." + ), + ), ) -> Sequence[Camera | CameraReadWithStatus]: """Get all Raspberry Pi cameras of the current user.""" statement = select(Camera).where(Camera.owner_id == current_user.id) - db_cameras = await get_models(session, Camera, model_filter=camera_filter, statement=statement) + statement = apply_filter(statement, Camera, camera_filter) + db_cameras = list((await session.execute(statement)).scalars().unique().all()) + + if not (include_status or include_telemetry): + return list(db_cameras) + + preview_thumbnail_urls: dict[UUID4, str | None] = {} + if include_telemetry: + preview_thumbnail_urls = get_preview_thumbnail_urls_per_camera([camera.id for camera in db_cameras]) return [ - await CameraReadWithStatus.from_db_model_with_status(camera) if include_status else camera + await CameraReadWithStatus.from_db_model_with_status( + camera, + redis, + include_telemetry=include_telemetry, + preview_thumbnail_url=preview_thumbnail_urls.get(camera.id), + ) for camera in db_cameras ] -@router.get("/{camera_id}", response_model=CameraRead | CameraReadWithStatus, summary="Get Raspberry Pi camera by ID") +@camera_router.get( + "/{camera_id}", + response_model=CameraRead | CameraReadWithStatus, + summary="Get Raspberry Pi camera by ID", +) async def get_user_camera( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, + db_camera: UserOwnedCameraDep, + redis: OptionalRedisDep, *, - include_status: bool = Query(default=False, description="Include camera online status"), + include_status: bool = Query( + default=False, + description="Include camera online status", + openapi_examples=CAMERA_INCLUDE_STATUS_OPENAPI_EXAMPLES, + ), + include_telemetry: bool = Query( + default=False, + description="Include last-known telemetry from the Redis cache. Implies ``include_status=true``.", + ), ) -> Camera | CameraReadWithStatus: """Get single Raspberry Pi camera by ID, if owned by the current user.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - - return await CameraReadWithStatus.from_db_model_with_status(db_camera) if include_status else db_camera + if not (include_status or include_telemetry): + return db_camera + preview_thumbnail_url: str | None = None + if include_telemetry: + preview_thumbnail_url = get_preview_thumbnail_urls_per_camera([db_camera.id]).get(db_camera.id) + return await CameraReadWithStatus.from_db_model_with_status( + db_camera, + redis, + include_telemetry=include_telemetry, + preview_thumbnail_url=preview_thumbnail_url, + ) -@router.get( +@camera_router.get( "/{camera_id}/status", summary="Get Raspberry Pi camera online status", ) async def get_user_camera_status( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, - *, - force_refresh: bool = Query(default=False, description="Force a refresh of the status by bypassing the cache"), + db_camera: UserOwnedCameraDep, + redis: OptionalRedisDep, ) -> CameraStatus: """Get Raspberry Pi camera online status.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - - return await db_camera.get_status(force_refresh=force_refresh) - - -## POST -@router.post("", response_model=CameraReadWithCredentials, summary="Register new Raspberry Pi camera", status_code=201) -async def register_user_camera( - camera: CameraCreate, session: AsyncSessionDep, current_user: CurrentActiveUserDep -) -> CameraReadWithCredentials: - """Register a new Raspberry Pi camera.""" - db_camera = await crud.create_camera( - session, - camera, - current_user.id, - ) - - return CameraReadWithCredentials.from_db_model_with_credentials(db_camera) + return await fetch_camera_status(redis, db_camera.id) -@router.post( - "/{camera_id}/regenerate-api-key", - response_model=CameraReadWithCredentials, - summary="Regenerate API key for the Raspberry Pi camera", +@camera_router.post( + "", + response_model=CameraRead, + summary="Register new Raspberry Pi camera", status_code=201, ) -async def regenerate_api_key( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, -) -> CameraReadWithCredentials: - """Regenerate API key for Raspberry Pi camera.""" - db_camera = await crud.regenerate_camera_api_key(session, camera_id, current_user.id) +async def register_user_camera( + camera: CameraCreate, session: AsyncSessionDep, current_user: CurrentActiveUserDep +) -> Camera: + """Register a new Raspberry Pi camera. - return CameraReadWithCredentials.from_db_model_with_credentials(db_camera) + The normal user flow is /plugins/rpi-cam/pairing/claim. This endpoint is + kept as a structured API surface for tests/admin automation and still + requires a public device key. + """ + return await crud.create_camera(session, camera, current_user.id) -## PATCH -@router.patch("/{camera_id}", response_model=CameraRead, summary="Update Raspberry Pi camera") +@camera_router.patch("/{camera_id}", response_model=CameraRead, summary="Update Raspberry Pi camera") async def update_user_camera( - *, session: AsyncSessionDep, db_camera: UserOwnedCameraDep, camera_in: CameraUpdate + *, + session: AsyncSessionDep, + db_camera: UserOwnedCameraDep, + camera_in: CameraUpdate, + transfer_owner_id: CameraTransferOwnerIDDep, ) -> Camera: """Update Raspberry Pi camera.""" - db_camera = await crud.update_camera(session, db_camera, camera_in) - - return db_camera + return await crud.update_camera(session, db_camera, camera_in, new_owner_id=transfer_owner_id) -## DELETE -@router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) -async def delete_user_camera(db: AsyncSessionDep, camera: UserOwnedCameraDep) -> None: +@camera_router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) +async def delete_user_camera( + background_tasks: BackgroundTasks, + db: AsyncSessionDep, + camera: UserOwnedCameraDep, + redis: OptionalRedisDep, +) -> None: """Delete Raspberry Pi camera.""" + preview_thumbnail_path = get_preview_thumbnail_path(camera.id) await db.delete(camera) await db.commit() + background_tasks.add_task(_notify_camera_unpair, camera.id, redis) + background_tasks.add_task(_remove_preview_thumbnail, preview_thumbnail_path) + + +@camera_router.delete( + "/{camera_id}/self", + summary="Pi-initiated self-unpair", + status_code=204, + description=( + "Called by the Pi when the user triggers unpair from the local /setup page. " + "Authenticates via the device's ES256 assertion. Deletes the camera from the " + "database so the backend no longer shows it as offline. Does NOT send a relay " + "message back to the Pi — the Pi is already unpairing itself." + ), +) +async def self_unpair_camera( + camera: AuthenticatedCameraDep, + db: AsyncSessionDep, +) -> None: + """Device-initiated self-deletion. Pi calls this on local unpair.""" + logger.info("Camera %s self-unpaired via device assertion", camera.id) + _remove_preview_thumbnail(get_preview_thumbnail_path(camera.id)) + await db.delete(camera) + await db.commit() + + +@camera_router.get( + "/{camera_id}/local-access", + response_model=LocalAccessInfo, + summary="Get local direct-connection info for a camera", + description=( + "Relays GET /system/local-access to the Pi via the WebSocket connection. " + "Returns the local API key and candidate IP addresses so the frontend can " + "auto-configure Ethernet/USB-C direct access without manual key copying. " + "Returns 503 when the camera is offline." + ), +) +async def get_camera_local_access( + db_camera: UserOwnedCameraDep, + redis: OptionalRedisDep, +) -> LocalAccessInfo: + """Relay local access info from the Pi to the authenticated frontend user.""" + response = await relay_via_websocket( + db_camera.id, + "GET", + "/system/local-access", + redis=redis, + error_msg="Could not retrieve local access info from camera", + ) + return LocalAccessInfo.model_validate(response.json()) + + +async def _notify_camera_unpair(camera_id: UUID4, redis: Redis | None) -> None: + """Best-effort relay of DELETE /pairing to the camera. + + Logs a warning and continues if the camera is offline or unresponsive — + deletion should never be blocked by camera connectivity. + """ + status = await fetch_camera_status(redis, camera_id) + if status.connection != CameraConnectionStatus.ONLINE: + logger.info("Skipping unpair relay for offline camera %s.", camera_id) + return + + try: + await relay_via_websocket(camera_id, "DELETE", "/pairing", redis=redis) + except HTTPException as exc: + logger.warning( + "Could not notify camera %s to unpair (HTTP %d) — deleting anyway.", + camera_id, + exc.status_code, + ) + + +def _remove_preview_thumbnail(path: Path) -> None: + """Best-effort cleanup of a camera's cached preview thumbnail file.""" + try: + path.unlink(missing_ok=True) + except OSError: + logger.warning("Could not remove preview thumbnail at %s", path) + + +router.include_router(camera_router, prefix="/plugins/rpi-cam/cameras") +router.include_router(camera_router, prefix="/users/me/cameras") diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py index e69de29b..610dd19e 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py @@ -0,0 +1,14 @@ +"""Main router for camera interaction.""" + +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.plugins.rpi_cam.routers.camera_interaction.hls import router as hls_router +from app.api.plugins.rpi_cam.routers.camera_interaction.images import router as images_router +from app.api.plugins.rpi_cam.routers.camera_interaction.streams import router as streams_router +from app.api.plugins.rpi_cam.routers.camera_interaction.telemetry import router as telemetry_router + +router = PublicAPIRouter(prefix="/plugins/rpi-cam/cameras", tags=["rpi-cam-interaction"]) + +router.include_router(images_router) +router.include_router(streams_router) +router.include_router(telemetry_router) +router.include_router(hls_router) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/hls.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/hls.py new file mode 100644 index 00000000..ed5dfcc0 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/hls.py @@ -0,0 +1,115 @@ +"""LL-HLS proxy for browser + native live preview. + +The browser/native video player asks for: + + GET /plugins/rpi-cam/cameras/{camera_id}/hls/cam-preview/index.m3u8 + +and the backend forwards it through the WebSocket relay to the Pi's own +``GET /preview/hls/{rest}`` endpoint, which proxies to its local MediaMTX LL-HLS +listener on port 8888. Segment fetches (``.mp4``) follow the same path and +come back as binary relay frames. The frontend constructs the URL itself from +the camera id; no signed-URL dance needed. + +The ``{rest}`` path catches both the playlist (``cam-preview/index.m3u8``) and +every segment/part URL the player dereferences (``cam-preview/segment0.mp4``, +``cam-preview/part0.mp4``, etc.) since LL-HLS resolves segments relative to +the playlist URL. +""" +# spell-checker: ignore muxer + +from __future__ import annotations + +import asyncio + +from fastapi import HTTPException, Response +from pydantic import UUID4 + +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.plugins.rpi_cam.constants import HttpMethod +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( + build_camera_request, + get_user_owned_camera, +) +from app.core.redis import OptionalRedisDep + +# Exponential backoff for LL-HLS manifest 404 retries. Totals ~7.75s; fast at the +# start for the hot path, longer tail for a cold MediaMTX warm-up. +_MANIFEST_RETRY_BACKOFF_S: tuple[float, ...] = (0.25, 0.5, 1.0, 2.0, 4.0) + +router = PublicAPIRouter() + + +@router.get( + "/{camera_id}/hls/{hls_path:path}", + summary="Proxy LL-HLS playlists and segments from the camera's MediaMTX", + description=( + "Forward an LL-HLS request to the camera over its WebSocket relay. " + "Playlist requests return ``application/vnd.apple.mpegurl`` text; " + "segment/part requests return binary ``video/mp4`` or ``video/iso.segment``. " + "The frontend simply points ``hls.js`` / ``expo-video`` at this URL and the " + "player walks the manifest on its own." + ), +) +async def proxy_hls( + camera_id: UUID4, + hls_path: str, + session: AsyncSessionDep, + current_user: CurrentActiveUserDep, + redis: OptionalRedisDep, +) -> Response: + """Proxy an LL-HLS URL through the camera's WebSocket relay.""" + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + + # MediaMTX creates the HLS muxer on first viewer but needs a few seconds + # to buffer the first segment before the playlist is valid. Retry 404s on + # manifest requests only — segments are never retried. Uses an exponential + # backoff (0.25 / 0.5 / 1.0 / 2.0 / 4.0 s, ~7.75s total) so the first attempts + # are snappy for the common "MediaMTX already warm" case while still giving + # a slow startup ~8 s headroom. + media_type = _resolve_media_type(hls_path) + is_manifest = hls_path.endswith(".m3u8") + max_attempts = len(_MANIFEST_RETRY_BACKOFF_S) + 1 if is_manifest else 1 + last_exc: HTTPException | None = None + for attempt in range(max_attempts): + if attempt: + await asyncio.sleep(_MANIFEST_RETRY_BACKOFF_S[attempt - 1]) + try: + relay_response = await camera_request( + endpoint=f"/preview/hls/{hls_path}", + method=HttpMethod.GET, + error_msg="Failed to fetch HLS data", + expect_binary=True, + ) + break + except HTTPException as exc: + if exc.status_code == 404 and attempt < max_attempts - 1: + last_exc = exc + continue + raise + else: + if last_exc is None: + raise HTTPException(status_code=404, detail="HLS manifest is not yet available") + raise last_exc + return Response( + content=relay_response.content, + media_type=media_type, + headers={ + # LL-HLS wants fresh data on every request — no caching at any + # intermediate layer. The player manages its own buffer. + "Cache-Control": "no-store", + }, + ) + + +def _resolve_media_type(hls_path: str) -> str: + """Map a MediaMTX LL-HLS path to its HTTP content type.""" + if hls_path.endswith(".m3u8"): + return "application/vnd.apple.mpegurl" + if hls_path.endswith(".mp4"): + return "video/mp4" + # MediaMTX also serves ``.m4s`` / raw fMP4 parts; fall back to a generic + # binary type so the player can still walk them. + return "application/octet-stream" diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py index 5143c484..5c57e3b3 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py @@ -1,25 +1,57 @@ """Routers for the Raspberry Pi Camera plugin.""" -from typing import Annotated +import contextlib +import json +import logging +from typing import TYPE_CHECKING, Annotated -from fastapi import Body +from fastapi import Body, File, Form, HTTPException, UploadFile from pydantic import UUID4, PositiveInt +from relab_rpi_cam_models import DeviceImageUploadAck, DevicePreviewThumbnailAck from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.common.crud.query import require_model from app.api.common.routers.dependencies import AsyncSessionDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.file_storage.models.models import Image -from app.api.file_storage.schemas import ImageRead -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import get_user_owned_camera +from app.api.data_collection.models.product import Product +from app.api.file_storage.crud.media_queries import create_image +from app.api.file_storage.models import Image, MediaParentType +from app.api.file_storage.schemas import ImageCreateInternal, ImageRead +from app.api.plugins.rpi_cam.device_assertion import AuthenticatedCameraDep +from app.api.plugins.rpi_cam.examples import ( + CAMERA_CAPTURE_IMAGE_DESCRIPTION_OPENAPI_EXAMPLES, + CAMERA_CAPTURE_IMAGE_PRODUCT_ID_OPENAPI_EXAMPLES, +) +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import build_camera_request, get_user_owned_camera +from app.api.plugins.rpi_cam.service_runtime import get_preview_thumbnail_path, get_preview_thumbnail_url from app.api.plugins.rpi_cam.services import capture_and_store_image +from app.core.redis import OptionalRedisDep -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) - +if TYPE_CHECKING: + from pathlib import Path + from typing import Any +logger = logging.getLogger(__name__) router = PublicAPIRouter() +def _unlink_quiet(path: Path) -> None: + with contextlib.suppress(FileNotFoundError): + path.unlink() + + +def _write_preview_thumbnail_atomic(path: Path, image_bytes: bytes) -> None: + """Write preview thumbnail bytes atomically to the deterministic cache path.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + tmp_path.write_bytes(image_bytes) + tmp_path.replace(path) + except BaseException: + _unlink_quiet(tmp_path) + raise + + ### Images ### @router.post( "/{camera_id}/image", @@ -35,11 +67,133 @@ async def capture_image( camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep, + redis: OptionalRedisDep, *, - product_id: Annotated[PositiveInt, Body(description="ID of product to associate the image with")], - description: Annotated[str | None, Body(description="Custom description for the image", max_length=500)] = None, + product_id: Annotated[ + PositiveInt, + Body( + description="ID of product to associate the image with", + openapi_examples=CAMERA_CAPTURE_IMAGE_PRODUCT_ID_OPENAPI_EXAMPLES, + ), + ], + description: Annotated[ + str | None, + Body( + description="Custom description for the image", + max_length=500, + openapi_examples=CAMERA_CAPTURE_IMAGE_DESCRIPTION_OPENAPI_EXAMPLES, + ), + ] = None, ) -> Image: """Capture a still image with a remote Raspberry Pi Camera.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + + return await capture_and_store_image( + session, + camera_request=camera_request, + product_id=product_id, + description=description, + ) + + +### Device-pushed uploads ### + + +@router.post( + "/{camera_id}/image-upload", + response_model=DeviceImageUploadAck, + summary="Internal: receive an image pushed directly from a paired Raspberry Pi", + description=( + "Called by the Pi after a successful capture. Authenticated with a short-lived ES256 " + "device assertion (same credential used by the WebSocket relay). The Pi provides the " + "JPEG body plus two JSON blobs: `capture_metadata` (libcamera metadata) and " + "`upload_metadata` (opaque dict forwarded by whichever caller triggered the capture — " + "typically `{product_id, description}`). The backend stores the image via the normal " + "image storage service and returns a tiny ack envelope the Pi consumes." + ), + status_code=201, +) +async def receive_camera_upload( + camera_id: UUID4, # noqa: ARG001 — consumed by AuthenticatedCameraDep + camera: AuthenticatedCameraDep, + session: AsyncSessionDep, + file: Annotated[UploadFile, File(description="Captured JPEG")], + capture_metadata: Annotated[str, Form(description="libcamera capture metadata as a JSON string")], + upload_metadata: Annotated[str, Form(description="Parent association metadata as a JSON string")], +) -> DeviceImageUploadAck: + """Receive a capture pushed from the Pi and persist it.""" + try: + capture_meta: dict[str, Any] = json.loads(capture_metadata) or {} + upload_meta: dict[str, Any] = json.loads(upload_metadata) or {} + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"Invalid JSON metadata: {exc}") from exc + + product_id = upload_meta.get("product_id") + if product_id is None: + raise HTTPException(status_code=400, detail="upload_metadata must include a product_id") + try: + product_id_int = int(product_id) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="product_id must be an integer") from exc + if product_id_int <= 0: + raise HTTPException(status_code=400, detail="product_id must be a positive integer") + + await require_model(session, Product, product_id_int) + + description = upload_meta.get("description") or f"Captured from camera {camera.name}." + image_data = ImageCreateInternal( + file=file, + description=description, + image_metadata=capture_meta, + parent_type=MediaParentType.PRODUCT, + parent_id=product_id_int, + ) + + logger.info( + "Receiving pushed image from camera %s for product %s (filename=%s)", + camera.id, + product_id_int, + file.filename, + ) + image = await create_image(session, image_data) + + # ImageRead computes the public `image_url` via a model_validator based on + # the image's storage path. Round-trip through it to reuse that logic. + image_read = ImageRead.model_validate(image) + if image_read.image_url is None: + raise HTTPException( + status_code=500, + detail="Image stored but URL could not be computed — storage layer misconfigured.", + ) + return DeviceImageUploadAck(image_id=image.id.hex, image_url=image_read.image_url) + + +@router.post( + "/{camera_id}/preview-thumbnail-upload", + response_model=DevicePreviewThumbnailAck, + summary="Internal: receive a cached preview thumbnail pushed directly from a paired Raspberry Pi", + description=( + "Called by the Pi's background thumbnail worker. Authenticated with the same short-lived " + "ES256 device assertion used by the WebSocket relay. Stores a deterministic per-camera JPEG " + "cache file for camera-card previews without creating an Image database row." + ), + status_code=201, +) +async def receive_preview_thumbnail_upload( + camera_id: UUID4, # noqa: ARG001 — consumed by AuthenticatedCameraDep + camera: AuthenticatedCameraDep, + file: Annotated[UploadFile, File(description="Cached preview JPEG thumbnail")], +) -> DevicePreviewThumbnailAck: + """Receive a cached preview thumbnail pushed from the Pi and persist it.""" + image_bytes = await file.read() + if not image_bytes: + raise HTTPException(status_code=400, detail="Preview thumbnail upload was empty") - return await capture_and_store_image(session, camera, product_id=product_id, description=description) + logger.info("Receiving cached preview thumbnail from camera %s", camera.id) + path = get_preview_thumbnail_path(camera.id) + _write_preview_thumbnail_atomic(path, image_bytes) + preview_thumbnail_url = get_preview_thumbnail_url(camera.id) + if preview_thumbnail_url is None: + raise HTTPException(status_code=500, detail="Preview thumbnail stored but URL could not be computed") + return DevicePreviewThumbnailAck(preview_thumbnail_url=preview_thumbnail_url) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/main.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/main.py deleted file mode 100644 index e206c1db..00000000 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/main.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Main router for camera interaction.""" - -from app.api.common.routers.openapi import PublicAPIRouter -from app.api.plugins.rpi_cam.routers.camera_interaction.images import router as images_router -from app.api.plugins.rpi_cam.routers.camera_interaction.remote_management import router as remote_management_router -from app.api.plugins.rpi_cam.routers.camera_interaction.streams import router as streams_router - -router = PublicAPIRouter(prefix="/plugins/rpi-cam/cameras", tags=["rpi-cam-interaction"]) - -router.include_router(images_router) -router.include_router(streams_router) -router.include_router(remote_management_router) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py deleted file mode 100644 index 818a0953..00000000 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Routers for the Raspberry Pi Camera plugin.""" - -import json - -from fastapi import HTTPException, Query -from httpx import QueryParams -from pydantic import UUID4, ValidationError -from relab_rpi_cam_models.camera import CameraMode - -from app.api.auth.dependencies import CurrentActiveUserDep -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import PublicAPIRouter -from app.api.plugins.rpi_cam.models import CameraConnectionStatus, CameraStatus, CameraStatusDetails -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( - HttpMethod, - fetch_from_camera_url, - get_user_owned_camera, -) - -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) - - -router = PublicAPIRouter() - - -### Camera Management ### -@router.post("/{camera_id}/open", response_model=CameraStatus, summary="Initialize camera") -async def init_camera( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, - mode: CameraMode = Query(default=CameraMode.PHOTO, description="Camera mode (photo or video)"), -) -> CameraStatus: - """Initialize camera for a given use mode (photo or video).""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, - endpoint="/camera/open", - method=HttpMethod.POST, - error_msg="Failed to open camera", - query_params=QueryParams({"mode": mode.value}), - ) - try: - return CameraStatus(connection=CameraConnectionStatus.ONLINE, details=CameraStatusDetails(**response.json())) - except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e - - -@router.post("/{camera_id}/close", summary="Close camera") -async def close_camera( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, -) -> CameraStatus: - """Close camera and free resources.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, - endpoint="/camera/close", - method=HttpMethod.POST, - error_msg="Failed to close camera", - ) - try: - return CameraStatus(connection=CameraConnectionStatus.ONLINE, details=CameraStatusDetails(**response.json())) - except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py index 810a4779..44727718 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py @@ -1,119 +1,165 @@ """Camera stream interaction routes.""" -import json -from datetime import UTC, datetime -from typing import Annotated - -from fastapi import Body, HTTPException, Request, Response -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from httpx import QueryParams -from pydantic import UUID4, AnyUrl, HttpUrl, PositiveInt, ValidationError +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Annotated + +from fastapi import Body, HTTPException +from pydantic import UUID4, PositiveInt, ValidationError from relab_rpi_cam_models.stream import StreamMode, StreamView -from sqlmodel import select +from sqlalchemy import select from app.api.auth.dependencies import CurrentActiveUserDep from app.api.auth.models import OAuthAccount -from app.api.auth.services.oauth import google_youtube_oauth_client -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.auth.services.oauth_clients import google_youtube_oauth_client +from app.api.common.crud.query import require_model +from app.api.common.exceptions import APIError +from app.api.common.routers.dependencies import AsyncSessionDep, ExternalHTTPClientDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.schemas.base import serialize_datetime_with_z -from app.api.data_collection.models import Product -from app.api.file_storage.crud import create_video -from app.api.file_storage.models.models import Video +from app.api.data_collection.models.product import Product +from app.api.file_storage.crud.video import create_video from app.api.file_storage.schemas import VideoCreate, VideoRead -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( - HttpMethod, - fetch_from_camera_url, - get_user_owned_camera, +from app.api.plugins.rpi_cam.constants import PLUGIN_STREAM_ENDPOINT, HttpMethod +from app.api.plugins.rpi_cam.examples import ( + CAMERA_START_RECORDING_DESCRIPTION_OPENAPI_EXAMPLES, + CAMERA_START_RECORDING_PRIVACY_OPENAPI_EXAMPLES, + CAMERA_START_RECORDING_PRODUCT_ID_OPENAPI_EXAMPLES, + CAMERA_START_RECORDING_TITLE_OPENAPI_EXAMPLES, +) +from app.api.plugins.rpi_cam.exceptions import ( + GoogleOAuthAssociationRequiredError, + InvalidCameraResponseError, + InvalidRecordingSessionDataError, + NoActiveYouTubeRecordingError, + RecordingSessionNotFoundError, ) -from app.api.plugins.rpi_cam.services import YouTubePrivacyStatus, YouTubeService -from app.core.config import settings +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import build_camera_request, get_user_owned_camera +from app.api.plugins.rpi_cam.schemas.youtube import YouTubeMonitorStreamResponse +from app.api.plugins.rpi_cam.services import ( + YouTubePrivacyStatus, + YouTubeRecordingSession, + YouTubeService, + build_recording_text, + clear_recording_session, + get_recording_session_cache_key, + load_recording_session, + serialize_stream_metadata, + store_recording_session, +) +from app.core.logging import sanitize_log_value +from app.core.redis import OptionalRedisDep, require_redis + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from redis.asyncio import Redis -# Initialize templates -templates = Jinja2Templates(directory=settings.templates_path) + from app.api.plugins.rpi_cam.websocket.protocol import RelayResponse # Initialize router router = PublicAPIRouter() +logger = logging.getLogger(__name__) -### Constants ### -# TODO: dynamically fetch the manifest file name from the camera -HLS_MANIFEST_FILENAME = "master.m3u8" -MAX_PREVIEW_STREAM_LENGTH_SECONDS = 7200 # 2 hours ### Common endpoints ### -# TODO: Move the CRUD like functionalities to services.py - - -@router.get("/{camera_id}/stream/status") +@router.get( + "/{camera_id}/stream/status", + summary="Get the active YouTube recording stream status", + description="Fetch the current remote camera stream status from the Raspberry Pi camera plugin.", +) async def get_camera_stream_status( camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep, + redis: OptionalRedisDep, ) -> StreamView: - """Get current stream status.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, - endpoint="/stream/status", + """Fetch the current remote camera stream status from the device plugin.""" + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + response = await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, method=HttpMethod.GET, error_msg="Failed to get stream status", ) try: - return StreamView(**response.json()) + return StreamView.model_validate(response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e -@router.delete("/{camera_id}/stream/stop", status_code=204, summary="Stop the active stream") +@router.delete( + "/{camera_id}/stream/stop", + status_code=204, + summary="Stop the active YouTube recording stream", + description="Stop the currently active remote camera stream.", +) async def stop_all_streams( camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep, + redis: OptionalRedisDep, ) -> None: - """Stop the active stream (either youtube recording or preview stream).""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - await fetch_from_camera_url( - camera=camera, - endpoint="/stream/stop", + """Stop the currently active remote camera stream.""" + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, method=HttpMethod.DELETE, - error_msg="Failed to stop the active streams", + error_msg="Failed to stop the active stream", ) ### Recording to Youtube ### -# TODO: Refine flow of video creation and product association in database. -# Currently, videos are creation in DB and associated with products on recording start. -# We should investigate whether it's better to save this on recording ending only. -# But how do we store the product id and description from recording start in the app state? Some smart caching? +# We cache the recording session in Redis on start and finalize the Video row on stop. @router.post( - "/{camera_id}/stream/record/start", response_model=VideoRead, status_code=201, summary="Start recording to YouTube" + "/{camera_id}/stream/record/start", response_model=StreamView, status_code=201, summary="Start recording to YouTube" ) async def start_recording( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + redis: OptionalRedisDep, current_user: CurrentActiveUserDep, - product_id: Annotated[PositiveInt, Body(description="ID of product to associate the video with")], - title: Annotated[str | None, Body(description="Custom video title")] = None, - description: Annotated[str | None, Body(description="Custom description for the video")] = None, + product_id: Annotated[ + PositiveInt, + Body( + description="ID of product to associate the video with", + openapi_examples=CAMERA_START_RECORDING_PRODUCT_ID_OPENAPI_EXAMPLES, + ), + ], + title: Annotated[ + str | None, + Body( + description="Custom video title", + openapi_examples=CAMERA_START_RECORDING_TITLE_OPENAPI_EXAMPLES, + ), + ] = None, + description: Annotated[ + str | None, + Body( + description="Custom description for the video", + openapi_examples=CAMERA_START_RECORDING_DESCRIPTION_OPENAPI_EXAMPLES, + ), + ] = None, privacy_status: Annotated[ - YouTubePrivacyStatus, Body(description="Privacy status for the YouTube video") + YouTubePrivacyStatus, + Body( + description="Privacy status for the YouTube video", + openapi_examples=CAMERA_START_RECORDING_PRIVACY_OPENAPI_EXAMPLES, + ), ] = YouTubePrivacyStatus.PRIVATE, -) -> Video: - """Start recording to YouTube. Video will be stored and can be associated with a product.""" - # TODO: Break down this function into smaller parts for better maintainability - +) -> StreamView: + """Start a YouTube recording stream and cache the backend-owned session in Redis.""" # Validate video data before starting stream - if product_id is not None: - await db_get_model_with_id_if_it_exists(session, Product, product_id) - video = VideoCreate( - url=HttpUrl("http://placeholder.com"), # Will be updated with actual stream URL + await require_model(session, Product, product_id) + redis_client = require_redis(redis) + resolved_title, resolved_description = build_recording_text( + product_id=product_id, title=title, description=description, - product_id=product_id, ) # Get Google OAuth account @@ -123,171 +169,245 @@ async def start_recording( ) ) if not oauth_account: - raise HTTPException( - 403, - "Google Oauth account association required for YouTube streaming. Use /api/auth/associate/google/authorize", - ) + raise GoogleOAuthAssociationRequiredError # Initialize YouTube service - youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client) + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) - # Create livestream - now_str = serialize_datetime_with_z(datetime.now(UTC)) - title = title or f"Product {product_id} recording at {now_str}" if product_id else f"Recording at {now_str}" - description = description or f"Recording {f'of product {product_id}' if product_id else ''} at {now_str}" + # Fetch user camera up-front so the idempotency check can verify an existing session against the Pi. + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + + # Idempotency guard: if a prior POST already started a recording but the response never reached + # the client (network retry), return the live StreamView instead of creating a second broadcast. + existing_stream = await _resolve_existing_recording(redis_client, session, camera_id, camera_request) + if existing_stream is not None: + return existing_stream + # Create livestream youtube_config = await youtube_service.setup_livestream( - title, privacy_status=privacy_status, description=description + resolved_title, + privacy_status=privacy_status, + description=resolved_description, ) - # Fetch user camera - camera = await get_user_owned_camera(session, camera_id, current_user.id) - # Start Youtube stream - response = await fetch_from_camera_url( - camera=camera, - endpoint="/stream/start", + response = await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, method=HttpMethod.POST, error_msg="Failed to start stream", - query_params=QueryParams({"mode": StreamMode.YOUTUBE.value}), - body=youtube_config.model_dump(exclude={"stream_id"}), + body={ + "stream_key": youtube_config.stream_key.get_secret_value(), + "broadcast_key": youtube_config.broadcast_key.get_secret_value(), + }, ) try: - stream_info = StreamView(**response.json()) + stream_info = StreamView.model_validate(response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e # Validate stream is active await youtube_service.validate_stream_status(youtube_config.stream_id) - # Update video with actual stream URL and store in database - video.url = stream_info.url - video.video_metadata = stream_info.metadata.model_dump() - return await create_video(session, video) + try: + await store_recording_session( + redis_client, + session, + camera_id, + YouTubeRecordingSession( + product_id=product_id, + title=resolved_title, + description=resolved_description, + stream_url=stream_info.url, + broadcast_key=youtube_config.broadcast_key.get_secret_value(), + video_metadata=serialize_stream_metadata(stream_info.metadata), + ), + ) + except HTTPException, APIError: + try: + await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, + method=HttpMethod.DELETE, + error_msg="Failed to roll back stream after recording session storage failure", + ) + except (HTTPException, APIError) as cleanup_error: + logger.warning( + "Failed to roll back camera stream for camera %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(cleanup_error), + ) + try: + await youtube_service.end_livestream(youtube_config.broadcast_key.get_secret_value()) + except APIError as cleanup_error: + logger.warning( + "Failed to roll back YouTube livestream for camera %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(cleanup_error), + ) + raise + + return stream_info + + +async def _resolve_existing_recording( + redis_client: Redis, + session: AsyncSessionDep, + camera_id: UUID4, + camera_request: Callable[..., Awaitable[RelayResponse]], +) -> StreamView | None: + """Return the live StreamView if a recording session already exists and is still active. + If the cached session is stale (Pi lost the stream, or mode is not YouTube), the session is + cleared and ``None`` is returned so the caller can proceed with a fresh recording. + """ + try: + await load_recording_session(redis_client, session, camera_id) + except RecordingSessionNotFoundError: + return None + except InvalidRecordingSessionDataError as exc: + logger.warning( + "Cached recording session for camera %s is corrupt (%s); clearing", + sanitize_log_value(camera_id), + sanitize_log_value(exc), + ) + await clear_recording_session(redis_client, session, camera_id) + return None -@router.delete("/{camera_id}/stream/record/stop", summary="Stop recording to YouTube") + try: + response = await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, + method=HttpMethod.GET, + error_msg="Failed to verify existing recording stream", + ) + stream_view = StreamView.model_validate(response.json()) + except (APIError, ValidationError) as exc: + logger.warning( + "Cached recording session for camera %s could not be verified (%s); clearing", + sanitize_log_value(camera_id), + sanitize_log_value(exc), + ) + await clear_recording_session(redis_client, session, camera_id) + return None + + if stream_view.mode != StreamMode.YOUTUBE: + logger.warning( + "Cached recording session for camera %s is stale (mode=%s); clearing", + sanitize_log_value(camera_id), + sanitize_log_value(stream_view.mode), + ) + await clear_recording_session(redis_client, session, camera_id) + return None + + return stream_view + + +@router.delete( + "/{camera_id}/stream/record/stop", + response_model=VideoRead, + summary="Stop recording to YouTube", +) async def stop_recording( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + redis: OptionalRedisDep, current_user: CurrentActiveUserDep, -) -> dict[str, AnyUrl]: - """Stop recording and save video to database.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - - # Get current stream info before stopping - stream_status_response = await fetch_from_camera_url( - camera=camera, - endpoint="/stream/status", - method=HttpMethod.GET, - error_msg="Failed to get stream status", - ) - - await fetch_from_camera_url( - camera=camera, - endpoint="/stream/stop", - method=HttpMethod.DELETE, - error_msg="Failed to stop stream", - query_params=QueryParams({"mode": StreamMode.YOUTUBE.value}), - ) +) -> VideoRead: + """Stop the active YouTube recording, end the livestream, and create the video record. - # TODO: Stop YouTube stream on YouTube API + Cleanup order is: YouTube first, then the Pi. If the Pi is offline the YouTube broadcast + must still be torn down to avoid leaving orphan broadcasts on the user's channel. A Pi + cleanup failure degrades to a warning — the recording state on YouTube is what the user + cares about, and a running MediaMTX stream will eventually be noticed and stopped anyway. + """ + redis_client = require_redis(redis) + recording_session = await load_recording_session(redis_client, session, camera_id) - try: - stream_info = StreamView(**stream_status_response.json()) - except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e - else: - return {"video_url": stream_info.url} + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + oauth_account = await session.scalar( + select(OAuthAccount).where( + OAuthAccount.user_id == current_user.id, OAuthAccount.oauth_name == google_youtube_oauth_client.name + ) + ) + if not oauth_account: + raise GoogleOAuthAssociationRequiredError -# TODO: Add Youtube livestream status monitoring endpoint using liveBroadcast.contentDetails.monitorStream + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) + camera_request = build_camera_request(camera, redis) + # End the YouTube broadcast first so a Pi outage cannot strand an orphan live broadcast. + # If this fails we leave the recording session in Redis so the caller can retry. + await youtube_service.end_livestream(recording_session.broadcast_key) -### Local stream preview ### -@router.post( - "/{camera_id}/stream/preview/start", response_model=StreamView, status_code=201, summary="Start preview stream" -) -async def start_preview(camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep) -> StreamView: - """Start local HLS preview stream. Stream will not be recorded.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, - endpoint="/stream/start", - method=HttpMethod.POST, - error_msg="Failed to start stream", - query_params=QueryParams({"mode": StreamMode.LOCAL.value}), - ) try: - return StreamView(**response.json()) - except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e - - -@router.delete("/{camera_id}/stream/preview/stop", status_code=204, summary="Stop preview stream") -async def stop_preview(camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep) -> None: - """Stop recording and save video to database.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) + await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, + method=HttpMethod.DELETE, + error_msg="Failed to stop stream", + ) + except (HTTPException, APIError) as camera_cleanup_error: + logger.warning( + "YouTube broadcast ended but Pi stream cleanup failed for camera %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(camera_cleanup_error), + ) - await fetch_from_camera_url( - camera=camera, - endpoint="/stream/stop", - method=HttpMethod.DELETE, - error_msg="Failed to stop stream", - query_params=QueryParams({"mode": StreamMode.LOCAL.value}), + video = VideoCreate( + url=recording_session.stream_url, + title=recording_session.title, + description=recording_session.description, + product_id=recording_session.product_id, + video_metadata=recording_session.video_metadata, ) + created_video = await create_video(session, video) + await clear_recording_session(redis_client, session, camera_id) + + return VideoRead.model_validate(created_video) @router.get( - "/{camera_id}/stream/preview/hls/{file_path:path}", - summary="Access HLS stream files from camera", - description="Fetches and serves HLS stream files (.m3u8, .ts) from the camera", + "/{camera_id}/stream/record/monitor", + response_model=YouTubeMonitorStreamResponse, + summary="Get YouTube livestream monitor stream", ) -async def hls_file_proxy( - camera_id: UUID4, file_path: str, session: AsyncSessionDep, current_user: CurrentActiveUserDep -) -> Response: - """Proxy HLS files from camera to client.""" - # TODO: Use StreamResponse here and in the RPI cam API instead of FileResponse - camera = await get_user_owned_camera(session, camera_id, current_user.id) - - response = await fetch_from_camera_url( - camera=camera, - endpoint=f"/stream/hls/{file_path}", +async def get_recording_monitor_stream( + camera_id: UUID4, + session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + redis: OptionalRedisDep, + current_user: CurrentActiveUserDep, +) -> YouTubeMonitorStreamResponse: + """Get the YouTube monitor stream for the active backend-owned recording session.""" + redis_client = require_redis(redis) + recording_session = await load_recording_session(redis_client, session, camera_id) + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + + stream_status_response = await camera_request( + endpoint=PLUGIN_STREAM_ENDPOINT, method=HttpMethod.GET, - error_msg=f"Failed to get HLS file {file_path}", - ) - - return Response( - content=response.content, - media_type=response.headers["content-type"], - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate" - if file_path.endswith(".m3u8") # Cache .ts segments but not playlists - else f"max-age={MAX_PREVIEW_STREAM_LENGTH_SECONDS}", - "Access-Control-Allow-Origin": "*", - }, + error_msg="Failed to get stream status", ) + try: + stream_info = StreamView.model_validate(stream_status_response.json()) + except ValidationError as e: + raise InvalidCameraResponseError(e.json()) from e + if stream_info.mode != StreamMode.YOUTUBE: + raise NoActiveYouTubeRecordingError -@router.get( - "/{camera_id}/stream/preview/watch", - response_class=HTMLResponse, - summary="Watch preview stream", - description="Returns HTML viewer for remote HLS stream.", -) -async def watch_preview( - request: Request, camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep -) -> HTMLResponse: - """Serve HLS stream viewer from camera. - - Note: HTML viewer makes authenticated requests directly to camera's stream endpoint. - """ - # Validate camera ownership - await get_user_owned_camera(session, camera_id, current_user.id) - - response = templates.TemplateResponse( - "plugins/rpi_cam/remote_stream_viewer.html", - {"request": request, "camera_id": camera_id, "hls_manifest_file": HLS_MANIFEST_FILENAME}, + oauth_account = await session.scalar( + select(OAuthAccount).where( + OAuthAccount.user_id == current_user.id, OAuthAccount.oauth_name == google_youtube_oauth_client.name + ) ) + if not oauth_account: + raise GoogleOAuthAssociationRequiredError - return response + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) + logger.debug( + "Using cached recording session for monitor stream lookup: %s", + sanitize_log_value(get_recording_session_cache_key(camera_id)), + ) + return await youtube_service.get_broadcast_monitor_stream(recording_session.broadcast_key) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/telemetry.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/telemetry.py new file mode 100644 index 00000000..0cb3ff71 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/telemetry.py @@ -0,0 +1,79 @@ +"""Camera telemetry forwarding with Redis caching. + +The mosaic dashboard polls one camera's telemetry every ~5s; with the cache, +those polls cost a Redis GET and no relay round-trip. The first poll (cold +cache) forwards to the Pi's ``GET /system/telemetry`` endpoint, caches the snapshot +for 120s, and returns it. Subsequent polls within 120s hit the cache. + +The backend telemetry contract lives in ``app.api.plugins.rpi_cam.telemetry`` +and is kept byte-compatible with the shared ``relab_rpi_cam_models.telemetry`` +module (shared package 0.3.0+). When 0.5.0 publishes to PyPI, swap the local +copy for a straight import and delete this note. +""" + +from __future__ import annotations + +import logging + +from pydantic import UUID4, ValidationError +from relab_rpi_cam_models.telemetry import TelemetrySnapshot + +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.plugins.rpi_cam.constants import HttpMethod +from app.api.plugins.rpi_cam.exceptions import InvalidCameraResponseError +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import build_camera_request, get_user_owned_camera +from app.api.plugins.rpi_cam.services import get_cached_telemetry, store_telemetry +from app.core.redis import OptionalRedisDep + +router = PublicAPIRouter() +logger = logging.getLogger(__name__) + +_TELEMETRY_ENDPOINT = "/system/telemetry" + + +@router.get( + "/{camera_id}/telemetry", + summary="Get a camera's latest telemetry snapshot", + description=( + "Return the most recent telemetry snapshot for the camera. Backed by a Redis cache with a 120s " + "TTL so mosaic polling does not fan out one relay round-trip per camera on every refresh. " + "Pass ``force_refresh=true`` to bypass the cache and re-fetch from the Pi." + ), +) +async def get_camera_telemetry( + camera_id: UUID4, + session: AsyncSessionDep, + current_user: CurrentActiveUserDep, + redis: OptionalRedisDep, + *, + force_refresh: bool = False, +) -> TelemetrySnapshot: + """Return a camera's telemetry snapshot, hitting Redis when possible.""" + if not force_refresh: + cached = await get_cached_telemetry(redis, camera_id) + if cached is not None: + return cached + + # Cache miss (or explicit refresh): resolve camera ownership + online status + # and forward to the Pi. ``get_user_owned_camera`` raises 503 if the camera + # is offline, which is the right behaviour — no point hitting a dead relay. + camera = await get_user_owned_camera(session, camera_id, current_user.id, redis) + camera_request = build_camera_request(camera, redis) + response = await camera_request( + endpoint=_TELEMETRY_ENDPOINT, + method=HttpMethod.GET, + error_msg="Failed to fetch camera telemetry", + ) + try: + snapshot = TelemetrySnapshot.model_validate(response.json()) + except ValidationError as exc: + raise InvalidCameraResponseError(exc.json()) from exc + + # ``force_refresh=True`` bypasses the cache on both read AND write — + # the caller explicitly doesn't trust the cache layer this time, so we + # don't taint the next cached read with the forced result either. + if redis is not None and not force_refresh: + await store_telemetry(redis, camera_id, snapshot) + return snapshot diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py index 178851eb..97cb0a26 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py @@ -1,34 +1,32 @@ """Utilities for the camera interaction endpoints.""" -from enum import Enum -from urllib.parse import urljoin +from __future__ import annotations + +from typing import TYPE_CHECKING from fastapi import HTTPException -from httpx import AsyncClient, Headers, HTTPStatusError, QueryParams, Response -from pydantic import UUID4 -from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.common.utils import get_user_owned_object +from app.api.common.ownership import get_user_owned_object +from app.api.plugins.rpi_cam.constants import HttpMethod from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus +from app.api.plugins.rpi_cam.services import get_camera_status +from app.api.plugins.rpi_cam.websocket.protocol import RelayResponse +from app.api.plugins.rpi_cam.websocket.relay import relay_via_websocket +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable -class HttpMethod(str, Enum): - """HTTP method type.""" - - GET = "GET" - OPTIONS = "OPTIONS" - HEAD = "HEAD" - POST = "POST" - PUT = "PUT" - PATCH = "PATCH" - DELETE = "DELETE" + from pydantic import UUID4 + from redis.asyncio import Redis + from sqlalchemy.ext.asyncio import AsyncSession -async def get_user_owned_camera(session: AsyncSession, camera_id: UUID4, user_id: UUID4) -> Camera: - """Get a camera owned by a user.""" +async def get_user_owned_camera( + session: AsyncSession, camera_id: UUID4, user_id: UUID4, redis: Redis | None = None +) -> Camera: + """Get a camera owned by a user, verifying it is connected.""" camera = await get_user_owned_object(session, Camera, camera_id, user_id) - - camera_status = await camera.get_status() + camera_status = await get_camera_status(redis, camera.id) if (camera_connection := camera_status.connection) != CameraConnectionStatus.ONLINE: status_code, msg = camera_connection.to_http_error() @@ -41,32 +39,50 @@ async def fetch_from_camera_url( camera: Camera, endpoint: str, method: HttpMethod, - headers: Headers | None = None, error_msg: str | None = None, - query_params: QueryParams | None = None, body: dict | None = None, *, - follow_redirects: bool = True, -) -> Response: - """Utility function to send HTTP requests to the camera API.""" - # Add camera auth header to request - if headers is None: - headers = Headers() - headers.update(camera.auth_headers) - - async with AsyncClient( - headers=headers, timeout=5.0, verify=camera.verify_ssl, follow_redirects=follow_redirects - ) as client: - try: - url = urljoin(str(camera.url), endpoint) - response = await client.request(method.value, url, params=query_params, json=body) - response.raise_for_status() - except HTTPStatusError as e: - if error_msg is None: - error_msg = f"Failed to {method.value} {endpoint}" - raise HTTPException( - status_code=e.response.status_code, - detail={"main API": error_msg, "Camera API": e.response.json().get("detail")}, - ) from e - else: - return response + expect_binary: bool = False, + redis: Redis | None = None, +) -> RelayResponse: + """Send a request to the camera through its active WebSocket relay.""" + return await relay_via_websocket( + camera.id, + method.value, + endpoint, + body=body, + error_msg=error_msg, + expect_binary=expect_binary, + redis=redis, + ) + + +def build_camera_request( + camera: Camera, + redis: Redis | None = None, +) -> Callable[..., Awaitable[RelayResponse]]: + """Build a reusable request callable bound to one camera. + + Pass ``redis`` so the relay can fall back to the cross-worker bridge when + the camera's WebSocket is registered in a different Uvicorn worker process. + """ + + async def request( + endpoint: str, + method: HttpMethod, + error_msg: str | None = None, + body: dict | None = None, + *, + expect_binary: bool = False, + ) -> RelayResponse: + return await fetch_from_camera_url( + camera=camera, + endpoint=endpoint, + method=method, + error_msg=error_msg, + body=body, + expect_binary=expect_binary, + redis=redis, + ) + + return request diff --git a/backend/app/api/plugins/rpi_cam/routers/main.py b/backend/app/api/plugins/rpi_cam/routers/main.py deleted file mode 100644 index ec1a4b7e..00000000 --- a/backend/app/api/plugins/rpi_cam/routers/main.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Routers for the Raspberry Pi Camera plugin.""" - -from fastapi import APIRouter - -from app.api.plugins.rpi_cam.routers.admin import router as admin_router -from app.api.plugins.rpi_cam.routers.camera_crud import router as public_crud_router -from app.api.plugins.rpi_cam.routers.camera_interaction.main import router as user_interact_router - -router = APIRouter() - -router.include_router(public_crud_router) -router.include_router(user_interact_router) -router.include_router(admin_router) diff --git a/backend/app/api/plugins/rpi_cam/routers/pairing.py b/backend/app/api/plugins/rpi_cam/routers/pairing.py new file mode 100644 index 00000000..1026916d --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/routers/pairing.py @@ -0,0 +1,201 @@ +"""Pairing endpoints for zero-config RPi camera registration. + +Flow: +1. RPi generates a key pair and 6-char code, then POSTs code + public key to /register. +2. User enters the code in the ReLab UI and POSTs to /claim. +3. Backend creates the camera and stores non-secret relay metadata in Redis. +4. RPi polls /poll until claimed, saves the camera id/backend URL, and starts the relay. +""" + +from __future__ import annotations + +import hmac +import logging + +from fastapi import Query, Request, status +from relab_rpi_cam_models import ( + PairingClaimedRecord, + PairingPendingRecord, + PairingPollResponse, + PairingRegisterResponse, +) + +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.auth.services.rate_limiter import limiter +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.plugins.rpi_cam import crud +from app.api.plugins.rpi_cam.exceptions import ( + PairingCodeAlreadyClaimedError, + PairingCodeCollisionError, + PairingCodeNotFoundError, + PairingFingerprintMismatchError, +) +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.schemas import CameraCreate, CameraRead +from app.api.plugins.rpi_cam.schemas.pairing import ( + PairingClaimRequest, + PairingRegisterRequest, +) +from app.api.plugins.rpi_cam.utils.device_contracts import ( + build_claimed_bootstrap, + build_claimed_record, + build_waiting_record, + dump_pairing_record, + parse_pairing_record, +) +from app.core.config import settings as core_settings +from app.core.logging import sanitize_log_value +from app.core.redis import RedisDep, delete_redis_key, get_redis_value, set_redis_value, set_redis_value_nx + +logger = logging.getLogger(__name__) + +router = PublicAPIRouter(prefix="/plugins/rpi-cam/pairing", tags=["RPi Camera Pairing"]) + +PAIRING_KEY_PREFIX = "rpi_cam:pairing" +PAIRING_TTL_SECONDS = 10 * 60 +PAIRING_CREDENTIAL_TTL_SECONDS = 300 + +REGISTER_RATE_LIMIT = "20/minute" +POLL_RATE_LIMIT = "60/minute" +CLAIM_RATE_LIMIT = "10/minute" + +_STATUS_WAITING = "waiting" + + +def _pairing_key(code: str) -> str: + return f"{PAIRING_KEY_PREFIX}:{code}" + + +def _build_ws_url() -> str: + """Derive the WebSocket relay URL from the backend's configured API URL.""" + base = str(core_settings.backend_api_url).rstrip("/") + ws_base = base.replace("https://", "wss://").replace("http://", "ws://") + return f"{ws_base}/plugins/rpi-cam/ws/connect" + + +@router.post( + "/register", + response_model=PairingRegisterResponse, + status_code=status.HTTP_201_CREATED, + summary="Register a pairing code (called by RPi)", +) +@limiter.limit(REGISTER_RATE_LIMIT) +async def register_pairing_code( + request: Request, + body: PairingRegisterRequest, + redis: RedisDep, +) -> PairingRegisterResponse: + """Register a short-lived pairing code and the camera's public device key.""" + del request + key = _pairing_key(body.code) + payload = dump_pairing_record( + build_waiting_record( + rpi_fingerprint=body.rpi_fingerprint, + public_key_jwk=body.public_key_jwk, + key_id=body.key_id, + ) + ) + stored = await set_redis_value_nx(redis, key, payload, ex=PAIRING_TTL_SECONDS) + if not stored: + raise PairingCodeCollisionError + + logger.info("Pairing code %s registered.", sanitize_log_value(body.code)) + return PairingRegisterResponse(code=body.code, expires_in=PAIRING_TTL_SECONDS) + + +@router.post( + "/claim", + response_model=CameraRead, + summary="Claim a pairing code and create a camera (called by user)", +) +@limiter.limit(CLAIM_RATE_LIMIT) +async def claim_pairing_code( + request: Request, + body: PairingClaimRequest, + session: AsyncSessionDep, + current_user: CurrentActiveUserDep, + redis: RedisDep, +) -> Camera: + """Claim a pairing code and create a WebSocket-relayed camera.""" + del request + key = _pairing_key(body.code) + raw = await get_redis_value(redis, key) + if raw is None: + raise PairingCodeNotFoundError + + record = parse_pairing_record(raw) + if not isinstance(record, PairingPendingRecord): + raise PairingCodeAlreadyClaimedError + + db_camera = await crud.create_camera( + session, + CameraCreate( + name=body.camera_name, + description=body.description, + relay_public_key_jwk=record.public_key_jwk.model_dump(exclude_none=True), + relay_key_id=record.key_id, + ), + current_user.id, + ) + paired_payload = dump_pairing_record( + build_claimed_record( + build_claimed_bootstrap( + camera_id=str(db_camera.id), + ws_url=_build_ws_url(), + key_id=db_camera.relay_key_id, + ), + rpi_fingerprint=record.rpi_fingerprint, + ) + ) + await set_redis_value(redis, key, paired_payload, ex=PAIRING_CREDENTIAL_TTL_SECONDS) + + logger.info( + "Pairing code %s claimed by user %s, camera %s.", + sanitize_log_value(body.code), + sanitize_log_value(current_user.id), + sanitize_log_value(db_camera.id), + ) + return db_camera + + +@router.get( + "/poll", + response_model=PairingPollResponse, + summary="Poll pairing status (called by RPi)", +) +@limiter.limit(POLL_RATE_LIMIT) +async def poll_pairing_status( + request: Request, + redis: RedisDep, + code: str = Query(min_length=6, max_length=6, pattern=r"^[A-Z0-9]{6}$"), + fingerprint: str = Query(min_length=8, max_length=64), +) -> PairingPollResponse: + """Poll for pairing completion. Returns non-secret relay metadata once claimed.""" + del request + key = _pairing_key(code) + raw = await get_redis_value(redis, key) + if raw is None: + raise PairingCodeNotFoundError + + record = parse_pairing_record(raw) + + if isinstance(record, PairingPendingRecord): + if not hmac.compare_digest(record.rpi_fingerprint, fingerprint): + raise PairingFingerprintMismatchError + return PairingPollResponse.waiting() + + if isinstance(record, PairingClaimedRecord): + if not hmac.compare_digest(record.rpi_fingerprint, fingerprint): + raise PairingFingerprintMismatchError + await delete_redis_key(redis, key) + logger.info("Pairing credentials retrieved for code %s.", sanitize_log_value(code)) + return PairingPollResponse.from_claimed_bootstrap( + build_claimed_bootstrap( + camera_id=record.camera_id, + ws_url=record.ws_url, + key_id=record.key_id, + ) + ) + + raise PairingCodeNotFoundError diff --git a/backend/app/api/plugins/rpi_cam/schemas.py b/backend/app/api/plugins/rpi_cam/schemas.py deleted file mode 100644 index 18de973b..00000000 --- a/backend/app/api/plugins/rpi_cam/schemas.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Pydantic models used to validate CRUD operations for the Raspberry Pi Camera plugin.""" - -from typing import Annotated, Self - -from fastapi_filter import FilterDepends, with_prefix -from fastapi_filter.contrib.sqlalchemy import Filter -from pydantic import ( - UUID4, - AfterValidator, - BaseModel, - Field, - HttpUrl, - PlainSerializer, - SecretStr, -) - -from app.api.auth.filters import UserFilter -from app.api.common.schemas.base import ( - BaseCreateSchema, - BaseReadSchemaWithTimeStamp, - BaseUpdateSchema, -) -from app.api.plugins.rpi_cam.config import settings -from app.api.plugins.rpi_cam.models import Camera, CameraBase, CameraStatus -from app.api.plugins.rpi_cam.utils.encryption import decrypt_str - - -### Filters ### -class CameraFilter(Filter): - """FastAPI-filter class for Camera filtering.""" - - name__ilike: str | None = None - description__ilike: str | None = None - url__ilike: str | None = None - - search: str | None = None - - class Constants(Filter.Constants): # Standard FastAPI-filter class - """FilterAPI class configuration.""" - - model = Camera - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - "url", - ] - - -class CameraFilterWithOwner(CameraFilter): - """FastAPI-filter class for Camera filtering with owner relationship.""" - - owner: UserFilter | None = FilterDepends(with_prefix("owner", UserFilter)) - - -### Auth Header Utils ### -MAX_AUTH_HEADERS_SIZE = 3500 # Max cookie size is 4096 bytes, 3500 allows buffer for server-generated headers - - -def validate_auth_header_key(key: str) -> str: - """Validate that the header key is not reserved for the server-generated API key.""" - if key.lower() == settings.api_key_header_name.lower(): - err_msg = f"Header key '{key}' is reserved for the server-generated API key." - raise ValueError(err_msg) - return key - - -class HeaderCreate(BaseModel): - """HTTP header key-value pair with validation.""" - - key: Annotated[ - str, - Field(description="Header key", min_length=1, max_length=100, pattern=r"^[a-zA-Z][-.a-zA-Z0-9]*$"), - AfterValidator(validate_auth_header_key), - ] - # TODO: Consider adding SecretStr for any secret values in all schemas. Requires custom (de-)serialization logic - value: SecretStr = Field(description="Header value", min_length=1, max_length=500) - - -def serialize_auth_headers(headers: list[HeaderCreate] | None) -> dict[str, str] | None: - """Convert list of HeaderCreate objects to a dictionary of headers.""" - if not headers: - return None - return {h.key: h.value.get_secret_value() for h in headers} - - -def validate_auth_headers_size(headers: list[HeaderCreate] | None) -> list[HeaderCreate] | None: - """Validate size of HeaderCreate list.""" - if ( - headers - and (header_size := sum(len(h.key) + len(h.value.get_secret_value()) for h in headers)) > MAX_AUTH_HEADERS_SIZE - ): - err_msg = f"Total size of headers is {header_size} bytes, exceeding maximum of {MAX_AUTH_HEADERS_SIZE} bytes." - raise ValueError(err_msg) - return headers - - -OptionalAuthHeaderCreateList = Annotated[ - list[HeaderCreate] | None, - Field(default=None, description="List of additional authentication headers for the camera API"), - PlainSerializer(serialize_auth_headers), - AfterValidator(validate_auth_headers_size), -] - - -### CRUD schemas ### -## Create schemas -class CameraCreate(BaseCreateSchema, CameraBase): - """Schema for creating a camera.""" - - auth_headers: OptionalAuthHeaderCreateList - - -## Read schemas -class CameraRead(BaseReadSchemaWithTimeStamp, CameraBase): - """Basic Camera Read schema.""" - - owner_id: UUID4 - - @classmethod - def _get_base_fields(cls, db_model: Camera) -> dict: - return { - **db_model.model_dump(exclude={"encrypted_api_key", "encrypted_auth_headers", "auth_headers", "status"}), - } - - -class CameraReadWithStatus(CameraRead): - """Schema for camera read with online status.""" - - status: CameraStatus - - @classmethod - async def from_db_model_with_status(cls, db_model: Camera) -> Self: - return cls(**CameraRead._get_base_fields(db_model), status=await db_model.get_status()) - - -class CameraReadWithCredentials(CameraRead): - """Schema for camera read with credentials.""" - - api_key: str - auth_headers: dict[str, str] | None - - @classmethod - def from_db_model_with_credentials(cls, db_model: Camera) -> Self: - decrypted_headers = db_model._decrypt_auth_headers() if db_model.encrypted_auth_headers else None - - return cls( - **CameraRead._get_base_fields(db_model), - api_key=decrypt_str(db_model.encrypted_api_key), - auth_headers=decrypted_headers, - ) - - -## Update schemas -class CameraUpdate(BaseUpdateSchema): - """Schema for updating a camera.""" - - name: str | None = Field(default=None, min_length=2, max_length=100) - description: str | None = Field(default=None, max_length=500) - url: HttpUrl | None = Field(default=None, description="HTTP(S) URL where the camera API is hosted") - auth_headers: OptionalAuthHeaderCreateList - - # TODO: Make it only possible to change ownership to existing users within the same organization - owner_id: UUID4 | None = None diff --git a/backend/app/api/plugins/rpi_cam/schemas/__init__.py b/backend/app/api/plugins/rpi_cam/schemas/__init__.py new file mode 100644 index 00000000..cdf7c616 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/schemas/__init__.py @@ -0,0 +1,121 @@ +"""Pydantic models used to validate CRUD operations for the Raspberry Pi Camera plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Self + +from fastapi_filter import FilterDepends, with_prefix +from fastapi_filter.contrib.sqlalchemy import Filter +from pydantic import UUID4, ConfigDict, Field, field_validator +from relab_rpi_cam_models import DevicePublicKeyJWK +from relab_rpi_cam_models.telemetry import TelemetrySnapshot + +from app.api.auth.filters import UserFilter +from app.api.common.schemas.base import BaseCreateSchema, BaseUpdateSchema, UUIDIdReadSchemaWithTimeStamp +from app.api.plugins.rpi_cam.examples import CAMERA_CREATE_EXAMPLES, CAMERA_READ_EXAMPLES, CAMERA_UPDATE_EXAMPLES +from app.api.plugins.rpi_cam.models import Camera, CameraBase, CameraCredentialStatus, CameraStatus +from app.api.plugins.rpi_cam.service_runtime import get_cached_telemetry, get_camera_status + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +class CameraFilter(Filter): + """FastAPI-filter class for Camera filtering.""" + + name__ilike: str | None = None + description__ilike: str | None = None + search: str | None = None + order_by: list[str] | None = None + + class Constants(Filter.Constants): + """FilterAPI class configuration.""" + + model = Camera + search_model_fields: list[str] = ["name", "description"] # noqa: RUF012 # fastapi-filter excepts this syntax + + +class CameraFilterWithOwner(CameraFilter): + """FastAPI-filter class for Camera filtering with owner relationship.""" + + owner: UserFilter | None = FilterDepends(with_prefix("owner", UserFilter)) + + +class RelayPublicKeyJWK(DevicePublicKeyJWK): + """Public P-256 JWK registered by an RPi camera.""" + + +class CameraCreate(BaseCreateSchema, CameraBase): + """Schema for creating a WebSocket-relayed camera.""" + + model_config = ConfigDict(json_schema_extra={"examples": CAMERA_CREATE_EXAMPLES}) + + relay_public_key_jwk: RelayPublicKeyJWK + relay_key_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9._~-]+$") + + @field_validator("relay_public_key_jwk") + @classmethod + def ensure_public_key_only(cls, value: RelayPublicKeyJWK) -> RelayPublicKeyJWK: + """Reject private JWK material if a client accidentally sends it.""" + if hasattr(value, "d"): + msg = "relay_public_key_jwk must not contain private key material." + raise ValueError(msg) + return value + + +class CameraRead(UUIDIdReadSchemaWithTimeStamp, CameraBase): + """Basic Camera Read schema.""" + + model_config = ConfigDict(json_schema_extra={"examples": CAMERA_READ_EXAMPLES}) + + owner_id: UUID4 + relay_key_id: str + relay_credential_status: CameraCredentialStatus + + +class CameraReadWithStatus(CameraRead): + """Schema for camera read with online status.""" + + status: CameraStatus + telemetry: TelemetrySnapshot | None = None + preview_thumbnail_url: str | None = None + + @classmethod + async def from_db_model_with_status( + cls, + db_model: Camera, + redis: Redis | None, + *, + include_telemetry: bool = False, + preview_thumbnail_url: str | None = None, + ) -> Self: + """Create CameraReadWithStatus instance from Camera database model, fetching online status.""" + telemetry = await get_cached_telemetry(redis, db_model.id) if include_telemetry else None + return cls( + **db_model.model_dump(exclude={"status", "relay_public_key_jwk"}), + status=await get_camera_status(redis, db_model.id), + telemetry=telemetry, + preview_thumbnail_url=preview_thumbnail_url, + ) + + +class CameraUpdate(BaseUpdateSchema): + """Schema for updating a camera.""" + + model_config = ConfigDict(json_schema_extra={"examples": CAMERA_UPDATE_EXAMPLES}) + + name: str | None = Field(default=None, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) + owner_id: UUID4 | None = Field( + default=None, + description="Transfer ownership to an existing user in the same organization as the current owner.", + ) + relay_public_key_jwk: RelayPublicKeyJWK | None = None + relay_key_id: str | None = Field(default=None, min_length=8, max_length=64, pattern=r"^[A-Za-z0-9._~-]+$") + relay_credential_status: CameraCredentialStatus | None = None + + def credential_updates(self) -> dict[str, Any]: + """Return credential fields included in this partial update.""" + return self.model_dump( + include={"relay_public_key_jwk", "relay_key_id", "relay_credential_status"}, exclude_unset=True + ) diff --git a/backend/app/api/plugins/rpi_cam/schemas/pairing.py b/backend/app/api/plugins/rpi_cam/schemas/pairing.py new file mode 100644 index 00000000..87cba15a --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/schemas/pairing.py @@ -0,0 +1,27 @@ +"""Pydantic models for the RPi camera pairing flow.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from relab_rpi_cam_models import PairingPollResponse, PairingRegisterRequest, PairingRegisterResponse + + +class PairingClaimRequest(BaseModel): + """User -> backend: claim a pairing code and create a camera.""" + + code: str = Field( + min_length=6, + max_length=6, + pattern=r"^[A-Z0-9]{6}$", + description="Pairing code displayed on the RPi's setup page.", + ) + camera_name: str = Field(min_length=2, max_length=100, description="Name for the new camera.") + description: str | None = Field(default=None, max_length=500) + + +__all__ = [ + "PairingClaimRequest", + "PairingPollResponse", + "PairingRegisterRequest", + "PairingRegisterResponse", +] diff --git a/backend/app/api/plugins/rpi_cam/schemas/streaming.py b/backend/app/api/plugins/rpi_cam/schemas/streaming.py new file mode 100644 index 00000000..80e5c7c4 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/schemas/streaming.py @@ -0,0 +1,10 @@ +"""Backend-local streaming workflow schemas.""" + +from pydantic import BaseModel, Field, SecretStr + + +class YoutubeStreamConfig(BaseModel): + """YouTube stream configuration sent to the Raspberry Pi plugin.""" + + stream_key: SecretStr = Field(description="Stream key for YouTube streaming") + broadcast_key: SecretStr = Field(description="Broadcast key for YouTube streaming") diff --git a/backend/app/api/plugins/rpi_cam/schemas/youtube.py b/backend/app/api/plugins/rpi_cam/schemas/youtube.py new file mode 100644 index 00000000..a5d82c6f --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/schemas/youtube.py @@ -0,0 +1,134 @@ +"""Pydantic models for YouTube API requests and responses.""" +# ruff: noqa: N815 # PascalCase field names match YouTube API conventions; ignore snake_case naming violation + +from pydantic import BaseModel, ConfigDict, Field + + +class YouTubeSnippetCreate(BaseModel): + """Common YouTube snippet payload.""" + + title: str + description: str = "" + scheduledStartTime: str | None = None + + +class YouTubeBroadcastStatusCreate(BaseModel): + """Broadcast status payload.""" + + privacyStatus: str + selfDeclaredMadeForKids: bool = False + + +class YouTubeBroadcastContentDetailsCreate(BaseModel): + """Broadcast content details payload.""" + + enableAutoStart: bool = True + enableAutoStop: bool = True + + +class YouTubeBroadcastCreateRequest(BaseModel): + """Create-live-broadcast request payload.""" + + snippet: YouTubeSnippetCreate + status: YouTubeBroadcastStatusCreate + contentDetails: YouTubeBroadcastContentDetailsCreate + + +class YouTubeStreamCDNCreate(BaseModel): + """Stream CDN configuration payload.""" + + frameRate: str = "30fps" + ingestionType: str = "hls" + resolution: str = "720p" + + +class YouTubeStreamCreateRequest(BaseModel): + """Create-live-stream request payload.""" + + snippet: YouTubeSnippetCreate + cdn: YouTubeStreamCDNCreate + description: str = "" + + +class YouTubeBroadcastResponse(BaseModel): + """Subset of broadcast response fields used by the app.""" + + id: str + + +class YouTubeMonitorStreamResponse(BaseModel): + """Subset of monitor stream fields used by the app.""" + + enableMonitorStream: bool + broadcastStreamDelayMs: int | None = None + embedHtml: str | None = None + + +class YouTubeBroadcastContentDetailsResponse(BaseModel): + """Subset of broadcast content details fields used by the app.""" + + monitorStream: YouTubeMonitorStreamResponse | None = None + + +class YouTubeBroadcastItemResponse(BaseModel): + """Single broadcast item from list response.""" + + id: str + contentDetails: YouTubeBroadcastContentDetailsResponse | None = None + + +class YouTubeBroadcastListResponse(BaseModel): + """List-broadcasts response payload.""" + + items: list[YouTubeBroadcastItemResponse] = Field(default_factory=list) + + +class YouTubeIngestionInfoResponse(BaseModel): + """Subset of ingestion info fields used by the app.""" + + streamName: str + + +class YouTubeCDNResponse(BaseModel): + """Subset of CDN response fields used by the app.""" + + ingestionInfo: YouTubeIngestionInfoResponse + + +class YouTubeStreamResponse(BaseModel): + """Subset of stream response fields used by the app.""" + + id: str + cdn: YouTubeCDNResponse + + +class YouTubeStreamStatusResponse(BaseModel): + """Subset of stream status response fields used by the app.""" + + streamStatus: str + + +class YouTubeStreamItemResponse(BaseModel): + """Single stream item from list response.""" + + status: YouTubeStreamStatusResponse + + +class YouTubeStreamListResponse(BaseModel): + """List-streams response payload.""" + + items: list[YouTubeStreamItemResponse] = Field(default_factory=list) + + +class YouTubeAPIErrorResponseDetail(BaseModel): + """Error detail object from YouTube API.""" + + message: str | None = None + + +class YouTubeAPIErrorResponse(BaseModel): + """Error response payload from YouTube API.""" + + model_config = ConfigDict(extra="ignore") + + error: YouTubeAPIErrorResponseDetail | None = None diff --git a/backend/app/api/plugins/rpi_cam/service_runtime.py b/backend/app/api/plugins/rpi_cam/service_runtime.py new file mode 100644 index 00000000..d5aae65b --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/service_runtime.py @@ -0,0 +1,354 @@ +"""Runtime-facing camera helpers for cache, recording sessions, and image capture.""" +# spell-checker: ignore astext + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, cast +from uuid import UUID + +from pydantic import UUID4, AnyUrl, BaseModel, PositiveInt, ValidationError +from relab_rpi_cam_models.images import ImageCaptureStatus +from relab_rpi_cam_models.telemetry import TelemetrySnapshot +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.crud.query import require_model +from app.api.common.schemas.base import serialize_datetime_with_z +from app.api.data_collection.models.product import Product +from app.api.file_storage.models import Image +from app.api.plugins.rpi_cam.constants import HttpMethod +from app.api.plugins.rpi_cam.exceptions import ( + InvalidCameraResponseError, + RecordingSessionNotFoundError, + RecordingSessionStoreError, +) +from app.api.plugins.rpi_cam.models import CameraConnectionStatus, CameraStatus, RecordingSession +from app.api.plugins.rpi_cam.websocket.protocol import RelayResponse +from app.core.config import settings +from app.core.logging import sanitize_log_value +from app.core.redis import delete_redis_key, get_redis_value, set_redis_value + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from pathlib import Path + + from httpx import Response + from redis.asyncio import Redis + + +logger = logging.getLogger(__name__) + +TELEMETRY_CACHE_PREFIX = "rpi_cam:telemetry" +TELEMETRY_CACHE_TTL_SECONDS = 120 +PREVIEW_THUMBNAIL_SUBDIR = "rpi-cam-preview" +YOUTUBE_RECORDING_SESSION_CACHE_PREFIX = "rpi_cam:youtube_recording" +YOUTUBE_RECORDING_SESSION_TTL_SECONDS = 60 * 60 * 48 + + +def get_camera_online_cache_key(camera_id: UUID4) -> str: + """Get the Redis key for tracking camera online status.""" + return f"rpi_cam:online:{camera_id}" + + +def get_camera_last_seen_cache_key(camera_id: UUID4) -> str: + """Get the Redis key for tracking when the camera was last seen.""" + return f"rpi_cam:last_seen:{camera_id}" + + +async def mark_camera_online(redis_client: Redis, camera_id: UUID4, ttl: int = 30) -> None: + """Mark a camera as online in Redis, updating its last seen timestamp.""" + now = serialize_datetime_with_z(datetime.now(UTC)) + await set_redis_value(redis_client, get_camera_online_cache_key(camera_id), "1", ex=ttl) + await set_redis_value(redis_client, get_camera_last_seen_cache_key(camera_id), now) + + +async def mark_camera_offline(redis_client: Redis, camera_id: UUID4) -> None: + """Remove a camera's online status in Redis.""" + await delete_redis_key(redis_client, get_camera_online_cache_key(camera_id)) + + +async def get_camera_status(redis_client: Redis | None, camera_id: UUID4) -> CameraStatus: + """Fetch connection status globally from Redis cache.""" + if not redis_client: + return CameraStatus(connection=CameraConnectionStatus.OFFLINE) + + pipeline = redis_client.pipeline() + pipeline.get(get_camera_online_cache_key(camera_id)) + pipeline.get(get_camera_last_seen_cache_key(camera_id)) + online, last_seen_str = await pipeline.execute() + + conn = CameraConnectionStatus.ONLINE if online else CameraConnectionStatus.OFFLINE + last_seen = datetime.fromisoformat(last_seen_str) if last_seen_str else None + return CameraStatus(connection=conn, last_seen_at=last_seen) + + +def get_telemetry_cache_key(camera_id: UUID4) -> str: + """Build the Redis key holding a camera's last-known telemetry snapshot.""" + return f"{TELEMETRY_CACHE_PREFIX}:{camera_id}" + + +async def store_telemetry( + redis_client: Redis, + camera_id: UUID4, + snapshot: TelemetrySnapshot, +) -> None: + """Cache a telemetry snapshot fetched from the Pi.""" + await set_redis_value( + redis_client, + get_telemetry_cache_key(camera_id), + snapshot.model_dump_json(), + ex=TELEMETRY_CACHE_TTL_SECONDS, + ) + + +async def get_cached_telemetry( + redis_client: Redis | None, + camera_id: UUID4, +) -> TelemetrySnapshot | None: + """Return the most recent cached telemetry snapshot, or ``None`` on miss.""" + if not redis_client: + return None + payload = await get_redis_value(redis_client, get_telemetry_cache_key(camera_id)) + if payload is None: + return None + try: + return TelemetrySnapshot.model_validate_json(payload) + except ValidationError: + logger.warning("Discarding malformed cached telemetry for camera %s", sanitize_log_value(camera_id)) + return None + + +def get_preview_thumbnail_path(camera_id: UUID4) -> Path: + """Return the deterministic backend storage path for one camera's preview thumbnail.""" + return settings.image_storage_path / PREVIEW_THUMBNAIL_SUBDIR / f"{camera_id}.jpg" + + +def get_preview_thumbnail_url(camera_id: UUID4) -> str | None: + """Return the public URL for one camera's cached preview thumbnail when present.""" + path = get_preview_thumbnail_path(camera_id) + try: + mtime = int(path.stat().st_mtime) + except FileNotFoundError: + return None + relative_path = path.relative_to(settings.image_storage_path) + return f"/uploads/images/{relative_path.as_posix()}?v={mtime}" + + +def get_preview_thumbnail_urls_per_camera(camera_ids: list[UUID4]) -> dict[UUID, str | None]: + """Return deterministic preview-thumbnail URLs for the given cameras.""" + return {UUID(str(camera_id)): get_preview_thumbnail_url(camera_id) for camera_id in camera_ids} + + +class YouTubeRecordingSession(BaseModel): + """Cached state for an in-progress YouTube recording.""" + + product_id: PositiveInt + title: str + description: str + stream_url: AnyUrl + broadcast_key: str + video_metadata: dict[str, Any] | None = None + + +def get_recording_session_cache_key(camera_id: UUID4) -> str: + """Build the Redis key for a camera's active YouTube recording.""" + return f"{YOUTUBE_RECORDING_SESSION_CACHE_PREFIX}:{camera_id}" + + +def build_recording_text( + *, + product_id: PositiveInt, + title: str | None, + description: str | None, +) -> tuple[str, str]: + """Build the final title and description for a YouTube recording.""" + now_str = serialize_datetime_with_z(datetime.now(UTC)) + resolved_title = title or f"Product {product_id} recording at {now_str}" + resolved_description = description or f"Recording of product {product_id} at {now_str}" + return resolved_title, resolved_description + + +def serialize_stream_metadata(metadata: object | None) -> dict[str, object] | None: + """Convert camera stream metadata into JSON-compatible data.""" + if metadata is None: + return None + if isinstance(metadata, BaseModel): + return cast("dict[str, object]", metadata.model_dump(mode="json")) + if isinstance(metadata, dict): + return cast("dict[str, object]", metadata) + msg = "Unsupported stream metadata type." + raise TypeError(msg) + + +async def store_recording_session( + redis_client: Redis, + db_session: AsyncSession, + camera_id: UUID4, + session: YouTubeRecordingSession, +) -> None: + """Persist in-progress recording state in Redis plus the DB backstop.""" + row = await db_session.get(RecordingSession, camera_id) + if row is None: + row = RecordingSession( + camera_id=camera_id, + product_id=int(session.product_id), + title=session.title, + description=session.description, + stream_url=str(session.stream_url), + broadcast_key=session.broadcast_key, + video_metadata=session.video_metadata, + ) + db_session.add(row) + else: + row.product_id = int(session.product_id) + row.title = session.title + row.description = session.description + row.stream_url = str(session.stream_url) + row.broadcast_key = session.broadcast_key + row.video_metadata = session.video_metadata + await db_session.commit() + + stored = await set_redis_value( + redis_client, + get_recording_session_cache_key(camera_id), + session.model_dump_json(), + ex=YOUTUBE_RECORDING_SESSION_TTL_SECONDS, + ) + if not stored: + persisted_row = await db_session.get(RecordingSession, camera_id) + if persisted_row is not None: + await db_session.delete(persisted_row) + await db_session.commit() + raise RecordingSessionStoreError + + +def _recording_session_from_row(row: RecordingSession) -> YouTubeRecordingSession: + """Convert the durable DB row into the service model.""" + return YouTubeRecordingSession.model_validate( + { + "product_id": row.product_id, + "title": row.title, + "description": row.description, + "stream_url": row.stream_url, + "broadcast_key": row.broadcast_key, + "video_metadata": row.video_metadata, + } + ) + + +async def load_recording_session( + redis_client: Redis, + db_session: AsyncSession, + camera_id: UUID4, +) -> YouTubeRecordingSession: + """Load recording state from Redis, falling back to the DB backstop.""" + payload = await get_redis_value(redis_client, get_recording_session_cache_key(camera_id)) + if payload is not None: + try: + return YouTubeRecordingSession.model_validate_json(payload) + except ValidationError: + logger.warning( + "Discarding malformed cached recording session for camera %s; falling back to DB", + sanitize_log_value(camera_id), + ) + + row = await db_session.get(RecordingSession, camera_id) + if row is None: + raise RecordingSessionNotFoundError + + session = _recording_session_from_row(row) + stored = await set_redis_value( + redis_client, + get_recording_session_cache_key(camera_id), + session.model_dump_json(), + ex=YOUTUBE_RECORDING_SESSION_TTL_SECONDS, + ) + if not stored: + logger.warning("Failed to repopulate Redis recording session for camera %s", sanitize_log_value(camera_id)) + return session + + +async def clear_recording_session(redis_client: Redis, db_session: AsyncSession, camera_id: UUID4) -> None: + """Remove recording state from Redis and the DB backstop.""" + row = await db_session.get(RecordingSession, camera_id) + if row is not None: + await db_session.delete(row) + await db_session.commit() + + cleared = await delete_redis_key(redis_client, get_recording_session_cache_key(camera_id)) + if not cleared: + logger.warning("Failed to clear YouTube recording session for camera %s", sanitize_log_value(camera_id)) + + +async def capture_and_store_image( + session: AsyncSession, + *, + camera_request: Callable[..., Awaitable[Response | RelayResponse]], + product_id: PositiveInt, + filename: str | None = None, + description: str | None = None, +) -> Image: + """Trigger a capture on the Pi and return the resulting stored ``Image``.""" + await require_model(session, Product, product_id) + + upload_metadata: dict[str, Any] = {"product_id": int(product_id)} + if description is not None: + upload_metadata["description"] = description + if filename is not None: + upload_metadata["filename"] = filename + + capture_response = await camera_request( + endpoint="/captures", + method=HttpMethod.POST, + body=upload_metadata, + error_msg="Failed to capture image", + ) + try: + capture_data = cast("dict[str, Any]", capture_response.json()) + except json.JSONDecodeError as e: + body_preview = getattr(capture_response, "content", b"")[:200] + logger.exception( + "Camera returned non-JSON response for POST /captures (%d bytes): %r", + len(getattr(capture_response, "content", b"")), + body_preview, + ) + raise InvalidCameraResponseError( + details=f"Expected JSON, got {len(body_preview)} bytes: {body_preview!r}", + ) from e + + status_value = str(capture_data.get("status") or ImageCaptureStatus.UPLOADED) + if status_value == ImageCaptureStatus.QUEUED: + raise InvalidCameraResponseError( + details=( + "Camera captured the image but the synchronous push to the backend failed; " + "it was queued on the device for retry. Please try again." + ), + ) + if status_value != ImageCaptureStatus.UPLOADED: + raise InvalidCameraResponseError( + details=f"Camera returned an unknown capture status: {status_value!r}", + ) + + image_id_hex = capture_data.get("image_id") + if not isinstance(image_id_hex, str): + raise InvalidCameraResponseError( + details=f"Camera response missing image_id: {capture_data!r}", + ) + try: + image_uuid = UUID(hex=image_id_hex) + except ValueError as exc: + raise InvalidCameraResponseError( + details=f"Camera returned malformed image_id: {image_id_hex!r}", + ) from exc + + image = await session.get(Image, image_uuid) + if image is None: + raise InvalidCameraResponseError( + details=( + f"Camera reported a successful upload but image {image_id_hex} was not found " + "in the backend database — upload may have been written to a different session." + ), + ) + return image diff --git a/backend/app/api/plugins/rpi_cam/services.py b/backend/app/api/plugins/rpi_cam/services.py index ca56db49..2900bd3e 100644 --- a/backend/app/api/plugins/rpi_cam/services.py +++ b/backend/app/api/plugins/rpi_cam/services.py @@ -1,82 +1,82 @@ """Camera interaction services.""" +from __future__ import annotations + +import asyncio +import logging +import secrets from datetime import UTC, datetime -from enum import Enum -from io import BytesIO - -from fastapi import UploadFile -from fastapi.datastructures import Headers -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from httpx_oauth.clients.google import GoogleOAuth2 -from pydantic import Field, PositiveInt -from relab_rpi_cam_models.stream import YoutubeStreamConfig -from sqlmodel.ext.asyncio.session import AsyncSession - -from app.api.auth.config import settings +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from httpx import AsyncClient, HTTPStatusError, RequestError, Response +from pydantic import Field, SecretStr, ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + from app.api.auth.models import OAuthAccount -from app.api.auth.services.oauth import GOOGLE_YOUTUBE_SCOPES -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists from app.api.common.exceptions import APIError from app.api.common.schemas.base import serialize_datetime_with_z -from app.api.data_collection.models import Product -from app.api.file_storage.crud import create_image -from app.api.file_storage.models.models import Image, ImageParentType -from app.api.file_storage.schemas import ImageCreateInternal -from app.api.plugins.rpi_cam.models import Camera -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import HttpMethod, fetch_from_camera_url - - -async def capture_and_store_image( - session: AsyncSession, - camera: Camera, - *, - product_id: PositiveInt, - filename: str | None = None, - description: str | None = None, -) -> Image: - """Capture image from camera and store in database. Optionally associate with a parent product.""" - # Validate the product_id - if product_id: - await db_get_model_with_id_if_it_exists(session, Product, product_id) - - # Capture image - capture_response = await fetch_from_camera_url( - camera=camera, - endpoint="/images", - method=HttpMethod.POST, - error_msg="Failed to capture image", - ) - capture_data = capture_response.json() - - # Download image - image_response = await fetch_from_camera_url( - camera=camera, - endpoint=capture_data["image_url"], - method=HttpMethod.GET, - error_msg="Failed to download image", - ) - - # Create image data and store in database - timestamp_str = capture_data.get("metadata", {}).get("image_properties", {}).get("capture_time") - image_data = ImageCreateInternal( - file=UploadFile( - file=BytesIO(image_response.content), - filename=filename if filename else f"{camera.name}_{serialize_datetime_with_z(datetime.now(UTC))}.jpg", - size=len(image_response.content), - headers=Headers({"content-type": "image/jpeg"}), - ), - description=(description if description else f"Captured from camera {camera.name} at {timestamp_str}."), - image_metadata=capture_data.get("metadata"), - parent_type=ImageParentType.PRODUCT, - parent_id=product_id, - ) - - return await create_image(session, image_data) - - -### Youtube API ### +from app.api.plugins.rpi_cam.exceptions import GoogleOAuthAssociationRequiredError +from app.api.plugins.rpi_cam.schemas.streaming import YoutubeStreamConfig +from app.api.plugins.rpi_cam.schemas.youtube import ( + YouTubeAPIErrorResponse, + YouTubeBroadcastContentDetailsCreate, + YouTubeBroadcastCreateRequest, + YouTubeBroadcastListResponse, + YouTubeBroadcastResponse, + YouTubeBroadcastStatusCreate, + YouTubeMonitorStreamResponse, + YouTubeSnippetCreate, + YouTubeStreamCDNCreate, + YouTubeStreamCreateRequest, + YouTubeStreamListResponse, + YouTubeStreamResponse, +) +from app.api.plugins.rpi_cam.service_runtime import ( + TELEMETRY_CACHE_PREFIX, + TELEMETRY_CACHE_TTL_SECONDS, + YOUTUBE_RECORDING_SESSION_CACHE_PREFIX, + YOUTUBE_RECORDING_SESSION_TTL_SECONDS, + YouTubeRecordingSession, + build_recording_text, + capture_and_store_image, + clear_recording_session, + get_cached_telemetry, + get_camera_last_seen_cache_key, + get_camera_online_cache_key, + get_camera_status, + get_preview_thumbnail_urls_per_camera, + get_recording_session_cache_key, + get_telemetry_cache_key, + load_recording_session, + mark_camera_offline, + mark_camera_online, + serialize_stream_metadata, + store_recording_session, + store_telemetry, +) + +if TYPE_CHECKING: + from httpx_oauth.clients.google import GoogleOAuth2 + + +logger = logging.getLogger(__name__) +YOUTUBE_API_BASE_URL = "https://www.googleapis.com/youtube/v3" + +# Retry schedule for transient YouTube API failures. Each entry is the base delay +# in seconds; jitter is added by ``_jittered_backoff_s``. Total worst-case wait is +# ~3.75 s + jitter across 3 attempts (initial + 2 retries). Retryable statuses are +# 429 / 5xx and any network-layer error; 4xx responses never retry. +_YOUTUBE_RETRY_BACKOFF_S: tuple[float, ...] = (0.25, 1.0) +_YOUTUBE_RETRYABLE_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503, 504}) + + +def _jittered_backoff_s(base: float) -> float: + """Return ``base`` with up to 25% additive jitter from ``secrets.SystemRandom``.""" + jitter = secrets.SystemRandom().random() * 0.25 + return base * (1.0 + jitter) + + class YouTubeAPIError(APIError): """Custom exception for YouTube API errors.""" @@ -85,7 +85,7 @@ def __init__(self, http_status_code: int = 500, details: str | None = None): super().__init__("YouTube API error.", details) -class YouTubePrivacyStatus(str, Enum): +class YouTubePrivacyStatus(StrEnum): """Enumeration of YouTube privacy statuses.""" PUBLIC = "public" @@ -102,30 +102,108 @@ class YoutubeStreamConfigWithID(YoutubeStreamConfig): class YouTubeService: """YouTube API service for creating and managing live streams.""" - def __init__(self, oauth_account: OAuthAccount, google_oauth_client: GoogleOAuth2): + def __init__( + self, + oauth_account: OAuthAccount, + google_oauth_client: GoogleOAuth2, + session: AsyncSession, + http_client: AsyncClient, + ) -> None: self.oauth_account = oauth_account self.google_oauth_client = google_oauth_client + self.session = session + self.http_client = http_client async def refresh_token_if_needed(self) -> None: - """Refresh OAuth token if expired.""" + """Refresh OAuth token if expired and persist to database.""" if self.oauth_account.expires_at and self.oauth_account.expires_at < datetime.now(UTC).timestamp(): - # TODO: if Refresh token is None, what to do? https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 + if not self.oauth_account.refresh_token: + raise GoogleOAuthAssociationRequiredError from None + new_token = await self.google_oauth_client.refresh_token(self.oauth_account.refresh_token) self.oauth_account.access_token = new_token["access_token"] self.oauth_account.expires_at = datetime.now(UTC).timestamp() + new_token["expires_in"] - def get_youtube_client(self) -> Resource: - """Get authenticated YouTube API client.""" - # TODO: Make Google API client thread safe and async if possible (using asyncio/asyncer): https://github.com/googleapis/google-api-python-client/blob/main/docs/thread_safety.md - credentials = Credentials( - token=self.oauth_account.access_token, - refresh_token=self.oauth_account.refresh_token, - token_uri="https://oauth2.googleapis.com/token", # noqa: S106 # No sensitive data in URL - client_id=settings.google_oauth_client_id, - client_secret=settings.google_oauth_client_secret, - scopes=GOOGLE_YOUTUBE_SCOPES, - ) - return build("youtube", "v3", credentials=credentials) + self.session.add(self.oauth_account) + await self.session.commit() + await self.session.refresh(self.oauth_account) + + async def request_youtube_api( + self, + method: str, + endpoint: str, + *, + params: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send an authenticated request to the YouTube Data API with retries.""" + total_attempts = len(_YOUTUBE_RETRY_BACKOFF_S) + 1 + for attempt in range(total_attempts): + if attempt: + await asyncio.sleep(_jittered_backoff_s(_YOUTUBE_RETRY_BACKOFF_S[attempt - 1])) + try: + response = await self.http_client.request( + method, + f"{YOUTUBE_API_BASE_URL}/{endpoint}", + params=params, + json=body, + headers={"Authorization": f"Bearer {self.oauth_account.access_token}"}, + ) + response.raise_for_status() + except HTTPStatusError as e: + status = e.response.status_code + error = YouTubeAPIError( + http_status_code=status, + details=self._build_error_detail(endpoint, e.response), + ) + if status in _YOUTUBE_RETRYABLE_STATUSES and attempt < total_attempts - 1: + logger.warning( + "YouTube API %s %s returned %d; retrying (%d/%d)", + method, + endpoint, + status, + attempt + 2, + total_attempts, + ) + continue + raise error from e + except RequestError as e: + if attempt < total_attempts - 1: + logger.warning( + "YouTube API %s %s network error; retrying (%d/%d): %s", + method, + endpoint, + attempt + 2, + total_attempts, + e, + ) + continue + raise YouTubeAPIError( + http_status_code=503, + details=f"Network error contacting YouTube API: {e}", + ) from e + + if response.status_code == 204: + return {} + return response.json() + + msg = f"YouTube API {method} {endpoint} exhausted retries without a terminal outcome" + raise RuntimeError(msg) + + @staticmethod + def _build_error_detail(endpoint: str, response: Response) -> str: + """Build a useful error message from a failed YouTube API response.""" + try: + error_payload = YouTubeAPIErrorResponse.model_validate(response.json()) + error_message = error_payload.error.message if error_payload.error else None + except ValueError: + error_message = response.text + except ValidationError: + error_message = response.text + + if error_message: + return f"Failed calling {endpoint}: {error_message}" + return f"Failed calling {endpoint}: HTTP {response.status_code}" async def setup_livestream( self, @@ -135,81 +213,134 @@ async def setup_livestream( ) -> YoutubeStreamConfigWithID: """Create a YouTube livestream and return stream configuration.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() - + broadcast_payload = YouTubeBroadcastCreateRequest( + snippet=YouTubeSnippetCreate( + title=title, + scheduledStartTime=serialize_datetime_with_z(datetime.now(UTC)), + description=description or "", + ), + status=YouTubeBroadcastStatusCreate(privacyStatus=privacy_status.value), + contentDetails=YouTubeBroadcastContentDetailsCreate(), + ) + broadcast = await self.request_youtube_api( + "POST", + "liveBroadcasts", + params={"part": "snippet,status,contentDetails"}, + body=broadcast_payload.model_dump(mode="json"), + ) try: - # Create broadcast - broadcast = ( - youtube.liveBroadcasts() - .insert( - part="snippet,status,contentDetails", - body={ - "snippet": { - "title": title, - "scheduledStartTime": serialize_datetime_with_z(datetime.now(UTC)), - "description": description or "", - }, - "status": {"privacyStatus": privacy_status.value, "selfDeclaredMadeForKids": False}, - "contentDetails": { # Enable auto start and stop of broadcast on stream start and stop - # TODO: Investigate potential pause function, which would require manual start/stop - "enableAutoStart": True, - "enableAutoStop": True, - }, - }, - ) - .execute() - ) - - # Create stream - # TODO: Create one stream per camera and store key and id in camera model - stream = ( - youtube.liveStreams() - .insert( - part="snippet,cdn", - body={ - "snippet": {"title": title}, - "cdn": {"frameRate": "30fps", "ingestionType": "hls", "resolution": "720p"}, - "description": description or "", - }, - ) - .execute() - ) - - # Bind them together - broadcast = ( - youtube.liveBroadcasts() - .bind(id=broadcast["id"], part="id,contentDetails", streamId=stream["id"]) - .execute() - ) - - return YoutubeStreamConfigWithID( - stream_key=stream["cdn"]["ingestionInfo"]["streamName"], - broadcast_key=broadcast["id"], - stream_id=stream["id"], - ) - - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to create livestream: {e}") from e + broadcast_response = YouTubeBroadcastResponse.model_validate(broadcast) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube broadcast response: {e}") from e + + stream_payload = YouTubeStreamCreateRequest( + snippet=YouTubeSnippetCreate(title=title, description=description or ""), + cdn=YouTubeStreamCDNCreate(), + description=description or "", + ) + stream = await self.request_youtube_api( + "POST", + "liveStreams", + params={"part": "snippet,cdn"}, + body=stream_payload.model_dump(mode="json"), + ) + try: + stream_response = YouTubeStreamResponse.model_validate(stream) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube stream response: {e}") from e + + broadcast = await self.request_youtube_api( + "POST", + "liveBroadcasts/bind", + params={"id": broadcast_response.id, "part": "id,contentDetails", "streamId": stream_response.id}, + ) + try: + bound_broadcast_response = YouTubeBroadcastResponse.model_validate(broadcast) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube bind response: {e}") from e + + return YoutubeStreamConfigWithID( + stream_key=SecretStr(stream_response.cdn.ingestionInfo.streamName), + broadcast_key=SecretStr(bound_broadcast_response.id), + stream_id=stream_response.id, + ) async def validate_stream_status(self, stream_id: str) -> bool: """Check if a YouTube livestream is live.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() try: - response = youtube.liveStreams().list(part="status", id=stream_id).execute() - return response["items"][0]["status"]["streamStatus"] in ("active", "ready") - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to validate livestream: {e}") from e - except KeyError as e: - raise YouTubeAPIError(details=f"Failed to validate livestream: {e}") from e + response = await self.request_youtube_api( + "GET", + "liveStreams", + params={"part": "status", "id": stream_id}, + ) + stream_list_response = YouTubeStreamListResponse.model_validate(response) + if not stream_list_response.items: + raise YouTubeAPIError(details="Failed to validate livestream: stream not found.") + return stream_list_response.items[0].status.streamStatus in ("active", "ready") + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube stream status response: {e}") from e async def end_livestream(self, broadcast_key: str) -> None: - """End a YouTube livestream.""" + """End a YouTube livestream by transitioning to 'complete', preserving the recording.""" + await self.refresh_token_if_needed() + await self.request_youtube_api( + "POST", + "liveBroadcasts/transition", + params={"broadcastStatus": "complete", "id": broadcast_key, "part": "status"}, + ) + + async def get_broadcast_monitor_stream(self, broadcast_key: str) -> YouTubeMonitorStreamResponse: + """Get the monitor stream configuration for a YouTube livestream.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() try: - youtube.liveBroadcasts().delete(id=broadcast_key).execute() - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to end livestream: {e}") from e + response = await self.request_youtube_api( + "GET", + "liveBroadcasts", + params={"part": "contentDetails", "id": broadcast_key}, + ) + broadcast_list_response = YouTubeBroadcastListResponse.model_validate(response) + if not broadcast_list_response.items: + raise YouTubeAPIError(details="Failed to fetch livestream monitor stream: broadcast not found.") + + content_details = broadcast_list_response.items[0].contentDetails + if content_details is None or content_details.monitorStream is None: + raise YouTubeAPIError( + details="Failed to fetch livestream monitor stream: monitor stream configuration missing." + ) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube broadcast response: {e}") from e + else: + return content_details.monitorStream + + +__all__ = [ + "TELEMETRY_CACHE_PREFIX", + "TELEMETRY_CACHE_TTL_SECONDS", + "YOUTUBE_API_BASE_URL", + "YOUTUBE_RECORDING_SESSION_CACHE_PREFIX", + "YOUTUBE_RECORDING_SESSION_TTL_SECONDS", + "YouTubeAPIError", + "YouTubePrivacyStatus", + "YouTubeRecordingSession", + "YouTubeService", + "YoutubeStreamConfigWithID", + "build_recording_text", + "capture_and_store_image", + "clear_recording_session", + "get_cached_telemetry", + "get_camera_last_seen_cache_key", + "get_camera_online_cache_key", + "get_camera_status", + "get_preview_thumbnail_urls_per_camera", + "get_recording_session_cache_key", + "get_telemetry_cache_key", + "load_recording_session", + "mark_camera_offline", + "mark_camera_online", + "serialize_stream_metadata", + "store_recording_session", + "store_telemetry", +] diff --git a/backend/app/api/plugins/rpi_cam/utils/device_contracts.py b/backend/app/api/plugins/rpi_cam/utils/device_contracts.py new file mode 100644 index 00000000..9d541f77 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/utils/device_contracts.py @@ -0,0 +1,58 @@ +"""Helpers for the private backend<->plugin device seam.""" + +from __future__ import annotations + +from pydantic import TypeAdapter +from relab_rpi_cam_models import ( + DevicePublicKeyJWK, + PairingClaimedBootstrap, + PairingClaimedRecord, + PairingPendingRecord, + RelayAuthScheme, +) + +_PAIRING_RECORD_ADAPTER = TypeAdapter(PairingPendingRecord | PairingClaimedRecord) + + +def parse_pairing_record(raw: str) -> PairingPendingRecord | PairingClaimedRecord: + """Parse a Redis-stored pairing record into its typed model.""" + return _PAIRING_RECORD_ADAPTER.validate_json(raw) + + +def dump_pairing_record(record: PairingPendingRecord | PairingClaimedRecord) -> str: + """Serialize a typed pairing record for Redis storage.""" + return record.model_dump_json(exclude_none=True) + + +def build_waiting_record( + *, + rpi_fingerprint: str, + public_key_jwk: DevicePublicKeyJWK, + key_id: str, +) -> PairingPendingRecord: + """Build the waiting-state record stored before claim.""" + return PairingPendingRecord( + rpi_fingerprint=rpi_fingerprint, + public_key_jwk=public_key_jwk, + key_id=key_id, + ) + + +def build_claimed_bootstrap( + *, + camera_id: str, + ws_url: str, + key_id: str, + auth_scheme: RelayAuthScheme = RelayAuthScheme.DEVICE_ASSERTION, +) -> PairingClaimedBootstrap: + """Build the backend-owned relay bootstrap payload returned to the Pi.""" + return PairingClaimedBootstrap(camera_id=camera_id, ws_url=ws_url, key_id=key_id, auth_scheme=auth_scheme) + + +def build_claimed_record(payload: PairingClaimedBootstrap, *, rpi_fingerprint: str) -> PairingClaimedRecord: + """Promote a claimed bootstrap payload into the Redis-stored paired record. + + The pending record's fingerprint is copied onto the claimed record so the + device poll can re-verify identity before receiving the bootstrap payload. + """ + return PairingClaimedRecord(**payload.model_dump(), rpi_fingerprint=rpi_fingerprint) diff --git a/backend/app/api/plugins/rpi_cam/utils/encryption.py b/backend/app/api/plugins/rpi_cam/utils/encryption.py index 0cfd2f29..2ecff105 100644 --- a/backend/app/api/plugins/rpi_cam/utils/encryption.py +++ b/backend/app/api/plugins/rpi_cam/utils/encryption.py @@ -2,14 +2,32 @@ import json import secrets -from typing import Any +from typing import TYPE_CHECKING from cryptography.fernet import Fernet, InvalidToken from app.api.plugins.rpi_cam.config import settings -# Initialize the Fernet cipher -CIPHER = Fernet(settings.rpi_cam_plugin_secret) +if TYPE_CHECKING: + from typing import Any + + +def _get_cipher() -> Fernet: + """Return the configured Fernet cipher. + + Lazily constructing the cipher avoids import-time failures in commands like + Alembic checks, where plugin models are imported but camera encryption is not used. + """ + secret = settings.rpi_cam_plugin_secret + if not secret: + msg = "RPi camera encryption secret is not configured." + raise RuntimeError(msg) + + try: + return Fernet(secret) + except ValueError as exc: + msg = "RPi camera encryption secret must be a 32-byte url-safe base64 Fernet key." + raise RuntimeError(msg) from exc def generate_api_key(prefix: str = "CAM") -> str: @@ -20,25 +38,25 @@ def generate_api_key(prefix: str = "CAM") -> str: def encrypt_str(s: str) -> str: """Encrypts a string before storing it in the database.""" - return CIPHER.encrypt(s.encode()).decode() + return _get_cipher().encrypt(s.encode()).decode() def decrypt_str(encrypted_key: str) -> str: """Decrypts a string when retrieving it from the database.""" - return CIPHER.decrypt(encrypted_key.encode()).decode() + return _get_cipher().decrypt(encrypted_key.encode()).decode() def encrypt_dict(data: dict[str, Any]) -> str: """Encrypt dictionary data using Fernet.""" json_data = json.dumps(data) - encrypted_data = CIPHER.encrypt(json_data.encode()) + encrypted_data = _get_cipher().encrypt(json_data.encode()) return encrypted_data.decode() def decrypt_dict(encrypted: str) -> dict[str, Any]: """Decrypt data back to dictionary.""" try: - decrypted_data = CIPHER.decrypt(encrypted.encode()) + decrypted_data = _get_cipher().decrypt(encrypted.encode()) return json.loads(decrypted_data) except InvalidToken as e: err_msg = f"Failed to decrypt data: {e}" diff --git a/backend/app/api/plugins/rpi_cam/websocket/__init__.py b/backend/app/api/plugins/rpi_cam/websocket/__init__.py new file mode 100644 index 00000000..2cd7e576 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket relay for Raspberry Pi Camera plugin.""" diff --git a/backend/app/api/plugins/rpi_cam/websocket/connection_manager.py b/backend/app/api/plugins/rpi_cam/websocket/connection_manager.py new file mode 100644 index 00000000..2a1598d5 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/connection_manager.py @@ -0,0 +1,137 @@ +"""In-process registry of active RPi camera WebSocket connections.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import uuid +from typing import TYPE_CHECKING + +from pydantic import UUID4 + +from app.api.plugins.rpi_cam.websocket.protocol import MSG_PONG, build_command + +if TYPE_CHECKING: + from fastapi import WebSocket + +logger = logging.getLogger(__name__) + +# How long to wait for a command response before giving up. +DEFAULT_COMMAND_TIMEOUT = 30.0 +# How long to wait for a response that includes a binary frame (e.g. image download). +BINARY_COMMAND_TIMEOUT = 60.0 + + +class CameraConnectionManager: + """Tracks WebSocket connections initiated by RPi cameras and routes commands to them.""" + + def __init__(self) -> None: + # camera_id → active WebSocket + self._connections: dict[UUID4, WebSocket] = {} + # msg_id → Future[tuple[dict, bytes | None]] + self._pending: dict[str, asyncio.Future[tuple[dict, bytes | None]]] = {} + + # ── Connection lifecycle ────────────────────────────────────────────────── + + async def register(self, camera_id: UUID4, ws: WebSocket) -> None: + """Register an active WebSocket connection for a camera. + + If a connection already exists for this camera (e.g. after reconnect), the + old WebSocket is closed before the new one is registered so its receive loop + exits cleanly instead of becoming an orphan. + """ + existing = self._connections.get(camera_id) + if existing is not None: + logger.warning("Camera %s reconnected; closing stale connection.", camera_id) + with contextlib.suppress(Exception): + await existing.close(code=1001) # 1001 = Going Away + self._connections[camera_id] = ws + logger.info("Camera %s connected via WebSocket", camera_id) + + def unregister(self, camera_id: UUID4) -> None: + """Remove a camera's connection and cancel any pending futures.""" + self._connections.pop(camera_id, None) + logger.info("Camera %s disconnected from WebSocket", camera_id) + + def is_connected(self, camera_id: UUID4) -> bool: + """Return True if the camera has an active WebSocket connection.""" + return camera_id in self._connections + + # ── Command dispatch ────────────────────────────────────────────────────── + + async def send_command( + self, + camera_id: UUID4, + method: str, + path: str, + params: dict | None = None, + body: dict | None = None, + headers: dict[str, str] | None = None, + ) -> tuple[dict, bytes | None]: + """Send a command to the camera and await its response. + + Returns (json_response, binary_bytes). binary_bytes is set when the + camera sends a binary frame after the JSON response (e.g. image data). + Wrap calls with ``asyncio.timeout()`` to enforce a deadline. + + Raises: + RuntimeError: Camera not connected. + """ + ws = self._connections.get(camera_id) + if ws is None: + msg = f"Camera {camera_id} is not connected via WebSocket." + raise RuntimeError(msg) + + msg_id = _new_msg_id() + loop = asyncio.get_running_loop() + future: asyncio.Future[tuple[dict, bytes | None]] = loop.create_future() + self._pending[msg_id] = future + + try: + payload = build_command(msg_id, method, path, params, body, headers) + await ws.send_text(payload) + return await future + finally: + self._pending.pop(msg_id, None) + + # ── Called by the receive loop in router.py ─────────────────────────────── + + def resolve_json(self, msg_id: str, data: dict, binary: bytes | None) -> None: + """Resolve a pending future with the response from the camera.""" + future = self._pending.get(msg_id) + if future and not future.done(): + future.set_result((data, binary)) + + async def handle_ping(self, camera_id: UUID4) -> None: + """Respond to a ping from the camera.""" + ws = self._connections.get(camera_id) + if ws: + await ws.send_text(json.dumps({"type": MSG_PONG})) + + +# ── Module-level singleton ──────────────────────────────────────────────────── + +_manager_state: dict[str, CameraConnectionManager | None] = {"manager": None} + + +def get_connection_manager() -> CameraConnectionManager: + """Return the global CameraConnectionManager (must be initialised at startup).""" + manager = _manager_state["manager"] + if manager is None: + msg = "CameraConnectionManager is not initialised." + raise RuntimeError(msg) + return manager + + +def set_connection_manager(manager: CameraConnectionManager) -> None: + """Set the global CameraConnectionManager (called during app startup).""" + _manager_state["manager"] = manager + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _new_msg_id() -> str: + return str(uuid.uuid4()) diff --git a/backend/app/api/plugins/rpi_cam/websocket/cross_worker_relay.py b/backend/app/api/plugins/rpi_cam/websocket/cross_worker_relay.py new file mode 100644 index 00000000..8ce7990d --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/cross_worker_relay.py @@ -0,0 +1,335 @@ +"""Cross-worker relay bridge for the RPi camera WebSocket tunnel. + +With multiple Uvicorn worker processes, a camera's WebSocket connection is +registered in exactly one worker's ``CameraConnectionManager``. HTTP relay +requests (HLS, image capture, telemetry, …) round-robin across all workers, +so the request may land on a different worker than the one holding the socket. + +This module provides a Redis-based bridge so any worker can dispatch a relay +command to the worker that owns the connection: + +:: + + Worker A (HTTP request) Worker B (holds WebSocket) + ─────────────────────────────────────────────────────────────── + relay_cross_worker() + RPUSH relay_cmd:{camera_id} ──► run_relay_listener() + {msg_id, method, path, …} BLPOP relay_cmd:{camera_id} + BLPOP relay_resp:{msg_id} ◄── manager.send_command() → Pi + timeout = 30 s / 60 s RPUSH relay_resp:{msg_id} + +Binary payloads (HLS segments, captured images) are base-64 encoded inside +the JSON response so a single ``decode_responses=True`` Redis client suffices. +""" +# spell-checker: ignore RPUSH, BLPOP + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import inspect +import json +import logging +import time +import uuid +from typing import TYPE_CHECKING, cast + +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from pydantic import UUID4 + from redis.asyncio import Redis + + from app.api.plugins.rpi_cam.websocket.connection_manager import CameraConnectionManager + +logger = logging.getLogger(__name__) + +# ── Blocking Redis singleton ─────────────────────────────────────────────────── +# BLPOP requires socket_timeout=None; the shared app Redis client uses +# socket_timeout=5 which causes TimeoutError mid-wait. main.py calls +# set_blocking_redis() at startup with a dedicated client. + +_blocking_redis_state: dict[str, Redis | None] = {"client": None} + + +def set_blocking_redis(client: Redis | None) -> None: + """Register the blocking Redis client (called once at startup).""" + _blocking_redis_state["client"] = client + + +def get_blocking_redis() -> Redis | None: + """Return the blocking Redis client, or None if unavailable.""" + return _blocking_redis_state["client"] + + +# ── Redis key templates ──────────────────────────────────────────────────────── + + +def _cmd_key(camera_id: UUID4) -> str: + return f"rpi_cam:relay_cmd:{camera_id}" + + +def _resp_key(msg_id: str) -> str: + return f"rpi_cam:relay_resp:{msg_id}" + + +# Expire stale response keys in case the requesting worker dies before reading. +_RESP_TTL_MIN_SECONDS = 120 + +# Cap the number of queued relay commands per camera so a misbehaving requester +# or a slow Pi cannot grow the Redis list unbounded. Oldest commands are +# dropped via LTRIM after the RPUSH; stale commands also self-filter on the +# listener side via their ``deadline`` field. +_CMD_QUEUE_MAX_LEN = 256 + + +def _resp_ttl_seconds(timeout_s: float) -> int: + return max(_RESP_TTL_MIN_SECONDS, int(timeout_s) + 10) + + +async def _await_redis_result[T](result: Awaitable[T] | T) -> T: + """Await Redis calls only when the type checker cannot prove they are async.""" + if inspect.isawaitable(result): + return await cast("Awaitable[T]", result) + return cast("T", result) + + +# ── Requesting-worker side ───────────────────────────────────────────────────── + + +async def relay_cross_worker( + redis: Redis, + camera_id: UUID4, + method: str, + path: str, + params: dict | None, + body: dict | None, + headers: dict[str, str] | None, + *, + timeout_s: float, +) -> tuple[dict, bytes | None]: + """Send a relay command to whichever worker holds the camera's WebSocket. + + Pushes the command onto a per-camera Redis list and blocks on a + per-request response list until the owning worker replies or the timeout + fires. + + Returns: + ``(json_response_dict, binary_bytes_or_None)`` — same shape as + ``CameraConnectionManager.send_command``. + + Raises: + RuntimeError: Camera did not respond (timeout or listener reported an + error). Callers convert this to HTTP 503. + """ + msg_id = str(uuid.uuid4()) + deadline = time.monotonic() + timeout_s + + command_payload = json.dumps( + { + "msg_id": msg_id, + "method": method, + "path": path, + "params": params, + "body": body, + "headers": headers or {}, + "deadline": time.time() + timeout_s, # wall-clock for cross-process comparison + "timeout_s": timeout_s, + } + ) + + resp_key = _resp_key(msg_id) + + cmd_key = _cmd_key(camera_id) + await _await_redis_result(redis.rpush(cmd_key, command_payload)) + # Keep only the most recent _CMD_QUEUE_MAX_LEN entries; older ones self-expire + # via their deadline on the listener side. + await _await_redis_result(redis.ltrim(cmd_key, -_CMD_QUEUE_MAX_LEN, -1)) + + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = f"Relay deadline already passed before waiting for response: {path}" + raise RuntimeError(msg) + + # Use the blocking client (socket_timeout=None) so BLPOP can wait for the + # full relay timeout without the socket being closed prematurely. + blocking_redis = get_blocking_redis() or redis + try: + async with asyncio.timeout(remaining): + result = await _await_redis_result(blocking_redis.blpop(resp_key, timeout=0)) + except TimeoutError as exc: + msg = f"Cross-worker relay timed out waiting for camera response: {path}" + raise RuntimeError(msg) from exc + if result is None: + msg = f"Cross-worker relay timed out waiting for camera response: {path}" + raise RuntimeError(msg) + + _key, raw_resp = result + try: + resp = json.loads(raw_resp) + except json.JSONDecodeError as exc: + msg = f"Cross-worker relay received malformed response JSON: {raw_resp!r}" + raise RuntimeError(msg) from exc + + if error := resp.get("error"): + raise RuntimeError(error) + + json_data: dict = resp.get("data") or {} + # Restore the full relay response structure the caller expects. + json_resp = { + "status": resp.get("status", 500), + "data": json_data, + } + + binary: bytes | None = None + if binary_b64 := resp.get("binary_b64"): + try: + binary = base64.b64decode(binary_b64) + except Exception as exc: + msg = "Cross-worker relay could not decode binary payload" + raise RuntimeError(msg) from exc + + return json_resp, binary + + +# ── Camera-owning-worker side ────────────────────────────────────────────────── + + +async def run_relay_listener( + redis: Redis, + camera_id: UUID4, + manager: CameraConnectionManager, +) -> None: + """Background task: service cross-worker relay commands for one camera. + + Runs for the lifetime of the camera's WebSocket connection. Cancelled + (via ``asyncio.Task.cancel()``) when the camera disconnects. + + The task pops commands from ``rpi_cam:relay_cmd:{camera_id}``, relays each + to the camera via the local ``CameraConnectionManager``, and pushes the + response to ``rpi_cam:relay_resp:{msg_id}`` so the requesting worker can + read it with ``BLPOP``. + """ + cmd_key = _cmd_key(camera_id) + camera_log_id = sanitize_log_value(camera_id) + # Use the blocking client (socket_timeout=None) for the indefinite BLPOP. + blocking_redis = get_blocking_redis() or redis + logger.debug("Cross-worker relay listener started for camera %s", camera_log_id) + + try: + while True: + # Block until a command arrives or the task is cancelled. + # timeout=0 means "block indefinitely" in redis-py. + try: + result = await _await_redis_result(blocking_redis.blpop(cmd_key, timeout=0)) + except asyncio.CancelledError: + break + + if result is None: + # Should not happen with timeout=0, but guard defensively. + continue + + _key, raw_cmd = result + try: + cmd = json.loads(raw_cmd) + except json.JSONDecodeError: + logger.warning( + "Relay listener for camera %s received malformed command JSON, skipping.", + camera_log_id, + ) + continue + + msg_id: str = cmd.get("msg_id", "") + msg_log_id = sanitize_log_value(msg_id) + if not msg_id: + logger.warning("Relay listener received command without msg_id, skipping.") + continue + + # Honour the deadline set by the requesting worker. + deadline: float = cmd.get("deadline", 0.0) + if deadline and time.time() > deadline: + logger.debug( + "Relay listener skipping expired command %s for camera %s", + msg_log_id, + camera_log_id, + ) + continue + + await _execute_and_respond(redis, camera_id, manager, cmd, msg_id) + + except asyncio.CancelledError: + pass + finally: + logger.debug("Cross-worker relay listener stopped for camera %s", camera_log_id) + + +async def _execute_and_respond( + redis: Redis, + camera_id: UUID4, + manager: CameraConnectionManager, + cmd: dict, + msg_id: str, +) -> None: + """Execute one relayed command and push the response to Redis.""" + resp_key = _resp_key(msg_id) + camera_log_id = sanitize_log_value(camera_id) + msg_log_id = sanitize_log_value(msg_id) + method: str = cmd.get("method", "GET") + path: str = cmd.get("path", "/") + params: dict | None = cmd.get("params") + body: dict | None = cmd.get("body") + headers: dict[str, str] | None = cmd.get("headers") + + try: + json_resp, binary = await manager.send_command( + camera_id, + method, + path, + params=params, + body=body, + headers=headers, + ) + except RuntimeError as exc: + # Camera disconnected mid-flight — report error and stop listening. + logger.warning( + "Relay listener: camera %s disconnected during cross-worker command %s: %s", + camera_log_id, + msg_log_id, + sanitize_log_value(exc), + ) + error_payload = json.dumps({"error": str(exc)}) + with contextlib.suppress(Exception): + await _await_redis_result(redis.rpush(resp_key, error_payload)) + await _await_redis_result(redis.expire(resp_key, _resp_ttl_seconds(cmd.get("timeout_s", 0)))) + return + except Exception as exc: + logger.exception( + "Relay listener: unexpected error executing command %s for camera %s", + msg_log_id, + camera_log_id, + ) + error_payload = json.dumps({"error": f"Internal relay error: {exc}"}) + with contextlib.suppress(Exception): + await _await_redis_result(redis.rpush(resp_key, error_payload)) + await _await_redis_result(redis.expire(resp_key, _resp_ttl_seconds(cmd.get("timeout_s", 0)))) + return + + response: dict = { + "status": json_resp.get("status", 500), + "data": json_resp.get("data"), + } + if binary is not None: + response["binary_b64"] = base64.b64encode(binary).decode() + + try: + await _await_redis_result(redis.rpush(resp_key, json.dumps(response))) + await _await_redis_result(redis.expire(resp_key, _resp_ttl_seconds(cmd.get("timeout_s", 0)))) + except Exception: + logger.exception( + "Relay listener: failed to push response for command %s (camera %s)", + msg_log_id, + camera_log_id, + ) diff --git a/backend/app/api/plugins/rpi_cam/websocket/protocol.py b/backend/app/api/plugins/rpi_cam/websocket/protocol.py new file mode 100644 index 00000000..c18fc422 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/protocol.py @@ -0,0 +1,58 @@ +"""WebSocket message protocol for the RPi camera relay.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from relab_rpi_cam_models import RelayCommandEnvelope, RelayMessageType + +# Message type sent backend → RPi +MSG_REQUEST = RelayMessageType.REQUEST +# Message type sent RPi → backend +MSG_RESPONSE = RelayMessageType.RESPONSE +# Heartbeat messages (bidirectional) +MSG_PING = RelayMessageType.PING +MSG_PONG = RelayMessageType.PONG + + +def build_command( + msg_id: str, + method: str, + path: str, + params: dict | None = None, + body: dict | None = None, + headers: dict[str, str] | None = None, +) -> str: + """Serialise a command message to send to the RPi.""" + return RelayCommandEnvelope( + id=msg_id, + method=method, + path=path, + params=params or {}, + body=body, + headers=headers or {}, + ).model_dump_json() + + +@dataclass +class RelayResponse: + """Mimics the subset of httpx.Response used by camera interaction code.""" + + status_code: int + _json_data: dict | list | None = field(default=None, repr=False) + _content: bytes = field(default=b"", repr=False) + + def json(self) -> dict | list: + """Return parsed JSON payload.""" + if self._json_data is not None: + return self._json_data + return json.loads(self._content) + + @property + def content(self) -> bytes: + """Return raw response bytes.""" + return self._content + + def raise_for_status(self) -> None: + """No-op — errors are raised by relay.py before returning this object.""" diff --git a/backend/app/api/plugins/rpi_cam/websocket/relay.py b/backend/app/api/plugins/rpi_cam/websocket/relay.py new file mode 100644 index 00000000..d7336431 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/relay.py @@ -0,0 +1,298 @@ +"""Relay camera HTTP-style commands through an active WebSocket connection.""" +# spell-checker: ignore BLPOP + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +from typing import TYPE_CHECKING + +from fastapi import HTTPException +from opentelemetry.propagate import inject +from pydantic import UUID4 +from redis.exceptions import RedisError +from relab_rpi_cam_models import SAFE_RELAY_TRACE_HEADERS + +from app.api.plugins.rpi_cam.websocket.connection_manager import ( + BINARY_COMMAND_TIMEOUT, + DEFAULT_COMMAND_TIMEOUT, + get_connection_manager, +) +from app.api.plugins.rpi_cam.websocket.cross_worker_relay import relay_cross_worker +from app.api.plugins.rpi_cam.websocket.protocol import RelayResponse +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) +_RELAY_RETRY_AFTER_SECONDS = "2" + +# ── Cross-worker relay circuit breaker ──────────────────────────────────────── +# When a camera's WebSocket is not local AND the cross-worker bridge keeps +# failing (camera genuinely offline, not just in another worker), we open a +# per-camera circuit so subsequent requests fast-fail in <1 ms instead of +# paying the full 30 s / 60 s BLPOP timeout. Resets on first success. +# +# State is per-worker (in-process) — each worker learns independently that a +# camera is unreachable. That's fine: the circuit breaker's job is just to +# absorb the stampede of incoming HTTP requests that pile up while a camera +# is down; persistence across workers is not required. +_CROSS_WORKER_CB_FAILURE_THRESHOLD = 3 +_CROSS_WORKER_CB_COOL_DOWN_S = 30.0 +# Map[camera_id] → (consecutive_failures, open_until_monotonic). Local counter; +# threshold crossings publish an "open" marker to Redis so other workers also +# fast-fail without each having to rediscover the camera is offline. +_cross_worker_cb_state: dict[UUID4, tuple[int, float]] = {} + + +def _cb_redis_key(camera_id: UUID4) -> str: + return f"rpi_cam:cb:{camera_id}" + + +async def _cb_is_open(camera_id: UUID4, redis: Redis | None, *, now: float | None = None) -> bool: + """Return True if the cross-worker circuit for ``camera_id`` is currently open.""" + entry = _cross_worker_cb_state.get(camera_id) + if entry is not None: + _failures, open_until = entry + if (now if now is not None else time.monotonic()) < open_until: + return True + if redis is None: + return False + try: + exists = await redis.exists(_cb_redis_key(camera_id)) + except TimeoutError, RedisError, OSError, ConnectionError: + return False + return bool(exists) + + +async def _cb_record_success(camera_id: UUID4, redis: Redis | None) -> None: + """Reset circuit state on a successful cross-worker call.""" + _cross_worker_cb_state.pop(camera_id, None) + if redis is not None: + with contextlib.suppress(Exception): + await redis.delete(_cb_redis_key(camera_id)) + + +async def _cb_record_failure(camera_id: UUID4, redis: Redis | None) -> None: + """Record a failed cross-worker call; open the circuit at the threshold.""" + failures, _ = _cross_worker_cb_state.get(camera_id, (0, 0.0)) + failures += 1 + open_until = ( + time.monotonic() + _CROSS_WORKER_CB_COOL_DOWN_S if failures >= _CROSS_WORKER_CB_FAILURE_THRESHOLD else 0.0 + ) + _cross_worker_cb_state[camera_id] = (failures, open_until) + if open_until: + logger.warning( + "Cross-worker relay circuit opened for camera %s after %d failures; " + "fast-failing subsequent requests for %.0fs", + camera_id, + failures, + _CROSS_WORKER_CB_COOL_DOWN_S, + ) + if redis is not None: + with contextlib.suppress(Exception): + await redis.set(_cb_redis_key(camera_id), "1", ex=int(_CROSS_WORKER_CB_COOL_DOWN_S)) + + +def _reset_cross_worker_cb_for_tests() -> None: + """Test hook: clear all per-camera circuit breaker state.""" + _cross_worker_cb_state.clear() + + +def _camera_not_connected() -> HTTPException: + """Return the canonical 503 for an unreachable camera.""" + return HTTPException( + status_code=503, + detail="Camera is not connected via WebSocket.", + headers={"Retry-After": _RELAY_RETRY_AFTER_SECONDS}, + ) + + +async def _attempt_cross_worker_relay( + redis: Redis, + camera_id: UUID4, + method: str, + path: str, + params: dict | None, + body: dict | None, + headers: dict[str, str] | None, + *, + timeout_s: float, +) -> tuple[dict, bytes | None]: + """Dispatch a relay command across worker processes, gated by the circuit breaker. + + Raises ``HTTPException(503)`` immediately when the circuit is open to spare + callers the full BLPOP timeout. Success resets the circuit; failure advances it + toward the open state. + """ + if await _cb_is_open(camera_id, redis): + logger.debug("Cross-worker relay circuit open for camera %s; fast-failing", camera_id) + raise _camera_not_connected() + + logger.debug("Camera %s not in local manager; attempting cross-worker relay.", camera_id) + try: + async with asyncio.timeout(timeout_s): + result = await relay_cross_worker( + redis, + camera_id, + method, + path, + params, + body, + headers, + timeout_s=timeout_s, + ) + except (RuntimeError, TimeoutError) as cross_exc: + logger.warning("Cross-worker relay failed for camera %s: %s", camera_id, cross_exc) + await _cb_record_failure(camera_id, redis) + raise + + await _cb_record_success(camera_id, redis) + return result + + +# Exact (method, path) pairs permitted through the relay. Anything outside this +# set and _ALLOWED_PATH_PREFIXES is rejected with 403. +# +# The relay carries commands only: captured image bytes travel over a direct +# Pi→backend HTTPS upload (see +# ``routers/camera_interaction/images.py::receive_camera_upload``), not +# through this allowlist. +_ALLOWED_COMMANDS = { + ("GET", "/camera"), + ("POST", "/captures"), + ("GET", "/streams/youtube"), + ("POST", "/streams/youtube"), + ("DELETE", "/streams/youtube"), + ("GET", "/system/telemetry"), + # Sent by the backend when a camera is deleted so the Pi clears its + # credentials and re-enters pairing mode automatically. + ("DELETE", "/pairing"), + # Fetched by the backend on behalf of the frontend to deliver the local + # API key and candidate IP addresses for Ethernet/USB-C direct-connect setup. + ("GET", "/system/local-access"), +} + +# Dynamic prefixes. Requests where `path.startswith(prefix)` are permitted for +# the matching method. Prefix entries must end in `/` to avoid accidental +# prefix-of-sibling matches. +_ALLOWED_PATH_PREFIXES: tuple[tuple[str, str], ...] = ( + # LL-HLS live preview. The Pi proxies to its local MediaMTX HLS listener + # on :8888 and returns playlist + segment bytes. Every segment fetch is + # one relay round-trip — at ~500kbps lores / 200ms parts that's ~12.5 KB + # per request, which the WebSocket carries fine. + ("GET", "/preview/hls/"), +) + + +def _relay_command_allowed(method: str, path: str) -> bool: + if (method, path) in _ALLOWED_COMMANDS: + return True + return any(method == m and path.startswith(p) for m, p in _ALLOWED_PATH_PREFIXES) + + +def _build_relay_trace_headers() -> dict[str, str]: + """Inject the current trace context into relay-safe headers.""" + carrier: dict[str, str] = {} + inject(carrier) + return {name: carrier[name] for name in SAFE_RELAY_TRACE_HEADERS if name in carrier} + + +async def relay_via_websocket( + camera_id: UUID4, + method: str, + path: str, + params: dict | None = None, + body: dict | None = None, + *, + error_msg: str | None = None, + expect_binary: bool = False, + redis: Redis | None = None, +) -> RelayResponse: + """Send an allowlisted command to a camera over its WebSocket connection. + + If the camera's WebSocket is registered in this worker the command is sent + directly (fast path). When it lives in a different worker process, + ``redis`` is used to bridge the request via ``cross_worker_relay`` — the + owning worker picks up the command, forwards it to the Pi, and posts the + response back. Pass ``redis=None`` to disable cross-worker bridging (the + call will raise 503 when the camera is not connected locally). + """ + normalized_method = method.upper() + if not _relay_command_allowed(normalized_method, path): + raise HTTPException(status_code=403, detail=f"Relay command is not allowed: {normalized_method} {path}") + + manager = get_connection_manager() + timeout = BINARY_COMMAND_TIMEOUT if expect_binary else DEFAULT_COMMAND_TIMEOUT + relay_headers = _build_relay_trace_headers() + + try: + async with asyncio.timeout(timeout): + json_resp, binary = await manager.send_command( + camera_id, + normalized_method, + path, + params=params, + body=body, + headers=relay_headers or None, + ) + except RuntimeError as exc: + # Camera not connected in this worker — try the cross-worker bridge. + if redis is None: + logger.warning( + "Camera %s not connected for relay: %s", + sanitize_log_value(camera_id), + sanitize_log_value(exc), + ) + raise _camera_not_connected() from exc + try: + json_resp, binary = await _attempt_cross_worker_relay( + redis, + camera_id, + normalized_method, + path, + params, + body, + relay_headers or None, + timeout_s=timeout, + ) + except HTTPException: + raise + except (RuntimeError, TimeoutError) as cross_exc: + raise _camera_not_connected() from cross_exc + except TimeoutError as exc: + raise HTTPException( + status_code=503, + detail=f"Camera did not respond in time: {path}", + headers={"Retry-After": _RELAY_RETRY_AFTER_SECONDS}, + ) from exc + + response_status = json_resp.get("status", 500) + response_data = json_resp.get("data") + + if response_status >= 400: + _detail = error_msg or f"Camera returned error for {normalized_method} {path}" + logger.warning( + "Camera %s returned %d for %s %s: %s", + sanitize_log_value(camera_id), + response_status, + sanitize_log_value(normalized_method), + sanitize_log_value(path), + sanitize_log_value(response_data), + ) + raise HTTPException(status_code=response_status, detail=_detail) + + if binary is not None: + return RelayResponse(status_code=response_status, _content=binary) + + # When the Pi returns a plain text body (e.g. an m3u8 playlist with content-type + # application/vnd.apple.mpegurl), the relay puts it in the JSON data field as a + # string rather than as a binary frame. Store it in _content so callers that use + # relay_response.content (like proxy_hls) receive the actual bytes. + if isinstance(response_data, str): + return RelayResponse(status_code=response_status, _content=response_data.encode()) + + return RelayResponse(status_code=response_status, _json_data=response_data) diff --git a/backend/app/api/plugins/rpi_cam/websocket/router.py b/backend/app/api/plugins/rpi_cam/websocket/router.py new file mode 100644 index 00000000..7cf42bdc --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/websocket/router.py @@ -0,0 +1,312 @@ +"""WebSocket endpoint that RPi cameras connect to for the relay tunnel.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import Mapping +from typing import TYPE_CHECKING + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status +from jwt import InvalidTokenError +from pydantic import UUID4 +from relab_rpi_cam_models import RelayResponseEnvelope +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.plugins.rpi_cam.device_assertion import verify_device_assertion +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.services import mark_camera_offline, mark_camera_online +from app.api.plugins.rpi_cam.websocket.connection_manager import CameraConnectionManager +from app.api.plugins.rpi_cam.websocket.cross_worker_relay import run_relay_listener +from app.api.plugins.rpi_cam.websocket.protocol import MSG_PING, MSG_PONG, MSG_RESPONSE +from app.core.database import get_async_session +from app.core.logging import sanitize_log_value +from app.core.middleware.client_ip import extract_client_ip +from app.core.runtime import get_connection_redis, require_connection_camera_manager, require_connection_redis + +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_WS_DISCONNECT = "websocket.disconnect" +_WS_TEXT = "text" +_WS_BYTES = "bytes" + +_HEARTBEAT_INTERVAL = 30.0 +_HEARTBEAT_TIMEOUT = 90.0 +_MAX_AUTH_FAILURES = 5 +_MAX_CAMERA_AUTH_FAILURES = 20 +_AUTH_LOCKOUT_SECONDS = 300.0 +_auth_failures: dict[str, tuple[int, float]] = {} +_camera_auth_failures: dict[str, tuple[int, float]] = {} + + +@router.websocket("/plugins/rpi-cam/ws/connect") +async def camera_websocket_connect(websocket: WebSocket, camera_id: UUID4) -> None: + """Persistent WebSocket connection for an RPi camera relay tunnel.""" + if not await _authenticate(websocket, camera_id): + return + + await websocket.accept() + + manager: CameraConnectionManager = require_connection_camera_manager(websocket) + await manager.register(camera_id, websocket) + + redis = get_connection_redis(websocket) + + last_pong_at: list[float] = [asyncio.get_running_loop().time()] + heartbeat = asyncio.create_task( + _heartbeat_loop(websocket, camera_id, last_pong_at), + name=f"ws-heartbeat-{camera_id}", + ) + # Cross-worker relay listener: allows other Uvicorn worker processes to + # dispatch relay commands to this worker (the one holding the WebSocket). + relay_listener: asyncio.Task | None = None + if redis is not None: + relay_listener = asyncio.create_task( + run_relay_listener(redis, camera_id, manager), + name=f"ws-relay-listener-{camera_id}", + ) + try: + await _receive_loop(websocket, camera_id, manager, last_pong_at, redis) + except WebSocketDisconnect: + pass + except Exception: + logger.exception("Unexpected error in WebSocket receive loop for camera %s", sanitize_log_value(camera_id)) + finally: + heartbeat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat + if relay_listener is not None: + relay_listener.cancel() + with contextlib.suppress(asyncio.CancelledError): + await relay_listener + manager.unregister(camera_id) + if redis: + await mark_camera_offline(redis, camera_id) + + +async def _check_lockout(websocket: WebSocket, client_ip: str, camera_key: str, now: float) -> tuple[int, int] | None: + """Return (ip_count, camera_count) if caller is not locked out, else None.""" + fail_count, last_fail_at = _auth_failures.get(client_ip, (0, 0.0)) + if now - last_fail_at > _AUTH_LOCKOUT_SECONDS: + fail_count = 0 + if fail_count >= _MAX_AUTH_FAILURES: + logger.warning("Auth from %s blocked — too many failures.", sanitize_log_value(client_ip)) + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Too many failed attempts.") + return None + + cam_fail_count, cam_last_fail_at = _camera_auth_failures.get(camera_key, (0, 0.0)) + if now - cam_last_fail_at > _AUTH_LOCKOUT_SECONDS: + cam_fail_count = 0 + if cam_fail_count >= _MAX_CAMERA_AUTH_FAILURES: + logger.warning("Auth for camera %s blocked — too many failures.", sanitize_log_value(camera_key)) + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Too many failed attempts.") + return None + + return fail_count, cam_fail_count + + +async def _authenticate(websocket: WebSocket, camera_id: UUID4) -> bool: + """Validate a short-lived signed camera device assertion.""" + client_ip = extract_client_ip(websocket.headers, websocket.client.host if websocket.client else "unknown") + camera_key = str(camera_id) + loop = asyncio.get_running_loop() + + lockout = await _check_lockout(websocket, client_ip, camera_key, loop.time()) + if lockout is None: + return False + fail_count, cam_fail_count = lockout + + assertion = _extract_bearer_token(websocket) + if not assertion: + _record_auth_failure(client_ip, camera_key, fail_count, cam_fail_count, loop.time()) + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing Authorization header.") + return False + + camera = await _get_camera(camera_id) + if camera is None or not camera.credential_is_active: + _record_auth_failure(client_ip, camera_key, fail_count, cam_fail_count, loop.time()) + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Authentication failed.") + return False + + try: + redis = require_connection_redis(websocket) + except RuntimeError as exc: + logger.warning("Redis is required for RPi camera relay assertion replay protection: %s", exc) + await websocket.close(code=status.WS_1011_INTERNAL_ERROR, reason="Authentication service unavailable.") + return False + + try: + payload = await verify_device_assertion(assertion, camera, redis) + except InvalidTokenError as exc: + logger.warning( + "Camera %s assertion rejected from %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(client_ip), + sanitize_log_value(exc), + ) + _record_auth_failure(client_ip, camera_key, fail_count, cam_fail_count, loop.time()) + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Authentication failed.") + return False + + await mark_camera_online(redis, camera_id) + _auth_failures.pop(client_ip, None) + _camera_auth_failures.pop(camera_key, None) + logger.info( + "Camera %s authenticated from %s with key %s.", + sanitize_log_value(camera_id), + sanitize_log_value(client_ip), + sanitize_log_value(payload.get("kid") or camera.relay_key_id), + ) + return True + + +def _extract_bearer_token(websocket: WebSocket) -> str: + raw_auth = websocket.headers.get("Authorization", "") + return raw_auth.removeprefix("Bearer ").strip() + + +async def _get_camera(camera_id: UUID4) -> Camera | None: + session_gen = get_async_session() + session: AsyncSession = await session_gen.__anext__() + try: + return await session.get(Camera, camera_id) + finally: + await session.close() + + +def _record_auth_failure(ip: str, camera_key: str, ip_count: int, camera_count: int, now: float) -> None: + new_ip = ip_count + 1 + new_cam = camera_count + 1 + _auth_failures[ip] = (new_ip, now) + _camera_auth_failures[camera_key] = (new_cam, now) + logger.warning( + "Auth failure (ip=%s %d/%d; camera=%s %d/%d).", + sanitize_log_value(ip), + new_ip, + _MAX_AUTH_FAILURES, + sanitize_log_value(camera_key), + new_cam, + _MAX_CAMERA_AUTH_FAILURES, + ) + + +async def _heartbeat_loop(websocket: WebSocket, camera_id: UUID4, last_pong_at: list[float]) -> None: + """Send periodic pings; disconnect if no pong arrives within the timeout.""" + loop = asyncio.get_running_loop() + while True: + await asyncio.sleep(_HEARTBEAT_INTERVAL) + elapsed = loop.time() - last_pong_at[0] + if elapsed > _HEARTBEAT_TIMEOUT: + logger.warning( + "Camera %s heartbeat timeout (%.0fs since last pong); closing.", sanitize_log_value(camera_id), elapsed + ) + with contextlib.suppress(Exception): + await websocket.close(code=1001) + return + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": MSG_PING})) + + +async def _receive_loop( + websocket: WebSocket, + camera_id: UUID4, + manager: CameraConnectionManager, + last_pong_at: list[float], + redis: Redis | None, +) -> None: + """Process incoming frames until the connection closes or a disconnect frame arrives.""" + pending_binary_id: str | None = None + pending_binary_json: dict | None = None + + while True: + raw = await websocket.receive() + + if raw["type"] == _WS_DISCONNECT: + break + + if _WS_TEXT in raw: + pending_binary_id, pending_binary_json = await _handle_text_frame( + raw, camera_id, manager, pending_binary_id, pending_binary_json, last_pong_at, redis + ) + elif _WS_BYTES in raw: + pending_binary_id, pending_binary_json = _handle_binary_frame( + raw, camera_id, manager, pending_binary_id, pending_binary_json + ) + + +async def _handle_text_frame( + raw: Mapping[str, Any], + camera_id: UUID4, + manager: CameraConnectionManager, + pending_id: str | None, + pending_json: dict | None, + last_pong_at: list[float], + redis: Redis | None, +) -> tuple[str | None, dict | None]: + """Parse a text frame and dispatch it to the appropriate handler.""" + try: + msg = json.loads(raw[_WS_TEXT]) + except json.JSONDecodeError: + logger.warning("Camera %s sent invalid JSON, ignoring.", sanitize_log_value(camera_id)) + return pending_id, pending_json + + msg_type = msg.get("type") + + if msg_type == MSG_PONG: + last_pong_at[0] = asyncio.get_running_loop().time() + if redis: + await mark_camera_online(redis, camera_id) + elif msg_type == MSG_PING: + await manager.handle_ping(camera_id) + if redis: + await mark_camera_online(redis, camera_id) + elif msg_type == MSG_RESPONSE: + return _handle_response(msg, manager, pending_id, pending_json) + + return pending_id, pending_json + + +def _handle_response( + msg: dict, + manager: CameraConnectionManager, + pending_id: str | None, + pending_json: dict | None, +) -> tuple[str | None, dict | None]: + """Resolve or defer a response frame depending on whether binary data follows.""" + envelope = RelayResponseEnvelope.model_validate(msg) + msg_id = envelope.id + if not msg_id: + return pending_id, pending_json + + if envelope.has_binary: + return msg_id, envelope.model_dump(mode="json") + + manager.resolve_json(msg_id, envelope.model_dump(mode="json"), None) + return None, None + + +def _handle_binary_frame( + raw: Mapping[str, Any], + camera_id: UUID4, + manager: CameraConnectionManager, + pending_id: str | None, + pending_json: dict | None, +) -> tuple[str | None, dict | None]: + """Pair an incoming binary frame with its preceding JSON header.""" + binary_data: bytes = raw[_WS_BYTES] + if pending_id and pending_json is not None: + manager.resolve_json(pending_id, pending_json, binary_data) + return None, None + + logger.warning("Camera %s sent unexpected binary frame, ignoring.", sanitize_log_value(camera_id)) + return pending_id, pending_json diff --git a/backend/app/core/background_tasks.py b/backend/app/core/background_tasks.py new file mode 100644 index 00000000..c675c079 --- /dev/null +++ b/backend/app/core/background_tasks.py @@ -0,0 +1,55 @@ +"""Base class for periodic async background tasks.""" + +import asyncio +import contextlib +import logging + +logger = logging.getLogger(__name__) + + +class PeriodicBackgroundTask: + """Base class for asyncio periodic background tasks. + + Subclasses must implement ``run_once``, which is called every + ``interval_seconds``. The first execution is delayed by one full interval + so that application startup is never blocked by background work. + + Lifecycle:: + + task = MyTask(interval_seconds=3600) + await task.initialize() # starts the background loop + ... + await task.close() # cancels the loop and waits for it + """ + + def __init__(self, interval_seconds: int) -> None: + self.interval_seconds = interval_seconds + self._task: asyncio.Task[None] | None = None + + async def run_once(self) -> None: + """Override with the work to perform each interval.""" + raise NotImplementedError + + async def initialize(self) -> None: + """Start the periodic background loop.""" + self._task = asyncio.create_task(self._loop()) + + async def _loop(self) -> None: + try: + while True: + await asyncio.sleep(self.interval_seconds) + try: + await self.run_once() + except Exception: + logger.exception("Error in periodic task %s:", self.__class__.__name__) + except asyncio.CancelledError: + logger.info("Periodic task %s cancelled.", self.__class__.__name__) + raise + + async def close(self) -> None: + """Cancel the background loop and wait for it to finish.""" + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 00000000..a7d2f6ce --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,228 @@ +"""Cache utilities for FastAPI endpoints and async methods. + +This module keeps the app-facing cache API small and stable while using +``cashews`` underneath for storage and TTL handling. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from functools import wraps +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast + +from cashews import Cache +from fastapi.responses import HTMLResponse +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.requests import Request +from starlette.responses import Response + +from app.core.config import settings +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +P = ParamSpec("P") +T = TypeVar("T") + +_HTML_RESPONSE_TYPE = "HTMLResponse" +_MEMORY_CACHE_BACKEND = "mem://" +_ETAG_WILDCARD = "*" +_MISSING = object() +_backend = Cache() +_cache_state = {"initialized": False} + +JSONValue = HTMLResponse | dict[str, Any] | list[Any] | str | float | bool | None + + +class HTMLCoder: + """Custom coder for caching HTMLResponse objects.""" + + @classmethod + def encode(cls, value: JSONValue) -> bytes: + """Encode value to bytes, handling HTMLResponse objects specially.""" + if isinstance(value, HTMLResponse): + data: dict[str, Any] = { + "type": _HTML_RESPONSE_TYPE, + "body": value.body.decode("utf-8") if isinstance(value.body, bytes) else value.body, + "status_code": value.status_code, + "media_type": value.media_type, + "headers": dict(value.headers), + } + return json.dumps(data).encode("utf-8") + return json.dumps(value).encode("utf-8") + + @classmethod + def decode(cls, value: bytes | str) -> JSONValue: + """Decode bytes to Python object, reconstructing HTMLResponse objects.""" + if isinstance(value, bytes): + value = value.decode("utf-8") + + data = json.loads(value) + if isinstance(data, dict) and data.get("type") == _HTML_RESPONSE_TYPE: + return HTMLResponse( + content=data["body"], + status_code=data.get("status_code", 200), + media_type=data.get("media_type", "text/html"), + headers=data.get("headers"), + ) + return data + + +_EXCLUDED_TYPES = (AsyncSession, Request, Response) + + +def _get_cache_backend_location(redis_client: Redis | None) -> str: + """Return the configured cache backend URL for the current runtime.""" + if not settings.enable_caching or redis_client is None: + return _MEMORY_CACHE_BACKEND + return settings.cache_url + + +def _log_cache_backend_selection(redis_client: Redis | None, backend_location: str) -> None: + """Log the cache backend choice in one place.""" + if backend_location == _MEMORY_CACHE_BACKEND: + if not settings.enable_caching: + logger.info("Caching disabled in '%s' environment. Using in-memory backend.", settings.environment) + elif redis_client is None: + logger.warning("Endpoint cache initialized with in-memory backend - Redis unavailable") + return + + logger.info("Endpoint cache initialized with Redis backend") + + +async def _get_cached_result(key: str, coder: type[HTMLCoder] | None) -> T | JSONValue | object: + """Read and decode a cached result when present.""" + cached_value = await _backend.get(key, default=_MISSING) + if cached_value is _MISSING: + return _MISSING + if coder is not None: + return coder.decode(cast("bytes | str", cached_value)) + return cast("T", cached_value) + + +async def _set_cached_result[T](key: str, result: T, *, expire: int, coder: type[HTMLCoder] | None) -> None: + """Encode and store a cached result.""" + value_to_store = coder.encode(cast("JSONValue", result)) if coder is not None else result + await _backend.set(key, value_to_store, expire=expire) + + +def _cache_namespace(namespace: str = "") -> str: + """Build a storage namespace under the configured cache prefix.""" + return f"{settings.cache.prefix}:{namespace}" if namespace else settings.cache.prefix + + +def key_builder_excluding_dependencies( + func: Callable[..., Any], + namespace: str = "", + *, + request: Request | None = None, + response: Response | None = None, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, +) -> str: + """Build cache key excluding dependency injection objects.""" + del request, response + if kwargs is None: + kwargs = {} + + filtered_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, _EXCLUDED_TYPES)} + filtered_args = tuple(arg for arg in args if not isinstance(arg, _EXCLUDED_TYPES)) + module_name = getattr(func, "__module__", "") + function_name = getattr(func, "__name__", func.__class__.__name__) + cache_key_source = f"{module_name}:{function_name}:{filtered_args}:{filtered_kwargs}" + cache_key = hashlib.sha1(cache_key_source.encode(), usedforsecurity=False).hexdigest() + return f"{namespace}:{cache_key}" + + +def _etag_matches(if_none_match: str | None, current_etag: str | None) -> bool: + """Return whether the request's ``If-None-Match`` header matches the cached ETag.""" + if if_none_match is None or current_etag is None: + return False + candidates = {candidate.strip() for candidate in if_none_match.split(",")} + return _ETAG_WILDCARD in candidates or current_etag in candidates or f"W/{current_etag}" in candidates + + +def _cached_not_modified_response(request: Request | None, cached_value: object) -> Response | None: + """Return a 304 response when a cached response already satisfies the client's ETag.""" + if request is None or not isinstance(cached_value, Response): + return None + + current_etag = cached_value.headers.get("ETag") + if not _etag_matches(request.headers.get("if-none-match"), current_etag): + return None + + return Response(status_code=304, headers=dict(cached_value.headers)) + + +def cache( + *, + expire: int, + namespace: str = "", + coder: type[HTMLCoder] | None = None, +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: + """Cache async endpoint/function results with ``cashews``.""" + + def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + request = kwargs.get("request") + if not isinstance(request, Request): + request = next((arg for arg in args if isinstance(arg, Request)), None) + + key = key_builder_excluding_dependencies( + func, + namespace=_cache_namespace(namespace), + args=args, + kwargs=dict(kwargs), + ) + cached_value = await _get_cached_result(key, coder) + if cached_value is not _MISSING: + if response := _cached_not_modified_response(request, cast("T", cached_value)): + return cast("T", response) + return cast("T", cached_value) + + result = await func(*args, **kwargs) + await _set_cached_result(key, result, expire=expire, coder=coder) + return result + + return wrapper + + return decorator + + +def init_fastapi_cache(redis_client: Redis | None) -> None: + """Initialize the shared cache backend for endpoint caching.""" + if _cache_state["initialized"]: + return + + backend_location = _get_cache_backend_location(redis_client) + try: + _backend.setup(backend_location) + _cache_state["initialized"] = True + _log_cache_backend_selection(redis_client, backend_location) + except OSError, RuntimeError, ValueError: + logger.warning("Endpoint cache fell back to in-memory backend - Redis unavailable", exc_info=True) + _backend.setup(_MEMORY_CACHE_BACKEND) + _cache_state["initialized"] = True + + +async def close_fastapi_cache() -> None: + """Close any open cache backend resources.""" + if not _cache_state["initialized"]: + return + + await _backend.close() + _cache_state["initialized"] = False + + +async def clear_cache_namespace(namespace: str) -> None: + """Clear all cache entries for a specific namespace.""" + await _backend.delete_match(f"{_cache_namespace(namespace)}:*") + logger.info("Cleared cache namespace: %s", sanitize_log_value(namespace)) diff --git a/backend/app/core/clients/__init__.py b/backend/app/core/clients/__init__.py new file mode 100644 index 00000000..4bdf27db --- /dev/null +++ b/backend/app/core/clients/__init__.py @@ -0,0 +1,5 @@ +"""Shared outbound client helpers.""" + +from app.core.clients.http import create_http_client + +__all__ = ["create_http_client"] diff --git a/backend/app/core/clients/http.py b/backend/app/core/clients/http.py new file mode 100644 index 00000000..017c577e --- /dev/null +++ b/backend/app/core/clients/http.py @@ -0,0 +1,18 @@ +"""Shared HTTP client utilities for outbound network calls.""" + +from httpx import AsyncClient, Limits, Timeout + +from app.core.config import settings + + +def create_http_client() -> AsyncClient: + """Create the shared outbound HTTP client.""" + return AsyncClient( + http2=True, + limits=Limits( + max_connections=settings.http_max_connections, + max_keepalive_connections=settings.http_max_keepalive_connections, + ), + timeout=Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), + headers={"User-Agent": "relab-backend/0.1"}, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py deleted file mode 100644 index 7bf27406..00000000 --- a/backend/app/core/config.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Configuration settings for the FastAPI app.""" - -from functools import cached_property -from pathlib import Path - -from pydantic import EmailStr, HttpUrl, PostgresDsn, computed_field -from pydantic_settings import BaseSettings, SettingsConfigDict - -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[2]).resolve() - - -class CoreSettings(BaseSettings): - """Settings class to store all the configurations for the app.""" - - # Database settings from .env file - database_host: str = "localhost" - database_port: int = 5432 - postgres_user: str = "postgres" - postgres_password: str = "" - postgres_db: str = "relab_db" - postgres_test_db: str = "relab_test_db" - - # Debug settings - debug: bool = False - - # Superuser settings - superuser_email: EmailStr = "your-email@example.com" - superuser_password: str = "" - - # Network settings - frontend_web_url: HttpUrl = HttpUrl("http://127.0.0.1:8000") - frontend_app_url: HttpUrl = HttpUrl("http://127.0.0.1:8004") - allowed_origins: list[str] = [str(frontend_web_url), str(frontend_app_url)] - - # Initialize the settings configuration from the environment (Docker) or .env file (local) - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") - - # Construct directory paths - uploads_path: Path = BASE_DIR / "data" / "uploads" - file_storage_path: Path = uploads_path / "files" - image_storage_path: Path = uploads_path / "images" - static_files_path: Path = BASE_DIR / "app" / "static" - templates_path: Path = BASE_DIR / "app" / "templates" - log_path: Path = BASE_DIR / "logs" - docs_path: Path = BASE_DIR / "docs" / "site" # Mkdocs site directory - - # Construct database URLs - def _build_database_url(self, driver: str, database: str) -> str: - """Build and validate PostgreSQL database URL.""" - url = ( - f"postgresql+{driver}://{self.postgres_user}:{self.postgres_password}" - f"@{self.database_host}:{self.database_port}/{database}" - ) - PostgresDsn(url) # Validate URL format - return url - - @computed_field - @cached_property - def async_database_url(self) -> str: - """Get async database URL.""" - return self._build_database_url("asyncpg", self.postgres_db) - - @computed_field - @cached_property - def sync_database_url(self) -> str: - """Get sync database URL.""" - return self._build_database_url("psycopg", self.postgres_db) - - @computed_field - @cached_property - def async_test_database_url(self) -> str: - """Get test database URL.""" - return self._build_database_url("asyncpg", self.postgres_test_db) - - @computed_field - @cached_property - def sync_test_database_url(self) -> str: - """Get test database URL.""" - return self._build_database_url("psycopg", self.postgres_test_db) - - -# Create a settings instance that can be imported throughout the app -settings = CoreSettings() diff --git a/backend/app/core/config/__init__.py b/backend/app/core/config/__init__.py new file mode 100644 index 00000000..1caf9293 --- /dev/null +++ b/backend/app/core/config/__init__.py @@ -0,0 +1,22 @@ +"""Public entrypoint for core application settings.""" + +from app.core.config.core import CoreSettings, settings +from app.core.config.models import ( + DEFAULT_CORS_ORIGIN_REGEX, + DEFAULT_SUPERUSER_EMAIL, + CacheNamespace, + CacheSettings, + Environment, + StorageBackend, +) + +__all__ = [ + "DEFAULT_CORS_ORIGIN_REGEX", + "DEFAULT_SUPERUSER_EMAIL", + "CacheNamespace", + "CacheSettings", + "CoreSettings", + "Environment", + "StorageBackend", + "settings", +] diff --git a/backend/app/core/config/core.py b/backend/app/core/config/core.py new file mode 100644 index 00000000..3a740ca6 --- /dev/null +++ b/backend/app/core/config/core.py @@ -0,0 +1,302 @@ +"""Configuration settings for the FastAPI app.""" +# spell-checker: ignore PGSSL + +from __future__ import annotations + +import re +from functools import cached_property +from pathlib import Path # noqa: TC003 # Runtime use is needed for Pydantic validation of settings +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +from pydantic import EmailStr, Field, HttpUrl, PostgresDsn, SecretStr, field_validator, model_validator +from sqlalchemy.engine import URL + +from app.core.config.models import ( + DEFAULT_CORS_ORIGIN_REGEX, + DEFAULT_SUPERUSER_EMAIL, + CacheSettings, + Environment, + StorageBackend, +) +from app.core.env import BACKEND_DIR, RelabBaseSettings + +if TYPE_CHECKING: + from typing import Self + + +# Constants for database drivers to resolve PLR2004 +DATABASE_DRIVER_PSYCOPG = "psycopg" +DATABASE_DRIVER_ASYNCPG = "asyncpg" +HTTPS_SCHEME = "https" + + +class CoreSettings(RelabBaseSettings): + """Settings class to store all the configurations for the app.""" + + # ── Environment ────────────────────────────────────────────────────────────── + environment: Environment = Environment.DEV + + # ── Database ───────────────────────────────────────────────────────────────── + database_host: str = "localhost" + database_port: int = Field(default=5432, ge=1, le=65535) + database_ssl: bool = False + postgres_user: str = "postgres" + postgres_password: SecretStr = SecretStr("") + postgres_db: str = "relab_db" + + # ── Redis ───────────────────────────────────────────────────────────────────── + redis_host: str = "localhost" + redis_port: int = Field(default=6379, ge=1, le=65535) + redis_db: int = Field(default=0, ge=0, le=15) + redis_password: SecretStr = SecretStr("") + + # ── Superuser ───────────────────────────────────────────────────────────────── + superuser_email: EmailStr = DEFAULT_SUPERUSER_EMAIL + superuser_name: str | None = None + superuser_password: SecretStr = SecretStr("") + + # ── Network & CORS ──────────────────────────────────────────────────────────── + backend_api_url: HttpUrl = HttpUrl("http://127.0.0.1:8001") + frontend_web_url: HttpUrl = HttpUrl("http://127.0.0.1:8000") + frontend_app_url: HttpUrl = HttpUrl("http://127.0.0.1:8003") + cors_origin_regex: str | None = Field(default=None) + + @field_validator("superuser_name") + @classmethod + def validate_superuser_name(cls, v: str | None) -> str | None: + """Enforce lowercase letters, digits, and underscores only.""" + if v is not None and not re.fullmatch(r"[a-z0-9_]+", v): + msg = "superuser_name may only contain lowercase letters, digits, and underscores" + raise ValueError(msg) + return v + + @field_validator("cors_origin_regex") + @classmethod + def validate_cors_origin_regex(cls, v: str | None) -> str | None: + """Reject patterns that would raise re.error at runtime.""" + if v is not None: + try: + re.compile(v) + except re.error as e: + msg = f"cors_origin_regex is not a valid regular expression: {e}" + raise ValueError(msg) from e + return v + + @field_validator("otel_exporter_otlp_endpoint", mode="before") + @classmethod + def normalize_empty_otel_endpoint(cls, v: str | None) -> str | None: + """Treat empty strings as an unset OTLP endpoint.""" + if v in ("", None): + return None + return v + + @staticmethod + def _normalize_origin(url: HttpUrl) -> str: + """Normalize URL-like values to browser Origin format.""" + parsed = urlsplit(str(url)) + return f"{parsed.scheme}://{parsed.netloc}" + + @cached_property + def allowed_origins(self) -> list[str]: + """Get CORS Origin allowlist (scheme + host + optional port).""" + return [ + self._normalize_origin(self.frontend_web_url), + self._normalize_origin(self.frontend_app_url), + ] + + @cached_property + def allowed_hosts(self) -> list[str]: + """Get trusted Host header values for backend requests.""" + if self.environment in (Environment.DEV, Environment.TESTING): + return ["*"] + + backend_host = urlsplit(str(self.backend_api_url)).hostname + if backend_host: + return [backend_host, "127.0.0.1", "localhost"] + return ["127.0.0.1", "localhost"] + + # ── Cache ───────────────────────────────────────────────────────────────────── + cache: CacheSettings = Field(default_factory=CacheSettings) + + # ── Concurrency & connection limits ────────────────────────────────────────── + db_pool_size: int = Field(default=10, ge=1, le=50) + db_pool_max_overflow: int = Field(default=10, ge=0, le=50) + image_resize_workers: int = Field(default=5, ge=1, le=64) + http_max_connections: int = Field(default=100, ge=1, le=1000) + http_max_keepalive_connections: int = Field(default=20, ge=0, le=1000) + request_body_limit_bytes: int = Field(default=1024 * 1024, ge=1024, le=50 * 1024 * 1024) + # OTEL on/off is derived from the endpoint; service.name is read by the + # OTEL SDK directly from the OTEL_SERVICE_NAME env var (set in compose). + otel_exporter_otlp_endpoint: str | None = None + + @property + def otel_enabled(self) -> bool: + """Enable OpenTelemetry tracing if an OTLP endpoint is configured.""" + return self.otel_exporter_otlp_endpoint is not None + + # ── File cleanup ────────────────────────────────────────────────────────────── + file_cleanup_enabled: bool = True + file_cleanup_interval_hours: int = Field(default=24, ge=1) + file_cleanup_min_file_age_minutes: int = Field(default=30, ge=0) + file_cleanup_dry_run: bool = False + + # ── Storage ─────────────────────────────────────────────────────────────────── + storage_backend: StorageBackend = StorageBackend.FILESYSTEM + s3_bucket: str = "" + s3_region: str = "us-east-1" + s3_access_key_id: SecretStr = SecretStr("") + s3_secret_access_key: SecretStr = SecretStr("") + s3_endpoint_url: str | None = None + s3_base_url: str | None = None + s3_file_prefix: str = "files" + s3_image_prefix: str = "images" + + # ── Paths ───────────────────────────────────────────────────────────────────── + uploads_path: Path = BACKEND_DIR / "data" / "uploads" + file_storage_path: Path = uploads_path / "files" + image_storage_path: Path = uploads_path / "images" + static_files_path: Path = BACKEND_DIR / "app" / "static" + templates_path: Path = BACKEND_DIR / "app" / "templates" + log_path: Path = BACKEND_DIR / "logs" + docs_path: Path = BACKEND_DIR / "docs" / "site" + + def build_database_url(self, driver: str, database: str) -> str: + """Build and validate PostgreSQL database URL.""" + query: dict[str, str] = {} + if driver == DATABASE_DRIVER_PSYCOPG: + query = {"sslmode": "require" if self.database_ssl else "disable"} + + url = URL.create( + f"postgresql+{driver}", + username=self.postgres_user, + password=self.postgres_password.get_secret_value(), + host=self.database_host, + port=self.database_port, + database=database, + query=query, + ) + rendered = url.render_as_string(hide_password=False) + PostgresDsn(rendered) + return rendered + + @cached_property + def async_database_url(self) -> str: + """Get async database URL.""" + return self.build_database_url(DATABASE_DRIVER_ASYNCPG, self.postgres_db) + + @cached_property + def sync_database_url(self) -> str: + """Get sync database URL.""" + return self.build_database_url(DATABASE_DRIVER_PSYCOPG, self.postgres_db) + + @cached_property + def async_database_connect_args(self) -> dict[str, bool]: + """Get async engine connect args. + + Be explicit about SSL so asyncpg does not inherit PGSSL* environment + variables from the container when talking to the internal Docker + Postgres service. + """ + return {"ssl": self.database_ssl} + + @cached_property + def cache_url(self) -> str: + """Get Redis cache URL.""" + return ( + f"redis://:{self.redis_password.get_secret_value() or ''}" + f"@{self.redis_host}:{self.redis_port}/{self.redis_db}" + ) + + @property + def debug(self) -> bool: + """Enable SQL echo and DEBUG logging in development only.""" + return self.environment == Environment.DEV + + @cached_property + def enable_caching(self) -> bool: + """Disable Redis caching in development and testing.""" + return self.environment not in (Environment.DEV, Environment.TESTING) + + @property + def secure_cookies(self) -> bool: + """Require HTTPS-only cookies in production and staging.""" + return self.environment in (Environment.PROD, Environment.STAGING) + + @property + def mock_emails(self) -> bool: + """Skip real email delivery in development and testing.""" + return self.environment in (Environment.DEV, Environment.TESTING) + + @property + def enable_rate_limit(self) -> bool: + """Disable rate limiting in development and testing.""" + return self.environment not in (Environment.DEV, Environment.TESTING) + + @model_validator(mode="after") + def validate_concurrency_settings(self) -> Self: + """Validate cross-field concurrency constraints.""" + if self.http_max_keepalive_connections > self.http_max_connections: + msg = ( + f"http_max_keepalive_connections ({self.http_max_keepalive_connections}) " + f"must not exceed http_max_connections ({self.http_max_connections})" + ) + raise ValueError(msg) + return self + + @model_validator(mode="after") + def validate_s3_settings(self) -> Self: + """Require a bucket name when the S3 backend is selected.""" + if self.storage_backend == StorageBackend.S3 and not self.s3_bucket: + msg = "S3_BUCKET must be set when STORAGE_BACKEND is 's3'" + raise ValueError(msg) + return self + + def _production_security_errors(self) -> list[str]: + """Collect environment-specific security validation errors.""" + errors: list[str] = [] + + if self.cors_origin_regex == DEFAULT_CORS_ORIGIN_REGEX: + errors.append("CORS_ORIGIN_REGEX must not be set in production/staging") + + if not self.postgres_password.get_secret_value(): + errors.append("POSTGRES_PASSWORD must not be empty in production") + + if not self.redis_password.get_secret_value(): + errors.append("REDIS_PASSWORD must not be empty in production") + + if not self.superuser_password.get_secret_value(): + errors.append("SUPERUSER_PASSWORD must not be empty in production") + + if self.superuser_email == DEFAULT_SUPERUSER_EMAIL: + errors.append("SUPERUSER_EMAIL must not be the default placeholder in production") + + if self.backend_api_url.scheme != HTTPS_SCHEME: + errors.append("BACKEND_API_URL must use https in production/staging") + + if self.frontend_app_url.scheme != HTTPS_SCHEME: + errors.append("FRONTEND_APP_URL must use https in production/staging") + + if self.frontend_web_url.scheme != HTTPS_SCHEME: + errors.append("FRONTEND_WEB_URL must use https in production/staging") + + return errors + + @model_validator(mode="after") + def validate_security_settings(self) -> Self: + """Validate environment-specific security settings.""" + if self.environment not in (Environment.PROD, Environment.STAGING): + if self.cors_origin_regex is None: + self.cors_origin_regex = DEFAULT_CORS_ORIGIN_REGEX + return self + + errors = self._production_security_errors() + if errors: + formatted = "\n - ".join(errors) + msg = f"Production security check failed:\n - {formatted}" + raise ValueError(msg) + + return self + + +settings = CoreSettings() diff --git a/backend/app/core/config/models.py b/backend/app/core/config/models.py new file mode 100644 index 00000000..6d86bf99 --- /dev/null +++ b/backend/app/core/config/models.py @@ -0,0 +1,45 @@ +"""Reusable value objects and enums for core application settings.""" + +from enum import StrEnum + +from pydantic import BaseModel, Field + +from app.core.constants import DAY, HOUR + +DEFAULT_SUPERUSER_EMAIL = "your-email@example.com" +DEFAULT_CORS_ORIGIN_REGEX = r"https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+)(:\d+)?" + + +class CacheNamespace(StrEnum): + """Cache namespace identifiers for different application areas.""" + + BACKGROUND_DATA = "background-data" + DOCS = "docs" + + +class CacheSettings(BaseModel): + """Centralized cache configuration for the application.""" + + prefix: str = "fastapi-cache" + ttls: dict[CacheNamespace, int] = Field( + default_factory=lambda: { + CacheNamespace.BACKGROUND_DATA: DAY, + CacheNamespace.DOCS: HOUR, + } + ) + + +class StorageBackend(StrEnum): + """Available file storage backends.""" + + FILESYSTEM = "filesystem" + S3 = "s3" + + +class Environment(StrEnum): + """Application execution environment.""" + + DEV = "dev" + STAGING = "staging" + PROD = "prod" + TESTING = "testing" diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 00000000..94e361e1 --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,8 @@ +"""Shared constants for the application.""" + +# Time constants in seconds +MINUTE = 60 +HOUR = 60 * MINUTE +DAY = 24 * HOUR +WEEK = 7 * DAY +MONTH = 30 * DAY diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0faa87dd..8e355974 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,40 +1,43 @@ -"""Database initialization and session management.""" +"""Async database initialization and session management.""" -from collections.abc import AsyncGenerator, Generator -from contextlib import asynccontextmanager, contextmanager +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING -from sqlalchemy import create_engine -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio.engine import AsyncEngine -from sqlmodel import Session -from sqlmodel.ext.asyncio.session import AsyncSession from app.core.config import settings +from app.core.model_registry import load_models + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + +# Ensure ORM class registry is populated before sessions are created. +load_models() ### Async database connection -async_engine: AsyncEngine = create_async_engine(settings.async_database_url, future=True, echo=settings.debug) +async_engine: AsyncEngine = create_async_engine( + settings.async_database_url, + connect_args=settings.async_database_connect_args, + future=True, + echo=settings.debug, + pool_size=settings.db_pool_size, + max_overflow=settings.db_pool_max_overflow, +) +async_sessionmaker_factory = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) async def get_async_session() -> AsyncGenerator[AsyncSession]: """Get a new asynchronous database session. Can be used in FastAPI dependencies.""" - async_session = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) - async with async_session() as session: + async with async_sessionmaker_factory() as session: yield session -# Async session context manager for 'async with' statements -async_session_context = asynccontextmanager(get_async_session) +async def close_async_engine() -> None: + """Dispose the shared async engine and close pooled DB connections.""" + await async_engine.dispose() -### Sync database connection -sync_engine = create_engine(settings.sync_database_url, echo=settings.debug) - - -@contextmanager -def sync_session_context() -> Generator[Session]: - """Get a new synchronous database session.""" - with Session(sync_engine) as session: - try: - yield session - finally: - session.close() +# Async session context manager for 'async with' statements +async_session_context = asynccontextmanager(get_async_session) diff --git a/backend/app/core/env.py b/backend/app/core/env.py new file mode 100644 index 00000000..4feddab3 --- /dev/null +++ b/backend/app/core/env.py @@ -0,0 +1,51 @@ +"""Shared helpers for environment-based settings loading.""" + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from pydantic_settings import BaseSettings, SettingsConfigDict + +if TYPE_CHECKING: + from pathlib import Path as PathType + +# Maps the ENVIRONMENT variable (matching app.core.config.Environment) to a .env filename. +# Mirrors the naming convention used by the frontend apps. +_ENV_FILE_MAP: dict[str, str] = { + "dev": ".env.dev", + "staging": ".env.staging", + "prod": ".env.prod", + "testing": ".env.test", +} + + +# Backend repo root. This file lives at ``backend/app/core/env.py``. +BACKEND_DIR = Path(__file__).parents[2].resolve() + + +def get_environment_name() -> str: + """Return the active backend environment name.""" + return os.environ.get("ENVIRONMENT", "dev") + + +def is_production_like_environment(environment: str | None = None) -> bool: + """Return True for staging/production-style runtime validation.""" + return (environment or get_environment_name()) in {"staging", "prod"} + + +def get_env_file(base_dir: PathType) -> Path: + """Return the .env file path for the current ENVIRONMENT. + + Falls back to ``dev`` (i.e. ``.env.dev``) when the variable is + absent. pydantic-settings silently ignores a missing file, so there is no + error if the file does not exist yet. + """ + env = get_environment_name() + filename = _ENV_FILE_MAP.get(env, f".env.{env}") + return base_dir / filename + + +class RelabBaseSettings(BaseSettings): + """Shared settings base class for backend modules.""" + + model_config = SettingsConfigDict(env_file=get_env_file(BACKEND_DIR), extra="ignore") diff --git a/backend/app/core/http.py b/backend/app/core/http.py new file mode 100644 index 00000000..d3318f41 --- /dev/null +++ b/backend/app/core/http.py @@ -0,0 +1,6 @@ +"""Compatibility wrapper for shared outbound HTTP client helpers.""" + +from app.core.clients.http import create_http_client +from app.core.config import settings + +__all__ = ["create_http_client", "settings"] diff --git a/backend/app/core/images/__init__.py b/backend/app/core/images/__init__.py new file mode 100644 index 00000000..0e9d5b10 --- /dev/null +++ b/backend/app/core/images/__init__.py @@ -0,0 +1,26 @@ +"""Image processing utilities using Pillow.""" +# spell-checker: ignore getexif, LANCZOS + +from .constants import ALLOWED_IMAGE_MIME_TYPES, FORMAT_JPEG, FORMAT_WEBP, MAX_IMAGE_DIMENSION, THUMBNAIL_WIDTHS +from .exif import apply_exif_orientation, strip_sensitive_exif +from .processing import process_image_for_storage, resize_image +from .thumbnails import delete_thumbnails, generate_thumbnails, thumbnail_path_for +from .validation import validate_image_dimensions, validate_image_file, validate_image_mime_type + +__all__ = [ + "ALLOWED_IMAGE_MIME_TYPES", + "FORMAT_JPEG", + "FORMAT_WEBP", + "MAX_IMAGE_DIMENSION", + "THUMBNAIL_WIDTHS", + "apply_exif_orientation", + "delete_thumbnails", + "generate_thumbnails", + "process_image_for_storage", + "resize_image", + "strip_sensitive_exif", + "thumbnail_path_for", + "validate_image_dimensions", + "validate_image_file", + "validate_image_mime_type", +] diff --git a/backend/app/core/images/constants.py b/backend/app/core/images/constants.py new file mode 100644 index 00000000..ccdffeb5 --- /dev/null +++ b/backend/app/core/images/constants.py @@ -0,0 +1,41 @@ +"""Shared constants for image validation and processing.""" +# spell-checker: ignore LANCZOS + +from __future__ import annotations + +from PIL import Image as PILImage + +FORMAT_JPEG = "JPEG" +FORMAT_WEBP = "WEBP" +MAX_IMAGE_DIMENSION = 8000 +ALLOWED_IMAGE_MIME_TYPES: frozenset[str] = frozenset( + { + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + } +) +THUMBNAIL_WIDTHS: tuple[int, ...] = (200, 800, 1600) + +_SENSITIVE_EXIF_TAGS: frozenset[int] = frozenset( + { + 0x8825, + 0x927C, + 0xA430, + 0xA431, + 0xA435, + 0x013B, + 0xA420, + } +) +_EXIF_ORIENTATION_TAG = 0x0112 + +try: + from PIL.Image import Resampling + + RESAMPLE_FILTER = Resampling.LANCZOS +except ImportError, AttributeError: + RESAMPLE_FILTER = getattr(PILImage, "LANCZOS", getattr(PILImage, "ANTIALIAS", 1)) diff --git a/backend/app/core/images/exif.py b/backend/app/core/images/exif.py new file mode 100644 index 00000000..83749b94 --- /dev/null +++ b/backend/app/core/images/exif.py @@ -0,0 +1,72 @@ +"""EXIF cleaning and orientation helpers.""" +# spell-checker: ignore getexif + +from __future__ import annotations + +import contextlib + +import piexif +from PIL import Image as PILImage +from PIL import ImageOps + +from .constants import _EXIF_ORIENTATION_TAG, _SENSITIVE_EXIF_TAGS + + +def _clean_exif_bytes(exif_bytes: bytes) -> bytes | None: + """Return cleaned EXIF bytes with sensitive tags removed, or None on failure.""" + try: + exif_dict = piexif.load(exif_bytes) + except ValueError, OSError, TypeError, UnboundLocalError: + return None + + for tag_id in _SENSITIVE_EXIF_TAGS | {_EXIF_ORIENTATION_TAG}: + for ifd in ("0th", "Exif", "GPS", "1st"): + exif_dict.get(ifd, {}).pop(tag_id, None) + + exif_dict.pop("GPS", None) + + try: + return piexif.dump(exif_dict) + except ValueError, OSError, TypeError, UnboundLocalError: + return None + + +def _get_exif_orientation(exif_bytes: bytes) -> int | None: + """Return the EXIF orientation tag value, or None if absent or unreadable.""" + try: + exif_dict = piexif.load(exif_bytes) + return exif_dict.get("0th", {}).get(_EXIF_ORIENTATION_TAG) + except ValueError, OSError, TypeError: + return None + + +def apply_exif_orientation(img: PILImage.Image) -> PILImage.Image: + """Rotate or flip image pixels to match EXIF orientation.""" + try: + return ImageOps.exif_transpose(img) + except AttributeError, ValueError, OSError, TypeError: + return img + + +def strip_sensitive_exif(img: PILImage.Image) -> None: + """Remove privacy-sensitive EXIF tags in-place from a Pillow image object.""" + exif_bytes = img.info.get("exif") + if not exif_bytes: + try: + exif_bytes = img.getexif().tobytes() + except AttributeError, ValueError, OSError, TypeError: + exif_bytes = None + + if not exif_bytes: + return + + cleaned = _clean_exif_bytes(exif_bytes) + if not cleaned: + return + + img.info["exif"] = cleaned + + with contextlib.suppress(AttributeError, KeyError, ValueError, OSError): + exif = img.getexif() + for tag_id in _SENSITIVE_EXIF_TAGS | {_EXIF_ORIENTATION_TAG}: + exif.pop(tag_id, None) diff --git a/backend/app/core/images/processing.py b/backend/app/core/images/processing.py new file mode 100644 index 00000000..7412abd4 --- /dev/null +++ b/backend/app/core/images/processing.py @@ -0,0 +1,84 @@ +"""Image processing helpers for originals and ad-hoc resized bytes.""" +# spell-checker: ignore getexif + +from __future__ import annotations + +import contextlib +import io +from typing import TYPE_CHECKING + +import piexif +from PIL import Image as PILImage +from PIL import ImageOps + +from .constants import FORMAT_JPEG, FORMAT_WEBP, RESAMPLE_FILTER +from .exif import _clean_exif_bytes, _get_exif_orientation +from .validation import validate_image_dimensions + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + +def process_image_for_storage(image_path: Path) -> None: + """Process an uploaded image in-place for storage.""" + with PILImage.open(image_path) as img: + original_format = img.format or FORMAT_JPEG + validate_image_dimensions(img) + + exif_bytes: bytes | None = img.info.get("exif") or None + if not exif_bytes: + with contextlib.suppress(AttributeError, ValueError, OSError, TypeError): + raw = img.getexif().tobytes() + exif_bytes = raw or None + + cleaned_exif_bytes = _clean_exif_bytes(exif_bytes) if exif_bytes else None + orientation = _get_exif_orientation(exif_bytes) if exif_bytes else None + + needs_rotation = orientation not in (None, 1) + if needs_rotation or original_format != FORMAT_JPEG: + try: + processed: PILImage.Image | None = ImageOps.exif_transpose(img) + except AttributeError, ValueError, OSError, TypeError: + processed = img + processed = processed.copy() + else: + processed = None + + if processed is None: + if not exif_bytes: + return + if cleaned_exif_bytes is not None: + piexif.insert(cleaned_exif_bytes, str(image_path)) + return + with PILImage.open(image_path) as img: + processed = img.copy() + + save_kwargs: dict[str, Any] = {"format": original_format} + if original_format == FORMAT_JPEG: + save_kwargs.update({"quality": 95, "optimize": True}) + if cleaned_exif_bytes: + save_kwargs["exif"] = cleaned_exif_bytes + + processed.save(image_path, **save_kwargs) + + +def resize_image(image_path: Path, width: int | None = None, height: int | None = None) -> bytes: + """Resize an image while maintaining aspect ratio, returning WebP bytes.""" + with PILImage.open(image_path) as img: + current_width, current_height = img.size + if width and not height: + height = int((width / current_width) * current_height) + elif height and not width: + width = int((height / current_height) * current_width) + elif not width and not height: + width, height = current_width, current_height + + final_width = width or current_width + final_height = height or current_height + + resized = img.resize((final_width, final_height), RESAMPLE_FILTER) + + buf = io.BytesIO() + resized.save(buf, format=FORMAT_WEBP, quality=85, method=6) + return buf.getvalue() diff --git a/backend/app/core/images/thumbnails.py b/backend/app/core/images/thumbnails.py new file mode 100644 index 00000000..a8a9e0d3 --- /dev/null +++ b/backend/app/core/images/thumbnails.py @@ -0,0 +1,45 @@ +"""Thumbnail helpers for stored images.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from PIL import Image as PILImage + +from .constants import FORMAT_WEBP, RESAMPLE_FILTER, THUMBNAIL_WIDTHS + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +def thumbnail_path_for(image_path: Path, width: int) -> Path: + """Return the expected filesystem path for a pre-computed thumbnail.""" + return image_path.parent / f"{image_path.stem}_thumb_{width}.webp" + + +def generate_thumbnails(image_path: Path, widths: tuple[int, ...] = THUMBNAIL_WIDTHS) -> list[Path]: + """Pre-compute WebP thumbnails at standard widths for a stored image.""" + generated: list[Path] = [] + with PILImage.open(image_path) as img: + original_width, original_height = img.size + for width in widths: + if width >= original_width: + continue + height = int((width / original_width) * original_height) + resized = img.resize((width, height), RESAMPLE_FILTER) + destination = thumbnail_path_for(image_path, width) + resized.save(destination, format=FORMAT_WEBP, quality=85, method=6) + generated.append(destination) + logger.debug("Generated thumbnail %s (%dx%d)", destination.name, width, height) + return generated + + +def delete_thumbnails(image_path: Path, widths: tuple[int, ...] = THUMBNAIL_WIDTHS) -> None: + """Remove all pre-computed thumbnails for an image.""" + for width in widths: + thumbnail = thumbnail_path_for(image_path, width) + if thumbnail.exists(): + thumbnail.unlink() diff --git a/backend/app/core/images/validation.py b/backend/app/core/images/validation.py new file mode 100644 index 00000000..7f3ee641 --- /dev/null +++ b/backend/app/core/images/validation.py @@ -0,0 +1,47 @@ +"""Validation helpers for uploaded and stored images.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PIL import Image as PILImage +from PIL import UnidentifiedImageError + +from .constants import ALLOWED_IMAGE_MIME_TYPES, MAX_IMAGE_DIMENSION + +if TYPE_CHECKING: + from typing import BinaryIO + + from fastapi import UploadFile + + +def validate_image_dimensions(img: PILImage.Image, max_dimension: int = MAX_IMAGE_DIMENSION) -> None: + """Raise ValueError if either image dimension exceeds the maximum allowed.""" + width, height = img.size + if width > max_dimension or height > max_dimension: + msg = f"Image dimensions {width}x{height} exceed the maximum allowed {max_dimension}px per side." + raise ValueError(msg) + + +def validate_image_mime_type(file: UploadFile | None) -> UploadFile | None: + """Validate the uploaded image MIME type.""" + if file is None: + return file + if file.content_type not in ALLOWED_IMAGE_MIME_TYPES: + allowed_types = ", ".join(sorted(ALLOWED_IMAGE_MIME_TYPES)) + msg = f"Invalid file type: {file.content_type}. Allowed types: {allowed_types}" + raise ValueError(msg) + return file + + +def validate_image_file(file: BinaryIO) -> None: + """Validate that a binary file contains a supported image.""" + file.seek(0) + try: + with PILImage.open(file) as image_file: + image_file.verify() + except (AttributeError, OSError, TypeError, UnidentifiedImageError) as exc: + err_msg = "Invalid image file" + raise ValueError(err_msg) from exc + finally: + file.seek(0) diff --git a/backend/app/core/lifecycle.py b/backend/app/core/lifecycle.py new file mode 100644 index 00000000..e96adc54 --- /dev/null +++ b/backend/app/core/lifecycle.py @@ -0,0 +1,154 @@ +"""Application lifecycle orchestration for runtime services.""" + +from __future__ import annotations + +import asyncio +import logging +import tempfile +from typing import TYPE_CHECKING + +import anyio +from fastapi import FastAPI +from httpx import CloseError +from loguru import logger as structured_logger + +from app.api.auth.services.email_checker import init_email_checker +from app.api.common.routers.file_mounts import mount_static_directories, register_favicon_route +from app.api.file_storage.services.manager import FileCleanupManager +from app.api.plugins.rpi_cam.websocket.connection_manager import CameraConnectionManager, set_connection_manager +from app.api.plugins.rpi_cam.websocket.cross_worker_relay import set_blocking_redis +from app.core.cache import close_fastapi_cache, init_fastapi_cache +from app.core.clients import create_http_client +from app.core.config import settings +from app.core.database import async_engine, async_sessionmaker_factory +from app.core.observability import init_telemetry, shutdown_telemetry +from app.core.redis import close_redis, init_blocking_redis, init_redis +from app.core.runtime import AppServices, get_app_services, reset_app_services + +if TYPE_CHECKING: + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + + +def log_startup_configuration() -> None: + """Log key startup configuration values.""" + logger.info("Starting up application...") + logger.info( + "Security config: allowed_hosts=%s allowed_origins=%s cors_origin_regex=%s", + settings.allowed_hosts, + settings.allowed_origins, + settings.cors_origin_regex, + ) + + +def ensure_storage_directories() -> None: + """Create configured storage directories and verify they are writable.""" + for path in [settings.file_storage_path, settings.image_storage_path]: + path.mkdir(parents=True, exist_ok=True) + try: + with tempfile.NamedTemporaryFile(dir=path, prefix=".write-test-", delete=True): + pass + except OSError as e: + msg = f"Storage path is not writable: {path}" + raise RuntimeError(msg) from e + + +async def _initialize_cache_services(services: AppServices) -> None: + """Initialize Redis-backed services.""" + services.redis = await init_redis() + services.email_checker = await init_email_checker(services.redis) + init_fastapi_cache(services.redis) + + services.blocking_redis = await init_blocking_redis() + set_blocking_redis(services.blocking_redis) + + +async def _initialize_camera_services(services: AppServices) -> None: + """Initialize in-process camera connection services.""" + services.camera_connection_manager = CameraConnectionManager() + set_connection_manager(services.camera_connection_manager) + + +async def _initialize_storage_services(app: FastAPI, services: AppServices) -> None: + """Initialize file storage and cleanup services.""" + services.file_cleanup_manager = FileCleanupManager(async_sessionmaker_factory) + await services.file_cleanup_manager.initialize() + + ensure_storage_directories() + services.storage_ready = True + mount_static_directories(app) + register_favicon_route(app) + + +def _initialize_http_and_observability(app: FastAPI, services: AppServices) -> None: + """Initialize shared HTTP and observability services.""" + services.http_client = create_http_client() + services.image_resize_limiter = anyio.CapacityLimiter(settings.image_resize_workers) + services.telemetry_enabled = init_telemetry(app, async_engine) + + +async def initialize_runtime_services(app: FastAPI) -> AppServices: + """Create and initialize all long-lived runtime services.""" + services = reset_app_services(app) + await _initialize_cache_services(services) + await _initialize_camera_services(services) + await _initialize_storage_services(app, services) + _initialize_http_and_observability(app, services) + structured_logger.info("Application services initialized") + return services + + +async def _shutdown_email_checker(services: AppServices) -> None: + if services.email_checker is not None: + try: + await services.email_checker.close() + except (RuntimeError, OSError) as e: + logger.warning("Error closing email checker: %s", e) + + +async def _close_redis_client(redis_client: Redis | None, label: str) -> None: + if redis_client is None: + return + try: + await close_redis(redis_client) + except (ConnectionError, OSError) as e: + logger.warning("Error closing %s Redis client: %s", label, e) + + +async def _shutdown_cache_services(services: AppServices) -> None: + await _close_redis_client(services.redis, "primary") + await _close_redis_client(services.blocking_redis, "blocking") + + try: + await close_fastapi_cache() + except RuntimeError as e: + logger.warning("Error closing endpoint cache: %s", e) + + +async def _shutdown_file_cleanup_manager(services: AppServices) -> None: + if services.file_cleanup_manager is not None: + try: + await services.file_cleanup_manager.close() + except asyncio.CancelledError as e: + logger.warning("Error closing file cleanup manager: %s", e) + + +async def _shutdown_http_client(services: AppServices) -> None: + if services.http_client is not None: + try: + await services.http_client.aclose() + except CloseError as e: + logger.warning("Error closing outbound HTTP client: %s", e) + + +async def shutdown_runtime_services(app: FastAPI) -> None: + """Shutdown and clear all runtime services.""" + services = get_app_services(app) + await _shutdown_email_checker(services) + await _shutdown_cache_services(services) + await _shutdown_file_cleanup_manager(services) + await _shutdown_http_client(services) + shutdown_telemetry(app) + services.telemetry_enabled = False + reset_app_services(app) diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 00000000..c9a3a7df --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,143 @@ +"""Main logger setup.""" + +import logging +import sys +from typing import TYPE_CHECKING + +import loguru + +from app.core.config import Environment, settings + +if TYPE_CHECKING: + from pathlib import Path + +### Logging formats +LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss!UTC} | " + "{level: <8} | " + "req={extra[request_id]} | " + "{name}:{function}:{line} - " + "{message}" +) +LOG_DIR = settings.log_path + +BASE_LOG_LEVEL = "DEBUG" if settings.debug else "INFO" + +_EXTRA_DEFAULTS = { + "request_id": "-", + "http_method": None, + "http_path": None, + "http_status_code": None, + "http_latency_ms": None, +} + + +def sanitize_log_value(value: object) -> str: + """Normalize a value before logging it.""" + return str(value).replace("\r", " ").replace("\n", " ") + + +class InterceptHandler(logging.Handler): + """Intercept standard logging messages and route them to loguru.""" + + def emit(self, record: logging.LogRecord) -> None: + """Override emit to route standard logging to loguru.""" + try: + level = loguru.logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = logging.currentframe(), 0 + while frame and ( + depth < 2 + or frame.f_code.co_filename == logging.__file__ + or frame.f_code.co_filename.endswith("logging/__init__.py") + ): + frame = frame.f_back + depth += 1 + + loguru.logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def patch_log_record(record: loguru.Record) -> None: + """Fill in default extras on every loguru record.""" + record["extra"] = {**_EXTRA_DEFAULTS, **record["extra"]} + + +def configure_loguru_handlers(log_dir: Path | None, base_log_level: str) -> None: + """Setup loguru sinks.""" + is_enqueued = settings.environment in (Environment.PROD, Environment.STAGING) + use_json_logs = settings.environment in (Environment.PROD, Environment.STAGING) + + # Console handler + loguru.logger.add( + sys.stderr, + level=base_log_level, + format=LOG_FORMAT, + colorize=not use_json_logs, + backtrace=True, + diagnose=True, + enqueue=is_enqueued, + serialize=use_json_logs, + ) + del log_dir + + +def setup_logging( + log_dir: Path | None = LOG_DIR, + base_log_level: str = BASE_LOG_LEVEL, + *, + stdout_only: bool = True, +) -> None: + """Setup loguru logging configuration and intercept standard logging.""" + if stdout_only: + log_dir = None + + # Remove standard loguru stdout handler to avoid duplicates + loguru.logger.remove() + + loguru.logger.configure(patcher=patch_log_record) + configure_loguru_handlers(log_dir, base_log_level) + + # Clear any existing root handlers + for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + + # Intercept everything at the root logger + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + + # Ensure uvicorn and other noisy loggers propagate correctly so that they are not duplicated in the logs + watchfiles_logger = "watchfiles.main" + + noisy_loggers = [ + watchfiles_logger, + "faker", + "faker.factory", + "uvicorn", + "uvicorn.error", + "uvicorn.access", + "watchfiles.main", + "sqlalchemy", + "sqlalchemy.engine", + "sqlalchemy.engine.Engine", + "sqlalchemy.pool", + "sqlalchemy.dialects", + "sqlalchemy.orm", + "fastapi", + "asyncio", + "starlette", + ] + for logger_name in noisy_loggers: + logging_logger = logging.getLogger(logger_name) + logging_logger.handlers = [] # Clear existing handlers + logging_logger.propagate = True # Propagate to InterceptHandler at the root + + # Keep known-noisy library loggers from spamming test and app output. + if logger_name in {watchfiles_logger, "faker", "faker.factory"}: + logging_logger.setLevel(logging.WARNING) + + +async def cleanup_logging() -> None: + """Cleanup loguru queues on shutdown.""" + loguru.logger.remove() + await loguru.logger.complete() diff --git a/backend/app/core/middleware/__init__.py b/backend/app/core/middleware/__init__.py new file mode 100644 index 00000000..db937ddd --- /dev/null +++ b/backend/app/core/middleware/__init__.py @@ -0,0 +1,13 @@ +"""HTTP middleware helpers for the backend app.""" + +from .client_ip import extract_client_ip, get_client_ip +from .request_id import REQUEST_ID_HEADER, register_request_id_middleware +from .request_size import register_request_size_limit_middleware + +__all__ = [ + "REQUEST_ID_HEADER", + "extract_client_ip", + "get_client_ip", + "register_request_id_middleware", + "register_request_size_limit_middleware", +] diff --git a/backend/app/core/middleware/client_ip.py b/backend/app/core/middleware/client_ip.py new file mode 100644 index 00000000..b6449174 --- /dev/null +++ b/backend/app/core/middleware/client_ip.py @@ -0,0 +1,49 @@ +"""Real client IP extraction for requests arriving via Cloudflare Tunnel. + +When cloudflared proxies traffic, ``request.client.host`` is always 127.0.0.1 +(the local tunnel endpoint). The real client IP is forwarded by Cloudflare via +the ``CF-Connecting-IP`` header before the request enters the tunnel. + +Security note: these headers are only safe to trust because cloudflared is the +*sole* entry point — the backend is not directly reachable from the internet. +If that changes (e.g. a port is accidentally exposed), header spoofing becomes +possible and this logic should be revisited. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Request + +if TYPE_CHECKING: + from starlette.datastructures import Headers + +# Ordered list of headers to try before falling back to the transport address. +_PROXY_HEADERS = ("CF-Connecting-IP", "X-Real-IP") + + +def extract_client_ip(headers: Headers, fallback: str = "unknown") -> str: + """Return the real client IP from proxy-forwarded headers. + + Checks headers in priority order: + 1. ``CF-Connecting-IP`` — set by Cloudflare (most reliable behind cloudflared) + 2. ``X-Real-IP`` — set by nginx and other reverse proxies + 3. First entry of ``X-Forwarded-For`` + 4. ``fallback`` — the raw transport address + """ + for header in _PROXY_HEADERS: + if ip := headers.get(header, "").strip(): + return ip + + forwarded_for = headers.get("X-Forwarded-For", "").strip() + if forwarded_for: + return forwarded_for.split(",")[0].strip() + + return fallback + + +def get_client_ip(request: Request) -> str: + """Rate-limiter key function returning the real client IP.""" + fallback = request.client.host if request.client else "unknown" + return extract_client_ip(request.headers, fallback) diff --git a/backend/app/core/middleware/request_id.py b/backend/app/core/middleware/request_id.py new file mode 100644 index 00000000..741d765c --- /dev/null +++ b/backend/app/core/middleware/request_id.py @@ -0,0 +1,60 @@ +"""Request ID middleware and request-scoped logging helpers.""" + +from __future__ import annotations + +from time import perf_counter +from typing import TYPE_CHECKING +from uuid import uuid4 + +from fastapi import FastAPI, Request +from loguru import logger as loguru_logger + +if TYPE_CHECKING: + from starlette.middleware.base import RequestResponseEndpoint + from starlette.responses import Response + +REQUEST_ID_HEADER = "X-Request-ID" +_MAX_REQUEST_ID_LENGTH = 255 + + +def _normalize_request_id(header_value: str | None) -> str: + """Return a safe request ID from the inbound header or generate a new one.""" + if header_value is None: + return str(uuid4()) + + normalized_value = header_value.strip() + if not normalized_value: + return str(uuid4()) + + return normalized_value[:_MAX_REQUEST_ID_LENGTH] + + +def register_request_id_middleware(app: FastAPI) -> None: + """Attach request ID propagation and access logging middleware to an app.""" + + @app.middleware("http") + async def request_id_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response: + request_id = _normalize_request_id(request.headers.get(REQUEST_ID_HEADER)) + request.state.request_id = request_id + + start_time = perf_counter() + + with loguru_logger.contextualize( + request_id=request_id, + http_method=request.method, + http_path=request.url.path, + ): + response = await call_next(request) + + latency_ms = round((perf_counter() - start_time) * 1000, 2) + response.headers[REQUEST_ID_HEADER] = request_id + + loguru_logger.bind( + request_id=request_id, + http_method=request.method, + http_path=request.url.path, + http_status_code=response.status_code, + http_latency_ms=latency_ms, + ).info("HTTP request completed") + + return response diff --git a/backend/app/core/middleware/request_size.py b/backend/app/core/middleware/request_size.py new file mode 100644 index 00000000..42a56ccd --- /dev/null +++ b/backend/app/core/middleware/request_size.py @@ -0,0 +1,64 @@ +"""Middleware for enforcing a global request body size limit.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.core.config import settings + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from starlette.responses import Response + from starlette.types import Message + +BODYLESS_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +MULTIPART_FORM_DATA = "multipart/form-data" + + +def _is_multipart_request(request: Request) -> bool: + """Return True when the request should use route-specific multipart validation instead.""" + return request.headers.get("content-type", "").lower().startswith(MULTIPART_FORM_DATA) + + +def _payload_too_large_response(limit_bytes: int) -> JSONResponse: + """Build the shared API error payload for oversized requests.""" + return JSONResponse( + status_code=413, + content={"detail": {"message": f"Request body too large. Maximum size: {limit_bytes} bytes"}}, + ) + + +def register_request_size_limit_middleware(app: FastAPI) -> None: + """Attach middleware that caps non-multipart request body size.""" + + @app.middleware("http") + async def request_size_limit_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + if request.method in BODYLESS_METHODS or _is_multipart_request(request): + return await call_next(request) + + limit_bytes = settings.request_body_limit_bytes + + content_length = request.headers.get("content-length") + if content_length is not None and int(content_length) > limit_bytes: + return _payload_too_large_response(limit_bytes) + + body = await request.body() + if len(body) > limit_bytes: + return _payload_too_large_response(limit_bytes) + + async def receive() -> Message: + return { + "type": "http.request", + "body": body, + "more_body": False, + } + + request_with_cached_body = Request(request.scope, receive) + return await call_next(request_with_cached_body) diff --git a/backend/app/core/model_registry.py b/backend/app/core/model_registry.py new file mode 100644 index 00000000..74b8fed5 --- /dev/null +++ b/backend/app/core/model_registry.py @@ -0,0 +1,22 @@ +"""Utilities to ensure all ORM models are registered before use.""" + +from functools import lru_cache + + +# ruff: noqa: F401, PLC0415 # We import all model modules here to ensure they're registered with SQLAlchemy before any ORM use. +@lru_cache(maxsize=1) +def load_models() -> None: + """Import all model modules once so SQLAlchemy can resolve string relationships. + + Models that use string-based relationship targets (e.g. ``"User"``) rely on + those classes being imported into the declarative registry before mapper + configuration runs. + """ + # data_collection is the hub: importing it pulls in auth, background_data, + # and file_storage transitively, registering all cross-module models. + from app.api.data_collection.models import product as _data_collection_models + + # rpi_cam and newsletter are self-contained; they only import auth and + # common.models which are already loaded via data_collection above. + from app.api.newsletter import models as _newsletter_models + from app.api.plugins.rpi_cam import models as _rpi_cam_models diff --git a/backend/app/core/observability/__init__.py b/backend/app/core/observability/__init__.py new file mode 100644 index 00000000..7f8e9a37 --- /dev/null +++ b/backend/app/core/observability/__init__.py @@ -0,0 +1,5 @@ +"""Observability helpers for instrumentation and runtime monitoring.""" + +from app.core.observability.telemetry import init_telemetry, shutdown_telemetry + +__all__ = ["init_telemetry", "shutdown_telemetry"] diff --git a/backend/app/core/observability/telemetry.py b/backend/app/core/observability/telemetry.py new file mode 100644 index 00000000..139efe62 --- /dev/null +++ b/backend/app/core/observability/telemetry.py @@ -0,0 +1,181 @@ +"""OpenTelemetry bootstrap helpers.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from importlib import import_module +from typing import TYPE_CHECKING, Any + +from app.core.config import settings + +if TYPE_CHECKING: + from fastapi import FastAPI + from loguru import Message as LoguruMessage + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from sqlalchemy.ext.asyncio import AsyncEngine + +logger = logging.getLogger(__name__) + + +@dataclass +class TelemetryState: + """Mutable telemetry runtime state for startup/shutdown lifecycle handling.""" + + initialized: bool = False + tracer_provider: TracerProvider | None = None + fastapi_instrumentor: FastAPIInstrumentor | None = None + sqlalchemy_instrumentor: SQLAlchemyInstrumentor | None = None + httpx_instrumentor: HTTPXClientInstrumentor | None = None + # Log export + log_provider: Any = field(default=None) + loguru_sink_id: int | None = None + + +_telemetry_state = TelemetryState() + + +def _init_log_export(resource: Resource, state: TelemetryState) -> None: + """Wire up OTLP log export and bridge loguru/stdlib logs into OTel. + + A dedicated bridge logger with propagate=False is used so loguru → OTel + emission does not loop back through the root InterceptHandler. + """ + try: + otlp_log_exporter_cls = import_module("opentelemetry.exporter.otlp.proto.http._log_exporter").OTLPLogExporter + logger_provider_cls = import_module("opentelemetry.sdk._logs").LoggerProvider + batch_log_processor_cls = import_module("opentelemetry.sdk._logs.export").BatchLogRecordProcessor + set_logger_provider = import_module("opentelemetry._logs").set_logger_provider + logging_handler_cls = import_module("opentelemetry.sdk._logs._internal").LoggingHandler + loguru_mod = import_module("loguru") + except ImportError, AttributeError: + logger.warning("OTel log export dependencies unavailable; skipping log export") + return + + log_provider = logger_provider_cls(resource=resource) + # No explicit endpoint: the SDK reads OTEL_EXPORTER_OTLP_ENDPOINT from the + # env and auto-appends /v1/logs. Passing endpoint= would use it as-is (no + # path append) and hit 404 at the collector. + log_provider.add_log_record_processor(batch_log_processor_cls(otlp_log_exporter_cls())) + set_logger_provider(log_provider) + + otel_handler = logging_handler_cls(level=logging.NOTSET, logger_provider=log_provider) + bridge = logging.getLogger("_otel_log_bridge") + bridge.propagate = False + bridge.setLevel(logging.NOTSET) + bridge.addHandler(otel_handler) + + def _otel_sink(message: LoguruMessage) -> None: + rec = message.record + exc = rec["exception"] + exc_info = (exc.type, exc.value, exc.traceback) if exc and exc.type and exc.value else None + log_record = logging.LogRecord( + name=rec["name"] or __name__, + level=getattr(logging, rec["level"].name, logging.INFO), + pathname=str(rec["file"].path), + lineno=rec["line"], + msg=str(rec["message"]), + args=(), + exc_info=exc_info, + ) + for key, val in rec["extra"].items(): + if val is not None: + setattr(log_record, key, val) + bridge.handle(log_record) + + state.loguru_sink_id = loguru_mod.logger.add(_otel_sink, level="INFO", format="{message}") + state.log_provider = log_provider + + +def init_telemetry(app: FastAPI, async_engine: AsyncEngine) -> bool: + """Initialize OpenTelemetry tracing and log export when explicitly enabled.""" + if not settings.otel_enabled: + return False + + if _telemetry_state.initialized: + return True + + try: + trace = import_module("opentelemetry.trace") + otlp_span_exporter = import_module("opentelemetry.exporter.otlp.proto.http.trace_exporter").OTLPSpanExporter + fastapi_instrumentor_cls = import_module("opentelemetry.instrumentation.fastapi").FastAPIInstrumentor + httpx_instrumentor_cls = import_module("opentelemetry.instrumentation.httpx").HTTPXClientInstrumentor + sqlalchemy_instrumentor_cls = import_module("opentelemetry.instrumentation.sqlalchemy").SQLAlchemyInstrumentor + resource_cls = import_module("opentelemetry.sdk.resources").Resource + tracer_provider_cls = import_module("opentelemetry.sdk.trace").TracerProvider + batch_span_processor_cls = import_module("opentelemetry.sdk.trace.export").BatchSpanProcessor + except ImportError: + logger.warning("OpenTelemetry is enabled but instrumentation dependencies are not installed") + return False + + # service.name comes from OTEL_SERVICE_NAME in the container env (set in + # compose.deploy.yml), auto-merged by Resource.create(). + resource = resource_cls.create({"deployment.environment.name": settings.environment}) + tracer_provider = tracer_provider_cls(resource=resource) + + # No explicit endpoint: same reason as the log exporter below — the SDK + # auto-appends /v1/traces when reading OTEL_EXPORTER_OTLP_ENDPOINT from env. + exporter = otlp_span_exporter() + tracer_provider.add_span_processor(batch_span_processor_cls(exporter)) + trace.set_tracer_provider(tracer_provider) + + fastapi_instrumentor = fastapi_instrumentor_cls() + fastapi_instrumentor.instrument_app(app) + + sqlalchemy_instrumentor = sqlalchemy_instrumentor_cls() + sqlalchemy_instrumentor.instrument(engine=async_engine.sync_engine) + + httpx_instrumentor = httpx_instrumentor_cls() + httpx_instrumentor.instrument() + + _telemetry_state.initialized = True + _telemetry_state.tracer_provider = tracer_provider + _telemetry_state.fastapi_instrumentor = fastapi_instrumentor + _telemetry_state.sqlalchemy_instrumentor = sqlalchemy_instrumentor + _telemetry_state.httpx_instrumentor = httpx_instrumentor + + _init_log_export(resource, _telemetry_state) + + logger.info("OpenTelemetry instrumentation enabled") + return True + + +def shutdown_telemetry(app: FastAPI) -> None: + """Uninstrument telemetry hooks and flush the tracer and log providers.""" + if not _telemetry_state.initialized: + return + + if _telemetry_state.fastapi_instrumentor is not None: + _telemetry_state.fastapi_instrumentor.uninstrument_app(app) + + if _telemetry_state.sqlalchemy_instrumentor is not None: + _telemetry_state.sqlalchemy_instrumentor.uninstrument() + + if _telemetry_state.httpx_instrumentor is not None: + _telemetry_state.httpx_instrumentor.uninstrument() + + if _telemetry_state.tracer_provider is not None: + _telemetry_state.tracer_provider.shutdown() + + if _telemetry_state.loguru_sink_id is not None: + try: + loguru_mod = import_module("loguru") + loguru_mod.logger.remove(_telemetry_state.loguru_sink_id) + except (ImportError, ValueError) as exc: + logger.debug("Could not remove loguru OTel sink: %s", exc) + + if _telemetry_state.log_provider is not None: + _telemetry_state.log_provider.shutdown() + + _telemetry_state.initialized = False + _telemetry_state.tracer_provider = None + _telemetry_state.fastapi_instrumentor = None + _telemetry_state.sqlalchemy_instrumentor = None + _telemetry_state.httpx_instrumentor = None + _telemetry_state.log_provider = None + _telemetry_state.loguru_sink_id = None + logger.info("OpenTelemetry instrumentation disabled") diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 00000000..101da188 --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,251 @@ +"""Redis connection management.""" + +# spell-checker: ignore BLPOP, BRPOP +import logging +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Depends, HTTPException, Request +from redis.asyncio import Redis +from redis.exceptions import RedisError + +from app.core.config import settings +from app.core.logging import sanitize_log_value +from app.core.runtime import get_request_services + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from redis.typing import EncodableT + +logger = logging.getLogger(__name__) + + +def _redis_from_request(request: Request) -> Redis | None: + """Return the Redis client from app state when available.""" + return get_request_services(request).redis + + +async def _execute_redis_operation[T]( + operation_name: str, + operation: Callable[[], Awaitable[T]], + failure_result: T, + *, + log_key: str | None = None, +) -> T: + """Run a Redis operation with consistent error handling.""" + try: + return await operation() + except TimeoutError, RedisError, OSError: + if log_key is None: + logger.exception("Redis %s failed.", operation_name) + else: + logger.exception("Redis %s failed for key %s.", operation_name, sanitize_log_value(log_key)) + return failure_result + + +async def init_redis() -> Redis | None: + """Initialize Redis client instance with connection pooling. + + Returns: + Redis: Async Redis client with connection pooling, or None if connection fails + + This should be called once during application startup. + Gracefully handles connection failures and returns None if Redis is unavailable. + """ + try: + redis_client = Redis( + host=settings.redis_host, + port=settings.redis_port, + db=settings.redis_db, + password=settings.redis_password.get_secret_value() if settings.redis_password else None, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5, + ) + + # Verify connection on startup + await cast("Awaitable[bool]", redis_client.ping()) + logger.info("Redis client initialized and connected: %s:%s", settings.redis_host, settings.redis_port) + + except (TimeoutError, RedisError, OSError, ConnectionError) as e: + logger.warning( + "Failed to connect to Redis during initialization: %s. Application will continue without Redis.", e + ) + return None + else: + return redis_client + + +async def init_blocking_redis() -> Redis | None: + """Initialize a Redis client for blocking commands (BLPOP/BRPOP). + + Identical to ``init_redis`` except ``socket_timeout`` is ``None`` so that + blocking pops (BLPOP with large or zero timeout) are not interrupted by the + socket-level timeout. Use this client *only* for blocking operations; all + other operations should use the regular client from ``init_redis``. + """ + try: + redis_client = Redis( + host=settings.redis_host, + port=settings.redis_port, + db=settings.redis_db, + password=settings.redis_password.get_secret_value() if settings.redis_password else None, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=None, # must be None for BLPOP — a finite timeout kills the socket mid-wait + ) + await cast("Awaitable[bool]", redis_client.ping()) + logger.info( + "Blocking Redis client initialized and connected: %s:%s", + settings.redis_host, + settings.redis_port, + ) + except (TimeoutError, RedisError, OSError, ConnectionError) as e: + logger.warning( + "Failed to connect to Redis (blocking client) during initialization: %s. " + "Cross-worker relay will be unavailable.", + e, + ) + return None + else: + return redis_client + + +async def close_redis(redis_client: Redis) -> None: + """Close Redis connection and connection pool. + + Args: + redis_client: Redis client to close + + This properly closes all connections in the pool. + """ + if redis_client: + await redis_client.aclose() + logger.info("Redis connection pool closed") + + +async def ping_redis(redis_client: Redis) -> bool: + """Check if Redis is available (health check). + + Args: + redis_client: Redis client to ping + + Returns: + bool: True if Redis is responding, False otherwise + + This is useful for health check endpoints. + """ + return await _execute_redis_operation( + "ping", + cast("Callable[[], Awaitable[bool]]", redis_client.ping), + failure_result=False, + ) + + +async def get_redis_value(redis_client: Redis, key: str) -> str | None: + """Get value from Redis. + + Args: + redis_client: Redis client + key: Redis key + + Returns: + Value as string, or None if not found + """ + return await _execute_redis_operation("get", lambda: redis_client.get(key), None, log_key=key) + + +async def set_redis_value(redis_client: Redis, key: str, value: EncodableT, ex: int | None = None) -> bool: + """Set value in Redis. + + Args: + redis_client: Redis client + key: Redis key + value: Value to store + ex: Expiration time in seconds (optional) + + Returns: + bool: True if successful, False otherwise + """ + + async def operation() -> bool: + await redis_client.set(key, value, ex=ex) + return True + + return await _execute_redis_operation("set", operation, failure_result=False, log_key=key) + + +async def set_redis_value_nx(redis_client: Redis, key: str, value: EncodableT, ex: int | None = None) -> bool: + """Set value in Redis only if the key does not already exist (atomic SET NX EX). + + Returns True if the value was stored, False if the key already existed or the + operation failed. + """ + + async def operation() -> bool: + result = await redis_client.set(key, value, ex=ex, nx=True) + return bool(result) + + return await _execute_redis_operation("set_nx", operation, failure_result=False, log_key=key) + + +async def delete_redis_key(redis_client: Redis, key: str) -> bool: + """Delete a key from Redis. + + Args: + redis_client: Redis client + key: Redis key + + Returns: + bool: True if successful, False otherwise + """ + + async def operation() -> bool: + await redis_client.delete(key) + return True + + return await _execute_redis_operation("delete", operation, failure_result=False, log_key=key) + + +def get_redis(request: Request) -> Redis: + """FastAPI dependency to get the shared Redis client (raises if unavailable). + + Args: + request: FastAPI request bound to the application's runtime services + + Returns: + Redis client from the runtime service container + + Raises: + RuntimeError: If Redis not initialized or unavailable + """ + redis_client = _redis_from_request(request) + + if redis_client is None: + msg = "Redis not available. Check Redis connection settings." + raise RuntimeError(msg) + + return redis_client + + +# Type annotation for Redis dependency injection +RedisDep = Annotated[Redis, Depends(get_redis)] + + +def get_redis_optional(request: Request) -> Redis | None: + """FastAPI dependency that returns Redis client or None without raising. + + Use this where Redis is optional (e.g. in development where Redis may be unavailable). + """ + return _redis_from_request(request) + + +# Optional Redis dependency annotation +OptionalRedisDep = Annotated[Redis | None, Depends(get_redis_optional)] + + +def require_redis(redis_client: Redis | None) -> Redis: + """Raise an HTTP-style error if Redis is unavailable.""" + if redis_client is None: + raise HTTPException(status_code=503, detail="Redis is required for this operation.") + return redis_client diff --git a/backend/app/core/responses.py b/backend/app/core/responses.py new file mode 100644 index 00000000..a61da372 --- /dev/null +++ b/backend/app/core/responses.py @@ -0,0 +1,130 @@ +"""HTTP response helpers for standardized payloads and conditional requests.""" +# spell-checker: ignore jsonable + +from __future__ import annotations + +import hashlib +import json +from http import HTTPStatus +from typing import TYPE_CHECKING + +from fastapi.encoders import jsonable_encoder +from fastapi.requests import Request +from fastapi.responses import HTMLResponse, JSONResponse, Response + +from app.core.middleware import REQUEST_ID_HEADER + +if TYPE_CHECKING: + from collections.abc import Mapping + +PROBLEM_CONTENT_TYPE = "application/problem+json" +ETAG_WILDCARD = "*" + + +def _quoted_etag(payload: bytes) -> str: + digest = hashlib.sha256(payload).hexdigest() + return f'"{digest}"' + + +def _request_id(request: Request | None) -> str | None: + if request is None: + return None + request_id = getattr(request.state, "request_id", None) + return request_id if isinstance(request_id, str) else None + + +def _etag_matches(if_none_match: str | None, current_etag: str) -> bool: + """Return whether the request's ``If-None-Match`` header matches ``current_etag``.""" + if if_none_match is None: + return False + if if_none_match.strip() == ETAG_WILDCARD: + return True + + candidates = {candidate.strip() for candidate in if_none_match.split(",")} + return current_etag in candidates or f"W/{current_etag}" in candidates + + +def _response_headers(request: Request | None, headers: Mapping[str, str] | None = None) -> dict[str, str]: + response_headers = dict(headers or {}) + request_id = _request_id(request) + if request_id and REQUEST_ID_HEADER not in response_headers: + response_headers[REQUEST_ID_HEADER] = request_id + return response_headers + + +def build_problem_response( + *, + request: Request | None, + status_code: int, + detail: str, + type_: str = "about:blank", + title: str | None = None, + code: str | None = None, + extra: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + """Build a Problem Details error response.""" + problem: dict[str, object] = { + "type": type_, + "title": title or HTTPStatus(status_code).phrase, + "status": status_code, + "detail": detail, + } + request_id = _request_id(request) + if request_id is not None: + problem["request_id"] = request_id + if code is not None: + problem["code"] = code + if extra: + problem.update(extra) + + return JSONResponse( + status_code=status_code, + content=problem, + media_type=PROBLEM_CONTENT_TYPE, + headers=_response_headers(request, headers), + ) + + +def conditional_json_response( + request: Request, + payload: object, + *, + etag_seed: str | None = None, + status_code: int = 200, + headers: Mapping[str, str] | None = None, +) -> Response: + """Return a JSON response with ETag support.""" + encoded_payload = jsonable_encoder(payload) + response_bytes = ( + etag_seed.encode("utf-8") + if etag_seed is not None + else json.dumps(encoded_payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + etag = _quoted_etag(response_bytes) + response_headers = _response_headers(request, headers) + response_headers["ETag"] = etag + + if _etag_matches(request.headers.get("if-none-match"), etag): + return Response(status_code=304, headers=response_headers) + + return JSONResponse(status_code=status_code, content=encoded_payload, headers=response_headers) + + +def conditional_html_response( + request: Request, + content: str, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, +) -> Response: + """Return an HTML response with ETag support.""" + response_bytes = content.encode("utf-8") + etag = _quoted_etag(response_bytes) + response_headers = _response_headers(request, headers) + response_headers["ETag"] = etag + + if _etag_matches(request.headers.get("if-none-match"), etag): + return Response(status_code=304, headers=response_headers) + + return HTMLResponse(content=content, status_code=status_code, headers=response_headers) diff --git a/backend/app/core/runtime.py b/backend/app/core/runtime.py new file mode 100644 index 00000000..1e2dbab6 --- /dev/null +++ b/backend/app/core/runtime.py @@ -0,0 +1,130 @@ +"""Typed runtime services stored on FastAPI connection state.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from fastapi import FastAPI, Request + +if TYPE_CHECKING: + import anyio + from httpx import AsyncClient + from redis.asyncio import Redis + from starlette.requests import HTTPConnection + + from app.api.auth.services.email_checker import EmailChecker + from app.api.file_storage.services.manager import FileCleanupManager + from app.api.plugins.rpi_cam.websocket.connection_manager import CameraConnectionManager + + +@dataclass(slots=True) +class AppServices: + """Typed container for long-lived runtime services.""" + + redis: Redis | None = None + blocking_redis: Redis | None = None + email_checker: EmailChecker | None = None + camera_connection_manager: CameraConnectionManager | None = None + file_cleanup_manager: FileCleanupManager | None = None + http_client: AsyncClient | None = None + image_resize_limiter: anyio.CapacityLimiter | None = None + storage_ready: bool = False + telemetry_enabled: bool = False + + +def get_connection_services(connection: HTTPConnection) -> AppServices: + """Return the typed runtime services container from any Starlette connection.""" + return get_app_services(cast("FastAPI", connection.app)) + + +def get_app_services(app: FastAPI) -> AppServices: + """Return the typed runtime services container from app state.""" + services = getattr(app.state, "services", None) + if not isinstance(services, AppServices): + services = AppServices() + app.state.services = services + return services + + +def get_request_services(request: Request) -> AppServices: + """Return the typed runtime services container from a request.""" + return get_connection_services(request) + + +def require_connection_services(connection: HTTPConnection) -> AppServices: + """Return runtime services for a connection. + + This helper documents intent at call sites that expect the container to be + present as part of normal application startup. + """ + return get_connection_services(connection) + + +def get_connection_redis(connection: HTTPConnection) -> Redis | None: + """Return the shared Redis client for a request or websocket.""" + return get_connection_services(connection).redis + + +def get_connection_blocking_redis(connection: HTTPConnection) -> Redis | None: + """Return the shared blocking Redis client for a request or websocket.""" + return get_connection_services(connection).blocking_redis + + +def get_connection_http_client(connection: HTTPConnection) -> AsyncClient | None: + """Return the shared outbound HTTP client for a request or websocket.""" + return get_connection_services(connection).http_client + + +def get_connection_camera_manager(connection: HTTPConnection) -> CameraConnectionManager | None: + """Return the shared camera connection manager for a request or websocket.""" + return get_connection_services(connection).camera_connection_manager + + +def get_connection_image_resize_limiter(connection: HTTPConnection) -> anyio.CapacityLimiter | None: + """Return the shared image resize limiter for a request or websocket.""" + return get_connection_services(connection).image_resize_limiter + + +def get_request_email_checker(request: Request) -> EmailChecker | None: + """Return the shared disposable-email checker for a request.""" + return get_request_services(request).email_checker + + +def get_connection_file_cleanup_manager(connection: HTTPConnection) -> FileCleanupManager | None: + """Return the shared file cleanup manager for a request or websocket.""" + return get_connection_services(connection).file_cleanup_manager + + +def require_connection_camera_manager(connection: HTTPConnection) -> CameraConnectionManager: + """Return the shared camera manager, raising when runtime init is incomplete.""" + manager = get_connection_camera_manager(connection) + if manager is None: + msg = "Camera connection manager is not initialized" + raise RuntimeError(msg) + return manager + + +def require_connection_redis(connection: HTTPConnection) -> Redis: + """Return the shared Redis client, raising when runtime init is incomplete.""" + redis = get_connection_redis(connection) + if redis is None: + msg = "Redis is not initialized" + raise RuntimeError(msg) + return redis + + +def require_connection_http_client(connection: HTTPConnection) -> AsyncClient: + """Return the shared outbound HTTP client, raising when runtime init is incomplete.""" + http_client = get_connection_http_client(connection) + if http_client is None: + msg = "HTTP client is not initialized" + raise RuntimeError(msg) + return http_client + + +def reset_app_services(app: FastAPI) -> AppServices: + """Reset runtime services to an empty container.""" + services = AppServices() + app.state.services = services + return services diff --git a/backend/app/core/utils/__init__.py b/backend/app/core/utils/__init__.py deleted file mode 100644 index 9b957b1c..00000000 --- a/backend/app/core/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Cross-package utility functions.""" diff --git a/backend/app/core/utils/custom_logging.py b/backend/app/core/utils/custom_logging.py deleted file mode 100644 index 4b7476ab..00000000 --- a/backend/app/core/utils/custom_logging.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Main logger setup.""" - -import logging -import time -from logging.handlers import TimedRotatingFileHandler -from pathlib import Path - -import coloredlogs - -from app.core.config import settings - -### Logging formats - -LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -DATE_FORMAT = "%Y-%m-%d %H:%M:%S" -LOG_DIR = settings.log_path - -LOG_CONFIG = { - # (level, rotation interval, backup count) - "debug": (logging.DEBUG, "midnight", 3), # All logs, 3 days - "info": (logging.INFO, "midnight", 14), # INFO and above, 14 days - "error": (logging.ERROR, "W0", 12), # ERROR and above, 12 weeks -} - -BASE_LOG_LEVEL = logging.DEBUG if settings.debug else logging.INFO - - -### Logging utils ### -# TODO: Move from coloredlogs to loguru for simpler logging configuration -def set_utc_logging() -> None: - """Configure logging to use UTC timestamps.""" - logging.Formatter.converter = time.gmtime - - -def create_file_handlers(log_dir: Path, fmt: str, datefmt: str) -> dict[str, logging.Handler]: - """Create file handlers for each log level.""" - handler_dict: dict[str, logging.Handler] = {} - for name, (level, interval, count) in LOG_CONFIG.items(): - handler = TimedRotatingFileHandler( - filename=log_dir / f"{name}.log", - when=interval, - backupCount=count, - encoding="utf-8", - utc=True, - ) - handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt)) - handler.setLevel(level) - handler_dict[name] = handler - return handler_dict - - -def setup_logging( - *, - fmt: str = LOG_FORMAT, - datefmt: str = DATE_FORMAT, - log_dir: Path = LOG_DIR, - base_log_level: int = BASE_LOG_LEVEL, -) -> None: - """Setup logging configuration with consistent handlers.""" - # Set UTC timezone for all logging - set_utc_logging() - - # Create log directory if it doesn't exist - log_dir.mkdir(exist_ok=True) - - # Configure root logger - root_logger: logging.Logger = logging.getLogger() - root_logger.setLevel(base_log_level) - - # Install colored console logging - coloredlogs.install(level=base_log_level, fmt=fmt, datefmt=datefmt, logger=root_logger) - - # Add file handlers to root logger - file_handlers: dict[str, logging.Handler] = create_file_handlers(log_dir, fmt, datefmt) - for handler in file_handlers.values(): - root_logger.addHandler(handler) - - # Ensure uvicorn loggers propagate to root and have no handlers of their own - for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access"]: - logger = logging.getLogger(logger_name) - logger.handlers.clear() - logger.propagate = True - - # Optionally, quiet noisy loggers - for logger_name in ["watchfiles.main"]: - logging.getLogger(logger_name).setLevel(logging.WARNING) diff --git a/backend/app/main.py b/backend/app/main.py index 8623cfea..c7109194 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,56 +1,104 @@ -"""Main application module for the Reverse Engineering Lab - Data collection API. +"""Main application entrypoint for the Reverse Engineering Lab backend.""" -This module initializes the FastAPI application, sets up the API routes, -mounts static and upload directories, and initializes the admin interface. -""" +import logging +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi_pagination import add_pagination +from starlette.middleware.trustedhost import TrustedHostMiddleware -from app.api.admin.main import init_admin from app.api.common.routers.exceptions import register_exception_handlers +from app.api.common.routers.health import router as health_router from app.api.common.routers.main import router from app.api.common.routers.openapi import init_openapi_docs +from app.core import lifecycle from app.core.config import settings -from app.core.database import async_engine -from app.core.utils.custom_logging import setup_logging - -# Initialize logging -setup_logging() - -# Initialize FastAPI application -app = FastAPI( - openapi_url=None, - docs_url=None, - redoc_url=None, -) - -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=settings.allowed_origins, - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["*"], -) - -# Include main API routes -app.include_router(router) - -# Initialize OpenAPI documentation -init_openapi_docs(app) - -# Initialize admin interface -admin = init_admin(app, async_engine) - -# Mount local file storage -app.mount("/uploads", StaticFiles(directory=settings.uploads_path), name="uploads") -app.mount("/static", StaticFiles(directory=settings.static_files_path), name="static") - -# Initialize exception handling -register_exception_handlers(app) - -# Add pagination -add_pagination(app) +from app.core.config.models import Environment +from app.core.logging import cleanup_logging, setup_logging +from app.core.middleware import register_request_id_middleware, register_request_size_limit_middleware + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +logger = logging.getLogger(__name__) + + +def ensure_storage_directories() -> None: + """Backward-compatible export for storage directory setup helpers.""" + lifecycle.ensure_storage_directories() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator: + """Manage application lifespan: startup and shutdown events.""" + logging_configured = False + if settings.environment != Environment.TESTING: + setup_logging() + logging_configured = True + + lifecycle.log_startup_configuration() + await lifecycle.initialize_runtime_services(app) + logger.info("Application startup complete") + + yield + + logger.info("Shutting down application...") + await lifecycle.shutdown_runtime_services(app) + logger.info("Application shutdown complete") + if logging_configured: + await cleanup_logging() + + +def create_app() -> FastAPI: + """Create and configure a FastAPI application instance.""" + app = FastAPI( + openapi_url=None, + docs_url=None, + redoc_url=None, + lifespan=lifespan, + ) + + # Add request ID propagation and request access logging + register_request_id_middleware(app) + + # Add global non-multipart request body size limits + register_request_size_limit_middleware(app) + + # Add host header validation middleware + app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=settings.allowed_hosts, + ) + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_origin_regex=settings.cors_origin_regex, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept", "X-Request-ID"], + expose_headers=["X-Request-ID"], + ) + + # Include health check routes (liveness and readiness probes) + app.include_router(health_router) + + # Include main API routes + app.include_router(router) + + # Initialize OpenAPI documentation + init_openapi_docs(app) + + # Initialize exception handling + register_exception_handlers(app) + + # Add pagination + add_pagination(app) + return app + + +# Initialize FastAPI application with lifespan +app = create_app() diff --git a/backend/app/static/css/styles.css b/backend/app/static/css/styles.css index 29528d3f..4bd5955e 100644 --- a/backend/app/static/css/styles.css +++ b/backend/app/static/css/styles.css @@ -1,63 +1,104 @@ :root { - --primary: #4755b6; - --on-primary: #fff; - --secondary: #485082; - --on-secondary: #fff; - --background: #fff; - --on-background: #1b1b1f; - --surface: #fffBff; - --border: #e3e1ec; - --shadow: rgba(71, 85, 182, 0.08); - --radius: 12px; - --font-family: 'Inter', sans-serif, system-ui; + --color-primary: #006783; + --color-primary-strong: #004d63; + --color-primary-light: #bce9ff; + --color-surface: #fbfcfe; + --color-surface-soft: #eef5f8; + --color-on-surface: #191c1e; + --color-muted: #40484c; + --color-border: #dce4e9; + --color-error: #ba1a1a; + --color-ring: rgba(0, 103, 131, 0.25); + --shadow-soft: 0 10px 30px rgba(25, 28, 30, 0.08); + --radius-md: 14px; + --radius-lg: 22px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-primary: #63d3ff; + --color-primary-strong: #3db5e5; + --color-primary-light: #004d63; + --color-surface: #191c1e; + --color-surface-soft: #1d2529; + --color-on-surface: #e1e2e4; + --color-muted: #c0c8cd; + --color-border: #40484c; + --color-ring: rgba(99, 211, 255, 0.28); + --shadow-soft: 0 14px 36px rgba(0, 0, 0, 0.35); + } +} + +* { + box-sizing: border-box; } body { - font-family: var(--font-family); - background: #fff; - color: var(--on-background); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: + linear-gradient(180deg, rgba(251, 252, 254, 0.6) 0%, rgba(251, 252, 254, 0.68) 100%), + url("/static/images/bg-light.jpg") center / cover no-repeat; + color: var(--color-on-surface); margin: 0; - padding: 2rem; + padding: 2rem 1rem; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + display: flex; + align-items: flex-start; + justify-content: center; } -.container { - max-width: 800px; - margin: 0 auto; - padding: 1.5rem; - background: var(--background); - border-radius: var(--radius); - box-shadow: 0 2px 2px var(--shadow); - border: 1px solid var(--border); - box-sizing: border-box; +@media (prefers-color-scheme: dark) { + body { + background: + linear-gradient(180deg, rgba(25, 28, 30, 0.72) 0%, rgba(25, 28, 30, 0.8) 100%), + url("/static/images/bg-dark.jpg") center / cover no-repeat; + } } -.section { - padding: 1rem; - border-radius: 6px; - margin-bottom: 1.5rem; +h1, h2, h3, h4 { + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + letter-spacing: -0.02em; +} + +.container { + width: 100%; + max-width: 640px; + margin-top: 2rem; + padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + border: 1px solid var(--color-border); } h1 { - color: var(--primary); - font-size: 2.5rem; - margin: 0 0 1.5rem 0; + color: var(--color-on-surface); + font-size: 2rem; + margin: 0 0 0.5rem 0; text-align: center; - border-bottom: 2px solid var(--primary); - padding-bottom: 0.5rem; - font-family: 'Source Serif 4', Georgia, 'Times New Roman', Times, serif; - font-weight: 400; } -h2, h3, h4, h5, h6 { - color: var(--secondary); - font-family: 'Source Serif 4', Georgia, 'Times New Roman', Times, serif; - font-weight: 400; - margin: 0 0 1rem 0; +p.description { + color: var(--color-muted); + margin: 0 0 1.75rem 0; + font-size: 0.9375rem; + text-align: center; } -.description { - color: #4a5568; - margin-bottom: 2rem; +.section { + padding: 1.25rem; + border-radius: var(--radius-md); + background: var(--color-surface-soft); + border: 1px solid var(--color-border); + margin-bottom: 1.25rem; +} + +.section h2 { + color: var(--color-on-surface); + font-size: 0.9375rem; + font-weight: 600; + margin: 0 0 0.875rem 0; } .form-group { @@ -66,76 +107,100 @@ h2, h3, h4, h5, h6 { label { display: block; - margin-bottom: 0.5rem; - color: var(--secondary); + margin-bottom: 0.375rem; + color: var(--color-muted); + font-size: 0.875rem; font-weight: 500; } -input, select { +input { width: 100%; - box-sizing: border-box; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: 6px; + padding: 0.625rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); font-size: 1rem; - background: #f6f7fa; - color: var(--on-background); - font-family: var(--font-family); - transition: border 0.2s; + background: var(--color-surface); + color: var(--color-on-surface); + font-family: "IBM Plex Sans", sans-serif; + transition: border-color 160ms ease, box-shadow 160ms ease; } -input:focus, select:focus { - border-color: var(--primary); +input:focus { + border-color: var(--color-primary); outline: none; + box-shadow: 0 0 0 3px var(--color-ring); } -button, .primary-btn, .secondary-btn { +button, +.primary-btn, +.secondary-btn { display: block; - margin: 1rem auto; - padding: 1rem 2.5rem; + width: 100%; + margin: 0.5rem auto; + padding: 0.625rem 1.5rem; border: none; - border-radius: var(--radius); - font-size: 1rem; - font-weight: 400; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; cursor: pointer; text-align: center; text-decoration: none; - transition: background 0.2s, color 0.2s, box-shadow 0.2s; - box-shadow: 0 1px 3px var(--shadow); - min-width: 180px; - max-width: 100%; - width: auto; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; + font-family: "IBM Plex Sans", sans-serif; } -/* Primary button style */ .primary-btn { - background: var(--primary); - color: var(--on-primary); + background: var(--color-primary); + width: 100%; + color: #fff; + box-shadow: 0 1px 4px rgba(0, 103, 131, 0.2); + margin-bottom: 1rem; } .primary-btn:hover { - background: var(--secondary); - color: var(--on-secondary); + background: var(--color-primary-strong); + box-shadow: 0 2px 8px rgba(0, 103, 131, 0.3); + color: #fff; } -/* Secondary button style */ .secondary-btn { - background: var(--on-primary); - color: var(--primary); - border: 1px solid var(--primary); + background: transparent; + color: var(--color-primary); + border: 1px solid var(--color-border); } .secondary-btn:hover { - background: var(--primary); - color: var(--on-primary); + background: var(--color-primary-light); + border-color: var(--color-primary); + color: var(--color-primary-strong); } .error { - color: #ba1a1a; - font-size: 0.95rem; + color: var(--color-error); + font-size: 0.875rem; margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(186, 26, 26, 0.08); + border-radius: 8px; + border: 1px solid rgba(186, 26, 26, 0.2); + display: none; +} + +.back-link { + display: block; + text-align: center; + margin-top: 1rem; } .back-link::before { content: '← '; } + +:focus-visible { + outline: 3px solid var(--color-ring); + outline-offset: 2px; +} + +::selection { + background: rgba(0, 103, 131, 0.2); +} diff --git a/backend/app/static/favicon.ico b/backend/app/static/favicon.ico new file mode 100644 index 00000000..19fb65a0 Binary files /dev/null and b/backend/app/static/favicon.ico differ diff --git a/backend/app/static/favicon.png b/backend/app/static/favicon_500.ico old mode 100644 new mode 100755 similarity index 100% rename from backend/app/static/favicon.png rename to backend/app/static/favicon_500.ico diff --git a/backend/app/static/images/bg-dark.jpg b/backend/app/static/images/bg-dark.jpg new file mode 100644 index 00000000..1b3fee6b Binary files /dev/null and b/backend/app/static/images/bg-dark.jpg differ diff --git a/backend/app/static/images/bg-light.jpg b/backend/app/static/images/bg-light.jpg new file mode 100644 index 00000000..e147e817 Binary files /dev/null and b/backend/app/static/images/bg-light.jpg differ diff --git a/backend/app/templates/emails/build/newsletter.html b/backend/app/templates/emails/build/newsletter.html new file mode 100644 index 00000000..67e01ccc --- /dev/null +++ b/backend/app/templates/emails/build/newsletter.html @@ -0,0 +1,167 @@ + + + + + {{subject}} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+
{{content}}
+
+
+ +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/newsletter_subscription.html b/backend/app/templates/emails/build/newsletter_subscription.html new file mode 100644 index 00000000..d159f1d0 --- /dev/null +++ b/backend/app/templates/emails/build/newsletter_subscription.html @@ -0,0 +1,149 @@ + + + + + Reverse Engineering Lab: Confirm Your Newsletter Subscription + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
Hello,
+
+
Thank you for subscribing to the Reverse Engineering Lab newsletter!
+
+
Please confirm your subscription by clicking the button below:
+
+ + + + + +
+ + Confirm Subscription + +
+ +
+
Or copy and paste this link in your browser:
+{{confirmation_link}}
+
+
This link will expire in 24 hours.
+
+
We'll keep you updated with our progress and let you know when the full application is launched.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/newsletter_unsubscribe.html b/backend/app/templates/emails/build/newsletter_unsubscribe.html new file mode 100644 index 00000000..bea9a4d9 --- /dev/null +++ b/backend/app/templates/emails/build/newsletter_unsubscribe.html @@ -0,0 +1,146 @@ + + + + + Reverse Engineering Lab: Unsubscribe Request + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello,
+
+
We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter.
+
+
If you made this request, please click the button below to unsubscribe:
+
+ + + + + +
+ + Unsubscribe + +
+ +
+
Or copy and paste this link in your browser:
+{{unsubscribe_link}}
+
+
If you did not request to unsubscribe, you can safely ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/password_reset.html b/backend/app/templates/emails/build/password_reset.html new file mode 100644 index 00000000..4c692f74 --- /dev/null +++ b/backend/app/templates/emails/build/password_reset.html @@ -0,0 +1,145 @@ + + + + + Password Reset + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{username}},
+
+
Please reset your password by clicking the button below:
+
+ + + + + +
+ + Reset Password + +
+ +
+
Or copy and paste this link in your browser:
+{{reset_link}}
+
+
This link will expire in 1 hour.
+
+
If you did not request a password reset, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/post_verification.html b/backend/app/templates/emails/build/post_verification.html new file mode 100644 index 00000000..1bdb7eba --- /dev/null +++ b/backend/app/templates/emails/build/post_verification.html @@ -0,0 +1,122 @@ + + + + + Email Verified + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + +
+
Hello {{username}},
+
+
Your email has been verified!
+
+
Thank you for verifying your email address. You can now enjoy full access to all features.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/registration.html b/backend/app/templates/emails/build/registration.html new file mode 100644 index 00000000..4e9d2c65 --- /dev/null +++ b/backend/app/templates/emails/build/registration.html @@ -0,0 +1,145 @@ + + + + + Welcome to Reverse Engineering Lab - Verify Your Email + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{ username }},
+
+
Thank you for registering! Please verify your email by clicking the button below:
+
+ + + + + +
+ + Verify Email Address + +
+ +
+
Or copy and paste this link in your browser:
+{{ verification_link }}
+
+
This link will expire in 1 hour.
+
+
If you did not register for this service, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/verification.html b/backend/app/templates/emails/build/verification.html new file mode 100644 index 00000000..222b7de7 --- /dev/null +++ b/backend/app/templates/emails/build/verification.html @@ -0,0 +1,145 @@ + + + + + Email Verification + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{username}},
+
+
Please verify your email by clicking the button below:
+
+ + + + + +
+ + Verify Email Address + +
+ +
+
Or copy and paste this link in your browser:
+{{verification_link}}
+
+
This link will expire in 1 hour.
+
+
If you did not request verification, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/src/components/footer.mjml b/backend/app/templates/emails/src/components/footer.mjml new file mode 100644 index 00000000..5ab4bdaf --- /dev/null +++ b/backend/app/templates/emails/src/components/footer.mjml @@ -0,0 +1,18 @@ + + + + + + + + + Best regards,
+ The Reverse Engineering Lab Team +
+
+
+ + + This email was sent from Reverse Engineering Lab + + diff --git a/backend/app/templates/emails/src/components/header.mjml b/backend/app/templates/emails/src/components/header.mjml new file mode 100644 index 00000000..4f30e345 --- /dev/null +++ b/backend/app/templates/emails/src/components/header.mjml @@ -0,0 +1,15 @@ + + + + + + + + Reverse Engineering Lab + + + + + + + diff --git a/backend/app/templates/emails/src/components/styles.mjml b/backend/app/templates/emails/src/components/styles.mjml new file mode 100644 index 00000000..5e624a23 --- /dev/null +++ b/backend/app/templates/emails/src/components/styles.mjml @@ -0,0 +1,41 @@ + + + + + + + + + + .header-title { + font-family: 'Space Grotesk', 'IBM Plex Sans', Arial, sans-serif; + font-size: 22px; + font-weight: 600; + color: #006783; + letter-spacing: -0.02em; + } + .footer-text { + font-size: 12px; + color: #40484c; + } + .muted { + color: #40484c; + } + .link { + color: #006783; + word-break: break-all; + } + diff --git a/backend/app/templates/emails/src/newsletter.mjml b/backend/app/templates/emails/src/newsletter.mjml new file mode 100644 index 00000000..0be45e03 --- /dev/null +++ b/backend/app/templates/emails/src/newsletter.mjml @@ -0,0 +1,26 @@ + + + {{ subject }} + {{include:styles}} + + + {{include:header}} + + + + {{ content }} + + + + {{include:footer}} + + + + + You're receiving this email because you subscribed to the Reverse Engineering Lab newsletter.
+ Unsubscribe +
+
+
+
+
diff --git a/backend/app/templates/emails/src/newsletter_subscription.mjml b/backend/app/templates/emails/src/newsletter_subscription.mjml new file mode 100644 index 00000000..c8658c06 --- /dev/null +++ b/backend/app/templates/emails/src/newsletter_subscription.mjml @@ -0,0 +1,28 @@ + + + Reverse Engineering Lab: Confirm Your Newsletter Subscription + {{include:styles}} + + + {{include:header}} + + + + Hello, + Thank you for subscribing to the Reverse Engineering Lab newsletter! + Please confirm your subscription by clicking the button below: + Confirm Subscription + + Or copy and paste this link in your browser:
+ {{ confirmation_link }} +
+ This link will expire in 24 hours. + + We'll keep you updated with our progress and let you know when the full application is launched. + +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/newsletter_unsubscribe.mjml b/backend/app/templates/emails/src/newsletter_unsubscribe.mjml new file mode 100644 index 00000000..566c787e --- /dev/null +++ b/backend/app/templates/emails/src/newsletter_unsubscribe.mjml @@ -0,0 +1,37 @@ + + + Reverse Engineering Lab: Unsubscribe Request + {{include:styles}} + + + + + + {{include:header}} + + + + Hello, + + We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter. + + If you made this request, please click the button below to unsubscribe: + Unsubscribe + + Or copy and paste this link in your browser:
+ {{ unsubscribe_link }} +
+ If you did not request to unsubscribe, you can safely ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/password_reset.mjml b/backend/app/templates/emails/src/password_reset.mjml new file mode 100644 index 00000000..da8d0c55 --- /dev/null +++ b/backend/app/templates/emails/src/password_reset.mjml @@ -0,0 +1,25 @@ + + + Password Reset + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Please reset your password by clicking the button below: + Reset Password + + Or copy and paste this link in your browser:
+ {{ reset_link }} +
+ This link will expire in 1 hour. + If you did not request a password reset, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/post_verification.mjml b/backend/app/templates/emails/src/post_verification.mjml new file mode 100644 index 00000000..9e52873b --- /dev/null +++ b/backend/app/templates/emails/src/post_verification.mjml @@ -0,0 +1,19 @@ + + + Email Verified + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Your email has been verified! + Thank you for verifying your email address. You can now enjoy full access to all features. + + + + {{include:footer}} + + diff --git a/backend/app/templates/emails/src/registration.mjml b/backend/app/templates/emails/src/registration.mjml new file mode 100644 index 00000000..bd6056da --- /dev/null +++ b/backend/app/templates/emails/src/registration.mjml @@ -0,0 +1,25 @@ + + + Welcome to Reverse Engineering Lab - Verify Your Email + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Thank you for registering! Please verify your email by clicking the button below: + Verify Email Address + + Or copy and paste this link in your browser:
+ {{ verification_link }} +
+ This link will expire in 1 hour. + If you did not register for this service, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/verification.mjml b/backend/app/templates/emails/src/verification.mjml new file mode 100644 index 00000000..9134774b --- /dev/null +++ b/backend/app/templates/emails/src/verification.mjml @@ -0,0 +1,25 @@ + + + Email Verification + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Please verify your email by clicking the button below: + Verify Email Address + + Or copy and paste this link in your browser:
+ {{ verification_link }} +
+ This link will expire in 1 hour. + If you did not request verification, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html index 7ddf4a8d..5665c06f 100644 --- a/backend/app/templates/index.html +++ b/backend/app/templates/index.html @@ -4,13 +4,16 @@ Reverse Engineering Labs API + + + - - + +
-

ReLab API

+

RELab API

This is the backend API for the Reverse Engineering Lab.

Go to the main website @@ -25,17 +28,10 @@

API Documentation

{% endif %}
- {% if show_full_docs %} -
-

Administration

- Admin Dashboard -
- {% endif %} -
-

API Login

+

User Access

{% if user %} - Logout + Logout {% else %} Login {% endif %} @@ -43,13 +39,19 @@

API Login

diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html index 3adfb68a..9e88fff8 100644 --- a/backend/app/templates/login.html +++ b/backend/app/templates/login.html @@ -4,9 +4,12 @@ Login - Reverse Engineering Lab API + + + - - + +
@@ -20,22 +23,22 @@

Login

- +
{% if next %} - + {% endif %} - + API Homepage - - - -
- -
- - -
-
-
- - - diff --git a/backend/data/seed/dummy_data.json b/backend/data/seed/dummy_data.json new file mode 100644 index 00000000..b0c3cdc6 --- /dev/null +++ b/backend/data/seed/dummy_data.json @@ -0,0 +1,125 @@ +{ + "user_data": [ + { + "email": "alice@example.com", + "password": "fake_password_1", + "username": "alice" + }, + { + "email": "bob@example.com", + "password": "fake_password_2", + "username": "bob" + } + ], + "taxonomy_data": [ + { + "name": "Electronics Taxonomy", + "description": "Taxonomy for electronic products.", + "version": "1.0", + "domains": ["PRODUCTS"], + "source": "https://example.com/electronics-taxonomy" + }, + { + "name": "Materials Taxonomy", + "description": "Taxonomy for materials.", + "version": "1.0", + "domains": ["MATERIALS"], + "source": "https://example.com/materials-taxonomy" + } + ], + "category_data": [ + { + "name": "Smartphones", + "description": "Category for smartphones.", + "taxonomy_name": "Electronics Taxonomy" + }, + { + "name": "Laptops", + "description": "Category for laptops.", + "taxonomy_name": "Electronics Taxonomy" + }, + { + "name": "Metals", + "description": "Category for metals.", + "taxonomy_name": "Materials Taxonomy" + }, + { + "name": "Plastics", + "description": "Category for plastics.", + "taxonomy_name": "Materials Taxonomy" + } + ], + "material_data": [ + { + "name": "Aluminum", + "description": "Lightweight metal.", + "source": "https://example.com/aluminum", + "density_kg_m3": 2700, + "is_crm": false, + "categories": ["Metals"] + }, + { + "name": "Polycarbonate", + "description": "Durable plastic.", + "source": "https://example.com/polycarbonate", + "density_kg_m3": 1200, + "is_crm": false, + "categories": ["Plastics"] + } + ], + "product_type_data": [ + { + "name": "Smartphone", + "description": "A handheld personal computer.", + "categories": ["Smartphones"] + }, + { + "name": "Laptop", + "description": "A portable personal computer.", + "categories": ["Laptops"] + } + ], + "product_data": [ + { + "name": "iPhone 12", + "description": "Apple smartphone.", + "brand": "Apple", + "model": "A2403", + "product_type_name": "Smartphone", + "physical_properties": { + "weight_g": 164, + "height_cm": 14.7, + "width_cm": 7.15, + "depth_cm": 0.74 + }, + "bill_of_materials": [ + {"material": "Aluminum", "quantity": 0.025, "unit": "kg"}, + {"material": "Polycarbonate", "quantity": 0.050, "unit": "kg"} + ] + }, + { + "name": "Dell XPS 13", + "description": "Dell laptop.", + "brand": "Dell", + "model": "XPS9380", + "product_type_name": "Laptop", + "physical_properties": { + "weight_g": 1230, + "height_cm": 1.16, + "width_cm": 30.2, + "depth_cm": 19.9 + }, + "bill_of_materials": [ + {"material": "Aluminum", "quantity": 0.5, "unit": "kg"}, + {"material": "Polycarbonate", "quantity": 0.3, "unit": "kg"} + ] + } + ], + "image_data": [ + { + "description": "Example phone image", + "filename": "example_phone.jpg", + "parent_product_name": "iPhone 12" + } + ] +} diff --git a/backend/justfile b/backend/justfile new file mode 100644 index 00000000..40c328ac --- /dev/null +++ b/backend/justfile @@ -0,0 +1,251 @@ +# Backend Task Runner +# Run `just --list` to see all available commands +# Run from root: `just backend/` or from backend/: `just ` + +# spell-checker: ignore esac, htmlcov + +# Show available recipes +default: + @just --list + +# ============================================================================ +# Development +# ============================================================================ + +# Install and sync dependencies +install: + uv sync --all-groups --frozen + @echo "āœ“ Backend dependencies installed" + +# Start development server +dev PORT="8000": + uv run uvicorn app.main:app --reload --reload-dir app --port {{ PORT }} + +# Start production server +serve: + uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 + +# Open Python REPL with app context +shell: + uv run python -i -c "from app.core.database import *; from app.api.auth.models import *; from app.api.data_collection.models.product import *" + +# ============================================================================ +# Linting & Formatting +# ============================================================================ + +pytest := "uv run pytest" + +# Lint backend Python code (allow TODOs with FIX002) +lint: + uv run ruff check --ignore FIX002 + @echo "āœ“ Backend linting passed" + +# Format backend Python code +format: + uv run ruff format + @echo "āœ“ Backend code formatted" + +# Auto-fix lint issues and format code (allow TODOs with FIX002) +fix: + uv run ruff check --fix --ignore FIX002 + just format + @echo "āœ“ Backend code fixed and formatted" + +# Run static type checks (all groups synced so optional deps resolve) +typecheck: + uv run --all-groups ty check + @echo "āœ“ Backend: Static type checks passed" + +# Verify formatting (no writes) +format-check: + uv run ruff format --check + @echo "āœ“ Backend formatting verified" + +# Run the fast backend quality gate (no database required) +check: lint typecheck format-check + @echo "āœ“ Backend checks passed" + +# ============================================================================ +# Testing +# ============================================================================ + +# Run all tests +test *ARGS: + {{ pytest }} {{ ARGS }} + +# Run tests with coverage report (FORMAT: html for local, xml for CI) +test-cov FORMAT="html": + mkdir -p reports/coverage + {{ pytest }} --cov --cov-report={{ FORMAT }} --cov-report=term + +# Run only unit tests (fast, no Docker/database path) +test-unit *ARGS: + {{ pytest }} tests/unit {{ ARGS }} + +# Run DB-backed persistence and schema tests +test-integration-db *ARGS: + {{ pytest }} tests/integration/db tests/integration/models tests/integration/core/test_database_operations.py tests/integration/core/test_migrations.py {{ ARGS }} + +# Run only API tests +test-api *ARGS: + {{ pytest }} tests/integration/api {{ ARGS }} + +# Run cross-boundary flow tests +test-flows *ARGS: + {{ pytest }} tests/integration/flows {{ ARGS }} + +# Run all integration tests +test-integration *ARGS: + {{ pytest }} tests/integration {{ ARGS }} + +# Run the backend CI test suite +test-ci: + mkdir -p reports/coverage + {{ pytest }} tests/unit tests/integration -n auto --dist=loadgroup --durations=20 --cov --cov-report=xml --cov-report=term + @echo "āœ“ Backend CI test suite passed" + +# Run the full backend CI pipeline locally +ci: check test-ci + @echo "āœ“ Backend CI pipeline passed" + +# ============================================================================ +# Database & Migrations +# ============================================================================ + +# Apply all pending migrations +migrate: + uv run alembic upgrade head + +# Rollback one migration +migrate-down: + uv run alembic downgrade -1 + +# Create new migration (use: just migrate-create "description") +migrate-create MESSAGE: + uv run alembic revision --autogenerate -m "{{ MESSAGE }}" + +# Check if migrations are up to date (requires a running database). + +# CI coverage for this behavior lives in the migration test suite. +migrate-check: + uv run alembic check + +# Show migration history +migrate-history: + uv run alembic history --verbose + +# Show current migration version +migrate-current: + uv run alembic current + +# Reset database (down to base, then up to head) +migrate-reset: + uv run alembic downgrade base + uv run alembic upgrade head + +# ============================================================================ +# Database Management +# ============================================================================ + +# Create superuser account +create-superuser: + uv run python -m scripts.users.create_superuser + +# Check if database is empty +db-is-empty: + uv run python -m scripts.db.is_empty + +# Seed database with dummy data +seed-dummy-data: + uv run python -m scripts.seed.dummy_data + +# Clear Redis cache (specify namespace: background-data, docs) +clear-cache NAMESPACE="background-data": + uv run python -m scripts.maintenance.clear_cache {{ NAMESPACE }} + +# Compile MJML email templates to HTML +compile-email: + uv run python -m scripts.generate.compile_email_templates + +# Refresh the committed disposable-email fallback list from the upstream source +refresh-disposable-email-domains: + uv run python -m scripts.maintenance.refresh_disposable_email_domains + +# Backfill user statistics cache for all users +backfill-stats: + uv run python -m scripts.maintenance.backfill_user_stats + +# Run the backend k6 baseline performance suite (requires k6 installed) +perf-baseline: + mkdir -p reports/performance + k6 run --summary-export reports/performance/latest-k6-summary.json perf/k6-baseline.js + +ci_compose := "docker compose -p relab_test -f ../compose.yml -f ../compose.ci.yml" +k6_docker_image := "grafana/k6:latest" + +# Run the backend k6 baseline suite against the Docker CI stack. +# Assumes the CI backend stack is already up and seeded with dummy data. +_perf-ci IMAGE_ID="" BASE_URL="http://api:8000": + #!/usr/bin/env bash + set -euo pipefail + image_id="{{ IMAGE_ID }}" + if [ -z "$image_id" ]; then + echo "→ No IMAGE_ID provided; running perf baseline without resized_image scenario." + fi + mkdir -p reports/performance + docker run --rm \ + --network relab_test_default \ + -v "$PWD/perf:/perf:ro" \ + -v "$PWD/reports/performance:/reports" \ + -e BASE_URL="{{ BASE_URL }}" \ + -e PERF_USER_EMAIL="e2e-admin@example.com" \ + -e PERF_USER_PASSWORD="E2eTestPass123!" \ + -e PERF_IMAGE_ID="$image_id" \ + {{ k6_docker_image }} run --summary-export /reports/latest-k6-summary.json /perf/k6-baseline.js + +# Write a dated CI baseline report from the latest k6 JSON summary export. +_perf-report-ci DATE="" BASE_URL="http://api:8000": + #!/usr/bin/env bash + set -euo pipefail + report_date="{{ DATE }}" + if [ -z "$report_date" ]; then + report_date="$(date +%F)" + fi + python3 -m scripts.perf.perf_ci write-report --date "$report_date" --base-url "{{ BASE_URL }}" + +# Recalibrate p95 thresholds in perf/k6-baseline.js from the latest k6 JSON summary export. +_perf-thresholds-apply HEADROOM="1.15": + #!/usr/bin/env bash + set -euo pipefail + python3 -m scripts.perf.perf_ci apply-thresholds "{{ HEADROOM }}" + +# ============================================================================ +# Maintenance +# ============================================================================ + +# Update dependencies +update: + uv lock --upgrade + @echo "āœ“ Backend dependencies updated" + +# Clean caches and build artifacts +clean: + rm -rf __pycache__ .pytest_cache .ruff_cache htmlcov .coverage reports/coverage + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + @echo "āœ“ Backend: Cleaned caches and build artifacts" + +# ============================================================================ +# Security +# ============================================================================ +# Scan Python dependencies for known CVEs (SCOPE: default runtime or all groups) +audit SCOPE="runtime": + #!/usr/bin/env bash + set -euo pipefail + case "{{ SCOPE }}" in + runtime) flag="--no-default-groups" ;; + all) flag="" ;; + *) echo "SCOPE must be 'runtime' or 'all'"; exit 1 ;; + esac + uv audit --preview-features audit --frozen $flag + echo "āœ“ Backend: {{ SCOPE }} dependency audit complete" diff --git a/backend/perf/README.md b/backend/perf/README.md new file mode 100644 index 00000000..1bbb0d8c --- /dev/null +++ b/backend/perf/README.md @@ -0,0 +1,157 @@ +# Backend Performance Baseline + +This directory contains a small `k6` baseline suite for the RELab backend. + +## Goals + +- catch obvious latency regressions in common backend paths +- keep a repeatable baseline script in the repo +- avoid a heavy load-testing stack for routine checks + +## Covered Scenarios + +- `live_probe` + - always enabled + - exercises the liveness path via `/live` +- `product_tree_read` + - always enabled + - exercises the public recursive product read path via `/products/tree` +- `bearer_login` + - enabled only when `PERF_USER_EMAIL` and `PERF_USER_PASSWORD` are set + - exercises the auth login path +- `resized_image` + - enabled only when `PERF_IMAGE_ID` is explicitly set or discovered by the Docker CI perf helper + - exercises the image resize hot path + +## Thresholds + +These thresholds are intentionally conservative and serve as a regression tripwire, not a capacity target. + +- `live_probe`: `p(95) < 1200ms` +- `product_tree_read`: `p(95) < 3800ms` +- `bearer_login`: `p(95) < 3400ms` +- `resized_image`: `p(95) < 3400ms` +- all enabled scenarios: failed request rate `< 1%` + +## Recommended Target + +Use the Docker CI stack as the canonical baseline environment. + +- it runs the backend in `testing` +- it uses committed test credentials from `backend/.env.test` +- it is more repeatable than the dev stack for regression checks + +Use the root perf entrypoint to manage that stack: + +```bash +just docker-ci-perf-baseline +``` + +Treat local Docker CI runs as smoke validation, not as the source of truth for threshold calibration. + +- local runs are useful for proving the workflow works end to end +- local runs are often distorted by laptop CPU contention, Docker overhead, and disk pressure +- threshold calibration should come from the GitHub Actions perf workflow, because that is the environment that will run the recurring baseline checks + +## Usage + +Run against a host-reachable backend: + +```bash +just perf-baseline +``` + +Enable login coverage: + +```bash +PERF_USER_EMAIL=user@example.com \ +PERF_USER_PASSWORD=secret \ +just perf-baseline +``` + +Enable image resize coverage when you have a sample image id: + +```bash +PERF_IMAGE_ID=123 \ +just perf-baseline +``` + +Run all scenarios together: + +```bash +PERF_USER_EMAIL=user@example.com \ +PERF_USER_PASSWORD=secret \ +PERF_IMAGE_ID=123 \ +just perf-baseline +``` + +Target a non-local backend: + +```bash +BASE_URL=https://api-test.cml-relab.org \ +just perf-baseline +``` + +## Useful Environment Variables + +- `BASE_URL` +- `PERF_PRODUCT_TREE_PATH` +- `PERF_LIVE_PATH` +- `PERF_USER_EMAIL` +- `PERF_USER_PASSWORD` +- `PERF_IMAGE_ID` +- `PERF_IMAGE_WIDTH` +- `PERF_PRODUCT_TREE_VUS` +- `PERF_PRODUCT_TREE_DURATION` +- `PERF_LIVE_VUS` +- `PERF_LIVE_DURATION` +- `PERF_LOGIN_VUS` +- `PERF_LOGIN_DURATION` +- `PERF_IMAGE_VUS` +- `PERF_IMAGE_DURATION` + +## Recommended Baseline Inputs + +- Use `just docker-ci-perf-baseline` so the database is seeded with stable sample products before the k6 run starts. +- `live_probe` and `product_tree_read` are the baseline scenarios and should always remain runnable. +- `resized_image` is opportunistic rather than required. The Docker CI helper will try to discover a usable image automatically, but the baseline must still run cleanly if no image is available. +- Use `/products/tree?recursion_depth=2` as the default product-read baseline unless you intentionally want a deeper tree. +- Reuse the CI superuser from `backend/.env.test` for login measurements unless you explicitly need another account. + +## Recording Results + +`just perf-baseline` writes a raw `k6` summary export to `reports/performance/latest-k6-summary.json`. + +After a meaningful run, save a short dated markdown summary in `reports/performance/` so the numbers are easy to review in PRs. + +The current thresholds are provisional and were first calibrated from a dockerized baseline captured on `2026-03-30`. Recalibrate them after the first canonical CI-stack baseline capture. + +## Make CI The Baseline + +Use this flow to replace the historical docker-dev baseline with a canonical CI-stack baseline. + +For local work, use the Docker CI stack only to validate the mechanics: + +1. Run the baseline: + `just docker-ci-perf-baseline` + +Then use GitHub Actions as the calibration source of truth: + +1. Run the `Performance Baseline` workflow with `workflow_dispatch`. +1. Download the `backend-perf-baseline-artifacts` artifact from that run. +1. Replace `reports/performance/latest-k6-summary.json` locally with the artifact copy from GitHub. +1. If those GitHub CI numbers should become the new regression baseline, use the maintainer-only perf helpers in `backend/justfile` to write a dated report and refresh thresholds. +1. Rerun the workflow or a local Docker CI smoke run to confirm the refreshed thresholds behave as expected. +1. Commit the new dated CI report and the updated thresholds in `perf/k6-baseline.js`. + +Treat that new CI report as the canonical baseline and keep older docker-dev reports only as historical context. + +## Notes On What We Measure + +- `/products/tree` is intentionally part of the baseline because it is a supported public hierarchical read, not just an internal implementation detail. +- Tree performance should reflect the explicit bounded tree loader, not ORM lazy-loading side effects. +- Do not make the perf workflow depend on embedded image data in `/products`; the baseline must stay useful even when no seeded image is present. + +## Maintainer Note + +Report writing and threshold refresh are maintenance operations, not routine commands. They stay available as hidden backend `just` recipes so the public task surface can stay small and stable. diff --git a/backend/perf/k6-baseline.js b/backend/perf/k6-baseline.js new file mode 100644 index 00000000..ab97e5f7 --- /dev/null +++ b/backend/perf/k6-baseline.js @@ -0,0 +1,119 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const baseUrl = __ENV.BASE_URL || "http://127.0.0.1:8000"; +const productTreePath = __ENV.PERF_PRODUCT_TREE_PATH || "/products/tree?recursion_depth=2"; +const livePath = __ENV.PERF_LIVE_PATH || "/live"; +const loginEmail = __ENV.PERF_USER_EMAIL; +const loginPassword = __ENV.PERF_USER_PASSWORD; +const imageId = __ENV.PERF_IMAGE_ID; +const imageWidth = __ENV.PERF_IMAGE_WIDTH || "200"; + +const scenarios = { + live_probe: { + executor: "constant-vus", + exec: "liveProbe", + vus: Number(__ENV.PERF_LIVE_VUS || 2), + duration: __ENV.PERF_LIVE_DURATION || "30s", + }, + product_tree_read: { + executor: "constant-vus", + exec: "productTreeRead", + vus: Number(__ENV.PERF_PRODUCT_TREE_VUS || 5), + duration: __ENV.PERF_PRODUCT_TREE_DURATION || "30s", + }, +}; + +const thresholds = { + "http_req_failed{scenario:live_probe}": ["rate<0.01"], + "http_req_duration{scenario:live_probe}": ["p(95)<1200"], + "http_req_failed{scenario:product_tree_read}": ["rate<0.01"], + "http_req_duration{scenario:product_tree_read}": ["p(95)<1800"], +}; + +if (loginEmail && loginPassword) { + scenarios.bearer_login = { + executor: "constant-vus", + exec: "bearerLogin", + vus: Number(__ENV.PERF_LOGIN_VUS || 2), + duration: __ENV.PERF_LOGIN_DURATION || "30s", + }; + thresholds["http_req_failed{scenario:bearer_login}"] = ["rate<0.01"]; + thresholds["http_req_duration{scenario:bearer_login}"] = ["p(95)<1600"]; +} + +if (imageId) { + scenarios.resized_image = { + executor: "constant-vus", + exec: "resizedImage", + vus: Number(__ENV.PERF_IMAGE_VUS || 2), + duration: __ENV.PERF_IMAGE_DURATION || "30s", + }; + thresholds["http_req_failed{scenario:resized_image}"] = ["rate<0.01"]; + thresholds["http_req_duration{scenario:resized_image}"] = ["p(95)<1400"]; +} + +export const options = { + scenarios, + thresholds, +}; + +export function liveProbe() { + const response = http.get(`${baseUrl}${livePath}`, { + tags: { scenario: "live_probe" }, + }); + + check(response, { + "live probe returned 200": (res) => res.status === 200, + "live probe returned alive": (res) => res.json("status") === "alive", + }); + + sleep(1); +} + +export function productTreeRead() { + const response = http.get(`${baseUrl}${productTreePath}`, { + tags: { scenario: "product_tree_read" }, + }); + + check(response, { + "product tree returned 200": (res) => res.status === 200, + "product tree returned array": (res) => Array.isArray(res.json()), + }); + + sleep(1); +} + +export function bearerLogin() { + const response = http.post( + `${baseUrl}/auth/bearer/login`, + { + username: loginEmail, + password: loginPassword, + }, + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + tags: { scenario: "bearer_login" }, + }, + ); + + check(response, { + "bearer login returned 200": (res) => res.status === 200, + "bearer login returned token": (res) => Boolean(res.json("access_token")), + }); + + sleep(1); +} + +export function resizedImage() { + const response = http.get(`${baseUrl}/images/${imageId}/resized?width=${imageWidth}`, { + tags: { scenario: "resized_image" }, + }); + + check(response, { + "resized image returned 200": (res) => res.status === 200, + "resized image has body": (res) => res.body && res.body.length > 0, + }); + + sleep(1); +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 79139bfb..8d249f5e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,13 +1,10 @@ [project] ## Project metadata - authors = [ - { name = "Franco Donati", email = "f.donati@cml.leidenuniv.nl" }, - { name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }, - ] + authors = [{ name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Framework :: FastAPI", - "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "License-Expression :: AGPL-3.0-or-later", "Natural Language :: English", "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering :: Artificial Intelligence", @@ -15,44 +12,55 @@ ] description = "Data collection app for the reverse engineering lab project at CML." keywords = ["automated-lca", "circular-economy", "computer-vision"] - license = "AGPL-3.0-or-later" + license = { text = "AGPL-3.0-or-later" } maintainers = [{ name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }] name = "relab-backend" readme = "README.md" ## Dependencies and version constraints - dependencies = [ # Core dependencies. - "aiosmtplib>=4.0.1", - "asyncache>=0.3.1", - "asyncpg>=0.30.0", - "cachetools>=5.5.2", - "email-validator>=2.2.0", + dependencies = [ + # Web framework and API layer "fastapi-filter>=2.0.1", + "fastapi-mail>=1.6.2", "fastapi-pagination>=0.13.2", - # NOTE: This is a heavy dependency (~40MB) due to its use of boto3, even though we don't use any cloud storage - # We should consider using a more lightweight alternative if it becomes available. - # Alternatively, we could write the local file storage backend ourselves. - "fastapi-storages >=0.3.0", - # NOTE: We use a custom fork of fastapi-users-db-sqlmodel to support Pydantic V2 - "fastapi-users-db-sqlmodel", - "fastapi-users[oauth,sqlalchemy]>=14.0.1", - "fastapi[standard] >=0.115.14", - "markdown>=3.8.2", - "pillow >=11.2.1", - "psycopg[binary] >=3.2.9", - # TODO: Investigate pydantic v2.12 compatibility issues (might have to do with custom fastapi-users-db-sqlmodel fork) - "pydantic >=2.12,<2.13", - "pydantic-extra-types >=2.10.5", - "pydantic-settings >=2.10.1", - "python-dotenv >=1.1.1", + "fastapi>=0.115.14", + "uvicorn[standard]>=0.42.0", + # Authentication, sessions, and rate limiting + "asyncpg>=0.30.0", + "email-validator>=2.2.0", + "cashews>=7.5.0", + "fastapi-users[oauth,redis,sqlalchemy]>=14.0.1", + "pyjwt[crypto]>=2.12.1", + "inflect>=7.5.0", + "redis>=5.2.1", + "limits[redis]>=5.8.0", + # Database and persistence + "sqlalchemy>=2.0.41", + # Shared application models and outbound integrations + "httpx[http2]>=0.28.1", + "relab-rpi-cam-models>=0.3.1", + # Password strength and breach checking + "zxcvbn>=4.4.28", + # Configuration, validation, and utility types + "loguru>=0.7.3", + "pydantic>=2.12", + "pydantic-extra-types>=2.10.5", + "pydantic-settings>=2.10.1", + "python-dotenv>=1.1.1", "python-slugify>=8.0.4", - "relab-rpi-cam-models>=0.1.1", - "sqlalchemy >=2.0.41", - "sqlmodel >=0.0.24", - "tldextract>=5.3.0", + # Media and image processing + "piexif>=1.1.3", + "pillow>=11.2.1", + # Observability + "opentelemetry-api>=1.41.0", + "opentelemetry-sdk>=1.41.0", + "opentelemetry-exporter-otlp-proto-http>=1.41.0", + "opentelemetry-instrumentation-fastapi>=0.62b0", + "opentelemetry-instrumentation-sqlalchemy>=0.62b0", + "opentelemetry-instrumentation-httpx>=0.62b0", ] - requires-python = ">= 3.13" - version = "0.1.0" + requires-python = ">=3.14" + version = "0.2.0" [project.urls] Homepage = "https://github.com/CMLPlatform/relab" @@ -61,180 +69,175 @@ ### Dependency groups [dependency-groups] - dev = [ # Development dependencies. See also https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies - "alembic-autogen-check >=1.1.1", - "paracelsus>=0.9.0", - "pyright>=1.1.402", - "ruff >=0.12.1", - ] + # Developer tooling and local maintenance scripts + dev = ["mjml>=0.11.1", "ruff>=0.12.1", "ty>=0.0.15"] - api = [ - "coloredlogs>=15.0.1", - # NOTE: This is a heavy dependency (~90MB) due to it storing all of Google's API specifications. - # We should consider using a more lightweight alternative if it becomes available. - # Alternatively, we could write the required API calls manually using `google-auth` and `google-auth-httplib2` directly. - "google-api-python-client>=2.174.0", - "google-auth>=2.40.3", - "itsdangerous>=2.2.0", - "markupsafe >=3.0.2", - "sqladmin >=0.20.1", - ] + # Schema changes, seeding, and migration-adjacent data-loading scripts + migrations = ["alembic>=1.16.2", "alembic-postgresql-enum>=1.7.0", "psycopg[binary]>=3.2.9"] + + # Optional S3-compatible storage backend + s3 = ["boto3>=1.38.0"] + + # Optional taxonomy-import tooling used by one-off seeding scripts + seed-taxonomies = ["openpyxl>=3.1.5", "pandas>=2.3.3", "requests>=2.32.0"] - migrations = ["alembic >=1.16.2", "alembic-postgresql-enum >=1.7.0", "openpyxl>=3.1.5", "pandas>=2.3.3"] + # Heavy deps for scripts/plugins/rpi_cam/webcam_fake_camera.py (local dev only) + fake-camera = ["opencv-python>=4.0", "websockets>=14.0"] + # Unit and integration test dependencies tests = [ - "factory-boy>=3.3.3", - "pytest >=8.4.1", - "pytest-alembic>=0.12.1", - "pytest-asyncio >=1.0.0", - "pytest-cov >=6.2.1", + # Core testing frameworks + "pytest-asyncio>=1.0.0", + "pytest-cov>=6.2.1", + "pytest-xdist>=3.5.0", # Parallel test execution + "pytest>=8.4.1", + + # HTTP and async testing + "httpx[http2]>=0.27.0", # Modern async HTTP client + + # Test data generation + "faker>=33.3.0", # Realistic test data + "polyfactory>=2.15.0", # Modern factories with Pydantic v2 support + + # Database testing + { include-group = "migrations" }, # Migrations are run to set up the testcontainers database schema + "pytest-alembic>=0.12.1", # Migration testing - verify schema changes + "testcontainers[postgres]>=4.8.2", # Ephemeral PostgreSQL for integration tests + + # Redis testing + "fakeredis[lua]>=2.25.0", # In-memory Redis for testing + + # Mocking and assertions + "dirty-equals>=0.8.0", # Flexible assertions for dynamic data + "pytest-mock>=3.14.0", # Better mocking with cleaner pytest integration ] ### Tool configuration -[tool.paracelsus] - base = "app.api.common.models.base:CustomBase" - column_sort = "preserve-order" - imports = [ - "app.api.auth.models", - "app.api.background_data.models", - "app.api.common.models.associations", - "app.api.data_collection.models", - "app.api.file_storage.models.models", - "app.api.plugins.rpi_cam.models", - ] - -[tool.pyright] - # NOTE: Pyright doesn't work well by only setting exclude, so we explicitly include the directories we want to check - include = ["app", "scripts", "tests"] - typeCheckingMode = "standard" - venv = ".venv" - venvPath = "." +[tool.alembic] + path_separator = "os" + prepend_sys_path = ["."] + script_location = "alembic" + + [[tool.alembic.post_write_hooks]] + executable = "%(here)s/.venv/bin/ruff" + name = "ruff" + options = "check --fix REVISION_SCRIPT_FILENAME" + type = "exec" + + [[tool.alembic.post_write_hooks]] + executable = "%(here)s/.venv/bin/ruff" + name = "ruff_format" + options = "format REVISION_SCRIPT_FILENAME" + type = "exec" [tool.pytest.ini_options] + addopts = "-ra --strict-markers --strict-config" + asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" + log_cli = false + log_cli_format = "%(message)s" + log_cli_level = "INFO" + markers = [ + "api: API endpoint tests (E2E, full stack)", + "db: Database-backed persistence and ORM tests", + "flow: Cross-boundary user journeys spanning multiple services or routers", + "migration: Database migration tests", + "slow: Intentionally slower tests (for example subprocess, migration, or heavy end-to-end checks)", + ] + python_classes = ["Test*"] + python_files = ["test_*.py"] + python_functions = ["test_*"] + testpaths = ["tests"] + +[tool.coverage.run] + branch = true + data_file = "reports/coverage/.coverage" + omit = ["*/__pycache__/*", "*/alembic/*", "*/tests/*"] + source = ["app"] + +[tool.coverage.report] + fail_under = 80 # Target 80%+ coverage + precision = 2 + show_missing = true + skip_covered = false + +[tool.coverage.html] + directory = "reports/coverage/html" + +[tool.coverage.xml] + output = "reports/coverage/coverage.xml" [tool.ruff] fix = true line-length = 120 - target-version = "py313" - - # Exclude automatically generated files from linting - extend-exclude = ["./alembic/versions"] + target-version = "py314" [tool.ruff.format] docstring-code-format = true [tool.ruff.lint] - extend-select = [ - "A", # flake8-builtins (checks for conflicts with Python builtins) - "ANN", # flake8-annotations (checks for missing type annotations) - "ARG", # flake8-unused-arguments - "ASYNC", # flake8-async - "B", # flake8-bugbear (fixes typical bugs) - "BLE", # flake8-blind-except - "C4", # flake8-comprehensions (fixes iterable comprehensions) - "C90", # mccabe - "D", # pydocstyle - "DJ", # flake8-django - "DTZ", # flake8-datetimez (checks for naive datetime uses without timezone) - "E", # pycodestyle errors - "EM", # flake8-errmsgs (checks for error messages) - "FAST", # fastapi - "FBT", # flake8-boolean-trap - "FIX", # flake8-fixme - "FLY", # flynt (replaces `str.join` calls with f-strings) - "FURB", # refurb (refurbishes code) - "G", # flake8-logging-format - "I", # isort - "ICN", # flake8-import-conventions - "INP", # flake8-no-pep420 (checks for implicit namespace packages) - "ISC", # flake8-implicit-str-concat (fixes implicit string concatenation) - "LOG", # flake8-logging - "N", # pep8-naming (checks for naming conventions) - "NPY", # NumPy-specific rules - "PD", # pandas-vet (checks for Pandas issues) - "PERF", # Perflint (checks for performance issues) - "PGH", # pygrep-hooks (checks for common Python issues) - "PIE", # flake8-pie (checks for miscellaneous issues) - "PL", # Pylint (checks for pylint errors) - "PT", # flake8-pytest-style (checks for pytest fixtures) - "PTH", # lake8-use-pathlib (ensures pathlib is used instead of os.path) - "Q004", # flake8-quotes: unnecessary-escaped-quote (other 'Q' rules can conflict with formatter) - "RET", # flake8-return (checks return values) - "RUF", # Ruff-specific rules - "S", # flake8-bandit (security) - "SIM", # flake8-simplify - "T10", # flake8-debugger (checks for debugger calls) - "T20", # flake8-print (checks for print calls) - "TCH", # flake8-type-checking - "TID252", # flake8-tidy-imports: relative-imports (replaces relative imports with absolute imports) - "TRY", # tryceratops (checks for common issues with try-except blocks) - "UP", # pyupgrade (upgrades Python syntax) - "W", # pycodestyle warnings - ] + fixable = ["ALL"] + select = ["ALL"] - fixable = [ - "ASYNC", # flake8-async - "B", # flake8-bugbear (fixes typical bugs) - "C4", # flake8-comprehensions (fixes iterable comprehensions) - "D", # pydocstyle - "E", # pycodestyle errors - "EM", # flake8-errmsgs (checks for error messages) - "FAST", # fastapi - "FLY", # flynt (replaces `str.join` calls with f-strings) - "FURB", # refurb (refurbishes code) - "G", # flake8-logging-format - "I", # isort - "ICN", # flake8-import-conventions - "ISC", # flake8-implicit-str-concat (fixes implicit string concatenation) - "LOG", # flake8-logging - "N", # pep8-naming (checks for naming conventions) - "NPY", # NumPy-specific rules - "PD", # pandas-vet (checks for Pandas issues) - "PERF", # Perflint (checks for performance issues) - "PGH", # pygrep-hooks (checks for common Python issues) - "PIE", # flake8-pie (checks for miscellaneous issues) - "PL", # Pylint (checks for pylint errors) - "PT", # flake8-pytest-style (checks for pytest fixtures) - "PTH", # lake8-use-pathlib (ensures pathlib is used instead of os.path) - "Q004", # flake8-quotes: unnecessary-escaped-quote (other 'Q' rules can conflict with formatter) - "RET", # flake8-return (checks return values) - "RUF", # Ruff-specific rules - "S", # flake8-bandit (security) - "SIM", # flake8-simplify - "TCH", # flake8-type-checking - "TID252", # flake8-tidy-imports: relative-imports (replaces relative imports with absolute imports) - "TRY", # tryceratops (checks for common issues with try-except blocks) - "UP", # pyupgrade (upgrades Python syntax) - "W", # pycodestyle warnings - ] - - # These rules are ignored to prevent conflicts with formatter or because they are overly strict ignore = [ + # Prevent conflicts with formatter (see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "ANN204", # missing-return-type-special-method - "ANN401", # any-type - "D102", # undocumented-public-method - "D104", # undocumented-public-package - "D105", # undocumented-magic-method - "D107", # undocumented-public-init - "D206", # indent-with-spaces - "D300", # triple-single-quotes + "COM812", # missing-trailing-comma + "COM819", # prohibited-trailing-comma "E111", # indentation-with-invalid-multiple "E114", # indentation-with-invalid-multiple-comment "E117", # over-indented - "ISC001", # single-line-implicit-string-concatenation - "ISC002", # multi-line-implicit-string-concatenation - "RET504", # unnecessary-assign + "Q", # flake8-quotes "W191", # tab-indentation + + # Overly strict rules + "D105", # undocumented-magic-method (magic methods are often self-explanatory) + "D107", # undocumented-public-init (__init__ methods are often self-explanatory) + + # TODO: Re-enable these linting rules when we have more than one developer. + "FIX002", # Allow todos + "TD002", # Allow todos without authors + "TD003", # Allow todos without issue links ] [tool.ruff.lint.flake8-bugbear] # Allow default arguments for FastAPI Depends and Query - extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] + extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi_filter.FilterDepends"] + + [tool.ruff.lint.flake8-type-checking] + # Allow runtime imports for FastAPI dependency injection and Pydantic validation + exempt-modules = [ + "app", + "fastapi", + "fastapi_users", + "pydantic", + "sqlalchemy", + "typing.Annotated", + "relab_rpi_cam_models", + ] + runtime-evaluated-base-classes = ["fastapi_filter.contrib.sqlalchemy.FilterSet", "pydantic.BaseModel"] [tool.ruff.lint.per-file-ignores] - # Ignore security issues over use of `assert` in test files - "test*.py" = ["S101"] + "**/alembic/**/*.py" = [ + "D103", # Up and downgrade functions are self-explanatory + "D415", # Alembic auto-generates script docs without punctuation + "F401", # Ignore unused imports, which are often required for schema changes but not directly referenced in the code + "INP001", # The alembic scripts are not meant to be a package + "TC003", # Allow runtime evaluation of base classes + ] + "**/tests/**/*.py" = [ + "PLR2004", # Allow magic values in test files + "S101", # Ignore security issues over use of `assert` in test files + "S105", # Any secrets defined in the test suite are not real secrets + "S106", # Any secrets defined in the test suite are not real secrets + ] + + [tool.ruff.lint.isort] + # Explicitly add known first-party and third-party modules to allow invocation from the project root + known-first-party = ["app", "scripts", "tests"] + + # Prevent the alembic directory from shadowing the alembic PyPI package + known-third-party = ["alembic"] [tool.ruff.lint.pydocstyle] convention = "google" @@ -242,10 +245,3 @@ [tool.ruff.lint.pylint] allow-magic-value-types = ["int"] max-args = 10 - -[tool.uv] - default-groups = ["api", "dev", "migrations", "tests"] - - [tool.uv.sources] - # Fetch FastAPI-Users-DB-SQLModel from custom fork on GitHub for Pydantic V2 support - fastapi-users-db-sqlmodel = { git = "https://github.com/simonvanlierde/fastapi-users-db-sqlmodel", rev = "a4337bf0c5a74b81f2fef5054b32efb72ce9bce4" } diff --git a/backend/reports/performance/2026-03-30-ci-baseline.md b/backend/reports/performance/2026-03-30-ci-baseline.md new file mode 100644 index 00000000..f711ca04 --- /dev/null +++ b/backend/reports/performance/2026-03-30-ci-baseline.md @@ -0,0 +1,18 @@ +# 2026-03-30 CI Baseline + +- environment: Docker CI backend at `http://api:8000` +- source summary: `reports/performance/latest-k6-summary.json` +- enabled scenarios: `product_tree_read`, `bearer_login`, `resized_image` + +## Results + +- `product_tree_read`: avg `1.28s`, p95 `4.33s`, failed requests `100.00%` +- `bearer_login`: avg `1.25s`, p95 `3.54s`, failed requests `100.00%` +- `resized_image`: avg `1.08s`, p95 `3.10s`, failed requests `100.00%` +- overall HTTP: avg `1.23s`, p95 `4.02s` + +## Notes + +- This file was generated from the latest CI-stack `k6` summary export. +- If these numbers replace the prior baseline, keep older docker-dev reports as historical context only. +- Re-run `just perf-thresholds-apply` if you intentionally want the thresholds to track this new baseline. diff --git a/backend/reports/performance/2026-03-30-docker-dev-baseline.md b/backend/reports/performance/2026-03-30-docker-dev-baseline.md new file mode 100644 index 00000000..c8322354 --- /dev/null +++ b/backend/reports/performance/2026-03-30-docker-dev-baseline.md @@ -0,0 +1,22 @@ +# 2026-03-30 Initial Docker Baseline + +- environment: dockerized backend at `http://127.0.0.1:8011` +- command: + `BASE_URL=http://127.0.0.1:8011 PERF_USER_EMAIL=test@example.com PERF_USER_PASSWORD=password PERF_IMAGE_ID=5e5d6f72-7706-43d6-b97f-28855efd4fcf just perf-baseline` +- enabled scenarios: `product_tree_read`, `bearer_login`, `resized_image` +- raw summary export: `reports/performance/latest-k6-summary.json` + +## Results + +- `product_tree_read`: avg `878.59ms`, p95 `3.28s`, failed requests `0.00%` +- `bearer_login`: avg `730.67ms`, p95 `2.92s`, failed requests `0.00%` +- `resized_image`: avg `582.87ms`, p95 `2.90s`, failed requests `0.00%` +- overall HTTP: avg `771.05ms`, p95 `3.34s` + +## Notes + +- This capture predates the switch to using the Docker CI stack as the canonical perf target. +- The initial placeholder thresholds were too aggressive for this environment and failed on the first capture. +- Thresholds were recalibrated to modest headroom above this baseline so the suite catches obvious regressions without failing by default on a known-good Docker environment. +- Product tree and image IDs came from existing seeded/sample dockerized data. +- Future dated reports should use `just docker-ci-perf-baseline` from the repo root or `just perf-ci` from `backend/`. diff --git a/backend/reports/performance/2026-04-16-ci-baseline.md b/backend/reports/performance/2026-04-16-ci-baseline.md new file mode 100644 index 00000000..4b390f8e --- /dev/null +++ b/backend/reports/performance/2026-04-16-ci-baseline.md @@ -0,0 +1,20 @@ +# 2026-04-16 CI Baseline + +- environment: Docker CI backend at `http://api:8000` +- source summary: `reports/performance/latest-k6-summary.json` +- enabled scenarios: `live_probe`, `product_tree_read`, `bearer_login`, `resized_image` + +## Results + +- `live_probe`: avg `31.94ms`, p95 `124.63ms`, failed requests `0.00%` +- `product_tree_read`: avg `251.21ms`, p95 `750.81ms`, failed requests `0.00%` +- `bearer_login`: avg `243.10ms`, p95 `689.92ms`, failed requests `0.00%` +- `resized_image`: avg `102.23ms`, p95 `375.46ms`, failed requests `0.00%` +- overall HTTP: avg `175.64ms`, p95 `539.62ms` + +## Notes + +- This file was generated from the latest CI-stack `k6` summary export. +- `resized_image` is optional and only runs when a sample image is available. +- If these numbers replace the prior baseline, keep older reports as historical context only. +- Threshold refresh remains a maintainer-only follow-up step. diff --git a/backend/reports/performance/README.md b/backend/reports/performance/README.md new file mode 100644 index 00000000..45601e0c --- /dev/null +++ b/backend/reports/performance/README.md @@ -0,0 +1,19 @@ +# Performance Reports + +Store short baseline summaries and comparison notes here. + +`just perf-baseline` writes the most recent machine-readable export to `latest-k6-summary.json`. + +Suggested naming: + +- `YYYY-MM-DD-local-baseline.md` +- `YYYY-MM-DD-staging-baseline.md` + +Recommended contents: + +- environment (`local`, `staging`, `prod-like`) +- commit or branch +- enabled `k6` scenarios +- key latency numbers (`avg`, `p95`) +- request failure rate +- notable caveats such as warm cache vs cold cache diff --git a/backend/reports/performance/latest-k6-summary.json b/backend/reports/performance/latest-k6-summary.json new file mode 100644 index 00000000..8a45e1b6 --- /dev/null +++ b/backend/reports/performance/latest-k6-summary.json @@ -0,0 +1,219 @@ +{ + "metrics": { + "http_req_sending": { + "avg": 0.12804143829787226, + "min": 0.003625, + "med": 0.025708, + "max": 5.94925, + "p(90)": 0.24241660000000004, + "p(95)": 0.5055917999999996 + }, + "vus": { + "value": 9, + "min": 9, + "max": 9 + }, + "http_req_failed{scenario:live_probe}": { + "passes": 0, + "fails": 60, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "http_req_blocked": { + "p(95)": 0.4698418999999928, + "avg": 0.1612794425531915, + "min": 0.001042, + "med": 0.007916, + "max": 7.671625, + "p(90)": 0.05145900000000001 + }, + "http_req_waiting": { + "max": 722.415209, + "p(90)": 511.6284588000001, + "p(95)": 558.2572670999999, + "avg": 161.71185822978723, + "min": 0.98875, + "med": 116.767709 + }, + "http_req_receiving": { + "min": 0.0225, + "med": 0.651291, + "max": 64.94325, + "p(90)": 3.664400400000001, + "p(95)": 7.125471299999987, + "avg": 1.7097080042553194 + }, + "http_req_connecting": { + "avg": 0.0473111744680851, + "min": 0, + "med": 0, + "max": 1.962083, + "p(90)": 0, + "p(95)": 0 + }, + "vus_max": { + "value": 9, + "min": 9, + "max": 9 + }, + "data_sent": { + "count": 26455, + "rate": 856.6195217460331 + }, + "data_received": { + "count": 505880, + "rate": 16380.521022902409 + }, + "http_req_duration": { + "med": 117.128958, + "max": 723.721709, + "p(90)": 513.8321252000002, + "p(95)": 559.6480042999999, + "avg": 163.54960767234047, + "min": 1.123542 + }, + "http_reqs": { + "count": 235, + "rate": 7.609358821028834 + }, + "http_req_failed{scenario:bearer_login}": { + "passes": 0, + "fails": 50, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "http_req_tls_handshaking": { + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0, + "med": 0, + "max": 0 + }, + "http_req_duration{expected_response:true}": { + "avg": 163.54960767234047, + "min": 1.123542, + "med": 117.128958, + "max": 723.721709, + "p(90)": 513.8321252000002, + "p(95)": 559.6480042999999 + }, + "http_req_failed": { + "passes": 0, + "fails": 235, + "value": 0 + }, + "iterations": { + "rate": 7.609358821028834, + "count": 235 + }, + "http_req_failed{scenario:product_tree_read}": { + "passes": 0, + "fails": 125, + "thresholds": { + "rate<0.01": false + }, + "value": 0 + }, + "http_req_duration{scenario:live_probe}": { + "med": 7.6839165, + "max": 220.409583, + "p(90)": 51.27561220000001, + "p(95)": 163.10332119999998, + "avg": 25.176900666666672, + "min": 1.123542, + "thresholds": { + "p(95)<1200": false + } + }, + "checks": { + "passes": 470, + "fails": 0, + "value": 1 + }, + "http_req_duration{scenario:bearer_login}": { + "p(90)": 520.4463837000001, + "p(95)": 565.281259, + "avg": 205.22747344000007, + "min": 53.564292, + "med": 138.99516699999998, + "max": 650.408209, + "thresholds": { + "p(95)<1600": false + } + }, + "http_req_duration{scenario:product_tree_read}": { + "med": 140.670125, + "max": 723.721709, + "p(90)": 557.5183752, + "p(95)": 572.7387255999998, + "avg": 213.29736072800003, + "min": 10.095041, + "thresholds": { + "p(95)<1800": false + } + }, + "iteration_duration": { + "p(90)": 1517.4815170000002, + "p(95)": 1566.0042055, + "avg": 1167.0133918212757, + "min": 1001.562792, + "med": 1118.605792, + "max": 1729.079376 + } + }, + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "live probe returned 200": { + "id": "6fd57dcc4dd370436bbd64ba4b413742", + "passes": 60, + "fails": 0, + "name": "live probe returned 200", + "path": "::live probe returned 200" + }, + "live probe returned alive": { + "name": "live probe returned alive", + "path": "::live probe returned alive", + "id": "25ff1b01db1ecffbd6e1f7a319188a69", + "passes": 60, + "fails": 0 + }, + "bearer login returned 200": { + "name": "bearer login returned 200", + "path": "::bearer login returned 200", + "id": "0432e88e47eb6e3ba9d27a897182e2b8", + "passes": 50, + "fails": 0 + }, + "bearer login returned token": { + "id": "ada4f75f17e7816b38f21ecf6542c36c", + "passes": 50, + "fails": 0, + "name": "bearer login returned token", + "path": "::bearer login returned token" + }, + "product tree returned 200": { + "name": "product tree returned 200", + "path": "::product tree returned 200", + "id": "5287709b26383bc9ed0c3c70250ee2a3", + "passes": 125, + "fails": 0 + }, + "product tree returned array": { + "name": "product tree returned array", + "path": "::product tree returned array", + "id": "659fb26f091e00c691f9cc83292ee38f", + "passes": 125, + "fails": 0 + } + } + } +} diff --git a/backend/scripts/backup/README.md b/backend/scripts/backup/README.md new file mode 100644 index 00000000..348df214 --- /dev/null +++ b/backend/scripts/backup/README.md @@ -0,0 +1,94 @@ +# RELab Backups + +These scripts back up RELab data locally and can optionally sync it to remote storage. + +Two kinds of data are covered: + +- PostgreSQL backups +- uploaded files and images + +## Important Note + +By default, these scripts target services reachable from the host. If your stack runs in Docker, make sure the scripts point at the right database host and upload storage path. Do not assume the defaults match your deployment. + +## Local Backups + +Set the root backup directory in the root `.env` file: + +```env +BACKUP_DIR=/path/to/local/backups +``` + +The scripts create: + +- `$BACKUP_DIR/postgres_db` +- `$BACKUP_DIR/user_upload_backups` + +### Manual Use + +Run from `backend/scripts/backup/`: + +```bash +./backup_user_uploads.sh +./backup_pg_database.sh +``` + +### Automated Use + +From the repo root: + +```bash +docker compose -f compose.yml -f compose.deploy.yml --profile backups up -d +``` + +This starts: + +- `uploads-backup` for user uploads +- `postgres-backup` for PostgreSQL dumps + +Schedules and retention settings live in [compose.deploy.yml](../../../compose.deploy.yml). + +## Remote Sync + +You can sync local backups to remote storage with either `rsync` or `rclone`. Both sync scripts include safety checks to reduce the chance of pushing an empty local directory over a valid remote backup set. + +### rsync + +Use this for SSH-accessible servers or local-network targets. + +Add to the root `.env`: + +```env +BACKUP_RSYNC_REMOTE_HOST=user@hostname +BACKUP_RSYNC_REMOTE_PATH=/path/to/remote/backup +``` + +Manual run: + +```bash +./backend/scripts/backup/rsync_backup.sh +``` + +### rclone + +Use this for cloud or remote object storage. + +Add to the root `.env`: + +```env +BACKUP_RCLONE_REMOTE=myremote:/backup/relab +BACKUP_RCLONE_MULTI_THREAD_STREAMS=16 +``` + +Manual run: + +```bash +./backend/scripts/backup/rclone_backup.sh +``` + +## Cron Examples + +```cron +30 3 * * * /path/to/relab/backend/scripts/backup/rsync_backup.sh >> /var/log/relab/rsync_backup.log 2>&1 +30 3 * * * /path/to/relab/backend/scripts/backup/rclone_backup.sh >> /var/log/relab/rclone_backup.log 2>&1 +``` diff --git a/backend/scripts/backup/backup_pg_database.sh b/backend/scripts/backup/backup_pg_database.sh new file mode 100755 index 00000000..7f745724 --- /dev/null +++ b/backend/scripts/backup/backup_pg_database.sh @@ -0,0 +1,72 @@ +#!/bin/sh +### Simple script to backup the postgres database manually +set -e + +# Load backend and root .env files +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKEND_ENV_NAME="${ENVIRONMENT:-dev}" +case "$BACKEND_ENV_NAME" in + dev) BACKEND_ENV_FILENAME=".env.dev" ;; + staging) BACKEND_ENV_FILENAME=".env.staging" ;; + prod) BACKEND_ENV_FILENAME=".env.prod" ;; + *) BACKEND_ENV_FILENAME=".env.$BACKEND_ENV_NAME" ;; +esac +PG_ENV_FILE="$SCRIPT_DIR/../../$BACKEND_ENV_FILENAME" +ROOT_ENV_FILE="$SCRIPT_DIR/../../../.env" + +if [ -f "$PG_ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$PG_ENV_FILE" + echo "[$(date)] Loaded backend env file: $PG_ENV_FILE" +else + echo "[$(date)] ERROR: Backend env file not found at $PG_ENV_FILE. Aborting." + exit 1 +fi + +if [ -f "$ROOT_ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ROOT_ENV_FILE" + echo "[$(date)] Loaded root env file: $ROOT_ENV_FILE" +else + echo "[$(date)] INFO: Root env file not found at $ROOT_ENV_FILE. Skipping." +fi + +# Configuration +BACKUP_DIR_PG="${BACKUP_DIR:-$SCRIPT_DIR/../../backups}/postgres_db/manual" +DATABASE_HOST="${DATABASE_HOST:-localhost}" +DATABASE_PORT="${DATABASE_PORT:-5432}" +POSTGRES_USER="${POSTGRES_USER:-postgres}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set}" +POSTGRES_DB="${POSTGRES_DB:?POSTGRES_DB not set}" + +COMPRESSION="${POSTGRES_COMPRESSION:-zstd:3}" +SCHEMA="${POSTGRES_SCHEMA:-public}" +FILENAME="${POSTGRES_DB}-$(date +%Y%m%d-%H%M%S).sql.zst" + +# Wait for PostgreSQL +echo "[$(date)] Waiting for PostgreSQL..." +for i in $(seq 1 10); do + if PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$POSTGRES_USER" -q; then + echo "[$(date)] PostgreSQL ready" + break + fi + [ "$i" -eq 10 ] && { echo "[$(date)] ERROR: PostgreSQL timeout"; exit 1; } + sleep 2 +done + +echo "Successfully connected to PostgreSQL." + +# Perform backup +mkdir -p "$BACKUP_DIR_PG" +echo "[$(date)] Backing up '$POSTGRES_DB' to $BACKUP_DIR_PG/$FILENAME" + +PGPASSWORD="$POSTGRES_PASSWORD" pg_dump \ + -h "$DATABASE_HOST" \ + -p "$DATABASE_PORT" \ + -U "$POSTGRES_USER" \ + --compress="$COMPRESSION" \ + --schema="$SCHEMA" \ + "$POSTGRES_DB" \ + > "$BACKUP_DIR_PG/$FILENAME" + +echo "[$(date)] Backup completed. Size: $(du -h "$BACKUP_DIR_PG/$FILENAME" | cut -f1)" diff --git a/backend/scripts/backup/rclone_backup.sh b/backend/scripts/backup/rclone_backup.sh new file mode 100755 index 00000000..fc1a7456 --- /dev/null +++ b/backend/scripts/backup/rclone_backup.sh @@ -0,0 +1,48 @@ +#!/bin/sh +### Rclone script to mirror a local backup directory to a remote server. +set -e + +# Load root .env file +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../../../.env" + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" + echo "[$(date)] Loaded env file: $ENV_FILE" +else + echo "[$(date)] ERROR: Env file not found at $ENV_FILE. Aborting." + exit 1 +fi + +# Configuration +BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backend/backups}" +BACKUP_RCLONE_REMOTE="${BACKUP_RCLONE_REMOTE?BACKUP_RCLONE_REMOTE not set}" +BACKUP_RCLONE_MULTI_THREAD_STREAMS="${BACKUP_RCLONE_MULTI_THREAD_STREAMS:-16}" +BACKUP_RCLONE_TIMEOUT="${BACKUP_RCLONE_TIMEOUT:-5m}" +BACKUP_RCLONE_USE_COOKIES="${BACKUP_RCLONE_USE_COOKIES:-false}" + +# Safety Check: If the local dir has 0 files AND the remote has more than 0 files, abort. +LOCAL_FILE_COUNT=$(find "$BACKUP_DIR" -type f | wc -l) +REMOTE_FILE_COUNT=$(rclone lsf "$BACKUP_RCLONE_REMOTE" --files-only --max-depth=3 2>/dev/null | wc -l) + +if [ "$LOCAL_FILE_COUNT" -eq 0 ] && [ "$REMOTE_FILE_COUNT" -gt 0 ]; then + echo "[$(date)] ERROR: Local backup directory is empty, but remote is not. Aborting sync to prevent data loss." + exit 1 +fi + +echo "[$(date)] Safety check passed. Syncing backups to $BACKUP_RCLONE_REMOTE..." +rclone sync "$BACKUP_DIR" "$BACKUP_RCLONE_REMOTE" \ + --multi-thread-streams="$BACKUP_RCLONE_MULTI_THREAD_STREAMS" \ + --links \ + --checksum \ + --transfers="$BACKUP_RCLONE_MULTI_THREAD_STREAMS" \ + --retries 3 \ + --low-level-retries 10 \ + --stats=30s \ + --stats-one-line-date \ + --timeout="$BACKUP_RCLONE_TIMEOUT" \ + --use-cookies="$BACKUP_RCLONE_USE_COOKIES" + +echo "[$(date)] Sync complete. Remote backup stats after sync:" +rclone size "$BACKUP_RCLONE_REMOTE" --max-depth=3 2>/dev/null | sed 's/^/ /' diff --git a/backend/scripts/backup/rsync_backup.sh b/backend/scripts/backup/rsync_backup.sh index 05e35372..a311ced2 100755 --- a/backend/scripts/backup/rsync_backup.sh +++ b/backend/scripts/backup/rsync_backup.sh @@ -7,6 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ENV_FILE="$SCRIPT_DIR/../../../.env" if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 . "$ENV_FILE" echo "[$(date)] Loaded env file: $ENV_FILE" else @@ -16,17 +17,19 @@ fi # Configuration BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backend/backups}" -BACKUP_REMOTE_HOST="${BACKUP_REMOTE_HOST?BACKUP_REMOTE_HOST not set}" -BACKUP_REMOTE_DIR="${BACKUP_REMOTE_DIR?BACKUP_REMOTE_DIR not set}" +BACKUP_RSYNC_REMOTE_HOST="${BACKUP_RSYNC_REMOTE_HOST?BACKUP_RSYNC_REMOTE_HOST not set}" +BACKUP_RSYNC_REMOTE_DIR="${BACKUP_RSYNC_REMOTE_DIR?BACKUP_RSYNC_REMOTE_DIR not set}" # Safety Check: If the local dir has 0 files AND the remote has more than 0 files, abort. -if [ "$(find "$BACKUP_DIR" -type f | wc -l)" -eq 0 ] && \ - [ "$(ssh "$BACKUP_REMOTE_HOST" "find '$BACKUP_REMOTE_DIR' -type f 2>/dev/null | wc -l")" -gt 0 ]; then +LOCAL_FILE_COUNT=$(find "$BACKUP_DIR" -type f | wc -l) +REMOTE_FILE_COUNT=$(ssh "$BACKUP_RSYNC_REMOTE_HOST" sh -c 'find "$1" -type f 2>/dev/null | wc -l' sh "$BACKUP_RSYNC_REMOTE_DIR") + +if [ "$LOCAL_FILE_COUNT" -eq 0 ] && [ "$REMOTE_FILE_COUNT" -gt 0 ]; then echo "[$(date)] ERROR: Local backup directory is empty, but remote is not. Aborting sync to prevent data loss." exit 1 fi -BACKUP_REMOTE="$BACKUP_REMOTE_HOST:$BACKUP_REMOTE_DIR" +BACKUP_REMOTE="$BACKUP_RSYNC_REMOTE_HOST:$BACKUP_RSYNC_REMOTE_DIR" echo "[$(date)] Safety check passed. Mirroring backups to $BACKUP_REMOTE..." rsync -avz --delete "$BACKUP_DIR"/ "$BACKUP_REMOTE" diff --git a/backend/scripts/backup/user_upload_backups_entrypoint.sh b/backend/scripts/backup/user_upload_backups_entrypoint.sh index 15636562..ba124ee5 100755 --- a/backend/scripts/backup/user_upload_backups_entrypoint.sh +++ b/backend/scripts/backup/user_upload_backups_entrypoint.sh @@ -1,14 +1,26 @@ #!/bin/sh +# spell-checker: ignore crond, crontabs # Entrypoint script for user uploads backup service. To be used in Alpine-based Docker container. +# +# Runs as root so it can fix ownership on the bind-mounted backup directory, then +# schedules the backup script to run as the unprivileged backupuser via su-exec. + set -e -# Create backup directory -mkdir -p "${UPLOADS_BACKUP_DIR:-/backups}" +BACKUP_USER="${BACKUP_USER:-backupuser}" +UPLOADS_BACKUP_DIR="${UPLOADS_BACKUP_DIR:-/backups}" +SCHEDULE="${SCHEDULE:-0 2 * * *}" +BACKUP_SCRIPT="${BACKUP_SCRIPT:-./backup_user_uploads.sh}" + +# Ensure the backup dir exists and is writable by backupuser. Docker auto-creates +# missing bind-mount paths as root, so we normalize ownership here. +mkdir -p "${UPLOADS_BACKUP_DIR}" +chown -R "${BACKUP_USER}:${BACKUP_USER}" "${UPLOADS_BACKUP_DIR}" -# Write the backup schedule to the crontab. -echo "${SCHEDULE-:'0 2 * * *'} ${BACKUP_SCRIPT:-./backup_user_uploads.sh} >> /proc/1/fd/1 2>&1" > /etc/crontabs/"${USER:-root}" +# Write the backup schedule to root's crontab; the job drops privileges via su-exec. +echo "${SCHEDULE} su-exec ${BACKUP_USER} ${BACKUP_SCRIPT} >> /proc/1/fd/1 2>&1" > /etc/crontabs/root -echo "[$(date)] User uploads backup service started. Schedule: ${SCHEDULE:-'0 2 * * *'}" +echo "[$(date)] User uploads backup service started. Schedule: ${SCHEDULE}" -# Start cron in foreground +# Start cron in foreground (crond itself must run as root). exec crond -f -l 2 diff --git a/backend/scripts/create_superuser.py b/backend/scripts/create_superuser.py deleted file mode 100755 index 1b044fee..00000000 --- a/backend/scripts/create_superuser.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 - -"""Create a FastAPI-Users superuser programmatically.""" - -import contextlib -import logging - -import anyio -from app.api.auth.schemas import UserCreate -from app.api.auth.utils.programmatic_user_crud import create_user -from app.core.config import settings -from app.core.database import get_async_session -from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists - -# Set up logging -logger: logging.Logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -# Create an async context manager to get an async session -get_async_session_context = contextlib.asynccontextmanager(get_async_session) - - -async def create_superuser() -> None: - """Create a FastAPI-Users superuser programmatically.""" - superuser_email = settings.superuser_email - superuser_password = settings.superuser_password - - if not superuser_email or not superuser_password: - err_msg = "SUPERUSER_EMAIL and SUPERUSER_PASSWORD must be set in the environment or .env file." - raise ValueError(err_msg) - - async with get_async_session_context() as async_session: - try: - await create_user( - async_session=async_session, - user_create=UserCreate( - email=superuser_email, - password=superuser_password, - organization_id=None, - is_superuser=True, - is_verified=True, - ), - send_registration_email=False, - ) - logger.info("Superuser %s created successfully.", superuser_email) - except (UserAlreadyExists, InvalidPasswordException) as e: - logger.warning("Superuser creation failed: %s", e) - - -if __name__ == "__main__": - anyio.run(create_superuser) diff --git a/backend/scripts/db/__init__.py b/backend/scripts/db/__init__.py new file mode 100644 index 00000000..6c3bfd07 --- /dev/null +++ b/backend/scripts/db/__init__.py @@ -0,0 +1 @@ +"""Database utility scripts.""" diff --git a/backend/scripts/db/is_empty.py b/backend/scripts/db/is_empty.py new file mode 100755 index 00000000..130f678f --- /dev/null +++ b/backend/scripts/db/is_empty.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +"""Check whether the database is empty. + +The shell-facing contract uses exit codes instead of parsing printed text: +- 0: database is empty +- 10: database contains data + +By default the CLI also prints a short human-readable message. Pass ``--quiet`` +when using it from shell scripts that only care about the exit status. +""" + +import argparse +from typing import TYPE_CHECKING + +from sqlalchemy import CursorResult, Inspector, MetaData, Select, Table, inspect, select + +from scripts.db.sync import sync_engine + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Any + +EXIT_EMPTY = 0 +EXIT_NOT_EMPTY = 10 + + +def database_is_empty(ignore_tables: set[str] | None = None) -> bool: + """Check if the database is empty by inspecting all tables, ignoring the alembic_version table.""" + # Ignore the alembic_version table by default + if ignore_tables is None: + ignore_tables = {"alembic_version"} + + inspector: Inspector = inspect(sync_engine) + metadata = MetaData() + + tables: list[str] = inspector.get_table_names() + if not tables: + # No tables exist + return True + metadata.reflect(bind=sync_engine) + with sync_engine.connect() as conn: + for table_name in tables: + if table_name in ignore_tables: + continue # Skip ignored tables + table: Table = metadata.tables[table_name] + query: Select[Any] = select(table).limit(1) + result: CursorResult[Any] = conn.execute(query) + if result.fetchone(): + # Found data in this table + return False + # All tables are empty + return True + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress text output and communicate the result only via exit code.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Return a shell-friendly exit code for the database emptiness check.""" + args = parse_args(argv) + is_empty = database_is_empty(ignore_tables={"alembic_version", "user"}) + + if not args.quiet: + print("Database is empty." if is_empty else "Database contains data.") # noqa: T201 # We want this output for human users when not in quiet mode. + + return EXIT_EMPTY if is_empty else EXIT_NOT_EMPTY + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/db/sync.py b/backend/scripts/db/sync.py new file mode 100644 index 00000000..cdbb39cf --- /dev/null +++ b/backend/scripts/db/sync.py @@ -0,0 +1,25 @@ +"""Synchronous database helpers for scripts and migration-related tasks.""" + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.core.config import settings + +if TYPE_CHECKING: + from collections.abc import Generator + + +sync_engine = create_engine(settings.sync_database_url, echo=settings.debug) + + +@contextmanager +def sync_session_context() -> Generator[Session]: + """Get a synchronous database session for scripts.""" + with Session(sync_engine) as session: + try: + yield session + finally: + session.close() diff --git a/backend/scripts/db_is_empty.py b/backend/scripts/db_is_empty.py deleted file mode 100755 index b66386e6..00000000 --- a/backend/scripts/db_is_empty.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 - -"""Check if the database is empty to determine if seeding is required. - -database_is_empty inspects all tables to check if they contain any data. If all tables are empty, -it returns True, indicating that seeding is required. Otherwise, it returns False. - -Usage: - Run this script directly to print 1 if the database is empty, or 0 if it is not. -""" - -from typing import Any - -from sqlalchemy import CursorResult, Engine, Inspector, MetaData, Select, Table, inspect, select -from sqlmodel import create_engine - -from app.core.config import settings - -sync_engine: Engine = create_engine(settings.sync_database_url, echo=settings.debug) - - -inspector: Inspector = inspect(sync_engine) -metadata = MetaData() - - -def database_is_empty(ignore_tables: set[str] | None = None) -> bool: - """Check if the database is empty by inspecting all tables, ignoring the alembic_version table.""" - # Ignore the alembic_version table by default - if ignore_tables is None: - ignore_tables = {"alembic_version"} - - tables: list[str] = inspector.get_table_names() - if not tables: - # No tables exist - return True - metadata.reflect(bind=sync_engine) - with sync_engine.connect() as conn: - for table_name in tables: - if table_name in ignore_tables: - continue # Skip ignored tables - table: Table = metadata.tables[table_name] - query: Select[Any] = select(table).limit(1) - result: CursorResult[Any] = conn.execute(query) - if result.fetchone(): - # Found data in this table - return False - # All tables are empty - return True - - -if __name__ == "__main__": - if database_is_empty(ignore_tables={"alembic_version"}): - print("TRUE") # noqa: T201 # for shell script usage - else: - print("FALSE") # noqa: T201 diff --git a/backend/scripts/generate/__init__.py b/backend/scripts/generate/__init__.py new file mode 100644 index 00000000..aee4894e --- /dev/null +++ b/backend/scripts/generate/__init__.py @@ -0,0 +1 @@ +"""Code and artifact generation scripts.""" diff --git a/backend/scripts/generate/compile_email_templates.py b/backend/scripts/generate/compile_email_templates.py new file mode 100755 index 00000000..e91c7719 --- /dev/null +++ b/backend/scripts/generate/compile_email_templates.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +"""Compile MJML email templates to HTML. + +This script reads MJML templates from app/templates/emails/src/, +expands any {{include:component}} directives from src/components/, +compiles them to HTML, and saves the output to app/templates/emails/build/. +""" + +import logging +from pathlib import Path + +from mjml.mjml2html import mjml_to_html + +from app.core.logging import setup_logging + +# Set up logging +setup_logging() +logger = logging.getLogger(__name__) + +# Paths +SCRIPT_DIR = Path(__file__).parent +BACKEND_DIR = SCRIPT_DIR.parents[1] +SRC_DIR = BACKEND_DIR / "app" / "templates" / "emails" / "src" +BUILD_DIR = BACKEND_DIR / "app" / "templates" / "emails" / "build" + + +def compile_mjml_templates() -> None: + """Compile all MJML templates in src/ to HTML in build/.""" + if not SRC_DIR.exists(): + logger.error("Source directory not found: %s", SRC_DIR) + return + + # Create build directory if it doesn't exist + BUILD_DIR.mkdir(parents=True, exist_ok=True) + + # Find all MJML files (sorted by modification time to reflect creation order) + mjml_files = sorted(SRC_DIR.glob("*.mjml"), key=lambda p: p.stat().st_mtime) + + if not mjml_files: + logger.warning("No MJML files found in %s", SRC_DIR) + return + + logger.info("Found %d MJML template(s) to compile", len(mjml_files)) + + # Compile each template + for mjml_file in mjml_files: + try: + logger.info("Compiling %s...", mjml_file.name) + + # Read MJML content + mjml_content = mjml_file.read_text() + + # Compile to HTML + html_dotmap = mjml_to_html(mjml_content) + html_content = html_dotmap.html + + # Write HTML to build directory + html_file = BUILD_DIR / mjml_file.with_suffix(".html").name + html_file.write_text(html_content) + + logger.info(" āœ“ Compiled to %s", html_file.name) + + except Exception: + logger.exception(" āœ— Failed to compile %s", mjml_file.name) + + logger.info("Compilation complete!") + + +def main() -> None: + """Entry point for the compile email templates script.""" + compile_mjml_templates() + + +if __name__ == "__main__": + main() diff --git a/backend/local_setup.sh b/backend/scripts/local_setup.sh similarity index 55% rename from backend/local_setup.sh rename to backend/scripts/local_setup.sh index 68ac6298..ed485773 100755 --- a/backend/local_setup.sh +++ b/backend/scripts/local_setup.sh @@ -1,21 +1,36 @@ #!/usr/bin/env bash +# This script sets up the local development environment for the backend. + # Exit immediately if a command exits with a non-zero status set -e -# Check if .env file exists, if not, prompt the user and exit -if [ ! -f ".env" ]; then - echo ".env not found. Please create it by copying from .env.example:" - echo "cp .env.example .env" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Resolve backend directory (one level up from `scripts`) +BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Running local setup script from $SCRIPT_DIR" +echo "Backend directory: $BACKEND_DIR" +echo "dev env file: $BACKEND_DIR/.env.dev" + +# Check if .env.dev file exists, if not, prompt the user and exit +if [ ! -f "$BACKEND_DIR/.env.dev" ]; then + echo ".env.dev not found. Please create it by copying from .env.dev.example:" + echo "cp ""$BACKEND_DIR""/.env.dev.example ""$BACKEND_DIR""/.env.dev" exit 1 fi echo "Setting up local development environment..." -# Load database environment variables from .env file +# Load database environment variables from .env.dev file set -a -source .env +echo "Loading environment variables from $BACKEND_DIR/.env.dev" +# shellcheck source=/dev/null +source "$BACKEND_DIR/.env.dev" set +a +# Set PGPASSWORD for non-interactive authentication with PostgreSQL +export PGPASSWORD="$POSTGRES_PASSWORD" + MAX_RETRIES=10 RETRY_COUNT=0 @@ -53,22 +68,23 @@ echo "Upgrading database to the latest revision..." uv run alembic upgrade head # Check if all tables are empty -echo "Checking if all tables in the database are empty using scripts/db_is_empty.py..." - -# Run the script and temporarily disable exit-on-error to capture the exit code -DB_EMPTY=$(.venv/bin/python -m scripts.db_is_empty) +echo "Checking if all tables in the database are empty using scripts/db/is_empty.py..." -if [ "$DB_EMPTY" = "TRUE" ]; then +if uv run python -m scripts.db.is_empty --quiet; then echo "All tables are empty, proceeding to seed dummy data..." - .venv/bin/python -m scripts.seed.dummy_data + uv run python -m scripts.seed.dummy_data else - echo "Database already has data, skipping seeding." + status=$? + if [ "$status" -eq 10 ]; then + echo "Database already has data, skipping seeding." + else + echo "Failed to determine whether the database is empty." + exit "$status" + fi fi # Create a superuser if the required environment variables are set echo "Creating a superuser..." -uv run -m scripts.create_superuser +uv run -m scripts.users.create_superuser -# Activate the virtual environment -echo "Activating the virtual environment..." -source .venv/bin/activate +echo "Local setup complete." diff --git a/backend/scripts/maintenance/__init__.py b/backend/scripts/maintenance/__init__.py new file mode 100644 index 00000000..fc73b797 --- /dev/null +++ b/backend/scripts/maintenance/__init__.py @@ -0,0 +1 @@ +"""Maintenance and cleanup scripts.""" diff --git a/backend/scripts/maintenance/backfill_user_stats.py b/backend/scripts/maintenance/backfill_user_stats.py new file mode 100755 index 00000000..5e861fc2 --- /dev/null +++ b/backend/scripts/maintenance/backfill_user_stats.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +"""Backfill user statistics in the stats_cache for all users. + +Run with: python -m scripts.maintenance.backfill_user_stats +""" + +import asyncio +import logging + +from sqlalchemy import select + +from app.api.auth.models import User +from app.api.auth.services.stats import recompute_user_stats +from app.core.database import async_session_context, close_async_engine +from app.core.logging import setup_logging + +# Configure logging +setup_logging() +logger = logging.getLogger(__name__) + + +async def backfill_stats() -> int: + """Iterate through all users and recompute their stats_cache.""" + logger.info("Starting backfill of user statistics...") + + async with async_session_context() as session: + # Get all user IDs + stmt = select(User.id) + result = await session.execute(stmt) + user_ids = [row[0] for row in result.all()] + + logger.info("Found %d users to process.", len(user_ids)) + + processed = 0 + for user_id in user_ids: + try: + await recompute_user_stats(session, user_id) + # Commit after each user to ensure progress is saved + await session.commit() + processed += 1 + if processed % 10 == 0: + logger.info("Processed %d/%d users...", processed, len(user_ids)) + except Exception: + logger.exception("Failed to recompute stats for user %s", user_id) + await session.rollback() + + logger.info("Backfill complete. Processed %d users.", processed) + await close_async_engine() + return 0 + + +def main() -> None: + """Run the backfill script.""" + raise SystemExit(asyncio.run(backfill_stats())) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/maintenance/cleanup_files.py b/backend/scripts/maintenance/cleanup_files.py new file mode 100755 index 00000000..a9cd77b8 --- /dev/null +++ b/backend/scripts/maintenance/cleanup_files.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +"""Standalone script to clean up unreferenced files in storage.""" + +import argparse +import logging +import sys +from pathlib import Path + +from anyio import run + +# Add project root to sys.path to allow imports from app +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from functools import partial + +from app.api.file_storage.services.cleanup import cleanup_unreferenced_files +from app.core.config import Environment, settings +from app.core.database import async_session_context, close_async_engine +from app.core.logging import setup_logging + +# Configure logging +setup_logging() +logger = logging.getLogger(__name__) + +_PROD_CONFIRMATION = "yes-delete-prod-files" + + +async def async_main(*, force: bool = False) -> None: + """Run the cleanup process.""" + try: + logger.info("Starting file cleanup...") + logger.info("Environment: %s", settings.environment) + logger.info("Database: %s", settings.database_host) + logger.info("File storage path: %s", settings.file_storage_path) + logger.info("Image storage path: %s", settings.image_storage_path) + + async with async_session_context() as session: + # Note: we use not force as the dry_run argument + await cleanup_unreferenced_files(session, dry_run=not force) + finally: + await close_async_engine() + + +def _confirm_prod_deletion() -> bool: + """Prompt the user to confirm destructive deletion in production.""" + print( # noqa: T201 + f"\nWARNING: You are about to PERMANENTLY DELETE files in PRODUCTION\n" + f" Database: {settings.database_host}\n" + f" File storage: {settings.file_storage_path}\n" + f"\nType '{_PROD_CONFIRMATION}' to confirm: ", + end="", + flush=True, + ) + return input().strip() == _PROD_CONFIRMATION + + +def main() -> None: + """Run the async main function.""" + parser = argparse.ArgumentParser(description="Clean up unreferenced files in storage.") + parser.add_argument( + "--force", action="store_true", help="Actually delete the files. Without this flag, it performs a dry run." + ) + args = parser.parse_args() + + if args.force and settings.environment == Environment.PROD and not _confirm_prod_deletion(): + logger.info("Confirmation not received, aborting.") + sys.exit(0) + + run(partial(async_main, force=args.force)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/maintenance/clear_cache.py b/backend/scripts/maintenance/clear_cache.py new file mode 100755 index 00000000..bf5c615f --- /dev/null +++ b/backend/scripts/maintenance/clear_cache.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +"""Clear cache entries in Redis by namespace. + +This script can be used to clear cache for specific namespaces. +Run with: python -m scripts.maintenance.clear_cache [namespace] + +Available namespaces: +- background-data (default): All background data GET endpoints +- docs: OpenAPI documentation endpoints +""" + +import asyncio +import logging +import sys + +from app.core.cache import clear_cache_namespace, init_fastapi_cache +from app.core.config import CacheNamespace +from app.core.logging import setup_logging +from app.core.redis import close_redis, init_redis + +# Configure logging for standalone script execution +setup_logging() +logger = logging.getLogger(__name__) + + +async def clear_cache(namespace: CacheNamespace) -> int: + """Clear all cache entries for the specified namespace. + + Args: + namespace: Cache namespace to clear + + Returns: + Exit code (0 for success, 1 for failure) + """ + redis_client = await init_redis() + if redis_client is None: + logger.warning("Redis unavailable; cache not cleared.") + return 1 + + init_fastapi_cache(redis_client) + await clear_cache_namespace(namespace) + await close_redis(redis_client) + + logger.info("Successfully cleared cache namespace: %s", namespace) + return 0 + + +def main() -> None: + """Run the cache clearing script.""" + # Parse namespace from command line argument, default to background-data + namespace_arg = sys.argv[1] if len(sys.argv) > 1 else CacheNamespace.BACKGROUND_DATA + + # Validate namespace + try: + namespace = CacheNamespace(namespace_arg) + except ValueError: + valid_namespaces = ", ".join([ns.value for ns in CacheNamespace]) + logger.exception("Invalid namespace '%s'. Valid namespaces: %s", namespace_arg, valid_namespaces) + raise SystemExit(1) from None + + logger.info("Clearing cache namespace: %s", namespace) + # Run the async function + raise SystemExit(asyncio.run(clear_cache(namespace))) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/maintenance/refresh_disposable_email_domains.py b/backend/scripts/maintenance/refresh_disposable_email_domains.py new file mode 100644 index 00000000..6dbe7135 --- /dev/null +++ b/backend/scripts/maintenance/refresh_disposable_email_domains.py @@ -0,0 +1,75 @@ +"""Refresh the committed disposable email fallback list from the upstream source.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +import anyio +import httpx + +from app.api.auth.services.email_checker import DISPOSABLE_DOMAINS_FALLBACK_PATH, DISPOSABLE_DOMAINS_URL + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +_WARN_FILE_SIZE_BYTES = 2 * 1024 * 1024 +_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 + + +def _normalize_domains(raw_text: str) -> list[str]: + """Normalize raw domain text into a sorted, unique list.""" + return sorted({line.strip().lower() for line in raw_text.splitlines() if line.strip() and not line.startswith("#")}) + + +def _render_domains_file(domains: list[str]) -> str: + """Render the fallback file contents.""" + header = [ + "# Curated local fallback for disposable email validation.", + "# Refresh from upstream with: `just refresh-disposable-email-domains`", + ] + return "\n".join([*header, *domains, ""]) + + +def _validate_rendered_size(content: str) -> None: + """Warn or fail if the refreshed fallback file becomes unexpectedly large.""" + size_bytes = len(content.encode("utf-8")) + if size_bytes > _MAX_FILE_SIZE_BYTES: + msg = ( + f"Disposable-email fallback file would be {size_bytes} bytes, " + f"which exceeds the hard limit of {_MAX_FILE_SIZE_BYTES} bytes" + ) + raise ValueError(msg) + if size_bytes > _WARN_FILE_SIZE_BYTES: + logger.warning( + "Disposable-email fallback file is %d bytes, above the warning threshold of %d bytes.", + size_bytes, + _WARN_FILE_SIZE_BYTES, + ) + + +async def refresh_disposable_domains(output_path: Path = DISPOSABLE_DOMAINS_FALLBACK_PATH) -> int: + """Download the current domain list and write it to the repo-local fallback file.""" + async with httpx.AsyncClient() as client: + response = await client.get(DISPOSABLE_DOMAINS_URL, timeout=20.0) + response.raise_for_status() + + domains = _normalize_domains(response.text) + rendered = _render_domains_file(domains) + _validate_rendered_size(rendered) + output_path.parent.mkdir(parents=True, exist_ok=True) + await anyio.Path(output_path).write_text(rendered, encoding="utf-8") + logger.info("Updated %s with %d disposable domains.", output_path, len(domains)) + return 0 + + +def main() -> None: + """Run the disposable domain refresh.""" + raise SystemExit(asyncio.run(refresh_disposable_domains())) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/perf/__init__.py b/backend/scripts/perf/__init__.py new file mode 100644 index 00000000..974bb9e3 --- /dev/null +++ b/backend/scripts/perf/__init__.py @@ -0,0 +1 @@ +"""Performance maintenance helpers.""" diff --git a/backend/scripts/perf/perf_ci.py b/backend/scripts/perf/perf_ci.py new file mode 100644 index 00000000..3e6505b5 --- /dev/null +++ b/backend/scripts/perf/perf_ci.py @@ -0,0 +1,153 @@ +"""Perf CI helpers: write baseline reports and refresh k6 p95 thresholds.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from pathlib import Path +from typing import Any, cast + +SUMMARY_PATH = Path("reports/performance/latest-k6-summary.json") +TARGET_JS = Path("perf/k6-baseline.js") +SCENARIOS = ("live_probe", "product_tree_read", "bearer_login", "resized_image") + + +def _load_metrics() -> dict[str, Any]: + if not SUMMARY_PATH.exists(): + msg = f"Missing summary export: {SUMMARY_PATH}" + raise SystemExit(msg) + data = json.loads(SUMMARY_PATH.read_text()) + return cast("dict[str, Any]", data["metrics"]) + + +def _has_scenario(metrics: dict[str, Any], scenario: str) -> bool: + return f"http_req_duration{{scenario:{scenario}}}" in metrics + + +# --- threshold refresh ------------------------------------------------------ + + +def _scenario_limit(metrics: dict[str, Any], scenario: str, headroom: float) -> int: + duration = cast("dict[str, Any]", metrics[f"http_req_duration{{scenario:{scenario}}}"]) + return math.ceil(float(duration["p(95)"]) * headroom / 100) * 100 + + +def apply_thresholds(headroom: float) -> None: + """Refresh k6 p95 thresholds from the latest summary export.""" + metrics = _load_metrics() + target_text = TARGET_JS.read_text() + + scenario_limits = { + scenario: _scenario_limit(metrics, scenario, headroom) + for scenario in SCENARIOS + if _has_scenario(metrics, scenario) + } + patterns = { + "live_probe": r'(http_req_duration\{scenario:live_probe\}": \["p\(95\)<)(\d+)("\])', + "product_tree_read": r'(http_req_duration\{scenario:product_tree_read\}": \["p\(95\)<)(\d+)("\])', + "bearer_login": r'(http_req_duration\{scenario:bearer_login\}"] = \["p\(95\)<)(\d+)("\])', + "resized_image": r'(http_req_duration\{scenario:resized_image\}"] = \["p\(95\)<)(\d+)("\])', + } + + updated = target_text + for scenario, limit in scenario_limits.items(): + updated = re.sub(patterns[scenario], rf"\g<1>{limit}\g<3>", updated) + TARGET_JS.write_text(updated) + + for scenario, limit in scenario_limits.items(): + sys.stdout.write(f"{scenario}: p95<{limit}\n") + + +# --- dated CI report -------------------------------------------------------- + + +def _scenario_metrics(metrics: dict[str, Any], name: str) -> tuple[float, float, float]: + duration = cast("dict[str, Any]", metrics[f"http_req_duration{{scenario:{name}}}"]) + failed = cast("dict[str, Any]", metrics[f"http_req_failed{{scenario:{name}}}"]) + return float(duration["avg"]), float(duration["p(95)"]), float(failed["value"]) + + +def _has_scenario_report(metrics: dict[str, Any], name: str) -> bool: + return f"http_req_duration{{scenario:{name}}}" in metrics and f"http_req_failed{{scenario:{name}}}" in metrics + + +def _fmt_ms(value: float) -> str: + return f"{value / 1000:.2f}s" if value >= 1000 else f"{value:.2f}ms" + + +def _fmt_rate(value: float) -> str: + return f"{value * 100:.2f}%" + + +def write_report(date: str, base_url: str) -> None: + """Write a dated markdown CI baseline report from the latest k6 summary.""" + metrics = _load_metrics() + overall = cast("dict[str, Any]", metrics["http_req_duration"]) + enabled_scenarios = [name for name in SCENARIOS if _has_scenario_report(metrics, name)] + scenario_lines = [] + for scenario in enabled_scenarios: + avg, p95, fail_rate = _scenario_metrics(metrics, scenario) + scenario_lines.append( + f"- `{scenario}`: avg `{_fmt_ms(avg)}`, p95 `{_fmt_ms(p95)}`, failed requests `{_fmt_rate(fail_rate)}`" + ) + overall_line = f"- overall HTTP: avg `{_fmt_ms(float(overall['avg']))}`, p95 `{_fmt_ms(float(overall['p(95)']))}`" + + report_path = Path(f"reports/performance/{date}-ci-baseline.md") + report_path.write_text( + "\n".join( + [ + f"# {date} CI Baseline", + "", + f"- environment: Docker CI backend at `{base_url}`", + "- source summary: `reports/performance/latest-k6-summary.json`", + "- enabled scenarios: " + ", ".join(f"`{name}`" for name in enabled_scenarios), + "", + "## Results", + "", + *scenario_lines, + overall_line, + "", + "## Notes", + "", + "- This file was generated from the latest CI-stack `k6` summary export.", + "- `resized_image` is optional and only runs when a sample image is available.", + "- If these numbers replace the prior baseline, keep older reports as historical context only.", + "- Threshold refresh remains a maintainer-only follow-up step.", + "", + ] + ) + + "\n" + ) + sys.stdout.write(f"{report_path}\n") + + +# --- CLI -------------------------------------------------------------------- + + +def main() -> None: + """Dispatch to the requested perf CI subcommand.""" + parser = argparse.ArgumentParser(prog="perf_ci") + sub = parser.add_subparsers(dest="command", required=True) + + cmd_apply = "apply-thresholds" + cmd_report = "write-report" + + thresholds = sub.add_parser(cmd_apply, help="Refresh k6 p95 thresholds from the latest summary") + thresholds.add_argument("headroom", nargs="?", type=float, default=1.15) + + report = sub.add_parser(cmd_report, help="Write a dated CI baseline report from the latest summary") + report.add_argument("--date", required=True) + report.add_argument("--base-url", default="http://api:8000") + + args = parser.parse_args() + if args.command == cmd_apply: + apply_thresholds(args.headroom) + elif args.command == cmd_report: + write_report(args.date, args.base_url) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/plugins/__init__.py b/backend/scripts/plugins/__init__.py new file mode 100644 index 00000000..aed68a81 --- /dev/null +++ b/backend/scripts/plugins/__init__.py @@ -0,0 +1 @@ +"""Scripts for plugins.""" diff --git a/backend/scripts/plugins/rpi_cam/__init__.py b/backend/scripts/plugins/rpi_cam/__init__.py new file mode 100644 index 00000000..becac95f --- /dev/null +++ b/backend/scripts/plugins/rpi_cam/__init__.py @@ -0,0 +1 @@ +"""Scripts for the Raspberry Pi Camera plugin.""" diff --git a/backend/scripts/plugins/rpi_cam/webcam_fake_camera.py b/backend/scripts/plugins/rpi_cam/webcam_fake_camera.py new file mode 100644 index 00000000..a378128b --- /dev/null +++ b/backend/scripts/plugins/rpi_cam/webcam_fake_camera.py @@ -0,0 +1,349 @@ +"""Pipe your webcam into the RPi camera API shape for local development. + +Supports both connection modes: + + HTTP mode (default): + uv run python scripts/plugins/rpi-cam/webcam_fake_camera.py + → Starts a local server on :8018 that the backend proxies to. + → Register camera as Direct HTTP with URL http://localhost:8018 + + WebSocket mode: + uv run python scripts/plugins/rpi-cam/webcam_fake_camera.py ws \ + --backend-url ws://localhost:8000/plugins/rpi-cam/ws/connect \ + --camera-id \ + --api-key + → Connects outbound to the backend WebSocket relay. + → Register camera as WebSocket in the UI, then copy the credentials. + +Requirements: + uv sync --group fake-camera +""" +# spell-checker: ignore imencode, IMWRITE + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys +import threading +import uuid +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +import cv2 +import uvicorn +import websockets +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse, Response + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("webcam-fake-camera") + +# ── Constants ────────────────────────────────────────────────────────────────── + +_MSG_TYPE_PING = "ping" +_MSG_TYPE_REQUEST = "request" + +_METHOD_GET = "GET" +_METHOD_POST = "POST" + +_PATH_IMAGES = "/captures" +_PATH_IMAGES_PREFIX = "/captures/" + +_MODE_WS = "ws" + +_FRAME_READ_ERR = "Failed to read frame from webcam" + +# ── Thread-safe webcam capture ───────────────────────────────────────────────── + + +class _CameraState: + """Mutable container for the shared webcam handle, avoiding module-level globals.""" + + camera: cv2.VideoCapture | None = None + lock = threading.Lock() + + +def _ensure_camera() -> cv2.VideoCapture: + """Open the webcam if not already open. Must be called with _CameraState.lock held.""" + if _CameraState.camera is None or not _CameraState.camera.isOpened(): + _CameraState.camera = cv2.VideoCapture(0) + if not _CameraState.camera.isOpened(): + logger.error("Could not open webcam — is one connected?") + sys.exit(1) + logger.info("Webcam opened (device 0)") + return _CameraState.camera + + +def grab_frame(*, quality: int = 85) -> bytes: + """Capture a single JPEG frame from the webcam.""" + with _CameraState.lock: + cam = _ensure_camera() + ok, frame = cam.read() + if not ok: + raise RuntimeError(_FRAME_READ_ERR) + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + return buf.tobytes() + + +def release_camera() -> None: + """Release the webcam device if open.""" + with _CameraState.lock: + if _CameraState.camera is not None: + _CameraState.camera.release() + _CameraState.camera = None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# HTTP mode — local FastAPI server the backend proxies to +# ═══════════════════════════════════════════════════════════════════════════════ + + +def _create_http_app() -> FastAPI: + """Build the FastAPI application with all HTTP-mode routes.""" + captured_images: dict[str, bytes] = {} + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + grab_frame() # fail fast if no webcam + logger.info("Webcam ready") + yield + release_camera() + + app = FastAPI(title="Webcam Fake RPi Camera (HTTP)", lifespan=lifespan) + + @app.post("/captures") + async def capture() -> JSONResponse: + loop = asyncio.get_running_loop() + jpeg = await loop.run_in_executor(None, lambda: grab_frame(quality=92)) + image_id = str(uuid.uuid4()) + captured_images[image_id] = jpeg + data = { + "image_url": f"/captures/{image_id}", + "metadata": { + "image_properties": { + "capture_time": datetime.now(UTC).isoformat(), + "resolution": {"width": 1920, "height": 1080}, + } + }, + } + logger.info("Captured image %s (%d bytes)", image_id[:8], len(jpeg)) + return JSONResponse(content=data) + + @app.get("/captures/{image_id}") + async def get_image(image_id: str) -> Response: + jpeg = captured_images.pop(image_id, None) + if jpeg is None: + raise HTTPException(404, "Image not found or already retrieved") + return Response(content=jpeg, media_type="image/jpeg") + + @app.get("/camera/status") + async def health() -> JSONResponse: + return JSONResponse(content={"status": "ok"}) + + return app + + +def run_http(port: int, host: str = "127.0.0.1") -> None: + """Start the local HTTP fake camera server.""" + app = _create_http_app() + logger.info("Starting HTTP fake camera on http://%s:%d", host, port) + logger.info("Register as Direct HTTP camera with URL: http://localhost:%d", port) + uvicorn.run(app, host=host, port=port) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# WebSocket mode — connects outbound to the backend relay +# ═══════════════════════════════════════════════════════════════════════════════ + +_ws_captured_images: dict[str, bytes] = {} + + +def run_websocket(backend_url: str, camera_id: str, api_key: str) -> None: + """Start the WebSocket fake camera client.""" + asyncio.run(_ws_main(backend_url, camera_id, api_key)) + + +async def _ws_main(backend_url: str, camera_id: str, api_key: str) -> None: + """Connect to the backend relay and handle reconnection.""" + url = f"{backend_url}?camera_id={camera_id}" + headers = {"Authorization": f"Bearer {api_key}"} + + while True: + try: + logger.info("Connecting to %s ...", backend_url) + async with websockets.connect(url, additional_headers=headers) as ws: + logger.info("Connected — waiting for commands") + await _ws_loop(ws) + except (OSError, websockets.exceptions.WebSocketException) as e: + logger.warning("Disconnected: %s — reconnecting in 3s", e) + release_camera() + await asyncio.sleep(3) + + +async def _ws_loop(ws: websockets.ClientConnection) -> None: + """Handle incoming commands from the backend relay.""" + async for raw in ws: + if isinstance(raw, bytes): + continue # we don't expect binary from backend + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type") + + if msg_type == _MSG_TYPE_PING: + await ws.send(json.dumps({"type": "pong"})) + continue + + if msg_type != _MSG_TYPE_REQUEST: + continue + + msg_id = msg["id"] + method = msg.get("method", _METHOD_GET).upper() + path = msg.get("path", "") + + logger.info("← %s %s (id=%s)", method, path, msg_id[:8]) + + try: + await _handle_command(ws, msg_id, method, path) + except (RuntimeError, KeyError, ValueError, TypeError) as e: + logger.exception("Error handling %s %s", method, path) + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "response", + "status": 500, + "data": {"detail": str(e)}, + } + ) + ) + + +async def _handle_command(ws: websockets.ClientConnection, msg_id: str, method: str, path: str) -> None: + """Dispatch a relay command and send the response back over WebSocket.""" + loop = asyncio.get_running_loop() + + if method == _METHOD_POST and path == _PATH_IMAGES: + jpeg = await loop.run_in_executor(None, lambda: grab_frame(quality=92)) + image_id = str(uuid.uuid4()) + _ws_captured_images[image_id] = jpeg + logger.info("Captured image %s (%d bytes)", image_id[:8], len(jpeg)) + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "response", + "status": 200, + "data": { + "image_url": f"/images/{image_id}", + "metadata": { + "image_properties": { + "capture_time": datetime.now(UTC).isoformat(), + "resolution": {"width": 1920, "height": 1080}, + } + }, + }, + } + ) + ) + + elif method == _METHOD_GET and path.startswith(_PATH_IMAGES_PREFIX): + image_id = path.split(_PATH_IMAGES_PREFIX, 1)[1] + jpeg = _ws_captured_images.pop(image_id, None) + if jpeg is None: + jpeg = await loop.run_in_executor(None, lambda: grab_frame(quality=92)) + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "response", + "status": 200, + "has_binary": True, + "data": {}, + } + ) + ) + await ws.send(jpeg) + + elif path in ("/status", "/camera/status"): + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "response", + "status": 200, + "data": {"status": "ok"}, + } + ) + ) + + else: + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "response", + "status": 404, + "data": {"detail": f"Unknown: {method} {path}"}, + } + ) + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CLI +# ═══════════════════════════════════════════════════════════════════════════════ + + +def main() -> None: + """Parse CLI arguments and run the selected camera mode.""" + parser = argparse.ArgumentParser( + description="Pipe your webcam into the RPi camera API for dev/testing.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + # HTTP mode — backend proxies to this server + python scripts/webcam_fake_camera.py + + # WebSocket mode — connects to the backend relay + python scripts/webcam_fake_camera.py ws \\ + --backend-url ws://localhost:8000/plugins/rpi-cam/ws/connect \\ + --camera-id 550e8400-e29b-41d4-a716-446655440000 \\ + --api-key abc123 + """, + ) + sub = parser.add_subparsers(dest="mode") + + # HTTP (default when no subcommand) + http_parser = sub.add_parser("http", help="Run local HTTP server (default)") + http_parser.add_argument("--port", type=int, default=8018, help="Port (default: 8018)") + http_parser.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)") + + # WebSocket + ws_parser = sub.add_parser("ws", help="Connect to backend via WebSocket relay") + ws_parser.add_argument("--backend-url", required=True, help="WebSocket URL of the backend relay") + ws_parser.add_argument("--camera-id", required=True, help="Camera UUID from ReLab") + ws_parser.add_argument("--api-key", required=True, help="API key from camera registration") + + args = parser.parse_args() + + if args.mode == _MODE_WS: + run_websocket(args.backend_url, args.camera_id, args.api_key) + else: + port = getattr(args, "port", 8018) + host = getattr(args, "host", "127.0.0.1") + run_http(port, host) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/render_erd.py b/backend/scripts/render_erd.py deleted file mode 100644 index 27871f26..00000000 --- a/backend/scripts/render_erd.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Use Paracelsus to generate ERDs from the SQLModel data schema.""" - -import logging -import re -from pathlib import Path - -from paracelsus.graph import get_graph_string -from paracelsus.pyproject import get_pyproject_settings - -# Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def process_erd_content(content: str, exclude_fields: tuple[str, ...] = ("created_at", "updated_at")) -> str: - """Remove specified fields and replace CHAR(32) with UUID in the ERD content.""" - # Remove specified fields - for field in exclude_fields: - content = re.sub(rf'\n [A-Z0-9()]+ {field}[",a-z]*?(?=\n)', "", content) - - # Replace CHAR(32) with UUID - return content.replace("CHAR(32)", "UUID") - - -def create_partial_erd(complete_erd: str, tables: list[str]) -> str: - """Create a partial ERD by extracting only the specified tables and their relationships from the complete ERD.""" - partial_erd = "```mermaid\nerDiagram\n" - - for table in tables: - # Extract table definitions - if table_match := re.search(rf"\n {table} \{{[^}}]+}}", complete_erd): - partial_erd += "\n" + table_match.group(0) + "\n" - - # Extract relationships where these tables are targets - for match in re.finditer(rf"\n \w+ [{{}}|o-]+ {table} : \w+", complete_erd): - partial_erd += match.group(0) - - return partial_erd + "\n\n```\n\n" - - -def inject_content(file_path: Path, begin_tag: str, end_tag: str, content: str) -> None: - """Inject content between tags in a file.""" - # Get content from current file. - with file_path.open() as file: - old_content = file.read() - - # Replace old content with newly generated content. - pattern = re.escape(begin_tag) + "(.*)" + re.escape(end_tag) - new_content = re.sub( - pattern, - f"{begin_tag}\n{content}\n{end_tag}", - old_content, - flags=re.MULTILINE | re.DOTALL, - ) - - with file_path.open("w") as file: - file.write(new_content) - - -REPLACE_BEGIN_TAG = "" -REPLACE_END_TAG = "" -MARKDOWN_FILE = Path(__file__).parents[1] / "README.md" - -if __name__ == "__main__": - # Get settings from pyproject.toml - settings = get_pyproject_settings() - - # Generate ERD content - logger.info("Generating complete ERD...") - complete_erd = get_graph_string( - base_class_path=settings.get("base", "app.api.common.models.base:CustomBase"), - import_module=settings.get("imports", []), - include_tables=set(), - exclude_tables=set(), - python_dir=[], - format="mermaid", - column_sort="preserve-order", - ) - complete_erd = process_erd_content(complete_erd) - - # Define modules with their tables - modules = [ - ("Auth", ["user", "oauthaccount", "organization"]), - ( - "Background Data", - [ - "taxonomy", - "category", - "material", - "producttype", - "categorymateriallink", - "categoryproducttypelink", - ], - ), - ( - "Data Collection", - [ - "product", - "physicalproperties", - "camera", - "materialproductlink", - ], - ), - ("File Management", ["file", "image", "video"]), - ] - - # Generate and write partial ERDs - markdown_content = "## Entity Relationship Diagrams\n\n" - for name, tables in modules: - logger.info("Generating ERD for %s module...", name) - markdown_content += f"### {name} Module\n\n" + create_partial_erd(complete_erd, tables) - - inject_content(MARKDOWN_FILE, REPLACE_BEGIN_TAG, REPLACE_END_TAG, markdown_content) - - logger.info("Added ERDs to file: %s", MARKDOWN_FILE) diff --git a/backend/scripts/seed/dummy_data.py b/backend/scripts/seed/dummy_data.py old mode 100755 new mode 100644 index b084400b..2474e206 --- a/backend/scripts/seed/dummy_data.py +++ b/backend/scripts/seed/dummy_data.py @@ -1,392 +1,29 @@ -#!/usr/bin/env python3 - """Seed the database with sample data for testing purposes.""" -import asyncio -import contextlib -import logging -import mimetypes -from typing import TYPE_CHECKING - -from fastapi import UploadFile -from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.datastructures import Headers - -from app.api.auth.models import User -from app.api.auth.schemas import UserCreate -from app.api.auth.utils.programmatic_user_crud import create_user -from app.api.background_data.models import ( - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, - TaxonomyDomain, -) -from app.api.common.models.associations import ( - MaterialProductLink, -) -from app.api.common.models.enums import Unit -from app.api.data_collection.models import PhysicalProperties, Product -from app.api.file_storage.crud import create_image -from app.api.file_storage.models.models import ImageParentType -from app.api.file_storage.schemas import ImageCreateFromForm -from app.core.config import settings -from app.core.database import get_async_session - -if TYPE_CHECKING: - from pathlib import Path - -# Set up logging -logger: logging.Logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -### Sample Data ### -# TODO: Add organization and Camera models -# Sample data for Users -user_data = [ - { - "email": "alice@example.com", - "password": "fake_password_1", - "username": "alice", - }, - { - "email": "bob@example.com", - "password": "fake_password_2", - "username": "bob", - }, -] - - -# Sample data for Taxonomies -taxonomy_data = [ - { - "name": "Electronics Taxonomy", - "description": "Taxonomy for electronic products.", - "version": "1.0", - "domains": {TaxonomyDomain.PRODUCTS}, - "source": "https://example.com/electronics-taxonomy", - }, - { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials.", - "version": "1.0", - "domains": {TaxonomyDomain.MATERIALS}, - "source": "https://example.com/materials-taxonomy", - }, -] - -# Sample data for Categories -category_data = [ - { - "name": "Smartphones", - "description": "Category for smartphones.", - "taxonomy_name": "Electronics Taxonomy", - }, - { - "name": "Laptops", - "description": "Category for laptops.", - "taxonomy_name": "Electronics Taxonomy", - }, - { - "name": "Metals", - "description": "Category for metals.", - "taxonomy_name": "Materials Taxonomy", - }, - { - "name": "Plastics", - "description": "Category for plastics.", - "taxonomy_name": "Materials Taxonomy", - }, -] - -# Sample data for Materials -material_data = [ - { - "name": "Aluminum", - "description": "Lightweight metal.", - "source": "https://example.com/aluminum", - "density_kg_m3": 2700, - "is_crm": False, - "categories": ["Metals"], - }, - { - "name": "Polycarbonate", - "description": "Durable plastic.", - "source": "https://example.com/polycarbonate", - "density_kg_m3": 1200, - "is_crm": False, - "categories": ["Plastics"], - }, -] - -# Sample data for Product Types -product_type_data = [ - { - "name": "Smartphone", - "description": "A handheld personal computer.", - "categories": ["Smartphones"], - }, - { - "name": "Laptop", - "description": "A portable personal computer.", - "categories": ["Laptops"], - }, -] - -# Sample data for Products -product_data = [ - { - "name": "iPhone 12", - "description": "Apple smartphone.", - "brand": "Apple", - "model": "A2403", - "product_type_name": "Smartphone", - "physical_properties": { - "weight_kg": 0.164, - "height_cm": 14.7, - "width_cm": 7.15, - "depth_cm": 0.74, - }, - "bill_of_materials": [ - {"material": "Aluminum", "quantity": 0.025, "unit": Unit.KILOGRAM}, - {"material": "Polycarbonate", "quantity": 0.050, "unit": Unit.KILOGRAM}, - ], - }, - { - "name": "Dell XPS 13", - "description": "Dell laptop.", - "brand": "Dell", - "model": "XPS9380", - "product_type_name": "Laptop", - "physical_properties": { - "weight_kg": 1.23, - "height_cm": 1.16, - "width_cm": 30.2, - "depth_cm": 19.9, - }, - "bill_of_materials": [ - {"material": "Aluminum", "quantity": 0.5, "unit": Unit.KILOGRAM}, - {"material": "Polycarbonate", "quantity": 0.3, "unit": Unit.KILOGRAM}, - ], - }, -] - -# Sample data for Images -image_data = [ - { - "description": "Example phone image", - "path": settings.static_files_path / "images" / "example_phone.jpg", - "parent_product_name": "iPhone 12", - } -] - - -### Async Functions ### -async def seed_users(session: AsyncSession) -> dict[str, User]: - """Seed the database with sample user data.""" - user_map = {} - for user_dict in user_data: - user_create = UserCreate( - email=user_dict["email"], - password=user_dict["password"], - username=user_dict["username"], - is_superuser=False, - is_verified=True, - ) - user = await create_user(session, user_create, send_registration_email=False) - user_map[user.email] = user - return user_map - - -async def seed_taxonomies(session: AsyncSession) -> dict[str, Taxonomy]: - """Seed the database with sample taxonomy data.""" - taxonomy_map = {} - for data in taxonomy_data: - taxonomy = Taxonomy( - name=data["name"], - version=data["version"], - description=data["description"], - domains=data["domains"], - source=data["source"], - ) - session.add(taxonomy) - await session.commit() - taxonomy_map[taxonomy.name] = taxonomy - return taxonomy_map - - -async def seed_categories(session: AsyncSession, taxonomy_map: dict[str, Taxonomy]) -> dict[str, Category]: - """Seed the database with sample category data.""" - category_map = {} - for data in category_data: - taxonomy = taxonomy_map[data["taxonomy_name"]] - if taxonomy.id: - category = Category(name=data["name"], description=data["description"], taxonomy_id=taxonomy.id) - session.add(category) - await session.commit() - category_map[category.name] = category - return category_map - - -async def seed_materials(session: AsyncSession, category_map: dict[str, Category]) -> dict[str, Material]: - """Seed the database with sample material data.""" - material_map = {} - for data in material_data: - material = Material( - name=data["name"], - description=data["description"], - source=data["source"], - density_kg_m3=data["density_kg_m3"], - is_crm=data["is_crm"], - ) - session.add(material) - await session.commit() - - # Associate material with categories - for category_name in data["categories"]: - category = category_map[category_name] - if category.id and material.id: - link = CategoryMaterialLink(material_id=material.id, category_id=category.id) - session.add(link) - await session.commit() - material_map[material.name] = material - return material_map - - -async def seed_product_types(session: AsyncSession, category_map: dict[str, Category]) -> dict[str, ProductType]: - """Seed the database with sample product type data.""" - product_type_map = {} - for data in product_type_data: - product_type = ProductType( - name=data["name"], - description=data["description"], - ) - session.add(product_type) - await session.commit() - - # Associate product type with categories - for category_name in data["categories"]: - category = category_map[category_name] - if category.id and product_type.id: - link = CategoryProductTypeLink(product_type_id=product_type.id, category_id=category.id) - session.add(link) - await session.commit() - product_type_map[product_type.name] = product_type - return product_type_map - - -async def seed_products( - session: AsyncSession, - product_type_map: dict[str, ProductType], - material_map: dict[str, Material], - user_map: dict[str, User], -) -> dict[str, Product]: - """Seed the database with sample product data.""" - product_map = {} - for data in product_data: - product_type = product_type_map[data["product_type_name"]] - - # Create product first - product = Product( - name=data["name"], - description=data["description"], - brand=data["brand"], - model=data["model"], - product_type_id=product_type.id, - owner_id=next(iter(user_map.values())).id, # pyright: ignore [reportArgumentType] # ID is guaranteed because these objects have been committed to the DB earlier. - ) - session.add(product) - await session.commit() - await session.refresh(product) # Ensures ID for product - - # Now create physical properties with product_id - physical_props = PhysicalProperties(**data["physical_properties"], product_id=product.id) # pyright: ignore [reportArgumentType] # ID is guaranteed because these objects have been committed to the DB earlier. - session.add(physical_props) - await session.commit() - - # Add bill of materials - for material_data in data["bill_of_materials"]: - material = material_map[material_data["material"]] - if material.id and product.id: - link = MaterialProductLink( - material_id=material.id, - product_id=product.id, - quantity=material_data["quantity"], - unit=material_data["unit"], - ) - session.add(link) - - await session.commit() - product_map[product.name] = product - return product_map - - -async def seed_images(session: AsyncSession, product_map: dict[str, Product]) -> None: - """Seed the database with initial image data.""" - for data in image_data: - path: Path = data["path"] - description: str = data["description"] - - parent_type = ImageParentType.PRODUCT - parent = product_map.get(data["parent_product_name"]) - if parent: - parent_id = parent.id - else: - logger.warning("Skipping image %s: parent not found", path.name) - continue - - filename: str = path.name - size: int = path.stat().st_size - mime_type, _ = mimetypes.guess_type(path) - - if mime_type is None: - err_msg = f"Could not determine MIME type for image file {filename}." - raise ValueError(err_msg) - - with path.open("rb") as file: - upload_file = UploadFile( - file=file, - filename=filename, - size=size, - headers=Headers( - { - "filename": filename, - "size": str(size), - "content-type": mime_type, - } - ), - ) +import argparse +from functools import partial - image_create = ImageCreateFromForm( - description=description, - file=upload_file, - parent_id=parent_id, - parent_type=parent_type, - ) - await create_image(session, image_create) +from anyio import run +from app.core.logging import setup_logging +from scripts.seed.dummy_seed.products import logger, normalize_unit +from scripts.seed.dummy_seed.runner import async_main -async def async_main() -> None: - """Seed the database with sample data.""" - get_async_session_context = contextlib.asynccontextmanager(get_async_session) +setup_logging() - async with get_async_session_context() as session: - # Seed all data - user_map = await seed_users(session) - taxonomy_map = await seed_taxonomies(session) - category_map = await seed_categories(session, taxonomy_map) - material_map = await seed_materials(session, category_map) - product_type_map = await seed_product_types(session, category_map) - product_map = await seed_products(session, product_type_map, material_map, user_map) - await seed_images(session, product_map) - logger.info("Database seeded with test data.") +__all__ = ["async_main", "logger", "main", "normalize_unit"] def main() -> None: """Run the async main function.""" - asyncio.run(async_main()) + parser = argparse.ArgumentParser(description="Seed the database with dummy data.") + parser.add_argument("--reset", action="store_true", help="Reset the database before seeding.") + parser.add_argument( + "--dry-run", action="store_true", help="Seed data but rollback the transaction instead of committing." + ) + args = parser.parse_args() + + run(partial(async_main, reset=args.reset, dry_run=args.dry_run)) if __name__ == "__main__": diff --git a/backend/scripts/seed/dummy_seed/__init__.py b/backend/scripts/seed/dummy_seed/__init__.py new file mode 100644 index 00000000..fc15e5d7 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/__init__.py @@ -0,0 +1,5 @@ +"""Helpers for seeding dummy backend data.""" + +from .runner import async_main + +__all__ = ["async_main"] diff --git a/backend/scripts/seed/dummy_seed/background.py b/backend/scripts/seed/dummy_seed/background.py new file mode 100644 index 00000000..10962e71 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/background.py @@ -0,0 +1,128 @@ +"""Dummy background-data seeding.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.background_data.models import ( + Category, + CategoryMaterialLink, + CategoryProductTypeLink, + Material, + ProductType, + Taxonomy, +) + +from .data import category_data, material_data, product_type_data, taxonomy_data + + +async def seed_taxonomies(session: AsyncSession) -> dict[str, Taxonomy]: + """Seed the database with sample taxonomy data.""" + taxonomy_map: dict[str, Taxonomy] = {} + for data in taxonomy_data: + stmt = select(Taxonomy).where(Taxonomy.name == data["name"]) + existing = (await session.execute(stmt)).scalars().first() + + if existing: + taxonomy_map[existing.name] = existing + continue + + taxonomy = Taxonomy( + name=data["name"], + version=data["version"], + description=data["description"], + domains=data["domains"], + source=data["source"], + ) + session.add(taxonomy) + await session.commit() + await session.refresh(taxonomy) + taxonomy_map[taxonomy.name] = taxonomy + return taxonomy_map + + +async def seed_categories(session: AsyncSession, taxonomy_map: dict[str, Taxonomy]) -> dict[str, Category]: + """Seed the database with sample category data.""" + category_map: dict[str, Category] = {} + for data in category_data: + taxonomy = taxonomy_map.get(data["taxonomy_name"]) + if not taxonomy: + continue + + stmt = select(Category).where(Category.name == data["name"]).where(Category.taxonomy_id == taxonomy.id) + existing = (await session.execute(stmt)).scalars().first() + + if existing: + category_map[existing.name] = existing + continue + + if taxonomy.id: + category = Category(name=data["name"], description=data["description"], taxonomy_id=taxonomy.id) + session.add(category) + await session.commit() + await session.refresh(category) + category_map[category.name] = category + return category_map + + +async def seed_materials(session: AsyncSession, category_map: dict[str, Category]) -> dict[str, Material]: + """Seed the database with sample material data.""" + material_map: dict[str, Material] = {} + for data in material_data: + stmt = select(Material).where(Material.name == data["name"]) + existing = (await session.execute(stmt)).scalars().first() + + if existing: + material_map[existing.name] = existing + continue + + material = Material( + name=data["name"], + description=data["description"], + source=data["source"], + density_kg_m3=data["density_kg_m3"], + is_crm=data["is_crm"], + ) + session.add(material) + await session.commit() + await session.refresh(material) + + for category_name in data["categories"]: + category = category_map.get(category_name) + if category and category.id and material.id: + stmt = select(CategoryMaterialLink).where( + CategoryMaterialLink.material_id == material.id, CategoryMaterialLink.category_id == category.id + ) + if not (await session.execute(stmt)).scalars().first(): + session.add(CategoryMaterialLink(material_id=material.id, category_id=category.id)) + await session.commit() + material_map[material.name] = material + return material_map + + +async def seed_product_types(session: AsyncSession, category_map: dict[str, Category]) -> dict[str, ProductType]: + """Seed the database with sample product type data.""" + product_type_map: dict[str, ProductType] = {} + for data in product_type_data: + stmt = select(ProductType).where(ProductType.name == data["name"]) + existing = (await session.execute(stmt)).scalars().first() + if existing: + product_type_map[existing.name] = existing + continue + + product_type = ProductType( + name=data["name"], + description=data["description"], + ) + session.add(product_type) + await session.commit() + await session.refresh(product_type) + + for category_name in data["categories"]: + category = category_map.get(category_name) + if category and category.id and product_type.id: + session.add(CategoryProductTypeLink(product_type_id=product_type.id, category_id=category.id)) + await session.commit() + product_type_map[product_type.name] = product_type + return product_type_map diff --git a/backend/scripts/seed/dummy_seed/data.py b/backend/scripts/seed/dummy_seed/data.py new file mode 100644 index 00000000..b6ee5582 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/data.py @@ -0,0 +1,23 @@ +"""Shared dummy seed data loading.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from app.core.env import BACKEND_DIR + +if TYPE_CHECKING: + from typing import Any + +data_file = BACKEND_DIR / "data" / "seed" / "dummy_data.json" +with data_file.open("r") as f: + _seed_data = json.load(f) + +user_data: list[dict[str, Any]] = _seed_data["user_data"] +taxonomy_data: list[dict[str, Any]] = _seed_data["taxonomy_data"] +category_data: list[dict[str, Any]] = _seed_data["category_data"] +material_data: list[dict[str, Any]] = _seed_data["material_data"] +product_type_data: list[dict[str, Any]] = _seed_data["product_type_data"] +product_data: list[dict[str, Any]] = _seed_data["product_data"] +image_data: list[dict[str, Any]] = _seed_data["image_data"] diff --git a/backend/scripts/seed/dummy_seed/images.py b/backend/scripts/seed/dummy_seed/images.py new file mode 100644 index 00000000..8c3b3122 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/images.py @@ -0,0 +1,83 @@ +"""Dummy image seeding.""" + +from __future__ import annotations + +import io +import logging +import mimetypes +from typing import TYPE_CHECKING + +from anyio import Path as AnyIOPath +from fastapi import UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.datastructures import Headers + +from app.api.file_storage.crud.media_queries import create_image +from app.api.file_storage.models import Image, MediaParentType +from app.api.file_storage.schemas import ImageCreateFromForm +from app.core.config import settings + +from .data import image_data + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from pathlib import Path + + +async def seed_images(session: AsyncSession, product_id_map: dict[str, int]) -> None: + """Seed the database with initial image data.""" + for data in image_data: + filename = data.get("filename") + if not filename: + continue + path: Path = settings.static_files_path / "images" / filename + + async_path = AnyIOPath(path) + if not await async_path.is_file(): + logger.warning("Image not found at %s, skipping.", path) + continue + + description: str = data.get("description", "") + parent_id = product_id_map.get(data["parent_product_name"]) + if not parent_id: + logger.warning("Skipping image %s: parent not found", path.name) + continue + + existing_stmt = ( + select(Image.id).where(Image.parent_id == parent_id, Image.parent_type == MediaParentType.PRODUCT).limit(1) + ) + if (await session.execute(existing_stmt)).scalars().first(): + logger.info("Product %s already has images, skipping.", data["parent_product_name"]) + continue + + size = (await async_path.stat()).st_size + mime_type, _ = mimetypes.guess_type(path) + if mime_type is None: + err_msg = f"Could not determine MIME type for image file {path.name}." + raise ValueError(err_msg) + + async with await async_path.open("rb") as file: + file_content = await file.read() + + upload_file = UploadFile( + file=io.BytesIO(file_content), + filename=path.name, + size=size, + headers=Headers( + { + "filename": path.name, + "size": str(size), + "content-type": mime_type, + } + ), + ) + + image_create = ImageCreateFromForm( + description=description, + file=upload_file, + parent_id=parent_id, + parent_type=MediaParentType.PRODUCT, + ) + await create_image(session, image_create) diff --git a/backend/scripts/seed/dummy_seed/products.py b/backend/scripts/seed/dummy_seed/products.py new file mode 100644 index 00000000..79d73359 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/products.py @@ -0,0 +1,135 @@ +"""Dummy product seeding.""" + +from __future__ import annotations + +import logging +from itertools import cycle +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.models import User +from app.api.background_data.models import Material, ProductType +from app.api.common.models.enums import Unit +from app.api.common.schemas.associations import MaterialProductLinkCreateWithinProduct +from app.api.data_collection.crud.products import create_product +from app.api.data_collection.models.product import Product +from app.api.data_collection.schemas import ProductCreateWithComponents + +from .data import product_data + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from typing import Any + + +def normalize_unit(raw_unit: object, product_name: str) -> Unit: + """Convert seed data unit values to a valid Unit enum.""" + if not isinstance(raw_unit, str): + return Unit.KILOGRAM + + try: + return Unit(raw_unit) + except ValueError: + try: + return Unit[raw_unit.upper()] + except KeyError: + logger.warning("Unknown unit '%s' for %s, defaulting to kilogram.", raw_unit, product_name) + return Unit.KILOGRAM + + +def build_bill_of_materials( + material_map: dict[str, Material], bom_data: list[dict[str, Any]], product_name: str +) -> list[MaterialProductLinkCreateWithinProduct]: + """Construct a BOM list for a product from seed data.""" + bill: list[MaterialProductLinkCreateWithinProduct] = [] + for mdata in bom_data: + mat = material_map.get(mdata["material"]) + if not mat or mat.id is None: + logger.warning("Skipping material link for %s: material %s not found.", product_name, mdata) + continue + bill.append( + MaterialProductLinkCreateWithinProduct( + material_id=mat.id, + quantity=mdata["quantity"], + unit=normalize_unit(mdata.get("unit"), product_name), + ) + ) + return bill + + +async def get_existing_product_id(session: AsyncSession, name: str) -> int | None: + """Return existing product id for a given name, or None.""" + stmt = select(Product.id, Product.name).where(Product.name == name) + row = (await session.execute(stmt)).first() + if not row: + return None + existing_id, _ = row + return int(existing_id) if existing_id is not None else None + + +def build_product_create_from_data( + data: dict[str, Any], product_type_id: int, bill_of_materials: list[MaterialProductLinkCreateWithinProduct] +) -> ProductCreateWithComponents: + """Build ProductCreateWithComponents from seed data dict.""" + physical_props = data.get("physical_properties", {}) + return ProductCreateWithComponents( + name=data["name"], + description=data["description"], + brand=data["brand"], + model=data["model"], + product_type_id=product_type_id, + weight_g=physical_props.get("weight_g"), + height_cm=physical_props.get("height_cm"), + width_cm=physical_props.get("width_cm"), + depth_cm=physical_props.get("depth_cm"), + bill_of_materials=bill_of_materials, + ) + + +async def seed_products( + session: AsyncSession, + product_type_map: dict[str, ProductType], + material_map: dict[str, Material], + user_map: dict[str, User], +) -> dict[str, int]: + """Seed the database with sample product data.""" + product_id_map: dict[str, int] = {} + users = [u for u in user_map.values() if u and getattr(u, "id", None) is not None] + if not users: + logger.warning("No users available for product seeding; skipping.") + return product_id_map + user_cycle = cycle(users) + + for data in product_data: + if data["name"] in product_id_map: + continue + + existing_id = await get_existing_product_id(session, data["name"]) + if existing_id is not None: + product_id_map[data["name"]] = existing_id + continue + + product_type = product_type_map.get(data["product_type_name"]) + if not product_type or product_type.id is None: + continue + + user = next(user_cycle) + + physical_properties_data = data.get("physical_properties") + bill_of_materials_data = data.get("bill_of_materials", []) + + if not physical_properties_data: + logger.warning("Skipping product %s: missing physical properties.", data["name"]) + continue + + bill_of_materials = build_bill_of_materials(material_map, bill_of_materials_data, data["name"]) + + product_create = build_product_create_from_data(data, int(product_type.id), bill_of_materials) + product = await create_product(session, product_create, owner_id=user.id) + + if product.id: + product_id_map[product.name] = product.id + return product_id_map diff --git a/backend/scripts/seed/dummy_seed/runner.py b/backend/scripts/seed/dummy_seed/runner.py new file mode 100644 index 00000000..01056d18 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/runner.py @@ -0,0 +1,78 @@ +"""Execution helpers for dummy-data seeding.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from alembic import command +from alembic.config import Config +from anyio.to_thread import run_sync +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import async_engine, async_session_context, close_async_engine + +from .background import seed_categories, seed_materials, seed_product_types, seed_taxonomies +from .images import seed_images +from .products import seed_products +from .users import seed_users + +logger = logging.getLogger(__name__) + + +class DryRunAsyncSession(AsyncSession): + """AsyncSession that flushes instead of committing for dry runs.""" + + async def commit(self) -> None: + """Override commit to flush instead for dry run mode.""" + await self.flush() + + +async def reset_db() -> None: + """Reset the database using Alembic.""" + logger.info("Resetting database with Alembic...") + + def run_alembic_reset() -> None: + project_root = Path(__file__).resolve().parents[3] + alembic_cfg = Config(toml_file=str(project_root / "pyproject.toml")) + command.downgrade(alembic_cfg, "base") + command.upgrade(alembic_cfg, "head") + + await run_sync(run_alembic_reset) + logger.info("Database reset successfully.") + + +async def run_seed_steps(session: AsyncSession) -> None: + """Run all dummy-data seed steps against an existing session.""" + user_map = await seed_users(session) + taxonomy_map = await seed_taxonomies(session) + category_map = await seed_categories(session, taxonomy_map) + material_map = await seed_materials(session, category_map) + product_type_map = await seed_product_types(session, category_map) + product_id_map = await seed_products(session, product_type_map, material_map, user_map) + await seed_images(session, product_id_map) + + +async def async_main(*, reset: bool = False, dry_run: bool = False) -> None: + """Seed the database with sample data.""" + try: + if dry_run and reset: + logger.warning("Dry run requested; skipping reset to avoid destructive changes.") + reset = False + + if reset: + await reset_db() + + if dry_run: + dry_run_factory = async_sessionmaker(async_engine, class_=DryRunAsyncSession, expire_on_commit=False) + async with dry_run_factory() as session: + await run_seed_steps(session) + await session.rollback() + logger.info("Dry run complete; all changes rolled back.") + return + + async with async_session_context() as session: + await run_seed_steps(session) + logger.info("Database seeded with test data.") + finally: + await close_async_engine() diff --git a/backend/scripts/seed/dummy_seed/users.py b/backend/scripts/seed/dummy_seed/users.py new file mode 100644 index 00000000..eb6c9505 --- /dev/null +++ b/backend/scripts/seed/dummy_seed/users.py @@ -0,0 +1,50 @@ +"""Dummy user seeding.""" + +from __future__ import annotations + +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate +from app.api.auth.services.programmatic_user_crud import create_user + +from .data import user_data + +logger = logging.getLogger(__name__) + + +async def seed_users(session: AsyncSession) -> dict[str, User]: + """Seed the database with sample user data.""" + user_map: dict[str, User] = {} + for user_dict in user_data: + stmt = select(User).where(User.email == user_dict["email"]) + result = await session.execute(stmt) + existing_user = result.scalars().first() + + if existing_user: + logger.info("User %s already exists, skipping creation.", user_dict["email"]) + user_map[existing_user.email] = existing_user + continue + + user_create = UserCreate( + email=user_dict["email"], + password=user_dict["password"], + username=user_dict["username"], + is_superuser=False, + is_verified=True, + ) + try: + user = await create_user(session, user_create, send_registration_email=False, skip_breach_check=True) + user_map[user.email] = user + except ValueError as err: + logger.warning("Failed to create user %s: %s", user_dict["email"], err) + stmt = select(User).where(User.email == user_dict["email"]) + result = await session.execute(stmt) + fetched_user = result.scalars().first() + if fetched_user is not None: + user_map[user_dict["email"]] = fetched_user + + return user_map diff --git a/backend/scripts/seed/migrations_entrypoint.sh b/backend/scripts/seed/migrations_entrypoint.sh index e48e483e..82d5fe18 100755 --- a/backend/scripts/seed/migrations_entrypoint.sh +++ b/backend/scripts/seed/migrations_entrypoint.sh @@ -3,8 +3,26 @@ # Exit immediately if a command exits with a non-zero status set -e +# Helper to lowercase a value (POSIX) +lc() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +# Defaults (so missing env vars behave as "false") +SEED_CPV_CATEGORIES="${SEED_CPV_CATEGORIES:-false}" +SEED_CPV_PRODUCT_TYPES="${SEED_CPV_PRODUCT_TYPES:-false}" +SEED_HS_CATEGORIES="${SEED_HS_CATEGORIES:-false}" +SEED_DUMMY_DATA="${SEED_DUMMY_DATA:-false}" +DEBUG="${DEBUG:-false}" + +require_taxonomy_seed_deps() { + if ! .venv/bin/python -c "import pandas, requests" >/dev/null 2>&1; then + echo "Taxonomy seeding requires the optional seed-taxonomies dependency group." >&2 + echo "Rebuild backend/Dockerfile.migrations with INCLUDE_TAXONOMY_SEED_DEPS=true to enable SEED_CPV_* or SEED_HS_CATEGORIES." >&2 + exit 1 + fi +} + # Run Alembic migrations -if [ "$DEBUG" = "True" ]; then +if [ "$(lc "$DEBUG")" = "true" ]; then echo "Current migration status:" .venv/bin/alembic current fi @@ -12,35 +30,48 @@ fi echo "Upgrading database to the latest revision..." .venv/bin/alembic upgrade head -# Check if we should seed taxonomies -if [ "$SEED_TAXONOMIES" = "true" ]; then - echo "Seeding taxonomies..." - .venv/bin/python -m scripts.seed.taxonomies.cpv - .venv/bin/python -m scripts.seed.taxonomies.harmonized_system -fi +# Seed dummy data if enabled and if the database is empty +if [ "$(lc "$SEED_DUMMY_DATA")" = "true" ]; then + echo "Dummy data seeding is enabled." + echo "Checking if all tables in the database are empty using scripts/db/is_empty.py..." -# Check if we should seed product types -if [ "$SEED_PRODUCT_TYPES" = "true" ]; then - echo "Seeding product types..." - .venv/bin/python -m scripts.seed.taxonomies.cpv --seed-product-types + if .venv/bin/python -m scripts.db.is_empty --quiet; then + echo "All tables are empty, proceeding to seed dummy data..." + .venv/bin/python -m scripts.seed.dummy_data + else + status=$? + if [ "$status" -eq 10 ]; then + echo "Database already has data, skipping seeding of dummy data." + else + echo "Failed to determine whether the database is empty." >&2 + exit "$status" + fi + fi +else + echo "Dummy data seeding is disabled." fi -# Check if all tables are empty -echo "Checking if all tables in the database are empty using scripts/db_is_empty.py..." - -# Run the script and temporarily disable exit-on-error to capture the exit code -DB_EMPTY=$(.venv/bin/python -m scripts.db_is_empty) +# Seed taxonomies: run cpv once and pass the product-types flag if requested +if [ "$(lc "$SEED_CPV_CATEGORIES")" = "true" ]; then + require_taxonomy_seed_deps + echo "Seeding CPV categories..." + if [ "$(lc "$SEED_CPV_PRODUCT_TYPES")" = "true" ]; then + .venv/bin/python -m scripts.seed.taxonomies.cpv --seed-product-types + else + .venv/bin/python -m scripts.seed.taxonomies.cpv + fi +elif [ "$(lc "$SEED_CPV_PRODUCT_TYPES")" = "true" ]; then + echo "SEED_CPV_PRODUCT_TYPES is true but SEED_CPV_CATEGORIES is not true. Skipping seeding of CPV product types since categories are required." +fi -if [ "$DB_EMPTY" = "TRUE" ]; then - echo "All tables are empty, proceeding to seed dummy data..." - .venv/bin/python -m scripts.seed.dummy_data -else - echo "Database already has data, skipping seeding." +if [ "$(lc "$SEED_HS_CATEGORIES")" = "true" ]; then + require_taxonomy_seed_deps + .venv/bin/python -m scripts.seed.taxonomies.harmonized_system fi # Create a superuser if the required environment variables are set echo "Creating a superuser..." -.venv/bin/python -m scripts.create_superuser +.venv/bin/python -m scripts.users.create_superuser # Start the server or other desired commands exec "$@" diff --git a/backend/scripts/seed/taxonomies/__init__.py b/backend/scripts/seed/taxonomies/__init__.py index e69de29b..4d9b7c66 100644 --- a/backend/scripts/seed/taxonomies/__init__.py +++ b/backend/scripts/seed/taxonomies/__init__.py @@ -0,0 +1 @@ +"""Seed logic for taxonomies and categories.""" diff --git a/backend/scripts/seed/taxonomies/common.py b/backend/scripts/seed/taxonomies/common.py index 7e716fb4..66610d7b 100644 --- a/backend/scripts/seed/taxonomies/common.py +++ b/backend/scripts/seed/taxonomies/common.py @@ -1,24 +1,19 @@ """Common utilities for seeding taxonomies and categories.""" import logging -from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy import select -from sqlalchemy.orm import Session from app.api.background_data.models import Category, Taxonomy -logger = logging.getLogger("seeding.taxonomies") +if TYPE_CHECKING: + from collections.abc import Callable + from sqlalchemy.orm import Session -def configure_logging(level: int = logging.INFO) -> None: - """Configure logging for seeding scripts.""" - logging.basicConfig( - level=level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) + +logger = logging.getLogger("seeding.taxonomies.common") def get_or_create_taxonomy( @@ -31,11 +26,12 @@ def get_or_create_taxonomy( ) -> Taxonomy: """Get existing taxonomy or create a new one.""" existing: Taxonomy | None = ( - session.execute(select(Taxonomy).where(Taxonomy.name == name, Taxonomy.version == version)).scalars().first() + session.execute(select(Taxonomy).where((Taxonomy.name == name) & (Taxonomy.version == version))) + .scalars() + .first() ) if existing: - logger.info("Taxonomy '%s' already exists (id: %s)", name, existing.id) return existing taxonomy = Taxonomy( @@ -53,7 +49,7 @@ def get_or_create_taxonomy( def seed_categories_from_rows( session: Session, - taxonomy: Taxonomy, + taxonomy_id: int, rows: list[dict[str, Any]], get_parent_id_fn: Callable[[dict[str, Any]], str | None], ) -> tuple[int, int]: @@ -61,14 +57,13 @@ def seed_categories_from_rows( Args: session: Database session - taxonomy: The taxonomy to add categories to + taxonomy_id: The taxonomy ID to add categories to (must be committed with non-None ID) rows: List of dictionaries with category data (must have 'external_id' and 'name') get_parent_id_fn: Function that takes a row and returns parent external_id or None Returns: Tuple of (categories_created, relationships_created) """ - # Build a map of external_id -> category for parent lookup id_to_category: dict[str, Category] = {} parent_relations: dict[str, str] = {} count = 0 @@ -82,7 +77,7 @@ def seed_categories_from_rows( category = Category( name=name, external_id=external_id, - taxonomy_id=taxonomy.id, + taxonomy_id=taxonomy_id, ) session.add(category) id_to_category[external_id] = category diff --git a/backend/scripts/seed/taxonomies/cpv.py b/backend/scripts/seed/taxonomies/cpv.py index 13fc9ed0..9092b970 100644 --- a/backend/scripts/seed/taxonomies/cpv.py +++ b/backend/scripts/seed/taxonomies/cpv.py @@ -6,20 +6,23 @@ import zipfile from io import BytesIO from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING import pandas as pd import requests -from sqlmodel import select +from sqlalchemy import func, select -from app.api.auth.models import User # noqa: F401 # Need to explictly import User for SQLModel relationships from app.api.background_data.models import ( Category, ProductType, # Adjust import as needed TaxonomyDomain, ) -from app.core.database import sync_session_context -from scripts.seed.taxonomies.common import configure_logging, get_or_create_taxonomy, seed_categories_from_rows +from app.core.logging import setup_logging +from scripts.db.sync import sync_session_context +from scripts.seed.taxonomies.common import get_or_create_taxonomy, seed_categories_from_rows + +if TYPE_CHECKING: + from typing import Any logger = logging.getLogger("seeding.taxonomies.cpv") @@ -47,7 +50,7 @@ "32000000", # Radio, television, communication, telecommunication and related equipment "33000000", # Medical equipments, pharmaceuticals and personal care products "34000000", # Transport equipment and auxiliary products to transportation - "35000000", # Security, fire-fighting, police and defence equipment + "35000000", # Security, fire-fighting, police and defense equipment "37000000", # Musical instruments, sport goods, games, toys, handicraft, art materials and accessories "38000000", # Laboratory, optical and precision equipments (excl. glasses) "42000000", # Industrial machinery @@ -55,20 +58,7 @@ "44000000", # Construction structures and materials; auxiliary products to construction (exc. electric apparatus) } -# TODO: Replace this manual override with an automatic lookup of the higher parent -# if no direct parent is present in the CPV taxonomy -CPV_PARENT_CODE_OVERRIDES = { - "30192120": "30192000", - "34511000": "34510000", - "35611000": "35610000", - "35612000": "35610000", - "35811000": "35810000", - "38527000": "38520000", - "39250000": "39200000", - "42924000": "42920000", - "44115300": "44115000", - "44613100": "44613000", -} +# We now do an algorithmic lookup to find the closest parent. def download_cpv_excel(excel_path: Path = EXCEL_PATH, source_url: str = TAXONOMY_SOURCE) -> None: @@ -133,17 +123,21 @@ def load_cpv_rows_from_excel( return df[["external_id", "name"]].to_dict(orient="records") -def get_cpv_parent_id(row: dict[str, Any]) -> str | None: - """Get parent code by zeroing the rightmost non-zero digit.""" +def get_cpv_parent_id(row: dict[str, Any], available_codes: set[str] | None = None) -> str | None: + """Get parent code by recursively zeroing the rightmost non-zero digit until a valid parent is found.""" code = str(row["external_id"]) - # Use regex to replace the rightmost non-zero digit with '0' - parent_code = re.sub(r"([1-9])([^1-9]*)$", r"0\2", code) - if set(parent_code) == {"0"}: # Top-level, no parent - return None - if parent_code in CPV_PARENT_CODE_OVERRIDES: - return CPV_PARENT_CODE_OVERRIDES[parent_code] - return parent_code + while True: + # Use regex to replace the rightmost non-zero digit with '0' + parent_code = re.sub(r"([1-9])([^1-9]*)$", r"0\2", code) + + if set(parent_code) == {"0"}: # Top-level, no parent + return None + + if available_codes is None or parent_code in available_codes: + return parent_code + + code = parent_code def seed_taxonomy(excel_path: Path = EXCEL_PATH) -> None: @@ -165,7 +159,10 @@ def seed_taxonomy(excel_path: Path = EXCEL_PATH) -> None: ) # If taxonomy already existed, skip seeding - existing_count = session.query(Category).filter_by(taxonomy_id=taxonomy.id).count() + existing_count = session.execute( + select(func.count()).select_from(Category).where(Category.taxonomy_id == taxonomy.id) + ).scalar_one() + if existing_count > 0: logger.info("Taxonomy already has %d categories, skipping seeding", existing_count) return @@ -175,7 +172,11 @@ def seed_taxonomy(excel_path: Path = EXCEL_PATH) -> None: logger.info("Loaded %d CPV codes from Excel", len(rows)) # Seed categories - cat_count, rel_count = seed_categories_from_rows(session, taxonomy, rows, get_parent_id_fn=get_cpv_parent_id) + available_codes = {row["external_id"] for row in rows} + taxonomy_id = taxonomy.id + cat_count, rel_count = seed_categories_from_rows( + session, taxonomy_id, rows, get_parent_id_fn=lambda r: get_cpv_parent_id(r, available_codes) + ) # Commit session.commit() @@ -201,7 +202,7 @@ def seed_product_types(excel_path: Path = EXCEL_PATH) -> None: logger.info("Starting %s %s seeding...", TAXONOMY_NAME, TAXONOMY_VERSION) with sync_session_context() as session: - existing_cpv = session.exec(select(ProductType).where(ProductType.name.startswith("CPV:"))).first() + existing_cpv = session.execute(select(ProductType).where(ProductType.name.startswith("CPV:"))).scalars().first() if existing_cpv: logger.info("CPV product types already exist, skipping seeding") return @@ -212,10 +213,10 @@ def seed_product_types(excel_path: Path = EXCEL_PATH) -> None: for row in rows: # Remove trailing zeros for product type code for cosmetic reasons cpv_code = row["external_id"].rstrip("0") - cpv_description = row["name"] + cpv_name = row["name"] # Create product type - pt = ProductType(name=f"CPV: {cpv_code}", description=cpv_description) + pt = ProductType(name=cpv_name, description=f"CPV: {cpv_code}") session.add(pt) product_types_created += 1 @@ -224,7 +225,7 @@ def seed_product_types(excel_path: Path = EXCEL_PATH) -> None: if __name__ == "__main__": - configure_logging() + setup_logging() # Parse command-line arguments parser = argparse.ArgumentParser(description="Seed CPV taxonomy and optionally product types") diff --git a/backend/scripts/seed/taxonomies/harmonized_system.py b/backend/scripts/seed/taxonomies/harmonized_system.py index 7e96f312..03c45d0d 100644 --- a/backend/scripts/seed/taxonomies/harmonized_system.py +++ b/backend/scripts/seed/taxonomies/harmonized_system.py @@ -3,15 +3,18 @@ import csv import logging from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING import pandas as pd +from sqlalchemy import func, select -# TODO: Fix circular import issue with User model in seeding scripts -from app.api.auth.models import User # noqa: F401 # Need to explictly import User for SQLModel relationships from app.api.background_data.models import Category, TaxonomyDomain -from app.core.database import sync_session_context -from scripts.seed.taxonomies.common import configure_logging, get_or_create_taxonomy, seed_categories_from_rows +from app.core.logging import setup_logging +from scripts.db.sync import sync_session_context +from scripts.seed.taxonomies.common import get_or_create_taxonomy, seed_categories_from_rows + +if TYPE_CHECKING: + from typing import Any logger = logging.getLogger("seeding.taxonomies.harmonized_system") @@ -63,7 +66,7 @@ def load_hs_rows_from_csv(csv_path: Path) -> list[dict[str, Any]]: rows.append( { - "external_id": row["hscode"].strip(), + "external_id": row["hscode"].strip(), # spell-checker: ignore hscode "name": row["description"].strip()[:250], # Truncate to 250 chars to fit DB "parent_id": row["parent"].strip(), } @@ -97,7 +100,10 @@ def seed_taxonomy() -> None: ) # If taxonomy already existed, skip seeding - existing_count = session.query(Category).filter_by(taxonomy_id=taxonomy.id).count() + existing_count = session.execute( + select(func.count()).select_from(Category).where(Category.taxonomy_id == taxonomy.id) + ).scalar_one() + if existing_count > 0: logger.info("Taxonomy already has %d categories, skipping seeding", existing_count) return @@ -106,7 +112,8 @@ def seed_taxonomy() -> None: rows = load_hs_rows_from_csv(CSV_PATH) # Seed categories - cat_count, rel_count = seed_categories_from_rows(session, taxonomy, rows, get_parent_id_fn=get_hs_parent_id) + taxonomy_id = taxonomy.id + cat_count, rel_count = seed_categories_from_rows(session, taxonomy_id, rows, get_parent_id_fn=get_hs_parent_id) # Commit session.commit() @@ -120,5 +127,5 @@ def seed_taxonomy() -> None: if __name__ == "__main__": - configure_logging() + setup_logging() seed_taxonomy() diff --git a/backend/scripts/users/__init__.py b/backend/scripts/users/__init__.py new file mode 100644 index 00000000..96af3304 --- /dev/null +++ b/backend/scripts/users/__init__.py @@ -0,0 +1 @@ +"""User management scripts.""" diff --git a/backend/scripts/users/create_superuser.py b/backend/scripts/users/create_superuser.py new file mode 100755 index 00000000..150462bc --- /dev/null +++ b/backend/scripts/users/create_superuser.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +"""Create a FastAPI-Users superuser programmatically.""" + +import logging + +import anyio +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists + +from app.api.auth.schemas import UserCreate +from app.api.auth.services.programmatic_user_crud import create_user +from app.core.config import settings +from app.core.database import async_session_context, close_async_engine + +# Set up logging +logger: logging.Logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +async def create_superuser() -> None: + """Create a FastAPI-Users superuser programmatically.""" + superuser_email = settings.superuser_email + superuser_name = settings.superuser_name or None + superuser_password = settings.superuser_password + + if not superuser_email or not superuser_password: + err_msg = "SUPERUSER_EMAIL and SUPERUSER_PASSWORD must be set in the environment or .env file." + raise ValueError(err_msg) + + try: + async with async_session_context() as async_session: + try: + await create_user( + async_session=async_session, + user_create=UserCreate.model_construct( + email=superuser_email, + username=superuser_name, + password=superuser_password.get_secret_value(), + organization_id=None, + is_superuser=True, + is_verified=True, + ), + send_registration_email=False, + skip_breach_check=True, + skip_password_validation=True, + ) + logger.info("Superuser %s created successfully.", superuser_email) + except (UserAlreadyExists, InvalidPasswordException) as e: + logger.warning("Superuser creation failed: %s", e) + finally: + await close_async_engine() + + +def main() -> None: + """Entry point for the create superuser script.""" + anyio.run(create_superuser) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/users/create_user.py b/backend/scripts/users/create_user.py new file mode 100755 index 00000000..a54bf09a --- /dev/null +++ b/backend/scripts/users/create_user.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +"""Create a normal, verified user programmatically (for admins). + +Usage examples: + python -m scripts.users.create_user --email user@example.com --username alice + python -m scripts.users.create_user --email user@example.com --password secret123 +""" + +import argparse +import getpass +import logging + +import anyio +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists + +from app.api.auth.schemas import UserCreate +from app.api.auth.services.programmatic_user_crud import create_user +from app.core.database import async_session_context, close_async_engine + +logger: logging.Logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +async def create_normal_user(email: str, username: str | None, password: str) -> None: + """Create a non-superuser who is marked as verified.""" + if not email or not password: + msg = "email and password must be provided" + raise ValueError(msg) + + try: + async with async_session_context() as async_session: + try: + await create_user( + async_session=async_session, + user_create=UserCreate( + email=email, + username=username or None, + password=password, + organization_id=None, + is_superuser=False, + is_verified=True, + ), + send_registration_email=False, + ) + logger.info("User %s created successfully.", email) + except (UserAlreadyExists, InvalidPasswordException) as e: + logger.warning("User creation failed: %s", e) + finally: + await close_async_engine() + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for creating a normal user.""" + parser = argparse.ArgumentParser(description="Create a normal verified user (admin use).") + parser.add_argument("--email", required=True, help="Email address for the new user") + parser.add_argument("--username", required=False, help="Optional username for the new user") + parser.add_argument("--password", required=False, help="Password for the new user (will prompt if omitted)") + return parser.parse_args() + + +def main() -> None: + """Entry point for the create user script.""" + args = parse_args() + password = args.password + if not password: + password = getpass.getpass(prompt="Password: ") + + anyio.run(create_normal_user, args.email, args.username, password) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index e69de29b..38c89b68 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Unit and integration tests.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0a4732a7..4254a8ec 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,96 +1,298 @@ -"""Test configuration file. +"""Root test configuration with containerized Postgres test database setup. -Inspired by https://medium.com/@gnetkov/testing-fastapi-application-with-pytest-57080960fd62. +This conftest provides: +- Ephemeral Postgres via Testcontainers (session-scoped) +- Database setup with transaction isolation +- Cross-suite logging glue +- Minimal global safety fixtures + +Key Fixtures: +- db_session: Isolated async database session with transaction rollback + +Architecture: +- Testcontainers starts lazily when a DB-backed fixture is first requested +- Container coordinates are written to environment variables +- Application settings load from these env vars when DB fixtures build URLs +- This keeps pure unit test runs from paying the Docker startup cost """ +from __future__ import annotations + +# spell-checker: ignore datname, collectonly +import asyncio import logging -from collections.abc import AsyncGenerator, Generator +import os +import re +import sys +from contextlib import suppress from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock + +# Ensure settings modules load from .env.test before any app imports happen. +# This must run before pytest_plugins triggers fixture-module imports and before +# importing app modules that instantiate settings at module level. +os.environ.setdefault("ENVIRONMENT", "testing") import pytest from alembic import command from alembic.config import Config -from app.core.config import settings -from app.main import app -from fastapi.testclient import TestClient -from sqlalchemy import Engine, create_engine, text -from sqlalchemy.exc import ProgrammingError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.ext.asyncio.engine import AsyncEngine -from sqlmodel.ext.asyncio.session import AsyncSession - -# Set up logger -logger: logging.Logger = logging.getLogger(__name__) - -# Set up sync engine for test database construction -sync_engine: Engine = create_engine(settings.sync_database_url, isolation_level="AUTOCOMMIT") - -# Set up an async test engine for the actual -TEST_SQLALCHEMY_DATABASE_URL: str = settings.async_test_database_url -TEST_DATABASE_NAME: str = settings.postgres_test_db -async_engine: AsyncEngine = create_async_engine(TEST_SQLALCHEMY_DATABASE_URL, echo=settings.debug) - -async_session_local = async_sessionmaker( - bind=async_engine, autocommit=False, autoflush=False, class_=AsyncSession, expire_on_commit=False -) - - -### Set up bare test database using sync engine -def create_test_database() -> None: - """Create the test database if it doesn't exist.""" +from loguru import logger as loguru_logger +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool +from testcontainers.postgres import PostgresContainer + +from app.core.logging import LOG_FORMAT, setup_logging + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + + from pytest_mock import MockerFixture + +logger = logging.getLogger(__name__) + +pytest_plugins = [ + "tests.fixtures.auth", + "tests.fixtures.client", + "tests.fixtures.data", + "tests.fixtures.migrations", + "tests.fixtures.redis", +] + +_DEFAULT_TEST_DB_NAME = "test_relab" +_MASTER_WORKER = "master" +_SAFE_DB_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +# Global container instance for entire test session +class _PostgresContainerState: + """Mutable holder for the session Postgres container.""" + + container: PostgresContainer | None = None + + +_POSTGRES_CONTAINER_STATE = _PostgresContainerState() + + +def pytest_configure(config: pytest.Config) -> None: + """Configure logging before test collection.""" + if config.option.collectonly: + return + + # Initialize logging for the test session + setup_logging() + # Remove all sinks initially to prevent logs from bypassing pytest's capture/filtering + loguru_logger.remove() + + # If -s (no capture) is active, add back a stderr sink for live output + if config.getoption("capture") == "no": + loguru_logger.add(sys.stderr, format=LOG_FORMAT, level="INFO") + + +def _ensure_testcontainers_postgres() -> None: + """Start Testcontainers Postgres once and publish its coordinates.""" + if _POSTGRES_CONTAINER_STATE.container is not None: + return + + logger.info("Starting Testcontainers Postgres...") + _POSTGRES_CONTAINER_STATE.container = PostgresContainer( + "postgres:18-alpine", + username="postgres", + password="postgres", # Test-password only + dbname="postgres", + ) + _POSTGRES_CONTAINER_STATE.container.start() + + host = _POSTGRES_CONTAINER_STATE.container.get_container_host_ip() + port = _POSTGRES_CONTAINER_STATE.container.get_exposed_port(5432) + + os.environ["DATABASE_HOST"] = str(host) + os.environ["DATABASE_PORT"] = str(port) + os.environ["POSTGRES_USER"] = "postgres" + os.environ["POSTGRES_PASSWORD"] = "postgres" # Test-password only + os.environ["POSTGRES_DB"] = "postgres" + + logger.info("Testcontainers Postgres started: %s:%s", host, port) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Stop Testcontainers after all tests complete.""" + del config + if _POSTGRES_CONTAINER_STATE.container: + logger.info("Stopping Testcontainers Postgres...") + _POSTGRES_CONTAINER_STATE.container.stop() + _POSTGRES_CONTAINER_STATE.container = None + + +def _get_worker_test_db_name() -> str: + """Generate worker-specific test database name for pytest-xdist parallelism.""" + base_name = os.getenv("POSTGRES_TEST_DB", _DEFAULT_TEST_DB_NAME) + worker_id = os.getenv("PYTEST_XDIST_WORKER") + + db_name = base_name + if worker_id and worker_id != _MASTER_WORKER: + db_name = f"{base_name}_{worker_id}" + + if not _SAFE_DB_NAME.match(db_name): + err = f"Unsafe test database name: {db_name!r}" + raise ValueError(err) + + return db_name + + +def _build_database_url(driver: str, database_name: str) -> str: + """Build database URL from environment variables set by pytest_configure.""" + host = os.environ["DATABASE_HOST"] + port = int(os.environ["DATABASE_PORT"]) + user = os.environ["POSTGRES_USER"] + password = os.environ["POSTGRES_PASSWORD"] + return URL.create( + f"postgresql+{driver}", + username=user, + password=password, + host=host, + port=port, + database=database_name, + ).render_as_string(hide_password=False) + + +def _drop_test_database(test_database_name: str) -> None: + """Terminate connections and drop the test database.""" + sync_admin_url = _build_database_url("psycopg", "postgres") + sync_engine = create_engine(sync_admin_url, isolation_level="AUTOCOMMIT") + with sync_engine.connect() as connection: - try: - connection.execute(text(f"CREATE DATABASE {TEST_DATABASE_NAME}")) - logger.info("Test database created successfully.") - except ProgrammingError: - logger.info("Test database already exists, continuing...") + term_query = text(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = :db_name + AND pid <> pg_backend_pid(); + """) + connection.execute(term_query, {"db_name": test_database_name}) + connection.execute(text(f"DROP DATABASE IF EXISTS {test_database_name}")) + + sync_engine.dispose() + + +def create_test_database(test_database_name: str) -> None: + """Create the test database. Recreate if it exists.""" + _drop_test_database(test_database_name) + + sync_admin_url = _build_database_url("psycopg", "postgres") + sync_engine = create_engine(sync_admin_url, isolation_level="AUTOCOMMIT") + with sync_engine.connect() as connection: + connection.execute(text(f"CREATE DATABASE {test_database_name}")) + sync_engine.dispose() + + logger.info("Test database created successfully: %s", test_database_name) -def get_alembic_config() -> Config: - """Get Alembic config for tests.""" - alembic_cfg = Config() +def get_alembic_config(test_database_name: str) -> Config: + """Get Alembic config for running migrations on the test database schema.""" + sync_test_database_url = _build_database_url("psycopg", test_database_name) + project_root: Path = Path(__file__).parents[1] + alembic_cfg = Config(toml_file=str(project_root / "pyproject.toml")) alembic_cfg.set_main_option("script_location", str(project_root / "alembic")) - alembic_cfg.set_main_option("sqlalchemy.url", TEST_SQLALCHEMY_DATABASE_URL) + alembic_cfg.set_main_option("is_test", "true") + alembic_cfg.set_main_option("sqlalchemy.url", sync_test_database_url) return alembic_cfg -@pytest.fixture(scope="session", autouse=True) -def setup_test_database() -> Generator: - """Create test database, run migrations, and cleanup after tests.""" - create_test_database() # Create empty database +@pytest.fixture(scope="session", name="test_database_name") +def _test_database_name_fixture() -> str: + """Get worker-specific test database name.""" + _ensure_testcontainers_postgres() + return _get_worker_test_db_name() + + +@pytest.fixture(scope="session") +def relab_alembic_config(_setup_test_database: None, test_database_name: str) -> Config: + """Provide Alembic config for integration tests in this repository.""" + return get_alembic_config(test_database_name) + + +@pytest.fixture(scope="session") +def async_engine(test_database_name: str) -> Generator[AsyncEngine]: + """Create async engine for test database.""" + async_test_database_url = _build_database_url("asyncpg", test_database_name) + + engine = create_async_engine( + async_test_database_url, + echo=False, + future=True, + poolclass=NullPool, + ) + yield engine + asyncio.run(engine.dispose()) + - # Run migrations - alembic_cfg: Config = get_alembic_config() +@pytest.fixture(scope="session") +def _setup_test_database(test_database_name: str) -> Generator[None]: + """Create test database and run migrations once per test session.""" + create_test_database(test_database_name) + + alembic_cfg = get_alembic_config(test_database_name) + logger.info("Running Alembic upgrade head...") command.upgrade(alembic_cfg, "head") + logger.info("Alembic upgrade complete.") yield - # Cleanup - with sync_engine.connect() as connection: - connection.execute(text("DROP DATABASE IF EXISTS " + TEST_DATABASE_NAME)) + _drop_test_database(test_database_name) -### Async test session generators -@pytest.fixture(scope="function") -async def get_async_session() -> AsyncGenerator[AsyncSession]: - """Create a new database session for each test and roll it back after the test.""" - async with async_engine.begin() as connection, async_session_local(bind=connection) as session: - transaction = await connection.begin_nested() - yield session - await transaction.rollback() +@pytest.fixture +async def db_session(_setup_test_database: None, async_engine: AsyncEngine) -> AsyncGenerator[AsyncSession]: + """Provide isolated database session using transaction rollback.""" + async with async_engine.connect() as connection: + transaction = await connection.begin() + session_factory = async_sessionmaker( + bind=connection, + class_=AsyncSession, + autocommit=False, + autoflush=False, + expire_on_commit=False, + ) -@pytest.fixture(scope="function") -async def client(db: AsyncSession) -> AsyncGenerator[TestClient]: - """Provide a TestClient that uses the test database session.""" + async with session_factory() as db_session: + yield db_session + if transaction.is_active: + await transaction.rollback() - async def override_get_db() -> AsyncGenerator[AsyncSession]: - yield db - app.dependency_overrides[get_async_session] = override_get_db +@pytest.fixture(autouse=True, scope="session") +def cleanup_loguru() -> Generator[None]: + """Ensure Loguru background queues are closed cleanly after testing session.""" + yield + loguru_logger.remove() + - with TestClient(app) as c: - yield c +@pytest.fixture(autouse=True) +def mock_email_sending(mocker: MockerFixture) -> AsyncMock: + """Automatically mock email sending for all tests.""" + return mocker.patch( + "app.api.auth.services.emails.fm.send_message", + new_callable=AsyncMock, + ) - app.dependency_overrides.clear() + +@pytest.fixture(autouse=True) +def caplog_loguru(caplog: pytest.LogCaptureFixture) -> Generator[None]: + """Propagate Loguru logs to Pytest's caplog handler. + + This allows loguru logs to be captured by pytest and shown in the CLI + according to the log_cli settings in pyproject.toml. + """ + sink_id = loguru_logger.add( + caplog.handler, + format="{message}", + level=0, + filter=lambda record: record["level"].no >= caplog.handler.level, + ) + yield + with suppress(ValueError): + loguru_logger.remove(sink_id) diff --git a/backend/tests/constants.py b/backend/tests/constants.py new file mode 100644 index 00000000..71dcb43d --- /dev/null +++ b/backend/tests/constants.py @@ -0,0 +1,21 @@ +"""Shared test constants.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +PRODUCT_BASE_NAME = "Test Product Base" +BRAND_X = "Brand X" +START_TIME = datetime(2020, 1, 1, tzinfo=UTC) +END_TIME = datetime(2020, 1, 2, tzinfo=UTC) +COMPONENT_NAME = "Test Component" +NEW_PRODUCT_NAME = "New API Product" +PRODUCT_DESC = "Via API" +WEIGHT_500 = 500.0 +HEIGHT_10 = 10.0 +RECYCLABILITY_GOOD = "Good" +UPDATED_PRODUCT_NAME = "Updated API Product" +NEW_COMPONENT_NAME = "New API Component" +COMPONENT_AMOUNT = 2 +BOM_QUANTITY = 10.0 +BOM_UNIT = "g" diff --git a/backend/tests/constants/__init__.py b/backend/tests/constants/__init__.py deleted file mode 100644 index 2564ef98..00000000 --- a/backend/tests/constants/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Constants used in testing.""" diff --git a/backend/tests/constants/background_data.py b/backend/tests/constants/background_data.py deleted file mode 100644 index 196044ae..00000000 --- a/backend/tests/constants/background_data.py +++ /dev/null @@ -1 +0,0 @@ -"""Constants for background data tests.""" diff --git a/backend/tests/factories/__init__.py b/backend/tests/factories/__init__.py index df3d47b1..8fa6b527 100644 --- a/backend/tests/factories/__init__.py +++ b/backend/tests/factories/__init__.py @@ -1 +1,4 @@ -"""Factory-boy factories for generating test objects.""" +"""Factories package. + +Contains Polyfactory model factories and TypedDict factories. +""" diff --git a/backend/tests/factories/background_data.py b/backend/tests/factories/background_data.py deleted file mode 100644 index 4ef99905..00000000 --- a/backend/tests/factories/background_data.py +++ /dev/null @@ -1,6 +0,0 @@ -import factory -from app.api.background_data.models import Taxonomy - - -class TaxonomyFactory(factory.alchemy.SQLAlchemyModelFactory): - pass diff --git a/backend/tests/factories/models.py b/backend/tests/factories/models.py new file mode 100644 index 00000000..72092ca5 --- /dev/null +++ b/backend/tests/factories/models.py @@ -0,0 +1,354 @@ +"""Modern test factories using polyfactory for backend test models.""" +# spell-checker: ignore bothify, numerify + +from typing import Any, TypeVar + +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory +from sqlalchemy.dialects.postgresql import TSVECTOR +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.models import Organization, User +from app.api.background_data.models import ( + Category, + CategoryMaterialLink, + CategoryProductTypeLink, + Material, + ProductType, + Taxonomy, + TaxonomyDomain, +) +from app.api.data_collection.models.product import ( + MaterialProductLink, + Product, +) + +T = TypeVar("T") + + +class BaseModelFactory[T](SQLAlchemyFactory[T]): + """Base factory with custom create_async support for explicit session.""" + + __is_base_factory__ = True + __set_relationships__ = False # Skip relationship introspection to avoid SQLAlchemy/polyfactory conflicts + + @classmethod + def get_sqlalchemy_types(cls) -> dict[Any, Any]: + """Extend polyfactory's built-in SQLAlchemy type support for project-specific columns.""" + sqlalchemy_types = super().get_sqlalchemy_types() + # Product.search_vector uses PostgreSQL full-text search, which polyfactory does not support natively. + sqlalchemy_types[TSVECTOR] = lambda: "" + return sqlalchemy_types + + @classmethod + def _get_type_from_type_engine(cls, type_engine: object) -> type: + """Normalize unsupported SQLAlchemy column types to a buildable Python type.""" + if isinstance(type_engine, TSVECTOR): + return str + return super()._get_type_from_type_engine(type_engine) + + @classmethod + def build(cls, **kwargs: Any) -> T: # noqa: ANN401 # Polyfactory accepts Any-typed kwargs for model fields + """Build an instance while skipping DB-computed columns like generated TSVECTOR fields.""" + build_context = cls._get_build_context(kwargs.get("_build_context")) + build_context["skip_computed_fields"] = True + kwargs["_build_context"] = build_context + return super().build(**kwargs) + + @classmethod + async def create_async( + cls, + session: AsyncSession | None = None, + *, + refresh_instance: bool = False, + **kwargs: Any, # noqa: ANN401 - Any-typed kwargs are expected by the parent class signature. + ) -> T: + """Create a new instance, optionally using a provided session.""" + if session: + instance = cls.build(**kwargs) + session.add(instance) + await session.flush() + if refresh_instance: + await session.refresh(instance) + return instance + return await super().create_async(**kwargs) + + +class UserFactory(BaseModelFactory[User]): + """Factory for creating User test instances.""" + + __model__ = User + + @classmethod + def email(cls) -> str: + """Generate mock value.""" + return cls.__faker__.email() + + @classmethod + def hashed_password(cls) -> str: + """Generate mock value.""" + return "not_really_hashed" + + @classmethod + def is_active(cls) -> bool: + """Generate mock value.""" + return True + + @classmethod + def is_superuser(cls) -> bool: + """Generate mock value.""" + return False + + @classmethod + def is_verified(cls) -> bool: + """Generate mock value.""" + return True + + @classmethod + def username(cls) -> str: + """Generate mock value.""" + return cls.__faker__.user_name() + + @classmethod + def organization(cls) -> None: + """Generate mock value.""" + return + + @classmethod + def organization_id(cls) -> None: + """Generate mock value.""" + return + + @classmethod + def owned_organization(cls) -> None: + """Generate mock value.""" + return + + @classmethod + def products(cls) -> list: + """Generate mock value.""" + return [] + + @classmethod + def oauth_accounts(cls) -> list: + """Generate mock value.""" + return [] + + +class TaxonomyFactory(BaseModelFactory[Taxonomy]): + """Factory for creating Taxonomy test instances.""" + + __model__ = Taxonomy + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + return cls.__faker__.catch_phrase() + + @classmethod + def version(cls) -> str: + """Generate mock value.""" + return cls.__faker__.numerify(text="v#.#.#") + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.text(max_nb_chars=200) if cls.__faker__.boolean() else None + + @classmethod + def domains(cls) -> set[TaxonomyDomain]: + """Generate mock value.""" + # Return at least one domain + domains = [TaxonomyDomain.MATERIALS] + if cls.__faker__.boolean(): + domains.append(TaxonomyDomain.PRODUCTS) + return set(domains) + + @classmethod + def categories(cls) -> list[Category]: + """Generate mock value.""" + return [] + + @classmethod + def source(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.url() if cls.__faker__.boolean() else None + + +class CategoryFactory(BaseModelFactory[Category]): + """Factory for creating Category test instances.""" + + __model__ = Category + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + return cls.__faker__.word().title() + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.sentence() if cls.__faker__.boolean() else None + + @classmethod + def external_id(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.uuid4() if cls.__faker__.boolean() else None + + @classmethod + def supercategory_id(cls) -> int | None: + """Generate mock value.""" + return None + + @classmethod + def supercategory(cls) -> None: + """Generate mock value.""" + return + + # taxonomy_id and supercategory_id should be set explicitly in tests + + +class MaterialFactory(BaseModelFactory[Material]): + """Factory for creating Material test instances.""" + + __model__ = Material + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + materials = ["Steel", "Aluminum", "Copper", "Titanium", "Carbon Fiber", "Glass", "Ceramic"] + return cls.__faker__.random_element(elements=materials) + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.sentence() if cls.__faker__.boolean() else None + + @classmethod + def source(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.url() if cls.__faker__.boolean() else None + + @classmethod + def density_kg_m3(cls) -> float | None: + """Generate mock value.""" + return ( + round(cls.__faker__.pyfloat(min_value=100, max_value=20000), 2) + if cls.__faker__.boolean(chance_of_getting_true=80) + else None + ) + + @classmethod + def is_crm(cls) -> bool | None: + """Generate mock value.""" + return cls.__faker__.boolean() if cls.__faker__.boolean(chance_of_getting_true=80) else None + + +class ProductTypeFactory(BaseModelFactory[ProductType]): + """Factory for creating ProductType test instances.""" + + __model__ = ProductType + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + product_types = ["Electronics", "Furniture", "Appliances", "Tools", "Packaging", "Automotive Parts"] + return cls.__faker__.random_element(elements=product_types) + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.sentence() if cls.__faker__.boolean() else None + + +class CategoryMaterialLinkFactory(BaseModelFactory[CategoryMaterialLink]): + """Factory for creating CategoryMaterialLink instances.""" + + __model__ = CategoryMaterialLink + + # category_id and material_id should be set explicitly + + +class CategoryProductTypeLinkFactory(BaseModelFactory[CategoryProductTypeLink]): + """Factory for creating CategoryProductTypeLink instances.""" + + __model__ = CategoryProductTypeLink + + # category_id and product_type_id should be set explicitly + + +class ProductFactory(BaseModelFactory[Product]): + """Factory for creating Product test instances.""" + + __model__ = Product + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + return cls.__faker__.bs().title() + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.text(max_nb_chars=200) + + @classmethod + def brand(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.company() + + @classmethod + def model(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.bothify(text="??-####") + + @classmethod + def parent_id(cls) -> int | None: + """Generate mock value.""" + return None + + @classmethod + def amount_in_parent(cls) -> int | None: + """Generate mock value.""" + return None + + @classmethod + def components(cls) -> list: + """Generate mock value.""" + return [] + + @classmethod + def bill_of_materials(cls) -> list: + """Generate mock value.""" + return [] + + +class MaterialProductLinkFactory(BaseModelFactory[MaterialProductLink]): + """Factory for creating MaterialProductLink instances.""" + + __model__ = MaterialProductLink + + @classmethod + def quantity(cls) -> float: + """Generate mock value.""" + return cls.__faker__.pyfloat(positive=True, min_value=0.1, max_value=10.0) + + +class OrganizationFactory(BaseModelFactory[Organization]): + """Factory for creating Organization test instances.""" + + __model__ = Organization + + @classmethod + def name(cls) -> str: + """Generate mock value.""" + return cls.__faker__.unique.company() + + @classmethod + def location(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.city() if cls.__faker__.boolean() else None + + @classmethod + def description(cls) -> str | None: + """Generate mock value.""" + return cls.__faker__.catch_phrase() if cls.__faker__.boolean() else None diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 00000000..be88f0f3 --- /dev/null +++ b/backend/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Pytest fixtures for the backend test suite.""" diff --git a/backend/tests/fixtures/auth.py b/backend/tests/fixtures/auth.py new file mode 100644 index 00000000..524e9106 --- /dev/null +++ b/backend/tests/fixtures/auth.py @@ -0,0 +1,36 @@ +"""Auth/user fixtures shared across integration test tiers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + + +@pytest.fixture +async def db_user(db_session: AsyncSession) -> User: + """Create a standard active user for authenticated tests.""" + return await UserFactory.create_async( + session=db_session, + is_superuser=False, + is_active=True, + refresh_instance=True, + ) + + +@pytest.fixture +async def db_superuser(db_session: AsyncSession) -> User: + """Create a superuser for admin and DB-backed tests.""" + return await UserFactory.create_async( + session=db_session, + is_superuser=True, + is_active=True, + refresh_instance=True, + ) diff --git a/backend/tests/fixtures/client.py b/backend/tests/fixtures/client.py new file mode 100644 index 00000000..d9d552e8 --- /dev/null +++ b/backend/tests/fixtures/client.py @@ -0,0 +1,220 @@ +"""HTTP Client fixtures for API testing.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING +from unittest.mock import patch + +import httpx +import pytest +from fastapi import FastAPI +from httpx import ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.dependencies import ( + current_active_superuser, + current_active_user, + current_active_verified_user, + optional_current_active_user, +) +from app.api.auth.models import User +from app.api.auth.services.rate_limiter import limiter +from app.api.auth.services.user_database import get_auth_async_session +from app.core.cache import close_fastapi_cache, init_fastapi_cache +from app.core.config import settings +from app.core.database import get_async_session +from app.main import create_app + + +class _NoNetworkTransport(httpx.AsyncBaseTransport): + """Async transport that returns empty 200 responses without touching the network. + + Used so tests that trigger outbound HTTP calls (e.g. Have I Been Pwnd password-breach checks) + never make real network requests. An empty 200 body is safe for every caller: + - Have I Been Pwnd interprets an empty body as "no suffixes matched → 0 breaches". + - Any other callers that fail open on non-OK responses are also fine. + """ + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + _ = request + return httpx.Response(200, content=b"") + + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator, Iterator + from pathlib import Path + + from redis.asyncio import Redis + + from app.api.auth.models import User + + +def _configure_test_storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Point storage settings at per-test temp dirs.""" + uploads_path = tmp_path / "uploads" + file_storage_path = uploads_path / "files" + image_storage_path = uploads_path / "images" + + monkeypatch.setattr(settings, "uploads_path", uploads_path) + monkeypatch.setattr(settings, "file_storage_path", file_storage_path) + monkeypatch.setattr(settings, "image_storage_path", image_storage_path) + + +@contextmanager +def override_authenticated_user( + test_app: FastAPI, + user: User, + *, + verified: bool = True, + optional: bool = True, + superuser: bool = False, +) -> Iterator[None]: + """Temporarily bind auth dependencies to a specific test user.""" + test_app.dependency_overrides[current_active_user] = lambda: user + if verified: + test_app.dependency_overrides[current_active_verified_user] = lambda: user + if optional: + test_app.dependency_overrides[optional_current_active_user] = lambda: user + if superuser: + test_app.dependency_overrides[current_active_superuser] = lambda: user + + try: + yield + finally: + test_app.dependency_overrides.pop(current_active_user, None) + test_app.dependency_overrides.pop(current_active_verified_user, None) + test_app.dependency_overrides.pop(optional_current_active_user, None) + test_app.dependency_overrides.pop(current_active_superuser, None) + + +@pytest.fixture +def test_app() -> Generator[FastAPI]: + """Provide fresh FastAPI app instance. + + Yields app with cleared dependency overrides after each test. + """ + app = create_app() + yield app + app.dependency_overrides.clear() + + +@pytest.fixture +async def api_client( + test_app: FastAPI, + db_session: AsyncSession, + mock_redis_dependency: Redis, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> AsyncGenerator[httpx.AsyncClient]: + """Provide async HTTP client for API testing. + + Uses httpx.AsyncClient for true async testing of ASGI application. + Automatically injects test database session. + Disables rate limiting for tests. + Sets up Redis for on_after_login hooks. + """ + _configure_test_storage(tmp_path, monkeypatch) + + async def override_get_session() -> AsyncGenerator[AsyncSession]: + yield db_session + + # Override both the app-wide DB session seam and the auth-specific seam that wraps it lazily. + test_app.dependency_overrides[get_async_session] = override_get_session + test_app.dependency_overrides[get_auth_async_session] = override_get_session + + limiter.enabled = False + outbound_http_client = httpx.AsyncClient(transport=_NoNetworkTransport()) + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis_dependency), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.create_http_client", return_value=outbound_http_client), + ): + async with test_app.router.lifespan_context(test_app): + init_fastapi_cache(mock_redis_dependency) + + async with httpx.AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + follow_redirects=True, + ) as client: + yield client + + # Cleanup + await close_fastapi_cache() + limiter.enabled = True + test_app.dependency_overrides.clear() + + +@pytest.fixture +async def api_client_light( + test_app: FastAPI, + db_session: AsyncSession, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> AsyncGenerator[httpx.AsyncClient]: + """Provide a lightweight async client without full app lifespan startup. + + Use this for read-focused API tests that only need the injected DB session + and in-memory cache initialization. + + Safe fits: + - Plain DB-backed reads and pagination/filtering assertions + - Tests that authenticate by dependency override rather than real auth backends + + Keep using the full ``api_client`` for routes that depend on runtime startup + services or auth/session wiring, including: + - Optional/guest auth resolution that still passes through auth backends + - Cookie/session/refresh/OAuth flows + - Newsletter, file-storage, and other runtime-service-heavy paths + """ + _configure_test_storage(tmp_path, monkeypatch) + + async def override_get_session() -> AsyncGenerator[AsyncSession]: + yield db_session + + test_app.dependency_overrides[get_async_session] = override_get_session + test_app.dependency_overrides[get_auth_async_session] = override_get_session + + limiter.enabled = False + init_fastapi_cache(None) + + try: + async with httpx.AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + follow_redirects=True, + ) as client: + yield client + finally: + await close_fastapi_cache() + limiter.enabled = True + test_app.dependency_overrides.clear() + + +@pytest.fixture +async def api_client_user( + api_client: httpx.AsyncClient, db_user: User, test_app: FastAPI +) -> AsyncGenerator[httpx.AsyncClient]: + """Provide an authenticated client for a regular active user.""" + with override_authenticated_user(test_app, db_user): + yield api_client + + +@pytest.fixture +async def api_client_superuser_light( + api_client_light: httpx.AsyncClient, db_superuser: User, test_app: FastAPI +) -> AsyncGenerator[httpx.AsyncClient]: + """Provide a lightweight authenticated client with superuser privileges.""" + with override_authenticated_user(test_app, db_superuser, superuser=True): + yield api_client_light + + +@pytest.fixture +async def api_client_superuser( + api_client: httpx.AsyncClient, db_superuser: User, test_app: FastAPI +) -> AsyncGenerator[httpx.AsyncClient]: + """Provide an authenticated client with superuser privileges (via dependency override).""" + with override_authenticated_user(test_app, db_superuser, superuser=True): + yield api_client diff --git a/backend/tests/fixtures/data.py b/backend/tests/fixtures/data.py new file mode 100644 index 00000000..71854187 --- /dev/null +++ b/backend/tests/fixtures/data.py @@ -0,0 +1,131 @@ +"""Data fixtures for pre-populating test database.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.background_data.models import Category, Material, ProductType, Taxonomy, TaxonomyDomain +from app.api.data_collection.models.product import Product +from tests.constants import BRAND_X, COMPONENT_NAME, END_TIME, PRODUCT_BASE_NAME, START_TIME +from tests.factories.models import ( + CategoryFactory, + MaterialFactory, + ProductTypeFactory, + TaxonomyFactory, +) + +if TYPE_CHECKING: + from app.api.auth.models import User + + +@dataclass(slots=True) +class ProductGraph: + """Compact seeded product graph for API tests.""" + + product_type: ProductType + product: Product + component: Product + + +@pytest.fixture +async def db_taxonomy(db_session: AsyncSession) -> Taxonomy: + """Create and return a test taxonomy in database.""" + return await TaxonomyFactory.create_async( + db_session, + name="CEN/TC 411 Materials Taxonomy", + version="v2.1.0", + description="European standard material classification for circular economy", + domains={TaxonomyDomain.MATERIALS}, + source="https://standards.cencenelec.eu", + ) + + +@pytest.fixture +async def db_category(db_session: AsyncSession, db_taxonomy: Taxonomy) -> Category: + """Create and return a test category in database.""" + return await CategoryFactory.create_async( + db_session, + name="Ferrous Metals", + description="Iron-based alloys including steel and cast iron", + taxonomy_id=db_taxonomy.id, + ) + + +@pytest.fixture +async def db_material(db_session: AsyncSession) -> Material: + """Create and return a test material in database.""" + return await MaterialFactory.create_async( + db_session, + name="Stainless Steel 304", + description="Austenitic chromium-nickel stainless steel", + density_kg_m3=7930.0, + is_crm=True, + ) + + +@pytest.fixture +async def db_product_type(db_session: AsyncSession) -> ProductType: + """Create and return a test product type in database.""" + return await ProductTypeFactory.create_async( + db_session, + name="Power Tool", + description="Handheld electric tools for construction and DIY", + ) + + +@pytest.fixture +async def setup_product(db_session: AsyncSession, db_superuser: User) -> Product: + """Create a top-level product owned by the authenticated superuser.""" + product_type = ProductType( + name="Power Tool", + description="Handheld electric tools for construction and DIY", + ) + product = Product( + owner_id=db_superuser.id, + name=PRODUCT_BASE_NAME, + brand=BRAND_X, + dismantling_time_start=START_TIME, + dismantling_time_end=END_TIME, + product_type=product_type, + ) + db_session.add_all([product_type, product]) + await db_session.flush() + return product + + +@pytest.fixture +async def setup_product_graph(db_session: AsyncSession, db_superuser: User) -> ProductGraph: + """Create a compact product graph with a root product and one child component.""" + product_type = ProductType( + name="Power Tool", + description="Handheld electric tools for construction and DIY", + ) + product = Product( + owner_id=db_superuser.id, + name=PRODUCT_BASE_NAME, + brand=BRAND_X, + dismantling_time_start=START_TIME, + dismantling_time_end=END_TIME, + product_type=product_type, + ) + component = Product( + owner_id=db_superuser.id, + name=COMPONENT_NAME, + dismantling_time_start=START_TIME, + dismantling_time_end=END_TIME, + product_type=product_type, + parent=product, + ) + db_session.add_all([product_type, product, component]) + await db_session.flush() + return ProductGraph(product_type=product_type, product=product, component=component) + + +@pytest.fixture +async def setup_component(setup_product_graph: ProductGraph) -> Product: + """Create a child component below ``setup_product``.""" + return setup_product_graph.component diff --git a/backend/tests/fixtures/migrations.py b/backend/tests/fixtures/migrations.py new file mode 100644 index 00000000..591b8a4d --- /dev/null +++ b/backend/tests/fixtures/migrations.py @@ -0,0 +1,140 @@ +"""Database migration testing fixtures. + +Utilities for testing Alembic migrations, schema changes, and database evolution. +""" + +from typing import TYPE_CHECKING + +import pytest +from alembic import command +from pydantic import PostgresDsn +from sqlalchemy import Engine, create_engine, inspect, text + +if TYPE_CHECKING: + from alembic.config import Config + + +class MigrationHelper: + """Helper class for testing database migrations.""" + + def __init__(self, alembic_cfg: Config): + """Initialize migration helper with Alembic config.""" + self.alembic_cfg = alembic_cfg + # Derive engine URL from the alembic config (already xdist-worker-aware) + url = self.alembic_cfg.get_main_option("sqlalchemy.url") + if not url: + msg = "Alembic config must have 'sqlalchemy.url' set for migration testing." + raise ValueError(msg) + PostgresDsn(url) # Validate URL format + self.sync_engine: Engine = create_engine(url, isolation_level="AUTOCOMMIT") + + def upgrade(self, revision: str = "head") -> None: + """Upgrade database to specific revision. + + Args: + revision: Target revision (default: 'head' - latest) + """ + command.upgrade(self.alembic_cfg, revision) + + def downgrade(self, revision: str) -> None: + """Downgrade database to specific revision. + + Args: + revision: Target revision to downgrade to + """ + command.downgrade(self.alembic_cfg, revision) + + def current_revision(self) -> str | None: + """Get current database revision.""" + with self.sync_engine.connect() as connection: + result = connection.execute( + text("SELECT version_num FROM alembic_version ORDER BY version_num DESC LIMIT 1") + ) + row = result.first() + return str(row[0]) if row else None + + def table_exists(self, table_name: str) -> bool: + """Check if table exists in database. + + Args: + table_name: Name of the table to check + + Returns: + True if table exists, False otherwise + """ + with self.sync_engine.connect() as connection: + inspector = inspect(connection) + return table_name in inspector.get_table_names() + + def column_exists(self, table_name: str, column_name: str) -> bool: + """Check if column exists in table. + + Args: + table_name: Table to check + column_name: Column to look for + + Returns: + True if column exists, False otherwise + """ + with self.sync_engine.connect() as connection: + inspector = inspect(connection) + if not self.table_exists(table_name): + return False + columns = [col["name"] for col in inspector.get_columns(table_name)] + return column_name in columns + + def get_table_columns(self, table_name: str) -> list[str]: + """Get list of column names for a table. + + Args: + table_name: Table to inspect + + Returns: + List of column names + """ + with self.sync_engine.connect() as connection: + inspector = inspect(connection) + if not self.table_exists(table_name): + return [] + return [col["name"] for col in inspector.get_columns(table_name)] + + def get_table_constraints(self, table_name: str) -> dict: + """Get constraints for a table (primary key, unique, foreign keys, checks). + + Args: + table_name: Table to inspect + + Returns: + Dictionary with constraint information + """ + with self.sync_engine.connect() as connection: + inspector = inspect(connection) + return { + "pk": inspector.get_pk_constraint(table_name), + "unique": inspector.get_unique_constraints(table_name), + "fk": inspector.get_foreign_keys(table_name), + "checks": inspector.get_check_constraints(table_name), + } + + def execute_sql(self, sql: str) -> list: + """Execute arbitrary SQL and return results. + + Args: + sql: SQL statement to execute + + Returns: + List of result rows + """ + with self.sync_engine.connect() as connection: + result = connection.execute(text(sql)) + return list(result.fetchall()) + + +@pytest.fixture +def migration_helper(relab_alembic_config: Config) -> MigrationHelper: + """Provide migration testing helper. + + Returns: + MigrationHelper instance for testing migrations + """ + return MigrationHelper(relab_alembic_config) diff --git a/backend/tests/fixtures/redis.py b/backend/tests/fixtures/redis.py new file mode 100644 index 00000000..43926bfe --- /dev/null +++ b/backend/tests/fixtures/redis.py @@ -0,0 +1,44 @@ +"""Redis fixtures for testing with fakeredis.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fakeredis.aioredis import FakeRedis + +from app.core.redis import get_redis + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi import FastAPI + from redis.asyncio import Redis + + +@pytest.fixture +async def redis_client() -> AsyncGenerator[Redis]: + """Provide a fake async Redis client for testing. + + Uses fakeredis to simulate Redis without requiring a running Redis server. + Each test gets its own isolated client instance, so teardown only needs to + close the connection. + """ + client = FakeRedis(decode_responses=True, version=7) + yield client + await client.aclose() + + +@pytest.fixture +async def mock_redis_dependency(test_app: FastAPI, redis_client: Redis) -> AsyncGenerator[Redis]: + """Override the Redis dependency in the FastAPI app. + + This allows tests to use the fake Redis client instead of connecting to a real Redis instance. + """ + + async def override_get_redis() -> Redis: + return redis_client + + test_app.dependency_overrides[get_redis] = override_get_redis + yield redis_client + test_app.dependency_overrides.pop(get_redis, None) diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 00000000..c66cd71b --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/backend/tests/integration/api/README.md b/backend/tests/integration/api/README.md new file mode 100644 index 00000000..cf95febc --- /dev/null +++ b/backend/tests/integration/api/README.md @@ -0,0 +1,10 @@ +# API Integration Tests + +Use this tier for request/response behavior against the ASGI app with real routing, dependency overrides, and database-backed state when needed. + +- Preferred fixtures: `api_client`, `api_client_user`, `api_client_superuser`, `db_session` +- Keep each test focused on one endpoint behavior +- Avoid multi-step CRUD stories here; move those to `tests/integration/flows/` +- Move multi-step scenarios to `tests/integration/flows/` +- Prefer small behavior-focused files such as `*_public_*`, `*_membership_*`, `*_callbacks_*` over one large endpoint catch-all module +- Keep fixture/plugin modules separate from pure helper modules so pytest plugin loading stays explicit and warning-free diff --git a/backend/tests/integration/api/__init__.py b/backend/tests/integration/api/__init__.py new file mode 100644 index 00000000..1c4bb232 --- /dev/null +++ b/backend/tests/integration/api/__init__.py @@ -0,0 +1 @@ +"""API/E2E tests package.""" diff --git a/backend/tests/integration/api/_newsletter_support.py b/backend/tests/integration/api/_newsletter_support.py new file mode 100644 index 00000000..b9adeb3f --- /dev/null +++ b/backend/tests/integration/api/_newsletter_support.py @@ -0,0 +1,27 @@ +"""Shared helpers for newsletter integration tests.""" + +from __future__ import annotations + +from typing import cast + +EMAIL_NEW = "new@example.com" +EMAIL_EXISTING = "existing@example.com" +EMAIL_CONFIRMED = "confirmed@example.com" +EMAIL_CONFIRM_REQ = "confirm@example.com" +EMAIL_UNSUBSCRIBE = "unsubscribe@example.com" +EMAIL_DELETE = "delete@example.com" +MSG_NOT_CONFIRMED = "Already subscribed, but not confirmed" +MSG_ALREADY_SUB = "Already subscribed" +HTTP_CREATED = 201 +HTTP_BAD_REQUEST = 400 +HTTP_OK = 200 +HTTP_NO_CONTENT = 204 + + +def detail_text(payload: dict[str, object]) -> str: + """Return a comparable error-detail string across supported error shapes.""" + detail = payload["detail"] + if isinstance(detail, dict): + detail_dict = cast("dict[str, object]", detail) + return str(detail_dict.get("message") or "") + return str(detail) diff --git a/backend/tests/integration/api/_organization_helpers.py b/backend/tests/integration/api/_organization_helpers.py new file mode 100644 index 00000000..1c030f5e --- /dev/null +++ b/backend/tests/integration/api/_organization_helpers.py @@ -0,0 +1,14 @@ +"""Pure helper functions for organization integration tests.""" + +from __future__ import annotations + +from typing import cast + + +def detail_text(payload: dict[str, object]) -> str: + """Return a comparable error-detail string across supported error shapes.""" + detail = payload["detail"] + if isinstance(detail, dict): + detail_dict = cast("dict[str, object]", detail) + return str(detail_dict.get("message") or "") + return str(detail) diff --git a/backend/tests/integration/api/_organization_support.py b/backend/tests/integration/api/_organization_support.py new file mode 100644 index 00000000..36874b47 --- /dev/null +++ b/backend/tests/integration/api/_organization_support.py @@ -0,0 +1,57 @@ +"""Pytest fixtures for organization integration tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import FastAPI + +from app.api.auth.models import Organization, OrganizationRole, User +from tests.factories.models import OrganizationFactory, UserFactory +from tests.fixtures.client import override_authenticated_user + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture +async def verified_user(db_session: AsyncSession) -> User: + """Non-superuser verified active user.""" + return await UserFactory.create_async( + session=db_session, + is_superuser=False, + is_active=True, + is_verified=True, + refresh_instance=True, + ) + + +@pytest.fixture +async def verified_user_client( + api_client: AsyncClient, verified_user: User, test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Authenticated client acting as a verified non-superuser.""" + with override_authenticated_user(test_app, verified_user, optional=False): + yield api_client + + +async def create_org_for_user(db_session: AsyncSession, owner: User) -> Organization: + """Create an organization with a real owner.""" + org = await OrganizationFactory.create_async(session=db_session, owner_id=owner.id) + owner.organization_id = org.id + owner.organization_role = OrganizationRole.OWNER + owner.organization = org + db_session.add(owner) + await db_session.flush() + await db_session.refresh(org, attribute_names=["members"]) + return org + + +@pytest.fixture +async def org_with_owner(db_session: AsyncSession, verified_user: User) -> Organization: + """Create an organization owned by verified_user.""" + return await create_org_for_user(db_session, verified_user) diff --git a/backend/tests/integration/api/auth/__init__.py b/backend/tests/integration/api/auth/__init__.py new file mode 100644 index 00000000..3c52ba73 --- /dev/null +++ b/backend/tests/integration/api/auth/__init__.py @@ -0,0 +1 @@ +"""Auth endpoint integration tests.""" diff --git a/backend/tests/integration/api/auth/_oauth_support.py b/backend/tests/integration/api/auth/_oauth_support.py new file mode 100644 index 00000000..a47c6a11 --- /dev/null +++ b/backend/tests/integration/api/auth/_oauth_support.py @@ -0,0 +1,89 @@ +"""Shared helpers for OAuth router unit tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast +from unittest.mock import AsyncMock, MagicMock + +from fastapi import Response, status + +from app.api.auth.services.oauth import ( + CSRF_TOKEN_KEY, + BaseOAuthRouterBuilder, + CustomOAuthAssociateRouterBuilder, + CustomOAuthRouterBuilder, + OAuthCookieSettings, + generate_csrf_token, + generate_state_token, +) + +from .shared import TEST_EMAIL, TEST_STATE_JWT_SECRET + +if TYPE_CHECKING: + from httpx_oauth.oauth2 import OAuth2Token + + +def make_base_builder() -> BaseOAuthRouterBuilder: + """Create a base OAuth builder with a mock client.""" + mock_client = MagicMock() + mock_client.name = "github" + return BaseOAuthRouterBuilder( + oauth_client=mock_client, + state_secret=TEST_STATE_JWT_SECRET, + cookie_settings=OAuthCookieSettings(secure=False), + ) + + +def make_auth_builder() -> CustomOAuthRouterBuilder: + """Create an auth OAuth builder with mock client/backend.""" + mock_client = MagicMock() + mock_client.name = "github" + mock_client.get_authorization_url = AsyncMock(return_value="https://github.com/login/oauth/authorize") + mock_client.get_id_email = AsyncMock(return_value=("provider-account-id", TEST_EMAIL)) + + mock_backend = MagicMock() + mock_backend.name = "cookie" + mock_backend.login = AsyncMock(return_value=Response(status_code=status.HTTP_200_OK)) + + return CustomOAuthRouterBuilder( + oauth_client=mock_client, + backend=mock_backend, + state_secret=TEST_STATE_JWT_SECRET, + cookie_settings=OAuthCookieSettings(secure=False), + ) + + +def make_associate_builder() -> CustomOAuthAssociateRouterBuilder: + """Create an associate OAuth builder with mock client/authenticator.""" + mock_client = MagicMock() + mock_client.name = "github" + mock_client.get_id_email = AsyncMock(return_value=("provider-account-id", TEST_EMAIL)) + mock_authenticator = MagicMock() + mock_schema = MagicMock() + mock_schema.model_validate.side_effect = lambda value: {"user_id": str(value.id), "email": value.email} + + return CustomOAuthAssociateRouterBuilder( + oauth_client=mock_client, + authenticator=mock_authenticator, + user_schema=mock_schema, + state_secret=TEST_STATE_JWT_SECRET, + cookie_settings=OAuthCookieSettings(secure=False), + ) + + +def make_request_with_valid_state() -> tuple[MagicMock, tuple[OAuth2Token, str]]: + """Create a mock request with a valid state token.""" + csrf_token = generate_csrf_token() + state = generate_state_token({CSRF_TOKEN_KEY: csrf_token}, TEST_STATE_JWT_SECRET) + mock_request = MagicMock() + mock_request.cookies = {OAuthCookieSettings.name: csrf_token} + return mock_request, (cast("OAuth2Token", {"access_token": "provider-access-token"}), state) + + +def make_associate_request_with_valid_state(user_id: str) -> tuple[MagicMock, tuple[OAuth2Token, str]]: + """Create a mock associate-flow request with a valid state token.""" + csrf_token = generate_csrf_token() + state = generate_state_token({CSRF_TOKEN_KEY: csrf_token, "sub": user_id}, TEST_STATE_JWT_SECRET) + mock_request = MagicMock() + mock_request.cookies = {OAuthCookieSettings.name: csrf_token} + return mock_request, (cast("OAuth2Token", {"access_token": "provider-access-token"}), state) diff --git a/backend/tests/integration/api/auth/shared.py b/backend/tests/integration/api/auth/shared.py new file mode 100644 index 00000000..014041ed --- /dev/null +++ b/backend/tests/integration/api/auth/shared.py @@ -0,0 +1,46 @@ +"""Shared constants for auth integration and unit-style endpoint tests.""" + +from functools import lru_cache + +from pwdlib import PasswordHash + +TEST_EMAIL = "newuser@example.com" +TEST_PASSWORD = "SecurePassword123" +TEST_USERNAME = "newuser" +DUPLICATE_EMAIL = "existing@example.com" +UNIQUE_USERNAME = "uniqueuser" +DIFFERENT_EMAIL = "different@example.com" +EXISTING_USERNAME = "existing_user" +DISPOSABLE_EMAIL = "temp@tempmail.com" +WEAK_PASSWORD = "short" +OWNER_EMAIL = "owner@example.com" +ORG_NAME = "Test Organization" +ORG_LOCATION = "Test City" +ORG_DESC = "Test Description" +LOGIN_EMAIL = "logintest@example.com" +LOGIN_USERNAME = "logintest" +COOKIE_EMAIL = "cookie_test@example.com" +COOKIE_USERNAME = "cookie_test" +INVALID_EMAIL = "nonexistent@example.com" +INVALID_PASSWORD = "WrongPassword123" +INVALID_REFRESH_TOKEN = "invalid-token-1234567890123456789012345678" +USER1_EMAIL = "update_user1@example.com" +USER1_USERNAME = "user_one_unique" +USER2_EMAIL = "update_user2@example.com" +USER2_USERNAME = "user_two_unique" +NEW_USERNAME = "totally_fresh_username" +TAKEN_USERNAME = "already_taken_user" +FRONTEND_REDIRECT_URI = "http://localhost:3000" +JWT_DOT_COUNT = 2 +TEST_STATE_JWT_SECRET = "test-state-jwt-secret-32-bytes-long" + + +@lru_cache(maxsize=1) +def _password_hasher() -> PasswordHash: + """Return a stable password hasher for auth integration test data.""" + return PasswordHash.recommended() + + +def hash_test_password(password: str) -> str: + """Hash a password with a real supported scheme for auth-focused tests.""" + return _password_hasher().hash(password) diff --git a/backend/tests/integration/api/auth/test_admin_routers.py b/backend/tests/integration/api/auth/test_admin_routers.py new file mode 100644 index 00000000..f91639a4 --- /dev/null +++ b/backend/tests/integration/api/auth/test_admin_routers.py @@ -0,0 +1,170 @@ +"""Admin router integration tests for user and organization management.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from tests.factories.models import OrganizationFactory, UserFactory + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + + +pytestmark = pytest.mark.api + + +class TestAdminUserRouters: + """Integration tests for admin user management endpoints.""" + + async def test_get_all_users_as_superuser( + self, api_client_superuser_light: AsyncClient, db_session: AsyncSession, db_superuser: User + ) -> None: + """Superuser can list all users.""" + # Create additional users + user1 = await UserFactory.create_async(db_session, email="user1@example.com", username="user1") + user2 = await UserFactory.create_async(db_session, email="user2@example.com", username="user2") + + response = await api_client_superuser_light.get("/admin/users") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["total"] >= 3 # superuser + 2 created users + assert len(data["items"]) >= 3 + # Verify users are in the list + user_emails = [u["email"] for u in data["items"]] + user_ids = [u["id"] for u in data["items"]] + assert str(db_superuser.id) in user_ids + assert user1.email in user_emails + assert user2.email in user_emails + + async def test_get_all_users_with_pagination( + self, api_client_superuser_light: AsyncClient, db_session: AsyncSession + ) -> None: + """Pagination works for user list.""" + # Create 5 users + for i in range(5): + await UserFactory.create_async(db_session, email=f"pag{i}@example.com", username=f"pag_user_{i}") + + # Request with page size 2 + response = await api_client_superuser_light.get("/admin/users?page=1&size=2") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["items"]) <= 2 + assert "page" in data + assert "total" in data + + async def test_get_user_by_id_as_superuser( + self, api_client_superuser: AsyncClient, db_session: AsyncSession + ) -> None: + """Superuser can retrieve a user by ID.""" + user = await UserFactory.create_async(db_session, email="getbyid@example.com", username="getbyid_user") + + response = await api_client_superuser.get(f"/admin/users/{user.id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == str(user.id) + assert data["email"] == "getbyid@example.com" + assert data["username"] == "getbyid_user" + assert "is_active" in data + assert "is_verified" in data + + async def test_admin_users_requires_superuser(self, api_client: AsyncClient, db_session: AsyncSession) -> None: + """Admin user endpoints require superuser role.""" + # Create regular user and authenticate + await UserFactory.create_async(db_session, email="regular@example.com", username="regular_user") + # Use unauthenticated client (since there's no regular user auth fixture) + # Admin endpoints should 403 without superuser + + response = await api_client.get("/admin/users") + + # Without authentication, should be 403 or similar (depends on auth middleware) + assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + + +class TestAdminOrganizationRouters: + """Integration tests for admin organization management endpoints.""" + + async def test_get_all_organizations_as_superuser( + self, api_client_superuser_light: AsyncClient, db_session: AsyncSession, db_superuser: User + ) -> None: + """Superuser can list all organizations.""" + org1 = await OrganizationFactory.create_async( + db_session, name="Org1", location="Location1", owner_id=db_superuser.id + ) + org2 = await OrganizationFactory.create_async( + db_session, name="Org2", location="Location2", owner_id=db_superuser.id + ) + + response = await api_client_superuser_light.get("/admin/organizations") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["total"] >= 2 + assert len(data["items"]) >= 2 + org_names = [o["name"] for o in data["items"]] + org_ids = [o["id"] for o in data["items"]] + assert str(org1.id) in org_ids + assert str(org2.id) in org_ids + assert "Org1" in org_names + assert "Org2" in org_names + + async def test_get_all_organizations_with_relationships( + self, api_client_superuser_light: AsyncClient, db_session: AsyncSession, db_superuser: User + ) -> None: + """Organization list includes members relationship.""" + org = await OrganizationFactory.create_async(db_session, name="OrgWithMembers", owner_id=db_superuser.id) + user1 = await UserFactory.create_async( + db_session, email="member1@example.com", username="member1", organization_id=org.id + ) + user2 = await UserFactory.create_async( + db_session, email="member2@example.com", username="member2", organization_id=org.id + ) + + response = await api_client_superuser_light.get("/admin/organizations") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Find our org in the list + org_data = next((o for o in data["items"] if o["name"] == "OrgWithMembers"), None) + assert org_data is not None + assert "members" in org_data + assert len(org_data["members"]) >= 2 + member_emails = [m["email"] for m in org_data["members"]] + assert user1.email in member_emails + assert user2.email in member_emails + + async def test_get_organization_by_id_with_relationships( + self, api_client_superuser_light: AsyncClient, db_session: AsyncSession, db_superuser: User + ) -> None: + """Superuser can retrieve organization with members.""" + org = await OrganizationFactory.create_async( + db_session, name="TestOrg", location="TestLoc", owner_id=db_superuser.id + ) + member = await UserFactory.create_async( + db_session, email="org_member@example.com", username="org_member", organization_id=org.id + ) + + response = await api_client_superuser_light.get(f"/admin/organizations/{org.id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == str(org.id) + assert data["name"] == "TestOrg" + assert "members" in data + member_emails = [m["email"] for m in data["members"]] + assert member.email in member_emails + assert "org_member@example.com" in member_emails + + async def test_admin_organizations_requires_superuser(self, api_client: AsyncClient) -> None: + """Admin organization endpoints require superuser role.""" + response = await api_client.get("/admin/organizations") + + assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) diff --git a/backend/tests/integration/api/auth/test_oauth_callbacks.py b/backend/tests/integration/api/auth/test_oauth_callbacks.py new file mode 100644 index 00000000..e8ad1418 --- /dev/null +++ b/backend/tests/integration/api/auth/test_oauth_callbacks.py @@ -0,0 +1,124 @@ +"""OAuth callback and association flow tests.""" +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException, status +from fastapi_users.exceptions import UserAlreadyExists +from fastapi_users.router.common import ErrorCode + +from ._oauth_support import ( + TEST_EMAIL, + make_associate_builder, + make_associate_request_with_valid_state, + make_auth_builder, + make_request_with_valid_state, +) +from .shared import USER1_EMAIL, USER2_EMAIL + +if TYPE_CHECKING: + from collections.abc import Mapping + + +pytestmark = pytest.mark.api + + +class TestOAuthCallbackLinkingPolicy: + """Cover account-linking rules in the OAuth callback flow.""" + + async def test_callback_passes_associate_by_email_false(self) -> None: + """Disables implicit email-based account linking.""" + builder = make_auth_builder() + request, access_token_state = make_request_with_valid_state() + + user = MagicMock() + user.is_active = True + + user_manager = MagicMock() + user_manager.oauth_callback = AsyncMock(return_value=user) + user_manager.on_after_login = AsyncMock() + + strategy = MagicMock() + + response = await builder._get_callback_handler(request, access_token_state, user_manager, strategy) + assert response.status_code == status.HTTP_200_OK + assert user_manager.oauth_callback.await_args is not None + assert user_manager.oauth_callback.await_args.kwargs["associate_by_email"] is False + + async def test_callback_returns_stable_existing_user_error(self) -> None: + """Maps duplicate-user errors to the stable OAuth error code.""" + builder = make_auth_builder() + request, access_token_state = make_request_with_valid_state() + + user_manager = MagicMock() + user_manager.oauth_callback = AsyncMock(side_effect=UserAlreadyExists()) + user_manager.on_after_login = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await builder._get_callback_handler(request, access_token_state, user_manager, MagicMock()) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == ErrorCode.OAUTH_USER_ALREADY_EXISTS + + +class TestOAuthAssociateFlow: + """Cover linking an OAuth provider to the current user.""" + + async def test_associate_callback_links_provider_for_current_user(self) -> None: + """Associates the provider when it is not already linked elsewhere.""" + builder = make_associate_builder() + current_user = MagicMock() + current_user.id = USER1_EMAIL + current_user.email = TEST_EMAIL + + request, access_token_state = make_associate_request_with_valid_state(str(current_user.id)) + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = None + mock_exec_result = MagicMock() + mock_exec_result.scalars.return_value = mock_scalars_result + mock_session.execute = AsyncMock(return_value=mock_exec_result) + + user_manager = MagicMock() + user_manager.user_db.session = mock_session + user_manager.oauth_associate_callback = AsyncMock(return_value=current_user) + + result = cast( + "Mapping[str, Any]", + await builder._get_callback_handler(request, current_user, access_token_state, user_manager), + ) + + assert result["email"] == TEST_EMAIL + assert user_manager.oauth_associate_callback.await_count == 1 + + async def test_associate_callback_rejects_provider_linked_to_other_user(self) -> None: + """Rejects association when the provider belongs to a different user.""" + builder = make_associate_builder() + current_user = MagicMock() + current_user.id = USER1_EMAIL + current_user.email = TEST_EMAIL + + request, access_token_state = make_associate_request_with_valid_state(str(current_user.id)) + existing_account = MagicMock() + existing_account.user_id = USER2_EMAIL + + mock_session = MagicMock() + mock_scalars_result = MagicMock() + mock_scalars_result.first.return_value = existing_account + mock_exec_result = MagicMock() + mock_exec_result.scalars.return_value = mock_scalars_result + mock_session.execute = AsyncMock(return_value=mock_exec_result) + + user_manager = MagicMock() + user_manager.user_db.session = mock_session + user_manager.oauth_associate_callback = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await builder._get_callback_handler(request, current_user, access_token_state, user_manager) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "This account is already linked to another user." diff --git a/backend/tests/integration/api/auth/test_oauth_helpers.py b/backend/tests/integration/api/auth/test_oauth_helpers.py new file mode 100644 index 00000000..b3a9ed1b --- /dev/null +++ b/backend/tests/integration/api/auth/test_oauth_helpers.py @@ -0,0 +1,88 @@ +"""OAuth helper and CSRF builder tests.""" + +from __future__ import annotations + +import secrets +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException, status +from fastapi_users.jwt import decode_jwt + +from app.api.auth.services.oauth import CSRF_TOKEN_KEY, OAuthCookieSettings, generate_csrf_token, generate_state_token + +from ._oauth_support import TEST_STATE_JWT_SECRET, make_base_builder +from .shared import FRONTEND_REDIRECT_URI, JWT_DOT_COUNT + +pytestmark = pytest.mark.api + + +class TestOAuthHelpers: + """Cover standalone OAuth token helpers.""" + + def test_generate_csrf_token_is_url_safe_string(self) -> None: + """Generates a non-empty CSRF token string.""" + token = generate_csrf_token() + assert isinstance(token, str) + assert len(token) > 0 + + def test_generate_csrf_token_is_unique(self) -> None: + """Generates distinct CSRF tokens on repeated calls.""" + assert generate_csrf_token() != generate_csrf_token() + + def test_generate_state_token_returns_jwt(self) -> None: + """Encodes state data as a JWT.""" + token = generate_state_token({CSRF_TOKEN_KEY: "test-csrf"}, TEST_STATE_JWT_SECRET) + assert isinstance(token, str) + assert token.count(".") == JWT_DOT_COUNT + + def test_generate_state_token_embeds_csrf(self) -> None: + """Embeds the CSRF token in the generated state JWT.""" + csrf = secrets.token_urlsafe(16) + token = generate_state_token({CSRF_TOKEN_KEY: csrf}, TEST_STATE_JWT_SECRET) + decoded = decode_jwt(token, TEST_STATE_JWT_SECRET, ["fastapi-users:oauth-state"]) + assert decoded[CSRF_TOKEN_KEY] == csrf + + +class TestOAuthRouterBuilderCSRF: + """Cover CSRF verification in the OAuth router builder.""" + + def test_verify_state_raises_on_invalid_jwt(self) -> None: + """Rejects state values that are not valid JWTs.""" + builder = make_base_builder() + mock_request = MagicMock() + mock_request.cookies = {} + + with pytest.raises(HTTPException) as exc_info: + builder.verify_state(mock_request, "not-a-valid-jwt") + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + + def test_verify_state_raises_on_csrf_mismatch(self) -> None: + """Rejects state values whose CSRF token does not match the cookie.""" + builder = make_base_builder() + csrf_token = generate_csrf_token() + state = generate_state_token({CSRF_TOKEN_KEY: csrf_token}, TEST_STATE_JWT_SECRET) + mock_request = MagicMock() + mock_request.cookies = {OAuthCookieSettings.name: "wrong-csrf-token"} + + with pytest.raises(HTTPException) as exc_info: + builder.verify_state(mock_request, state) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + + def test_verify_state_succeeds_with_matching_csrf(self) -> None: + """Accepts state values with a matching CSRF cookie.""" + builder = make_base_builder() + csrf_token = generate_csrf_token() + state = generate_state_token( + {CSRF_TOKEN_KEY: csrf_token, "frontend_redirect_uri": FRONTEND_REDIRECT_URI}, + TEST_STATE_JWT_SECRET, + ) + mock_request = MagicMock() + mock_request.cookies = {OAuthCookieSettings.name: csrf_token} + + state_data = builder.verify_state(mock_request, state) + + assert state_data[CSRF_TOKEN_KEY] == csrf_token + assert state_data["frontend_redirect_uri"] == FRONTEND_REDIRECT_URI diff --git a/backend/tests/integration/api/auth/test_oauth_redirects.py b/backend/tests/integration/api/auth/test_oauth_redirects.py new file mode 100644 index 00000000..fd58fe87 --- /dev/null +++ b/backend/tests/integration/api/auth/test_oauth_redirects.py @@ -0,0 +1,146 @@ +"""OAuth redirect validation tests.""" +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi import HTTPException, Response, status + +from ._oauth_support import make_auth_builder, make_base_builder + +pytestmark = pytest.mark.api + + +class TestOAuthRedirectValidation: + """Cover redirect-uri validation and redirect rewriting.""" + + async def test_authorize_rejects_untrusted_redirect_uri(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Rejects redirect URIs outside the configured allowlist.""" + builder = make_auth_builder() + + monkeypatch.setattr( + "app.api.auth.services.oauth.base.core_settings.allowed_origins", + ["https://app.example.com"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.cors_origin_regex", None) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.settings.oauth_allowed_redirect_paths", + ["/auth/callback"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.settings.oauth_allowed_native_redirect_uris", []) + + mock_request = MagicMock() + mock_request.query_params = {"redirect_uri": "https://evil.example.org/auth/callback"} + mock_request.url_for.return_value = "https://api.example.com/auth/oauth/callback" + + with pytest.raises(HTTPException) as exc_info: + await builder._get_authorize_handler(mock_request, Response(), scopes=None) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "Invalid redirect_uri" + + async def test_authorize_accepts_trusted_redirect_uri(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Accepts a trusted HTTPS redirect URI.""" + builder = make_auth_builder() + + monkeypatch.setattr( + "app.api.auth.services.oauth.base.core_settings.allowed_origins", + ["https://app.example.com"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.cors_origin_regex", None) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.settings.oauth_allowed_redirect_paths", + ["/auth/callback"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.settings.oauth_allowed_native_redirect_uris", []) + + mock_request = MagicMock() + mock_request.query_params = {"redirect_uri": "https://app.example.com/auth/callback"} + mock_request.url_for.return_value = "https://api.example.com/auth/oauth/callback" + + result = await builder._get_authorize_handler(mock_request, Response(), scopes=None) + assert result.authorization_url == "https://github.com/login/oauth/authorize" + + async def test_authorize_accepts_dev_regex_redirect_uri(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Accepts a development redirect URI matched by the regex.""" + builder = make_auth_builder() + + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.allowed_origins", []) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.core_settings.cors_origin_regex", + r"https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+)(:\d+)?", + ) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.settings.oauth_allowed_redirect_paths", + ["/auth/callback"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.settings.oauth_allowed_native_redirect_uris", []) + + mock_request = MagicMock() + mock_request.query_params = {"redirect_uri": "http://192.168.1.50:3000/auth/callback"} + mock_request.url_for.return_value = "https://api.example.com/auth/oauth/callback" + + result = await builder._get_authorize_handler(mock_request, Response(), scopes=None) + assert result.authorization_url == "https://github.com/login/oauth/authorize" + + async def test_authorize_accepts_allowlisted_native_redirect_uri(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Accepts an explicitly allowlisted native redirect URI.""" + builder = make_auth_builder() + + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.allowed_origins", []) + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.cors_origin_regex", None) + monkeypatch.setattr("app.api.auth.services.oauth.base.settings.oauth_allowed_redirect_paths", []) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.settings.oauth_allowed_native_redirect_uris", + ["relab://oauth-callback"], + ) + + mock_request = MagicMock() + mock_request.query_params = {"redirect_uri": "relab://oauth-callback"} + mock_request.url_for.return_value = "https://api.example.com/auth/oauth/callback" + + result = await builder._get_authorize_handler(mock_request, Response(), scopes=None) + assert result.authorization_url == "https://github.com/login/oauth/authorize" + + async def test_authorize_rejects_redirect_uri_with_embedded_credentials( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Rejects redirect URIs containing embedded credentials.""" + builder = make_auth_builder() + + monkeypatch.setattr( + "app.api.auth.services.oauth.base.core_settings.allowed_origins", + ["https://app.example.com"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.core_settings.cors_origin_regex", None) + monkeypatch.setattr( + "app.api.auth.services.oauth.base.settings.oauth_allowed_redirect_paths", + ["/auth/callback"], + ) + monkeypatch.setattr("app.api.auth.services.oauth.base.settings.oauth_allowed_native_redirect_uris", []) + + mock_request = MagicMock() + mock_request.query_params = {"redirect_uri": "https://user:pass@app.example.com/auth/callback"} + mock_request.url_for.return_value = "https://api.example.com/auth/oauth/callback" + + with pytest.raises(HTTPException) as exc_info: + await builder._get_authorize_handler(mock_request, Response(), scopes=None) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "Invalid redirect_uri" + + def test_success_redirect_removes_access_token_from_query(self) -> None: + """Strips leaked access tokens from success redirects.""" + builder = make_base_builder() + + response = builder._create_success_redirect( + "https://app.example.com/auth/callback?foo=bar&access_token=leaky", + Response(), + ) + + query = parse_qs(urlparse(response.headers["location"]).query) + assert "access_token" not in query + assert query.get("success") == ["true"] diff --git a/backend/tests/integration/api/auth/test_refresh.py b/backend/tests/integration/api/auth/test_refresh.py new file mode 100644 index 00000000..06539ccc --- /dev/null +++ b/backend/tests/integration/api/auth/test_refresh.py @@ -0,0 +1,149 @@ +"""Refresh-token endpoint integration tests.""" + +from __future__ import annotations + +from http.cookies import SimpleCookie +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from app.api.auth.services.refresh_token_service import create_refresh_token +from app.api.auth.services.user_database import UserDatabaseAsync +from tests.factories.models import UserFactory + +from .shared import INVALID_REFRESH_TOKEN, hash_test_password + +if TYPE_CHECKING: + from httpx import AsyncClient + from redis.asyncio import Redis + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.api + + +class TestRefreshTokenEndpoint: + """Tests for custom refresh token endpoints.""" + + async def test_cookie_refresh_token_requires_cookie( + self, api_client: AsyncClient, mock_redis_dependency: Redis + ) -> None: + """Test that the cookie refresh endpoint requires a refresh token cookie.""" + del mock_redis_dependency + response = await api_client.post("/auth/cookie/refresh") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_bearer_refresh_token_invalid(self, api_client: AsyncClient, mock_redis_dependency: Redis) -> None: + """Test that the bearer refresh endpoint rejects invalid refresh tokens.""" + del mock_redis_dependency + response = await api_client.post("/auth/refresh", json={"refresh_token": INVALID_REFRESH_TOKEN}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_bearer_refresh_rotates_and_replay_fails( + self, + api_client: AsyncClient, + mock_redis_dependency: Redis, + db_session: AsyncSession, + ) -> None: + """Test that the bearer refresh endpoint rotates refresh tokens and prevents replay.""" + user = await UserFactory.create_async( + db_session, + email="refresh-rotation@example.com", + username="refresh_rotation_user", + hashed_password=hash_test_password("pw"), + is_active=True, + is_verified=True, + ) + assert user.id is not None + + old_refresh_token = await create_refresh_token(mock_redis_dependency, user.id) + + response = await api_client.post("/auth/refresh", json={"refresh_token": old_refresh_token}) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + new_refresh_token = data["refresh_token"] + assert new_refresh_token != old_refresh_token + + replay_response = await api_client.post("/auth/refresh", json={"refresh_token": old_refresh_token}) + assert replay_response.status_code == status.HTTP_401_UNAUTHORIZED + + second_refresh = await api_client.post("/auth/refresh", json={"refresh_token": new_refresh_token}) + assert second_refresh.status_code == status.HTTP_200_OK + + async def test_bearer_refresh_uses_injected_request_session( + self, + api_client: AsyncClient, + mock_redis_dependency: Redis, + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Refresh must resolve users through the injected FastAPI test session.""" + user = await UserFactory.create_async( + db_session, + email="refresh-session@example.com", + username="refresh_session_user", + hashed_password=hash_test_password("pw"), + is_active=True, + is_verified=True, + ) + refresh_token = await create_refresh_token(mock_redis_dependency, user.id) + + original_get = UserDatabaseAsync.get + + async def asserted_get(self: UserDatabaseAsync, id: object) -> object | None: # noqa: A002 + assert self.session is db_session + return await original_get(self, id) + + monkeypatch.setattr(UserDatabaseAsync, "get", asserted_get) + + response = await api_client.post("/auth/refresh", json={"refresh_token": refresh_token}) + + assert response.status_code == status.HTTP_200_OK + + async def test_cookie_refresh_rotates_and_replay_fails( + self, + api_client: AsyncClient, + mock_redis_dependency: Redis, + db_session: AsyncSession, + ) -> None: + """Test that the cookie refresh endpoint rotates refresh tokens and prevents replay.""" + user = await UserFactory.create_async( + db_session, + email="cookie-refresh-rotation@example.com", + username="cookie_refresh_rotation_user", + hashed_password=hash_test_password("pw"), + is_active=True, + is_verified=True, + ) + assert user.id is not None + + old_refresh_token = await create_refresh_token(mock_redis_dependency, user.id) + + api_client.cookies.set("refresh_token", old_refresh_token) + first_response = await api_client.post("/auth/cookie/refresh") + assert first_response.status_code == status.HTTP_204_NO_CONTENT + + parsed_cookies = SimpleCookie() + for header in first_response.headers.get_list("set-cookie"): + parsed_cookies.load(header) + + assert "refresh_token" in parsed_cookies + new_refresh_token = parsed_cookies["refresh_token"].value + assert new_refresh_token + assert new_refresh_token != old_refresh_token + + api_client.cookies.clear() + api_client.cookies.set("refresh_token", old_refresh_token) + replay_response = await api_client.post("/auth/cookie/refresh") + assert replay_response.status_code == status.HTTP_401_UNAUTHORIZED + + api_client.cookies.clear() + api_client.cookies.set("refresh_token", new_refresh_token) + second_response = await api_client.post("/auth/cookie/refresh") + assert second_response.status_code == status.HTTP_204_NO_CONTENT + + api_client.cookies.clear() diff --git a/backend/tests/integration/api/auth/test_registration_login.py b/backend/tests/integration/api/auth/test_registration_login.py new file mode 100644 index 00000000..00a1067d --- /dev/null +++ b/backend/tests/integration/api/auth/test_registration_login.py @@ -0,0 +1,309 @@ +"""Registration, login, logout, and auth rate-limit tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import status +from fastapi_users.exceptions import UserAlreadyExists + +from app.api.auth.exceptions import DisposableEmailError, UserNameAlreadyExistsError +from app.api.auth.schemas import UserCreate +from app.api.auth.services.user_database import UserDatabaseAsync + +from .shared import ( + COOKIE_EMAIL, + COOKIE_USERNAME, + DIFFERENT_EMAIL, + DISPOSABLE_EMAIL, + DUPLICATE_EMAIL, + EXISTING_USERNAME, + INVALID_EMAIL, + INVALID_PASSWORD, + LOGIN_EMAIL, + LOGIN_USERNAME, + ORG_DESC, + ORG_LOCATION, + ORG_NAME, + OWNER_EMAIL, + TEST_EMAIL, + TEST_PASSWORD, + TEST_USERNAME, + UNIQUE_USERNAME, + WEAK_PASSWORD, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.api + + +class TestRegistrationEndpoint: + """Tests for the /auth/register endpoint.""" + + async def test_register_success(self, api_client: AsyncClient) -> None: + """Test successful user registration.""" + user_data = {"email": TEST_EMAIL, "password": TEST_PASSWORD, "username": TEST_USERNAME} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["email"] == user_data["email"] + assert data["username"] == user_data["username"] + assert "password" not in data + assert "hashed_password" not in data + + async def test_register_duplicate_email(self, api_client: AsyncClient) -> None: + """Test registering with a duplicate email.""" + user_data = {"email": DUPLICATE_EMAIL, "password": TEST_PASSWORD, "username": UNIQUE_USERNAME} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + await api_client.post("/auth/register", json=user_data) + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + + with patch("app.api.auth.dependencies.get_user_manager") as mock_get_manager: + mock_manager = AsyncMock() + mock_manager.create.side_effect = UserAlreadyExists() + + async def get_manager() -> AsyncGenerator[AsyncMock]: + yield mock_manager + + mock_get_manager.return_value = get_manager() + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_409_CONFLICT + assert "already exists" in response.json()["detail"].lower() + + async def test_register_duplicate_username(self, api_client: AsyncClient) -> None: + """Test registering with a duplicate username.""" + user_data = {"email": DIFFERENT_EMAIL, "password": TEST_PASSWORD, "username": EXISTING_USERNAME} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.side_effect = UserNameAlreadyExistsError(user_data["username"]) + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_409_CONFLICT + assert "username" in response.json()["detail"].lower() + + async def test_register_disposable_email(self, api_client: AsyncClient) -> None: + """Test registering with a disposable email.""" + user_data = {"email": DISPOSABLE_EMAIL, "password": TEST_PASSWORD, "username": "tempuser"} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.side_effect = DisposableEmailError(user_data["email"]) + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "disposable" in response.json()["detail"].lower() + + async def test_register_weak_password(self, api_client: AsyncClient) -> None: + """Test registering with a weak password.""" + user_data = {"email": "user@example.com", "password": WEAK_PASSWORD, "username": "user"} + response = await api_client.post("/auth/register", json=user_data) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + async def test_register_with_organization(self, api_client: AsyncClient) -> None: + """Test registering with an organization.""" + user_data = { + "email": OWNER_EMAIL, + "password": TEST_PASSWORD, + "username": "owner", + "organization": {"name": ORG_NAME, "location": ORG_LOCATION, "description": ORG_DESC}, + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_201_CREATED + + async def test_register_uses_injected_request_session( + self, + api_client: AsyncClient, + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Registration should use the FastAPI-injected test session for user lookups.""" + user_data = { + "email": "session-register@example.com", + "password": TEST_PASSWORD, + "username": "session_register", + } + + original_get_by_email = UserDatabaseAsync.get_by_email + + async def asserted_get_by_email(self: UserDatabaseAsync, email: str) -> object | None: + assert self.session is db_session + return await original_get_by_email(self, email) + + monkeypatch.setattr(UserDatabaseAsync, "get_by_email", asserted_get_by_email) + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + response = await api_client.post("/auth/register", json=user_data) + + assert response.status_code == status.HTTP_201_CREATED + + +class TestLoginEndpoint: + """Tests for FastAPI-Users login endpoints.""" + + async def test_bearer_login_with_email(self, api_client: AsyncClient) -> None: + """Test logging in with email and password to get bearer tokens.""" + user_data = {"email": LOGIN_EMAIL, "password": TEST_PASSWORD, "username": LOGIN_USERNAME} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + await api_client.post("/auth/register", json=user_data) + + response = await api_client.post( + "/auth/bearer/login", + data={"username": user_data["email"], "password": user_data["password"]}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "access_token" in data + assert "refresh_token" in response.cookies or "set-cookie" in response.headers + + async def test_bearer_login_invalid_credentials(self, api_client: AsyncClient) -> None: + """Test logging in with invalid credentials.""" + response = await api_client.post( + "/auth/bearer/login", + data={"username": INVALID_EMAIL, "password": INVALID_PASSWORD}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_cookie_login(self, api_client: AsyncClient) -> None: + """Test logging in with email and password to get session cookies.""" + user_data = {"email": COOKIE_EMAIL, "password": TEST_PASSWORD, "username": COOKIE_USERNAME} + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + await api_client.post("/auth/register", json=user_data) + + response = await api_client.post( + "/auth/cookie/login", + data={"username": user_data["email"], "password": user_data["password"]}, + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert len(response.cookies) > 0 or "set-cookie" in response.headers + + async def test_current_user_resolution_uses_injected_request_session( + self, + api_client: AsyncClient, + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Current-user auth resolution should use the injected request session.""" + user_data = { + "email": "me-session@example.com", + "password": TEST_PASSWORD, + "username": "me_session", + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_create_override: + mock_create_override.return_value = UserCreate( + email=user_data["email"], + password=user_data["password"], + username=user_data["username"], + ) + registration_response = await api_client.post("/auth/register", json=user_data) + + assert registration_response.status_code == status.HTTP_201_CREATED + + original_get = UserDatabaseAsync.get + + async def asserted_get(self: UserDatabaseAsync, id: object) -> object | None: # noqa: A002 + assert self.session is db_session + return await original_get(self, id) + + monkeypatch.setattr(UserDatabaseAsync, "get", asserted_get) + + login_response = await api_client.post( + "/auth/bearer/login", + data={"username": user_data["email"], "password": TEST_PASSWORD}, + ) + + if login_response.status_code != status.HTTP_200_OK: + pytest.skip("Bearer login did not return an access token response") + + access_token = login_response.json().get("access_token") + if not access_token: + pytest.skip("Bearer login did not return an access token") + + response = await api_client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) + + assert response.status_code == status.HTTP_200_OK + + +class TestLogoutEndpoint: + """Tests for FastAPI-Users logout endpoints.""" + + async def test_bearer_logout_unauthenticated(self, api_client: AsyncClient) -> None: + """Test logging out of bearer auth without credentials.""" + response = await api_client.post("/auth/bearer/logout") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_cookie_logout(self, api_client: AsyncClient) -> None: + """Test logging out of cookie auth.""" + response = await api_client.post("/auth/cookie/logout") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestRateLimiting: + """Tests for rate limiting on auth endpoints.""" + + async def test_login_rate_limit_disabled_in_tests(self, api_client: AsyncClient) -> None: + """Test that the login endpoint does not enforce rate limits in the test environment.""" + responses = [] + for _ in range(10): + response = await api_client.post( + "/auth/bearer/login", + data={"username": INVALID_EMAIL, "password": "WrongPassword"}, + ) + responses.append(response.status_code) + + assert status.HTTP_429_TOO_MANY_REQUESTS not in responses, f"Rate limiting not disabled: {responses}" diff --git a/backend/tests/integration/api/auth/test_user_updates.py b/backend/tests/integration/api/auth/test_user_updates.py new file mode 100644 index 00000000..f6c21bcc --- /dev/null +++ b/backend/tests/integration/api/auth/test_user_updates.py @@ -0,0 +1,95 @@ +"""User update validation and endpoint tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest +from fastapi import status + +from app.api.auth.crud.users import update_user_override +from app.api.auth.exceptions import UserNameAlreadyExistsError +from app.api.auth.schemas import UserUpdate +from tests.factories.models import UserFactory + +from .shared import NEW_USERNAME, TAKEN_USERNAME, USER1_EMAIL, USER1_USERNAME, USER2_EMAIL, USER2_USERNAME + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.api + + +class TestUpdateUserValidation: + """Integration tests for update_user_override() username uniqueness logic.""" + + async def test_update_username_to_available_name_succeeds(self, db_session: AsyncSession) -> None: + """Updating to an available username should succeed.""" + user = await UserFactory.create_async( + db_session, + email=USER1_EMAIL, + username=USER1_USERNAME, + hashed_password="pw", + ) + user_db = MagicMock() + user_db.session = db_session + result = await update_user_override(user_db, user, UserUpdate(username=NEW_USERNAME)) + assert result.username == NEW_USERNAME + + async def test_update_username_to_same_name_succeeds(self, db_session: AsyncSession) -> None: + """Updating to the same username should succeed.""" + user = await UserFactory.create_async( + db_session, + email=USER1_EMAIL, + username=USER1_USERNAME, + hashed_password="pw", + ) + user_db = MagicMock() + user_db.session = db_session + result = await update_user_override(user_db, user, UserUpdate(username=USER1_USERNAME)) + assert result.username == USER1_USERNAME + + async def test_update_username_to_taken_name_raises(self, db_session: AsyncSession) -> None: + """Updating to a taken username should raise an error.""" + await UserFactory.create_async(db_session, email=USER1_EMAIL, username=TAKEN_USERNAME, hashed_password="pw") + user2 = await UserFactory.create_async( + db_session, + email=USER2_EMAIL, + username=USER2_USERNAME, + hashed_password="pw", + ) + user_db = MagicMock() + user_db.session = db_session + + with pytest.raises(UserNameAlreadyExistsError): + await update_user_override(user_db, user2, UserUpdate(username=TAKEN_USERNAME)) + + async def test_update_without_username_change_passes_through(self, db_session: AsyncSession) -> None: + """Updating without changing the username should pass through.""" + user = await UserFactory.create_async( + db_session, + email=USER1_EMAIL, + username=USER1_USERNAME, + hashed_password="pw", + ) + user_db = MagicMock() + user_db.session = db_session + result = await update_user_override(user_db, user, UserUpdate(username=None)) + assert result.username is None + + +class TestUpdateUserEndpoint: + """Integration tests for the user update API endpoint.""" + + async def test_update_user_unauthenticated_returns_401(self, api_client: AsyncClient) -> None: + """Test that updating a user without authentication returns 401.""" + response = await api_client.patch("/users/me", json={"username": "any_name"}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_get_me_unauthenticated_returns_401(self, api_client: AsyncClient) -> None: + """Test that getting user info without authentication returns 401.""" + response = await api_client.get("/users/me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/backend/tests/integration/api/conftest.py b/backend/tests/integration/api/conftest.py new file mode 100644 index 00000000..a4ae54c8 --- /dev/null +++ b/backend/tests/integration/api/conftest.py @@ -0,0 +1 @@ +"""API integration-test fixture loading.""" diff --git a/backend/tests/integration/api/test_background_data_endpoints.py b/backend/tests/integration/api/test_background_data_endpoints.py new file mode 100644 index 00000000..aac1556d --- /dev/null +++ b/backend/tests/integration/api/test_background_data_endpoints.py @@ -0,0 +1,193 @@ +"""Integration tests for background-data HTTP contracts.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from dirty_equals import IsInt, IsPositive, IsStr +from fastapi import status + +from app.api.background_data.models import TaxonomyDomain +from tests.factories.models import CategoryFactory, TaxonomyFactory + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.background_data.models import Category, Taxonomy + +TAXONOMY_NAME = "Test API Taxonomy" +TAXONOMY_VERSION = "v1.0.0" +TAXONOMY_DESC = "Created via API" +PARENT_CATEGORY = "Parent Category" +CHILD_CATEGORY = "Child Category" +NONEXISTENT_ID = "99999" + +pytestmark = pytest.mark.api + + +async def test_create_taxonomy_contract(api_client_superuser: AsyncClient) -> None: + """Admin taxonomy creation should return the created resource contract.""" + response = await api_client_superuser.post( + "/admin/taxonomies", + json={ + "name": TAXONOMY_NAME, + "version": TAXONOMY_VERSION, + "description": TAXONOMY_DESC, + "domains": ["materials"], + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == TAXONOMY_NAME + + +async def test_get_taxonomy_returns_expected_shape(api_client: AsyncClient, db_taxonomy: Taxonomy) -> None: + """Public taxonomy reads should expose the stable response contract.""" + response = await api_client.get(f"/taxonomies/{db_taxonomy.id}") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "id": IsInt & IsPositive, + "name": IsStr, + "version": IsStr | None, + "description": IsStr | None, + "domains": ["materials"], + "source": IsStr | None, + "created_at": IsStr, + "updated_at": IsStr, + } + + +async def test_unknown_taxonomy_returns_404(api_client: AsyncClient) -> None: + """Missing taxonomies should return 404.""" + response = await api_client.get(f"/taxonomies/{NONEXISTENT_ID}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_list_taxonomies_returns_paginated_items( + api_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Listing taxonomies should return paginated data once rows exist.""" + for index in range(2): + await TaxonomyFactory.create_async( + db_session, + name=f"Taxonomy {index}", + version=f"v{index}.0.0", + domains={TaxonomyDomain.MATERIALS}, + ) + + response = await api_client.get("/taxonomies") + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["items"]) >= 2 + + +async def test_taxonomy_category_endpoints_return_flat_and_tree_views( + api_client: AsyncClient, + db_session: AsyncSession, + db_taxonomy: Taxonomy, +) -> None: + """Taxonomy category endpoints should expose both flat and nested read shapes.""" + parent = await CategoryFactory.create_async(db_session, taxonomy_id=db_taxonomy.id, name=PARENT_CATEGORY) + await CategoryFactory.create_async( + db_session, + taxonomy_id=db_taxonomy.id, + supercategory_id=parent.id, + name=CHILD_CATEGORY, + ) + + flat_response = await api_client.get(f"/taxonomies/{db_taxonomy.id}/categories") + tree_response = await api_client.get(f"/taxonomies/{db_taxonomy.id}/categories/tree?recursion_depth=2") + + assert flat_response.status_code == status.HTTP_200_OK + assert {item["name"] for item in flat_response.json()["items"]} >= {PARENT_CATEGORY, CHILD_CATEGORY} + assert tree_response.status_code == status.HTTP_200_OK + assert tree_response.json()["items"][0]["name"] == PARENT_CATEGORY + assert tree_response.json()["items"][0]["subcategories"][0]["name"] == CHILD_CATEGORY + + +async def test_category_tree_endpoints_return_bounded_recursive_children( + api_client: AsyncClient, + db_session: AsyncSession, + db_taxonomy: Taxonomy, +) -> None: + """Category tree endpoints return nested children without relying on lazy loads.""" + parent = await CategoryFactory.create_async(db_session, taxonomy_id=db_taxonomy.id, name=f"{PARENT_CATEGORY} Root") + child = await CategoryFactory.create_async( + db_session, + taxonomy_id=db_taxonomy.id, + supercategory_id=parent.id, + name=f"{CHILD_CATEGORY} Branch", + ) + + categories_tree = await api_client.get("/categories/tree?recursion_depth=2") + subtree = await api_client.get(f"/categories/{parent.id}/subcategories/tree?recursion_depth=1") + + assert categories_tree.status_code == status.HTTP_200_OK + parent_payload = next((item for item in categories_tree.json() if item["id"] == parent.id), None) + assert parent_payload is not None + assert [item["id"] for item in parent_payload["subcategories"]] == [child.id] + + assert subtree.status_code == status.HTTP_200_OK + assert [item["id"] for item in subtree.json()] == [child.id] + + +async def test_category_reads_support_conditional_get(api_client: AsyncClient, db_category: Category) -> None: + """Category detail responses should return 304 when the ETag matches.""" + first_response = await api_client.get(f"/categories/{db_category.id}") + second_response = await api_client.get( + f"/categories/{db_category.id}", + headers={"If-None-Match": first_response.headers["etag"]}, + ) + + assert first_response.status_code == status.HTTP_200_OK + assert second_response.status_code == status.HTTP_304_NOT_MODIFIED + + +async def test_admin_category_creation_supports_nested_subcategories( + api_client_superuser: AsyncClient, + db_taxonomy: Taxonomy, +) -> None: + """Admin category creation should accept nested subcategory payloads.""" + response = await api_client_superuser.post( + "/admin/categories", + json={ + "name": PARENT_CATEGORY, + "taxonomy_id": db_taxonomy.id, + "subcategories": [{"name": CHILD_CATEGORY}], + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == PARENT_CATEGORY + + +async def test_material_validation_rejects_negative_density(api_client_superuser: AsyncClient) -> None: + """Materials with negative density should fail schema validation.""" + response = await api_client_superuser.post( + "/admin/materials", + json={"name": "Bad Material", "density_kg_m3": -100.0}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +async def test_product_type_creation_returns_created_resource(api_client_superuser: AsyncClient) -> None: + """Admin product-type creation should return the created item.""" + response = await api_client_superuser.post( + "/admin/product-types", + json={"name": "Test API Product Type", "description": "Created via API"}, + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == "Test API Product Type" + + +async def test_units_endpoint_returns_available_units(api_client: AsyncClient) -> None: + """The units endpoint should return the supported unit values.""" + response = await api_client.get("/units") + assert response.status_code == status.HTTP_200_OK + assert "g" in response.json() or "kg" in response.json() diff --git a/backend/tests/integration/api/test_data_collection_brand_endpoints.py b/backend/tests/integration/api/test_data_collection_brand_endpoints.py new file mode 100644 index 00000000..90444ee7 --- /dev/null +++ b/backend/tests/integration/api/test_data_collection_brand_endpoints.py @@ -0,0 +1,127 @@ +"""Integration tests for brand listing endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from app.api.background_data.models import ProductType +from app.api.data_collection.models.product import Product +from tests.constants import ( + BRAND_X, +) + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + +pytestmark = pytest.mark.api + + +async def seed_brands( + db_session: AsyncSession, + owner_id: object, + *brands: str | None, +) -> None: + """Create multiple products sharing one product type with a single flush.""" + product_type = ProductType(name="Power Tool", description="Handheld electric tools for construction and DIY") + products = [ + Product( + owner_id=owner_id, + product_type=product_type, + brand=brand, + name=f"Brand Product {index}", + ) + for index, brand in enumerate(brands, start=1) + ] + db_session.add_all([product_type, *products]) + await db_session.flush() + + +async def test_get_brands(api_client_light: AsyncClient, setup_product: Product) -> None: + """GET /brands returns the unique brands from product data.""" + del setup_product + response = await api_client_light.get("/brands") + + assert response.status_code == status.HTTP_200_OK + assert BRAND_X in response.json()["items"] + + +async def test_returns_empty_when_no_products(api_client_light: AsyncClient) -> None: + """GET /brands returns an empty page when no products exist.""" + response = await api_client_light.get("/brands") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["items"] == [] + + +async def test_returns_brands(api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User) -> None: + """GET /brands title-cases product brands.""" + await seed_brands(db_session, db_superuser.id, "apple") + + response = await api_client_light.get("/brands") + + assert response.status_code == status.HTTP_200_OK + assert "Apple" in response.json()["items"] + + +async def test_deduplicates_brands(api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User) -> None: + """GET /brands collapses case-insensitive duplicates.""" + await seed_brands(db_session, db_superuser.id, "dell", "DELL") + + response = await api_client_light.get("/brands") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["items"].count("Dell") == 1 + + +async def test_excludes_null_brands( + api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User +) -> None: + """GET /brands excludes products without a brand.""" + await seed_brands(db_session, db_superuser.id, None) + + response = await api_client_light.get("/brands") + + assert response.status_code == status.HTTP_200_OK + assert None not in response.json()["items"] + + +async def test_search_filters_brands( + api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User +) -> None: + """GET /brands supports search filtering.""" + await seed_brands(db_session, db_superuser.id, "apple", "samsung") + + response = await api_client_light.get("/brands", params={"search": "apple"}) + + assert response.status_code == status.HTTP_200_OK + brands = response.json()["items"] + assert "Apple" in brands + assert "Samsung" not in brands + + +async def test_order_asc(api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User) -> None: + """GET /brands returns ascending order by default.""" + await seed_brands(db_session, db_superuser.id, "zebra", "apple") + + response = await api_client_light.get("/brands", params={"order": "asc"}) + + assert response.status_code == status.HTTP_200_OK + brands = response.json()["items"] + assert brands.index("Apple") < brands.index("Zebra") + + +async def test_order_desc(api_client_light: AsyncClient, db_session: AsyncSession, db_superuser: User) -> None: + """GET /brands returns descending order when requested.""" + await seed_brands(db_session, db_superuser.id, "zebra", "apple") + + response = await api_client_light.get("/brands", params={"order": "desc"}) + + assert response.status_code == status.HTTP_200_OK + brands = response.json()["items"] + assert brands.index("Zebra") < brands.index("Apple") diff --git a/backend/tests/integration/api/test_data_collection_component_endpoints.py b/backend/tests/integration/api/test_data_collection_component_endpoints.py new file mode 100644 index 00000000..457a7402 --- /dev/null +++ b/backend/tests/integration/api/test_data_collection_component_endpoints.py @@ -0,0 +1,85 @@ +"""Integration tests for component-focused data-collection endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from app.api.background_data.models import Material +from app.api.data_collection.models.product import Product +from tests.constants import ( + BOM_QUANTITY, + BOM_UNIT, + COMPONENT_AMOUNT, + COMPONENT_NAME, + NEW_COMPONENT_NAME, +) + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from tests.fixtures.data import ProductGraph + +pytestmark = pytest.mark.api + + +async def test_get_product_components(api_client: AsyncClient, setup_product_graph: ProductGraph) -> None: + """GET /products/{id}/components returns the direct children.""" + response = await api_client.get(f"/products/{setup_product_graph.product.id}/components") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 1 + assert data[0]["name"] == COMPONENT_NAME + + +async def test_get_product_component_by_id(api_client: AsyncClient, setup_product_graph: ProductGraph) -> None: + """GET /products/{pid}/components/{cid} returns the requested component.""" + response = await api_client.get( + f"/products/{setup_product_graph.product.id}/components/{setup_product_graph.component.id}" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == setup_product_graph.component.id + + +async def test_get_product_component_tree( + api_client: AsyncClient, + setup_product_graph: ProductGraph, +) -> None: + """GET /products/{id}/components/tree returns the bounded component subtree.""" + response = await api_client.get(f"/products/{setup_product_graph.product.id}/components/tree?recursion_depth=1") + + assert response.status_code == status.HTTP_200_OK + assert [item["id"] for item in response.json()] == [setup_product_graph.component.id] + + +async def test_add_component_to_product( + api_client_superuser: AsyncClient, db_session: AsyncSession, setup_product: Product +) -> None: + """POST /products/{id}/components adds a component.""" + material = Material(name="Steel") + db_session.add(material) + await db_session.flush() + payload = { + "name": NEW_COMPONENT_NAME, + "amount_in_parent": COMPONENT_AMOUNT, + "bill_of_materials": [{"material_id": material.id, "quantity": BOM_QUANTITY, "unit": BOM_UNIT}], + } + + response = await api_client_superuser.post(f"/products/{setup_product.id}/components", json=payload) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == NEW_COMPONENT_NAME + + +async def test_delete_product_component(api_client_superuser: AsyncClient, setup_product_graph: ProductGraph) -> None: + """DELETE /products/{pid}/components/{cid} removes the component.""" + response = await api_client_superuser.delete( + f"/products/{setup_product_graph.product.id}/components/{setup_product_graph.component.id}" + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/backend/tests/integration/api/test_data_collection_product_endpoints.py b/backend/tests/integration/api/test_data_collection_product_endpoints.py new file mode 100644 index 00000000..14a9a6c4 --- /dev/null +++ b/backend/tests/integration/api/test_data_collection_product_endpoints.py @@ -0,0 +1,195 @@ +"""Integration tests for product-focused data-collection endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from app.api.background_data.models import Material, ProductType +from app.api.common.models.enums import Unit +from app.api.data_collection.models.product import MaterialProductLink, Product +from tests.constants import ( + BOM_QUANTITY, + BOM_UNIT, + BRAND_X, + END_TIME, + HEIGHT_10, + NEW_PRODUCT_NAME, + PRODUCT_BASE_NAME, + PRODUCT_DESC, + RECYCLABILITY_GOOD, + START_TIME, + UPDATED_PRODUCT_NAME, + WEIGHT_500, +) + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + from tests.fixtures.data import ProductGraph + +pytestmark = pytest.mark.api + + +async def test_get_products(api_client: AsyncClient, db_session: AsyncSession, db_superuser: User) -> None: + """GET /products returns the current product page.""" + product_type = ProductType(name="Power Tool", description="Handheld electric tools for construction and DIY") + product = Product( + owner_id=db_superuser.id, + name=PRODUCT_BASE_NAME, + brand=BRAND_X, + dismantling_time_start=START_TIME, + dismantling_time_end=END_TIME, + product_type=product_type, + ) + db_session.add_all([product_type, product]) + await db_session.flush() + + response = await api_client.get("/products") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["items"] + assert data["items"][0]["name"] == PRODUCT_BASE_NAME + + +async def test_get_products_tree(api_client: AsyncClient, setup_product: Product) -> None: + """GET /products/tree returns the product hierarchy.""" + response = await api_client.get("/products/tree?recursion_depth=1") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert isinstance(data, list) + if data: + tree_product = next((item for item in data if item["id"] == setup_product.id), None) + assert tree_product is not None + assert tree_product["name"] == PRODUCT_BASE_NAME + + +async def test_get_products_tree_includes_nested_components_without_async_lazy_loads( + api_client: AsyncClient, + setup_product_graph: ProductGraph, +) -> None: + """GET /products/tree returns nested components at bounded depth without crashing.""" + response = await api_client.get("/products/tree?recursion_depth=2") + + assert response.status_code == status.HTTP_200_OK + tree_product = next((item for item in response.json() if item["id"] == setup_product_graph.product.id), None) + assert tree_product is not None + assert [component["id"] for component in tree_product["components"]] == [setup_product_graph.component.id] + + +async def test_get_product_by_id(api_client: AsyncClient, setup_product: Product) -> None: + """GET /products/{id} returns the requested product.""" + response = await api_client.get(f"/products/{setup_product.id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == setup_product.id + assert data["name"] == PRODUCT_BASE_NAME + + +async def test_get_product_by_id_supports_conditional_get(api_client: AsyncClient, setup_product: Product) -> None: + """GET /products/{id} returns 304 when the entity tag matches.""" + first_response = await api_client.get(f"/products/{setup_product.id}") + assert first_response.status_code == status.HTTP_200_OK + assert "etag" in first_response.headers + + second_response = await api_client.get( + f"/products/{setup_product.id}", + headers={"If-None-Match": first_response.headers["etag"]}, + ) + + assert second_response.status_code == status.HTTP_304_NOT_MODIFIED + + +async def test_validate_product_tree( + api_client: AsyncClient, + db_session: AsyncSession, + db_superuser: User, +) -> None: + """POST /products/{id}/validate handles a fully loaded tree.""" + product_type = ProductType(name="Power Tool", description="Handheld electric tools for construction and DIY") + root = Product( + owner_id=db_superuser.id, + name=f"{PRODUCT_BASE_NAME} Root", + product_type=product_type, + ) + child = Product( + owner_id=db_superuser.id, + name=f"{PRODUCT_BASE_NAME} Child", + parent=root, + product_type=product_type, + ) + material = Material(name="Steel") + db_session.add_all( + [ + product_type, + root, + child, + material, + MaterialProductLink( + material=material, + product=child, + quantity=1.0, + unit=Unit.GRAM, + ), + ] + ) + await db_session.flush() + + response = await api_client.post(f"/products/{root.id}/validate") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["valid"] is True + + +async def test_create_product(api_client_superuser: AsyncClient, db_session: AsyncSession) -> None: + """POST /products creates a new product.""" + product_type = ProductType(name="Power Tool", description="Handheld electric tools for construction and DIY") + material = Material(name="Steel") + db_session.add_all([product_type, material]) + await db_session.flush() + payload = { + "name": NEW_PRODUCT_NAME, + "description": PRODUCT_DESC, + "product_type_id": product_type.id, + "weight_g": WEIGHT_500, + "height_cm": HEIGHT_10, + "recyclability_observation": RECYCLABILITY_GOOD, + "bill_of_materials": [{"material_id": material.id, "quantity": BOM_QUANTITY, "unit": BOM_UNIT}], + } + + response = await api_client_superuser.post("/products", json=payload) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == NEW_PRODUCT_NAME + assert "id" in data + + +async def test_update_product(api_client_superuser: AsyncClient, setup_product: Product) -> None: + """PATCH /products/{id} updates a product.""" + response = await api_client_superuser.patch(f"/products/{setup_product.id}", json={"name": UPDATED_PRODUCT_NAME}) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == UPDATED_PRODUCT_NAME + + +async def test_delete_product(api_client_superuser: AsyncClient, setup_product: Product) -> None: + """DELETE /products/{id} removes the product.""" + response = await api_client_superuser.delete(f"/products/{setup_product.id}") + + assert response.status_code == status.HTTP_204_NO_CONTENT + + +async def test_user_products_redirect(api_client_superuser: AsyncClient, db_superuser: User) -> None: + """GET /users/me/products follows the redirect to the user's products.""" + del db_superuser + response = await api_client_superuser.get("/users/me/products") + + assert response.status_code == status.HTTP_200_OK diff --git a/backend/tests/integration/api/test_error_responses.py b/backend/tests/integration/api/test_error_responses.py new file mode 100644 index 00000000..15fdf15d --- /dev/null +++ b/backend/tests/integration/api/test_error_responses.py @@ -0,0 +1,125 @@ +"""Integration tests for HTTP error response shapes. + +Verifies that authentication failures, authorisation failures, missing +resources, and invalid payloads all return the expected status codes and +response bodies. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import FastAPI, HTTPException, status + +from app.api.auth.dependencies import current_active_superuser +from tests.factories.models import UserFactory +from tests.fixtures.client import override_authenticated_user + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.mark.api +class TestUnauthenticated: + """Endpoints that require authentication must return 401 when no credentials are sent.""" + + async def test_create_product_without_auth_returns_401(self, api_client: AsyncClient) -> None: + """POST /products requires a verified user; anonymous request → 401.""" + response = await api_client.post("/products", json={}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_get_me_without_auth_returns_401_with_detail(self, api_client: AsyncClient) -> None: + """GET /users/me requires authentication and returns the standard error shape.""" + response = await api_client.get("/users/me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + body = response.json() + assert "detail" in body + + +@pytest.mark.api +class TestForbidden: + """Superuser-only endpoints must return 403 when called by an ordinary user.""" + + @pytest.fixture + async def regular_user_client( + self, + api_client: AsyncClient, + db_session: AsyncSession, + test_app: FastAPI, + ) -> AsyncGenerator[AsyncClient]: + """Authenticated client for a non-superuser. + + Overrides current_active_superuser to raise 403, simulating what + FastAPI-Users does when a verified-but-not-superuser hits a superuser + endpoint. + """ + + def raise_forbidden() -> None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + user = await UserFactory.create_async(session=db_session, is_superuser=False, is_active=True) + with override_authenticated_user(test_app, user, optional=False): + test_app.dependency_overrides[current_active_superuser] = raise_forbidden + yield api_client + test_app.dependency_overrides.pop(current_active_superuser, None) + + async def test_admin_taxonomy_create_as_regular_user_returns_403(self, regular_user_client: AsyncClient) -> None: + """POST /admin/taxonomies is superuser-only; regular user → 403.""" + data = {"name": "Forbidden Taxonomy", "version": "v1", "domains": ["materials"]} + response = await regular_user_client.post("/admin/taxonomies", json=data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + async def test_403_response_has_detail_key(self, regular_user_client: AsyncClient) -> None: + """403 responses must include a 'detail' key.""" + response = await regular_user_client.post("/admin/taxonomies", json={}) + assert response.status_code == status.HTTP_403_FORBIDDEN + body = response.json() + assert "detail" in body + + +@pytest.mark.api +class TestNotFound: + """Requests for non-existent resources must return 404.""" + + async def test_get_nonexistent_taxonomy_returns_404_with_detail(self, api_client_light: AsyncClient) -> None: + """GET /taxonomies/{id} with an id that does not exist returns the standard error shape.""" + response = await api_client_light.get("/taxonomies/999999") + assert response.status_code == status.HTTP_404_NOT_FOUND + body = response.json() + assert "detail" in body + + async def test_get_nonexistent_material_returns_404(self, api_client_light: AsyncClient) -> None: + """GET /materials/{id} with an id that does not exist → 404.""" + response = await api_client_light.get("/materials/999999") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.api +class TestUnprocessableEntity: + """Invalid request bodies must return 422 with structured error details.""" + + async def test_create_taxonomy_missing_required_fields_returns_structured_422( + self, api_client_superuser: AsyncClient + ) -> None: + """POST /admin/taxonomies with an empty body → 422 with validation error objects.""" + response = await api_client_superuser.post("/admin/taxonomies", json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + body = response.json() + assert "detail" in body + assert isinstance(body["detail"], list) + errors = body["detail"] + assert len(errors) > 0 + for error in errors: + assert "loc" in error, f"Missing 'loc' in error: {error}" + assert "msg" in error, f"Missing 'msg' in error: {error}" + assert "type" in error, f"Missing 'type' in error: {error}" + + async def test_create_material_with_negative_density_returns_422(self, api_client_superuser: AsyncClient) -> None: + """Materials with negative density must fail schema validation with 422.""" + data = {"name": "Bad Material", "density_kg_m3": -500.0} + response = await api_client_superuser.post("/admin/materials", json=data) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/backend/tests/integration/api/test_file_storage_endpoints.py b/backend/tests/integration/api/test_file_storage_endpoints.py new file mode 100644 index 00000000..c35b4374 --- /dev/null +++ b/backend/tests/integration/api/test_file_storage_endpoints.py @@ -0,0 +1,123 @@ +"""Integration tests for file storage endpoints.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from tests.factories.models import ProductFactory, ProductTypeFactory + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + from app.api.data_collection.models.product import Product + +# Constants for test values +PRODUCT_FILES_NAME = "Test Product Files" +FILE_NAME = "test.txt" +FILE_CONTENT = b"test content" +FILE_MIMETYPE = "text/plain" +FILE_DESC = "A test file description" +IMAGE_NAME = "image.gif" +IMAGE_MIMETYPE = "image/gif" +IMAGE_DESC = "A test image description" +IMAGE_METADATA = {"category": "test"} + +# 1x1 pixel transparent GIF (broken into parts to avoid long lines) +GIF_BYTES = ( + b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04" + b"\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;" +) + + +def _upload_request(kind: str) -> tuple[str, dict[str, tuple[str, bytes, str]], dict[str, str], str, str]: + """Return the endpoint and multipart payload for a file/image upload.""" + if kind == "file": + return ( + "files", + {"file": (FILE_NAME, FILE_CONTENT, FILE_MIMETYPE)}, + {"description": FILE_DESC}, + FILE_NAME, + "file_url", + ) + return ( + "images", + {"file": (IMAGE_NAME, GIF_BYTES, IMAGE_MIMETYPE)}, + {"description": IMAGE_DESC, "image_metadata": json.dumps(IMAGE_METADATA)}, + IMAGE_NAME, + "image_url", + ) + + +@pytest.fixture +async def setup_product_for_files(db_session: AsyncSession, db_superuser: User) -> Product: + """Fixture to set up a product for file storage testing.""" + pt = await ProductTypeFactory.create_async(session=db_session) + return await ProductFactory.create_async( + session=db_session, + owner_id=db_superuser.id, + product_type_id=pt.id, + name=PRODUCT_FILES_NAME, + ) + + +class TestFileStorageEndpoints: + """Tests for file storage API endpoints.""" + + @pytest.mark.parametrize( + ("kind", "description"), + [("file", FILE_DESC), ("images", IMAGE_DESC)], + ) + async def test_upload_media_returns_the_stored_contract( + self, + api_client_superuser: AsyncClient, + setup_product_for_files: Product, + kind: str, + description: str, + ) -> None: + """Uploading media should return the stored file/image contract.""" + request_kind = "file" if kind == "file" else "image" + endpoint, files, data, filename, url_field = _upload_request(request_kind) + response = await api_client_superuser.post( + f"/products/{setup_product_for_files.id}/{endpoint}", + files=files, + data=data, + ) + + assert response.status_code == status.HTTP_201_CREATED, response.text + resp_data = response.json() + assert resp_data["filename"].endswith(filename) + assert resp_data["description"] == description + assert url_field in resp_data + assert "id" in resp_data + + @pytest.mark.parametrize( + ("kind", "endpoint"), + [("file", "files"), ("image", "images")], + ) + async def test_delete_uploaded_media_removes_the_resource( + self, api_client_superuser: AsyncClient, setup_product_for_files: Product, kind: str, endpoint: str + ) -> None: + """Deleting uploaded media should make follow-up reads return 404.""" + _, files, data, _, _ = _upload_request(kind) + create_response = await api_client_superuser.post( + f"/products/{setup_product_for_files.id}/{endpoint}", + files=files, + data=data, + ) + media_id = create_response.json()["id"] + + response_del = await api_client_superuser.delete( + f"/products/{setup_product_for_files.id}/{endpoint}/{media_id}" + ) + response_get_deleted = await api_client_superuser.get( + f"/products/{setup_product_for_files.id}/{endpoint}/{media_id}" + ) + + assert response_del.status_code == status.HTTP_204_NO_CONTENT + assert response_get_deleted.status_code == status.HTTP_404_NOT_FOUND diff --git a/backend/tests/integration/api/test_newsletter_admin.py b/backend/tests/integration/api/test_newsletter_admin.py new file mode 100644 index 00000000..d7f65d74 --- /dev/null +++ b/backend/tests/integration/api/test_newsletter_admin.py @@ -0,0 +1,27 @@ +"""Behavior-focused tests for newsletter admin endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.newsletter.models import NewsletterSubscriber +from tests.integration.api._newsletter_support import HTTP_OK + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +async def test_admin_get_subscribers_returns_list(api_client_superuser: AsyncClient, db_session: AsyncSession) -> None: + """Test that the admin endpoint for getting newsletter subscribers returns a list of subscribers.""" + emails = ["sub1@example.com", "sub2@example.com"] + for email in emails: + db_session.add(NewsletterSubscriber(email=email, is_confirmed=True)) + await db_session.flush() + + response = await api_client_superuser.get("/admin/newsletter/subscribers") + + assert response.status_code == HTTP_OK + returned_emails = {subscriber["email"] for subscriber in response.json()["items"]} + for email in emails: + assert email in returned_emails diff --git a/backend/tests/integration/api/test_newsletter_preferences.py b/backend/tests/integration/api/test_newsletter_preferences.py new file mode 100644 index 00000000..a749a9d2 --- /dev/null +++ b/backend/tests/integration/api/test_newsletter_preferences.py @@ -0,0 +1,103 @@ +"""Behavior-focused tests for newsletter preference endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import FastAPI +from sqlalchemy import select + +from app.api.auth.models import User +from app.api.newsletter.models import NewsletterSubscriber +from tests.factories.models import UserFactory +from tests.fixtures.client import override_authenticated_user +from tests.integration.api._newsletter_support import HTTP_OK + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture +async def newsletter_user(db_session: AsyncSession) -> User: + """Create an active user for newsletter preference tests.""" + return await UserFactory.create_async(session=db_session, is_active=True) + + +@pytest.fixture +async def newsletter_user_client( + api_client: AsyncClient, newsletter_user: User, test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Provide an authenticated client for the newsletter user.""" + with override_authenticated_user(test_app, newsletter_user, verified=False, optional=False): + yield api_client + + +async def test_get_newsletter_preference_returns_subscribed_state( + newsletter_user_client: AsyncClient, + newsletter_user: User, + db_session: AsyncSession, +) -> None: + """Returns subscription state for a user with a confirmed subscriber record.""" + newsletter_user.email = "pref@example.com" + db_session.add(newsletter_user) + await db_session.flush() + + subscriber = NewsletterSubscriber(email=newsletter_user.email, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + response = await newsletter_user_client.get("/newsletter/me") + + assert response.status_code == HTTP_OK + assert response.json()["email"] == newsletter_user.email + assert response.json()["subscribed"] is True + assert response.json()["is_confirmed"] is True + + +async def test_enable_newsletter_preference_without_email_verification( + newsletter_user_client: AsyncClient, + newsletter_user: User, + db_session: AsyncSession, +) -> None: + """Enables newsletter delivery even when the user email is not verified.""" + newsletter_user.email = "signup@example.com" + db_session.add(newsletter_user) + await db_session.flush() + + response = await newsletter_user_client.put("/newsletter/me", json={"subscribed": True}) + + assert response.status_code == HTTP_OK + assert response.json()["subscribed"] is True + assert response.json()["is_confirmed"] is True + + stored = await db_session.execute( + select(NewsletterSubscriber).where(NewsletterSubscriber.email == newsletter_user.email) + ) + subscriber = stored.scalar_one_or_none() + assert subscriber is not None + assert subscriber.is_confirmed is True + + +async def test_disable_newsletter_preference_removes_subscriber( + newsletter_user_client: AsyncClient, + newsletter_user: User, + db_session: AsyncSession, +) -> None: + """Removes the subscriber record when a user opts out.""" + newsletter_user.email = "leave@example.com" + db_session.add(newsletter_user) + await db_session.flush() + + subscriber = NewsletterSubscriber(email=newsletter_user.email, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + response = await newsletter_user_client.put("/newsletter/me", json={"subscribed": False}) + + assert response.status_code == HTTP_OK + assert response.json()["subscribed"] is False + assert await db_session.get(NewsletterSubscriber, subscriber.id) is None diff --git a/backend/tests/integration/api/test_newsletter_public_endpoints.py b/backend/tests/integration/api/test_newsletter_public_endpoints.py new file mode 100644 index 00000000..1c0604eb --- /dev/null +++ b/backend/tests/integration/api/test_newsletter_public_endpoints.py @@ -0,0 +1,182 @@ +"""Behavior-focused tests for public newsletter endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from app.api.newsletter.models import NewsletterSubscriber +from app.api.newsletter.utils.tokens import JWTType, create_jwt_token +from tests.integration.api._newsletter_support import ( + EMAIL_CONFIRM_REQ, + EMAIL_CONFIRMED, + EMAIL_DELETE, + EMAIL_EXISTING, + EMAIL_NEW, + EMAIL_UNSUBSCRIBE, + HTTP_BAD_REQUEST, + HTTP_CREATED, + HTTP_NO_CONTENT, + HTTP_OK, + MSG_ALREADY_SUB, + MSG_NOT_CONFIRMED, + detail_text, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture +def mock_send_subscription_email() -> Generator[AsyncMock]: + """Mock newsletter subscription emails.""" + with patch("app.api.newsletter.routers.send_newsletter_subscription_email", new_callable=AsyncMock) as mocked: + yield mocked + + +@pytest.fixture +def mock_send_unsubscription_email() -> Generator[AsyncMock]: + """Mock newsletter unsubscription emails.""" + with patch( + "app.api.newsletter.routers.send_newsletter_unsubscription_request_email", new_callable=AsyncMock + ) as mocked: + yield mocked + + +async def test_subscribe_new_email(api_client: AsyncClient, mock_send_subscription_email: AsyncMock) -> None: + """Creates an unconfirmed subscriber for a new email address.""" + response = await api_client.post("/newsletter/subscribe", json=EMAIL_NEW) + assert response.status_code == HTTP_CREATED + data = response.json() + assert data["email"] == EMAIL_NEW + assert data["is_confirmed"] is False + mock_send_subscription_email.assert_called_once() + + +async def test_subscribe_existing_unconfirmed_email( + api_client: AsyncClient, + db_session: AsyncSession, + mock_send_subscription_email: AsyncMock, +) -> None: + """Rejects duplicate subscription requests for an unconfirmed subscriber.""" + subscriber = NewsletterSubscriber(email=EMAIL_EXISTING, is_confirmed=False) + db_session.add(subscriber) + await db_session.flush() + + response = await api_client.post("/newsletter/subscribe", json=EMAIL_EXISTING) + assert response.status_code == HTTP_BAD_REQUEST + assert MSG_NOT_CONFIRMED in detail_text(response.json()) + mock_send_subscription_email.assert_called_once() + + +async def test_subscribe_existing_confirmed_email( + api_client: AsyncClient, + db_session: AsyncSession, + mock_send_subscription_email: AsyncMock, +) -> None: + """Rejects duplicate subscription requests for a confirmed subscriber.""" + subscriber = NewsletterSubscriber(email=EMAIL_CONFIRMED, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + response = await api_client.post("/newsletter/subscribe", json=EMAIL_CONFIRMED) + assert response.status_code == HTTP_BAD_REQUEST + assert MSG_ALREADY_SUB in detail_text(response.json()) + mock_send_subscription_email.assert_not_called() + + +async def test_confirm_subscription_success(api_client: AsyncClient, db_session: AsyncSession) -> None: + """Confirms a pending newsletter subscription with a valid token.""" + email = EMAIL_CONFIRM_REQ + subscriber = NewsletterSubscriber(email=email, is_confirmed=False) + db_session.add(subscriber) + await db_session.flush() + + test_token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) + response = await api_client.post("/newsletter/confirm", json=test_token) + + assert response.status_code == HTTP_OK + assert response.json()["is_confirmed"] is True + + await db_session.refresh(subscriber) + assert subscriber.is_confirmed is True + + +async def test_confirm_subscription_invalid_token(api_client: AsyncClient) -> None: + """Rejects confirmation requests with an invalid token.""" + response = await api_client.post("/newsletter/confirm", json="invalid_token") + assert response.status_code == HTTP_BAD_REQUEST + + +async def test_request_unsubscribe_success( + api_client: AsyncClient, + db_session: AsyncSession, + mock_send_unsubscription_email: AsyncMock, +) -> None: + """Sends an unsubscribe email for an existing confirmed subscriber.""" + email = EMAIL_UNSUBSCRIBE + subscriber = NewsletterSubscriber(email=email, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + response = await api_client.post("/newsletter/request-unsubscribe", json=email) + assert response.status_code == HTTP_OK + mock_send_unsubscription_email.assert_called_once() + + +async def test_unsubscribe_with_token_success(api_client: AsyncClient, db_session: AsyncSession) -> None: + """Deletes a subscriber when given a valid unsubscribe token.""" + email = EMAIL_DELETE + subscriber = NewsletterSubscriber(email=email, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + test_token = create_jwt_token(email, JWTType.NEWSLETTER_UNSUBSCRIBE) + response = await api_client.post("/newsletter/unsubscribe", json=test_token) + + assert response.status_code == HTTP_NO_CONTENT + assert await db_session.get(NewsletterSubscriber, subscriber.id) is None + + +async def test_confirm_subscription_already_confirmed_returns_400( + api_client: AsyncClient, db_session: AsyncSession +) -> None: + """Rejects confirmation for an already confirmed subscription.""" + email = "already_confirmed@example.com" + subscriber = NewsletterSubscriber(email=email, is_confirmed=True) + db_session.add(subscriber) + await db_session.flush() + + token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) + response = await api_client.post("/newsletter/confirm", json=token) + + assert response.status_code == HTTP_BAD_REQUEST + assert "Already confirmed" in detail_text(response.json()) + + +async def test_confirm_subscription_unknown_email_returns_404(api_client: AsyncClient) -> None: + """Returns 404 when a confirmation token references no subscriber.""" + token = create_jwt_token("ghost@example.com", JWTType.NEWSLETTER_CONFIRMATION) + response = await api_client.post("/newsletter/confirm", json=token) + + assert response.status_code == 404 + assert "not found" in detail_text(response.json()).lower() + + +async def test_unsubscribe_with_invalid_token_returns_400(api_client: AsyncClient) -> None: + """Rejects unsubscribe requests with an invalid token.""" + response = await api_client.post("/newsletter/unsubscribe", json="not-a-valid-token") + assert response.status_code == HTTP_BAD_REQUEST + + +async def test_request_unsubscribe_unknown_email_returns_safe_message(api_client: AsyncClient) -> None: + """Returns a safe generic message for unknown unsubscribe requests.""" + response = await api_client.post("/newsletter/request-unsubscribe", json="nobody@example.com") + + assert response.status_code == HTTP_OK + assert "If you are subscribed" in response.json()["message"] diff --git a/backend/tests/integration/api/test_oauth_router_endpoints.py b/backend/tests/integration/api/test_oauth_router_endpoints.py new file mode 100644 index 00000000..4b772c42 --- /dev/null +++ b/backend/tests/integration/api/test_oauth_router_endpoints.py @@ -0,0 +1,84 @@ +"""Integration tests for small OAuth router endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import pytest +from fastapi import FastAPI, status + +from app.api.auth.models import OAuthAccount, User +from tests.factories.models import UserFactory +from tests.fixtures.client import override_authenticated_user + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +def _detail_text(payload: dict[str, object]) -> str: + """Return a comparable error-detail string across supported error shapes.""" + detail = payload["detail"] + if isinstance(detail, dict): + detail_dict = cast("dict[str, object]", detail) + return str(detail_dict.get("message") or "") + return str(detail) + + +@pytest.fixture +async def active_user(db_session: AsyncSession) -> User: + """Create a regular active user for OAuth route tests.""" + return await UserFactory.create_async(session=db_session, is_superuser=False, is_active=True, is_verified=True) + + +@pytest.fixture +async def active_user_client( + api_client: AsyncClient, active_user: User, test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Authenticated client acting as a regular active user.""" + with override_authenticated_user(test_app, active_user, optional=False): + yield api_client + + +class TestRemoveOAuthAssociation: + """Tests for DELETE /auth/oauth/{provider}/associate.""" + + async def test_rejects_invalid_provider(self, active_user_client: AsyncClient) -> None: + """Unsupported providers should return a stable 400 response.""" + response = await active_user_client.delete("/auth/oauth/discord/associate") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "invalid oauth provider" in _detail_text(response.json()).lower() + + async def test_returns_404_when_account_not_linked(self, active_user_client: AsyncClient) -> None: + """Deleting a missing OAuth association should return 404.""" + response = await active_user_client.delete("/auth/oauth/google/associate") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not linked" in _detail_text(response.json()).lower() + + async def test_deletes_existing_oauth_account( + self, + active_user_client: AsyncClient, + active_user: User, + db_session: AsyncSession, + ) -> None: + """Deleting a linked OAuth account should remove it from the database.""" + oauth_account = OAuthAccount( + user_id=active_user.id, + oauth_name="google", + access_token="access-token", + expires_at=None, + refresh_token=None, + account_id="provider-user-123", + account_email=active_user.email, + ) + db_session.add(oauth_account) + await db_session.flush() + + response = await active_user_client.delete("/auth/oauth/google/associate") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert await db_session.get(OAuthAccount, oauth_account.id) is None diff --git a/backend/tests/integration/api/test_oauth_token_endpoints.py b/backend/tests/integration/api/test_oauth_token_endpoints.py new file mode 100644 index 00000000..0467c688 --- /dev/null +++ b/backend/tests/integration/api/test_oauth_token_endpoints.py @@ -0,0 +1,195 @@ +"""Integration tests for the PKCE Google token-exchange endpoints. + +These endpoints receive a Google ID token obtained by the frontend via +expo-auth-session (PKCE) and exchange it for an app session without any +backend OAuth redirect. + +Covered: + POST /auth/oauth/google/cookie/token — sets httpOnly session cookies + POST /auth/oauth/google/bearer/token — returns bearer + refresh tokens +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from fastapi import status + +from app.api.auth.exceptions import OAuthStateDecodeError, OAuthStateExpiredError + +if TYPE_CHECKING: + from contextlib import AbstractContextManager + + from httpx import AsyncClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_VALID_GOOGLE_PAYLOAD = { + "sub": "google-pkce-user-42", + "email": "pkce-user@example.com", + "email_verified": True, + "iss": "https://accounts.google.com", + # Use a value within PostgreSQL int4 range (expires 2033) to avoid overflow. + "exp": 2_000_000_000, +} + +_COOKIE_ENDPOINT = "/auth/oauth/google/cookie/token" +_BEARER_ENDPOINT = "/auth/oauth/google/bearer/token" + + +def _patch_verify( + payload: dict | None = None, + *, + side_effect: type[Exception] | None = None, +) -> AbstractContextManager[MagicMock]: + """Patch _verify_google_id_token so tests never call Google's JWKS endpoint.""" + if side_effect: + return patch("app.api.auth.routers.oauth_token._verify_google_id_token", side_effect=side_effect) + return patch("app.api.auth.routers.oauth_token._verify_google_id_token", return_value=payload) + + +# --------------------------------------------------------------------------- +# Cookie endpoint +# --------------------------------------------------------------------------- + + +class TestGoogleCookieTokenEndpoint: + """Tests for POST /auth/oauth/google/cookie/token.""" + + async def test_valid_token_returns_204(self, api_client: AsyncClient) -> None: + """A valid Google ID token should create the user and set session cookies.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + response = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "mock-id-token"}) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_valid_token_sets_auth_cookie(self, api_client: AsyncClient) -> None: + """The response should set an 'auth' cookie for the browser session.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + response = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "mock-id-token"}) + + set_cookie_headers = response.headers.get_list("set-cookie") + assert any("auth=" in header for header in set_cookie_headers), "Expected an 'auth' session cookie to be set" + + async def test_accepts_optional_access_token(self, api_client: AsyncClient) -> None: + """Providing access_token alongside id_token should succeed.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + response = await api_client.post( + _COOKIE_ENDPOINT, + json={"id_token": "mock-id-token", "access_token": "mock-access-token"}, + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_second_login_with_same_sub_returns_204(self, api_client: AsyncClient) -> None: + """Calling the endpoint twice with the same Google sub should link to the same user account.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + resp1 = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "mock-id-token"}) + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + resp2 = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "mock-id-token"}) + + assert resp1.status_code == status.HTTP_204_NO_CONTENT + assert resp2.status_code == status.HTTP_204_NO_CONTENT + + async def test_expired_token_returns_400(self, api_client: AsyncClient) -> None: + """An expired ID token should return 400.""" + with _patch_verify(side_effect=OAuthStateExpiredError): + response = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "expired-token"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_invalid_token_returns_400(self, api_client: AsyncClient) -> None: + """A malformed or untrusted ID token should return 400.""" + with _patch_verify(side_effect=OAuthStateDecodeError): + response = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "garbage"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_missing_id_token_returns_422(self, api_client: AsyncClient) -> None: + """A request with no body should fail schema validation.""" + response = await api_client.post(_COOKIE_ENDPOINT, json={}) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + async def test_endpoint_is_public(self, api_client: AsyncClient) -> None: + """The endpoint must be reachable without an existing session (it *is* the login flow).""" + with _patch_verify(side_effect=OAuthStateDecodeError): + response = await api_client.post(_COOKIE_ENDPOINT, json={"id_token": "any"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# --------------------------------------------------------------------------- +# Bearer endpoint +# --------------------------------------------------------------------------- + + +class TestGoogleBearerTokenEndpoint: + """Tests for POST /auth/oauth/google/bearer/token.""" + + async def test_valid_token_returns_201_with_token_response(self, api_client: AsyncClient) -> None: + """A valid Google ID token should return bearer + refresh tokens.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + response = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "mock-id-token"}) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert isinstance(data.get("access_token"), str) + assert data["access_token"] + assert isinstance(data.get("refresh_token"), str) + assert data["refresh_token"] + assert data.get("token_type") == "bearer" + assert isinstance(data.get("expires_in"), int) + assert data["expires_in"] > 0 + + async def test_second_login_issues_fresh_tokens(self, api_client: AsyncClient) -> None: + """Repeated calls with the same Google sub should succeed and return fresh tokens each time.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + resp1 = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "mock-id-token"}) + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + resp2 = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "mock-id-token"}) + + assert resp1.status_code == status.HTTP_201_CREATED + assert resp2.status_code == status.HTTP_201_CREATED + # Each login issues a distinct access token + assert resp1.json()["access_token"] != resp2.json()["access_token"] + + async def test_accepts_optional_access_token(self, api_client: AsyncClient) -> None: + """Providing access_token alongside id_token should succeed.""" + with _patch_verify(_VALID_GOOGLE_PAYLOAD): + response = await api_client.post( + _BEARER_ENDPOINT, + json={"id_token": "mock-id-token", "access_token": "mock-access-token"}, + ) + + assert response.status_code == status.HTTP_201_CREATED + + async def test_expired_token_returns_400(self, api_client: AsyncClient) -> None: + """An expired ID token should return 400.""" + with _patch_verify(side_effect=OAuthStateExpiredError): + response = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "expired-token"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_invalid_token_returns_400(self, api_client: AsyncClient) -> None: + """A malformed or untrusted ID token should return 400.""" + with _patch_verify(side_effect=OAuthStateDecodeError): + response = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "garbage"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_missing_id_token_returns_422(self, api_client: AsyncClient) -> None: + """A request with no body should fail schema validation.""" + response = await api_client.post(_BEARER_ENDPOINT, json={}) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + async def test_endpoint_is_public(self, api_client: AsyncClient) -> None: + """The endpoint must not require an existing session.""" + with _patch_verify(side_effect=OAuthStateDecodeError): + response = await api_client.post(_BEARER_ENDPOINT, json={"id_token": "any"}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/backend/tests/integration/api/test_openapi_endpoints.py b/backend/tests/integration/api/test_openapi_endpoints.py new file mode 100644 index 00000000..21445dff --- /dev/null +++ b/backend/tests/integration/api/test_openapi_endpoints.py @@ -0,0 +1,180 @@ +"""Integration tests for OpenAPI schema generation endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status +from httpx import ASGITransport, AsyncClient + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi import FastAPI + + +@pytest.fixture +async def openapi_client(test_app: FastAPI) -> AsyncGenerator[AsyncClient]: + """Provide a minimal client for schema tests without full runtime startup.""" + async with AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + follow_redirects=True, + ) as client: + yield client + + +class TestOpenAPIEndpoints: + """Tests for public and full OpenAPI schema generation.""" + + async def test_public_openapi_json_can_be_generated(self, openapi_client: AsyncClient) -> None: + """The public OpenAPI schema endpoint should return valid JSON.""" + response = await openapi_client.get("/openapi.json") + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["openapi"].startswith("3.") + assert isinstance(payload["paths"], dict) + assert payload["paths"] + assert "x-tagGroups" in payload + assert "/auth/bearer/login" in payload["paths"] + assert "/admin/materials" not in payload["paths"] + assert "/admin/categories" not in payload["paths"] + assert "/admin/taxonomies" not in payload["paths"] + assert "/admin/cache/clear/{namespace}" not in payload["paths"] + assert "/admin/users" not in payload["paths"] + assert "/admin/organizations" not in payload["paths"] + assert "/admin/newsletter/subscribers" not in payload["paths"] + assert "/newsletter/subscribe" not in payload["paths"] + + categories_name_filter_param = next( + parameter + for parameter in payload["paths"]["/categories"]["get"]["parameters"] + if parameter["name"] == "name__ilike" + ) + assert categories_name_filter_param["schema"]["anyOf"][0]["type"] == "string" + + category_schema_examples = payload["components"]["schemas"]["CategoryRead"]["examples"] + assert category_schema_examples[0]["taxonomy_id"] == 1 + + image_resize_width_param = next( + parameter + for parameter in payload["paths"]["/images/{image_id}/resized"]["get"]["parameters"] + if parameter["name"] == "width" + ) + assert image_resize_width_param["examples"]["thumbnail"]["value"] == 200 + + rpi_include_status_param = next( + parameter + for parameter in payload["paths"]["/plugins/rpi-cam/cameras"]["get"]["parameters"] + if parameter["name"] == "include_status" + ) + assert rpi_include_status_param["examples"]["enabled"]["value"] is True + + camera_create_examples = payload["components"]["schemas"]["CameraCreate"]["examples"] + assert camera_create_examples[0]["name"] == "Workbench Camera" + + video_create_examples = payload["components"]["schemas"]["VideoCreateWithinProduct"]["examples"] + assert video_create_examples[0]["video_metadata"]["source"] == "youtube" + + user_create_examples = payload["components"]["schemas"]["UserCreate"]["examples"] + assert user_create_examples[0]["username"] == "username" + + refresh_response_examples = payload["components"]["schemas"]["RefreshTokenResponse"]["examples"] + assert refresh_response_examples[0]["token_type"] == "bearer" + + async def test_full_openapi_json_can_be_generated(self, openapi_client: AsyncClient) -> None: + """The full OpenAPI schema endpoint should render successfully in dev/test.""" + response = await openapi_client.get("/openapi_full.json") + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["openapi"].startswith("3.") + assert isinstance(payload["paths"], dict) + assert payload["paths"] + assert "/admin/materials" in payload["paths"] + assert "/admin/categories" in payload["paths"] + assert "/admin/taxonomies" in payload["paths"] + assert "/admin/cache/clear/{namespace}" in payload["paths"] + assert "/admin/users" in payload["paths"] + assert "/admin/organizations" in payload["paths"] + assert "/admin/newsletter/subscribers" in payload["paths"] + assert "/auth/oauth/google/session/authorize" in payload["paths"] + assert "/newsletter/subscribe" in payload["paths"] + + newsletter_subscribe_request = payload["paths"]["/newsletter/subscribe"]["post"]["requestBody"]["content"][ + "application/json" + ] + assert newsletter_subscribe_request["examples"]["subscriber_email"]["value"] == "subscriber@example.com" + + newsletter_preference_examples = payload["components"]["schemas"]["NewsletterPreferenceRead"]["examples"] + assert newsletter_preference_examples[0]["subscribed"] is True + + admin_users_email_filter_param = next( + parameter + for parameter in payload["paths"]["/admin/users"]["get"]["parameters"] + if parameter["name"] == "email__ilike" + ) + assert admin_users_email_filter_param["schema"]["anyOf"][0]["type"] == "string" + + admin_users_response_examples = payload["paths"]["/admin/users"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["examples"] + assert admin_users_response_examples["with_organization"]["value"][0]["organization"]["name"] == ( + "University of Example" + ) + + async def test_openapi_includes_centralized_data_collection_examples(self, openapi_client: AsyncClient) -> None: + """The OpenAPI schema should expose centralized data-collection examples.""" + response = await openapi_client.get("/openapi.json") + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + + create_product_request_body = payload["paths"]["/products"]["post"]["requestBody"]["content"][ + "application/json" + ] + assert create_product_request_body["examples"]["basic"]["value"]["name"] == "Office Chair" + assert "components" in create_product_request_body["examples"]["with_components"]["value"] + + create_materials_request_body = payload["paths"]["/products/{product_id}/materials"]["post"]["requestBody"][ + "content" + ]["application/json"] + assert create_materials_request_body["examples"]["multiple_materials"]["value"][0]["material_id"] == 1 + + product_schema_examples = payload["components"]["schemas"]["ProductCreateWithComponents"]["examples"] + assert product_schema_examples[0]["name"] == "Office Chair" + + product_list_parameters = payload["paths"]["/products"]["get"].get("parameters", []) + assert all(parameter["name"] != "include" for parameter in product_list_parameters) + + user_product_list_parameters = payload["paths"]["/users/{user_id}/products"]["get"].get("parameters", []) + assert all(parameter["name"] != "include" for parameter in user_product_list_parameters) + + product_detail_parameters = payload["paths"]["/products/{product_id}"]["get"].get("parameters", []) + assert all(parameter["name"] != "include" for parameter in product_detail_parameters) + + product_component_list_parameters = payload["paths"]["/products/{product_id}/components"]["get"].get( + "parameters", [] + ) + assert all(parameter["name"] != "include" for parameter in product_component_list_parameters) + + product_component_detail_parameters = payload["paths"]["/products/{product_id}/components/{component_id}"][ + "get" + ].get("parameters", []) + assert all(parameter["name"] != "include" for parameter in product_component_detail_parameters) + + async def test_openapi_etag_supports_conditional_get(self, openapi_client: AsyncClient) -> None: + """The public OpenAPI schema should return 304 for matching ETags.""" + first_response = await openapi_client.get("/openapi.json") + + assert first_response.status_code == status.HTTP_200_OK + assert "etag" in first_response.headers + + second_response = await openapi_client.get( + "/openapi.json", + headers={"If-None-Match": first_response.headers["etag"]}, + ) + + assert second_response.status_code == status.HTTP_304_NOT_MODIFIED diff --git a/backend/tests/integration/api/test_organization_membership_endpoints.py b/backend/tests/integration/api/test_organization_membership_endpoints.py new file mode 100644 index 00000000..b8906611 --- /dev/null +++ b/backend/tests/integration/api/test_organization_membership_endpoints.py @@ -0,0 +1,151 @@ +"""Behavior-focused tests for organization membership endpoints.""" +# spell-checker: ignore usefixtures + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from app.api.auth.models import OrganizationRole +from tests.factories.models import OrganizationFactory, UserFactory +from tests.integration.api._organization_helpers import detail_text + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import Organization, User + +pytest_plugins = ("tests.integration.api._organization_support",) + + +class TestGetOrganizationMembers: + """Cover organization member listing behavior.""" + + async def test_member_can_list_members( + self, verified_user_client: AsyncClient, org_with_owner: Organization + ) -> None: + """Allows members to view their organization's member list.""" + response = await verified_user_client.get(f"/organizations/{org_with_owner.id}/members") + + assert response.status_code == status.HTTP_200_OK + assert "items" in response.json() + + async def test_non_member_cannot_list_members( + self, + verified_user_client: AsyncClient, + db_session: AsyncSession, + verified_user: User, + ) -> None: + """Rejects member-list access for users outside the organization.""" + other_owner = await UserFactory.create_async(session=db_session) + other_org = await OrganizationFactory.create_async(session=db_session, owner_id=other_owner.id) + verified_user.organization_id = None + verified_user.organization_role = None + db_session.add(verified_user) + await db_session.flush() + + response = await verified_user_client.get(f"/organizations/{other_org.id}/members") + assert response.status_code == status.HTTP_403_FORBIDDEN + + async def test_superuser_can_list_any_org_members( + self, + api_client_superuser: AsyncClient, + db_session: AsyncSession, + db_superuser: User, + ) -> None: + """Allows a superuser to inspect any organization's members.""" + org = await OrganizationFactory.create_async(session=db_session, owner_id=db_superuser.id) + response = await api_client_superuser.get(f"/organizations/{org.id}/members") + assert response.status_code == status.HTTP_200_OK + + +class TestJoinOrganization: + """Cover organization join behavior.""" + + async def test_user_can_join_organization( + self, + verified_user_client: AsyncClient, + db_session: AsyncSession, + ) -> None: + """Lets an eligible user join another organization.""" + other_owner = await UserFactory.create_async(session=db_session) + org = await OrganizationFactory.create_async(session=db_session, owner_id=other_owner.id) + + response = await verified_user_client.post(f"/organizations/{org.id}/members/me") + + assert response.status_code == status.HTTP_201_CREATED + assert "email" in response.json() + + @pytest.mark.usefixtures("org_with_owner") + async def test_owner_can_join_another_org_if_old_org_is_empty( + self, + api_client: AsyncClient, + verified_user_client: AsyncClient, + db_session: AsyncSession, + org_with_owner: Organization, + ) -> None: + """Allows owners to switch when their current organization becomes empty.""" + other_owner = await UserFactory.create_async(session=db_session) + other_org = await OrganizationFactory.create_async(session=db_session, owner_id=other_owner.id) + + response = await verified_user_client.post(f"/organizations/{other_org.id}/members/me") + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["organization"]["name"] == other_org.name + + old_org_response = await api_client.get(f"/organizations/{org_with_owner.id}") + assert old_org_response.status_code == status.HTTP_404_NOT_FOUND + + +class TestUpdateOrganization: + """Cover organization update behavior.""" + + async def test_owner_can_transfer_ownership( + self, + verified_user_client: AsyncClient, + db_session: AsyncSession, + org_with_owner: Organization, + verified_user: User, + ) -> None: + """Transfers ownership to another existing member.""" + new_owner = await UserFactory.create_async( + session=db_session, + organization_id=org_with_owner.id, + organization_role=OrganizationRole.MEMBER, + ) + + response = await verified_user_client.patch("/users/me/organization", json={"owner_id": str(new_owner.id)}) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["owner_id"] == str(new_owner.id) + + await db_session.refresh(verified_user) + await db_session.refresh(new_owner) + assert verified_user.organization_role == OrganizationRole.MEMBER + assert new_owner.organization_role == OrganizationRole.OWNER + + async def test_owner_cannot_transfer_to_non_member( + self, + verified_user_client: AsyncClient, + db_session: AsyncSession, + ) -> None: + """Rejects ownership transfer to a user outside the organization.""" + outsider = await UserFactory.create_async(session=db_session) + + response = await verified_user_client.patch("/users/me/organization", json={"owner_id": str(outsider.id)}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestUserOrganizationMembers: + """Cover the current user's organization member endpoint.""" + + async def test_returns_404_when_user_has_no_organization(self, verified_user_client: AsyncClient) -> None: + """Returns 404 when the current user does not belong to an organization.""" + response = await verified_user_client.get("/users/me/organization/members") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "organization" in detail_text(response.json()).lower() diff --git a/backend/tests/integration/api/test_organization_public_endpoints.py b/backend/tests/integration/api/test_organization_public_endpoints.py new file mode 100644 index 00000000..e42990ef --- /dev/null +++ b/backend/tests/integration/api/test_organization_public_endpoints.py @@ -0,0 +1,73 @@ +"""Behavior-focused tests for public organization endpoints.""" +# spell-checker: ignore usefixtures + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from tests.factories.models import OrganizationFactory, UserFactory + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + +pytest_plugins = ("tests.integration.api._organization_support",) + + +class TestGetOrganizations: + """Cover organization list responses.""" + + async def test_list_includes_created_org(self, api_client_light: AsyncClient, db_session: AsyncSession) -> None: + """Returns newly created organizations in the listing.""" + owner = await UserFactory.create_async(session=db_session) + await OrganizationFactory.create_async(session=db_session, name="Test Corp", owner_id=owner.id) + + response = await api_client_light.get("/organizations") + + assert response.status_code == status.HTTP_200_OK + names = [item["name"] for item in response.json()["items"]] + assert "Test Corp" in names + + +class TestGetOrganizationById: + """Cover organization detail lookups.""" + + async def test_returns_org_by_id(self, api_client_light: AsyncClient, db_session: AsyncSession) -> None: + """Returns the requested organization when it exists.""" + owner = await UserFactory.create_async(session=db_session) + org = await OrganizationFactory.create_async(session=db_session, name="My Org", owner_id=owner.id) + + response = await api_client_light.get(f"/organizations/{org.id}") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "My Org" + + async def test_returns_404_for_unknown_id(self, api_client_light: AsyncClient) -> None: + """Returns 404 for an unknown organization id.""" + response = await api_client_light.get(f"/organizations/{uuid.uuid4()}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestCreateOrganization: + """Cover organization creation behavior.""" + + async def test_create_organization_success(self, verified_user_client: AsyncClient, verified_user: User) -> None: + """Creates an organization for an eligible verified user.""" + response = await verified_user_client.post("/organizations", json={"name": "New Corp"}) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "New Corp" + assert data["owner_id"] == str(verified_user.id) + + @pytest.mark.usefixtures("org_with_owner") + async def test_create_organization_already_member_raises(self, verified_user_client: AsyncClient) -> None: + """Rejects creation when the user is already in an organization.""" + response = await verified_user_client.post("/organizations", json={"name": "Conflict Org"}) + assert response.status_code == status.HTTP_409_CONFLICT diff --git a/backend/tests/integration/api/test_pagination.py b/backend/tests/integration/api/test_pagination.py new file mode 100644 index 00000000..60b6ebf0 --- /dev/null +++ b/backend/tests/integration/api/test_pagination.py @@ -0,0 +1,106 @@ +"""Integration tests for shared pagination behaviour. + +These tests verify the Page envelope shape and parameter semantics +(page, size, total, pages) using the /materials endpoint as the +representative paginated list. Smoke tests for each newly-paginated +router are included at the end. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +from tests.factories.models import ( + MaterialFactory, + OrganizationFactory, + ProductTypeFactory, + TaxonomyFactory, + UserFactory, +) + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.mark.api +class TestPaginationEnvelope: + """The Page response envelope must always be present and well-formed.""" + + async def test_response_contains_consistent_pagination_metadata( + self, api_client_light: AsyncClient, db_session: AsyncSession + ) -> None: + """The response must include the standard envelope and consistent totals.""" + for i in range(3): + await MaterialFactory.create_async(session=db_session, name=f"PaginationMaterial{i}") + response = await api_client_light.get("/materials") + body = response.json() + for key in ("total", "page", "size", "pages"): + assert key in body, f"Missing pagination key: '{key}'" + assert body["total"] == len(body["items"]) + + async def test_size_and_pages_parameters_are_applied( + self, api_client_light: AsyncClient, db_session: AsyncSession + ) -> None: + """Pagination parameters should limit returned items and report page counts.""" + for i in range(3): + await MaterialFactory.create_async(session=db_session, name=f"PageMeta{i}") + response = await api_client_light.get("/materials?size=2&page=1") + body = response.json() + assert body["total"] >= 3 + assert body["size"] == 2 + assert body["pages"] >= 2 + assert len(body["items"]) == 2 + + async def test_page_beyond_total_returns_empty_items( + self, api_client_light: AsyncClient, db_session: AsyncSession + ) -> None: + """Requesting a page past the last page must return an empty items list.""" + await MaterialFactory.create_async(session=db_session) + response = await api_client_light.get("/materials?size=1&page=9999") + body = response.json() + assert body["items"] == [] + + +@pytest.mark.api +class TestPaginationSmoke: + """One smoke test per newly-paginated endpoint: confirms the Page envelope is returned.""" + + @pytest.mark.parametrize( + ("path", "factory"), + [ + ("/taxonomies", TaxonomyFactory), + ("/product-types", ProductTypeFactory), + ], + ) + async def test_endpoint_returns_page_envelope( + self, + api_client_light: AsyncClient, + db_session: AsyncSession, + path: str, + factory: type[TaxonomyFactory | ProductTypeFactory], + ) -> None: + """Representative paginated endpoints must return a Page envelope.""" + await factory.create_async(session=db_session) + response = await api_client_light.get(path) + assert response.status_code == status.HTTP_200_OK + assert "items" in response.json() + + async def test_organizations_returns_page_envelope( + self, api_client_light: AsyncClient, db_session: AsyncSession + ) -> None: + """GET /organizations must return a Page envelope.""" + owner = await UserFactory.create_async(session=db_session) + await OrganizationFactory.create_async(session=db_session, owner_id=owner.id) + response = await api_client_light.get("/organizations") + assert response.status_code == status.HTTP_200_OK + assert "items" in response.json() + + async def test_admin_users_returns_page_envelope(self, api_client_superuser_light: AsyncClient) -> None: + """GET /admin/users must return a Page envelope.""" + response = await api_client_superuser_light.get("/admin/users") + assert response.status_code == status.HTTP_200_OK + assert "items" in response.json() diff --git a/backend/tests/integration/api/test_privacy_redaction.py b/backend/tests/integration/api/test_privacy_redaction.py new file mode 100644 index 00000000..499b4796 --- /dev/null +++ b/backend/tests/integration/api/test_privacy_redaction.py @@ -0,0 +1,184 @@ +"""Integration tests for the 3-tier privacy system.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from fastapi import FastAPI, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.models import User +from app.api.background_data.models import ProductType +from app.api.data_collection.models.product import Product +from tests.factories.models import UserFactory +from tests.fixtures.client import override_authenticated_user + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from httpx import AsyncClient + + +@pytest.fixture +async def setup_data(db_session: AsyncSession, db_superuser: User) -> dict[str, Any]: + """Set up test products and users. + + Returns a dict with keys: `user`, `other_user`, `superuser`, `product`. + """ + pt = ProductType(name="Power Tool", description="Handheld electric tools for construction and DIY") + # Using explicit usernames to avoid collisions and ensure searchability + user = await UserFactory.create_async(session=db_session, is_active=True, username="privacy_test_user") + product = Product(owner_id=user.id, product_type=pt, name="User Product") + + # Add another user for "Community" testing + other_user = await UserFactory.create_async(session=db_session, is_active=True, username="other_user") + db_session.add_all([pt, product]) + await db_session.flush() + + return {"user": user, "other_user": other_user, "superuser": db_superuser, "product": product} + + +async def set_profile_visibility(db_session: AsyncSession, user: User, visibility: str) -> None: + """Persist a profile visibility setting for a user.""" + user.preferences = {"profile_visibility": visibility} + await db_session.flush() + + +@pytest.fixture +async def owner_client( + api_client_light: AsyncClient, setup_data: dict[str, Any], test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Provide an authenticated client for the private-profile owner. + + The fixture overrides authentication dependencies to simulate the owner. + """ + user = setup_data["user"] + with override_authenticated_user(test_app, user): + yield api_client_light + + +@pytest.fixture +async def other_user_client( + api_client_light: AsyncClient, setup_data: dict[str, Any], test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Provide an authenticated client for a different signed-in user. + + The fixture overrides authentication dependencies to simulate a non-owner. + """ + user = setup_data["other_user"] + with override_authenticated_user(test_app, user): + yield api_client_light + + +@pytest.fixture +async def superuser_client_light( + api_client_light: AsyncClient, db_superuser: User, test_app: FastAPI +) -> AsyncGenerator[AsyncClient]: + """Provide a lightweight authenticated client for a superuser.""" + with override_authenticated_user(test_app, db_superuser, superuser=True): + yield api_client_light + + +class TestPrivacyRedaction: + """Tests for profile visibility and username redaction.""" + + async def test_public_profile_visibility(self, api_client: AsyncClient, setup_data: dict[str, Any]) -> None: + """Public profiles are visible to everyone.""" + username = setup_data["user"].username + response = await api_client.get(f"/users/{username}/profile") + assert response.status_code == status.HTTP_200_OK + + async def test_community_profile_visibility_guest( + self, db_session: AsyncSession, api_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Community profiles are hidden from guests.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "community") + + response = await api_client.get(f"/users/{user.username}/profile") + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_community_profile_visibility_logged_in( + self, db_session: AsyncSession, owner_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Community profiles are visible to logged-in users.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "community") + + response = await owner_client.get(f"/users/{user.username}/profile") + assert response.status_code == status.HTTP_200_OK + + async def test_private_profile_visibility_guest( + self, db_session: AsyncSession, api_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Private profiles are hidden from everyone (except self/admin).""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "private") + + response = await api_client.get(f"/users/{user.username}/profile") + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_private_profile_visibility_owner_preserves_owner_identity( + self, db_session: AsyncSession, owner_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Owners can still view private profiles and retain visible ownership on list routes.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "private") + + profile_response = await owner_client.get(f"/users/{user.username}/profile") + assert profile_response.status_code == status.HTTP_200_OK + + products_response = await owner_client.get("/products") + assert products_response.status_code == status.HTTP_200_OK + data = products_response.json() + product_item = next((p for p in data["items"] if p["id"] == setup_data["product"].id), None) + assert product_item is not None + assert product_item["owner_username"] == user.username + + async def test_private_profile_visibility_superuser_preserves_detail_identity( + self, db_session: AsyncSession, superuser_client_light: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Superusers can view private profiles and private owner names on product detail.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "private") + + profile_response = await superuser_client_light.get(f"/users/{user.username}/profile") + assert profile_response.status_code == status.HTTP_200_OK + + detail_response = await superuser_client_light.get(f"/products/{setup_data['product'].id}") + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.json()["owner_username"] == user.username + + async def test_private_profile_redacts_identity_for_regular_users_across_product_routes( + self, db_session: AsyncSession, other_user_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """Regular users should not see private owner usernames on list or detail routes.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "private") + + products_response = await other_user_client.get("/products") + assert products_response.status_code == status.HTTP_200_OK + list_data = products_response.json() + product_item = next((p for p in list_data["items"] if p["id"] == setup_data["product"].id), None) + assert product_item is not None + assert product_item["owner_username"] is None + + detail_response = await other_user_client.get(f"/products/{setup_data['product'].id}") + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.json()["owner_username"] is None + + async def test_identity_redaction_on_user_products( + self, db_session: AsyncSession, owner_client: AsyncClient, setup_data: dict[str, Any] + ) -> None: + """The user-scoped products route should also preserve owner identity for the owner.""" + user = setup_data["user"] + await set_profile_visibility(db_session, user, "private") + + response = await owner_client.get(f"/users/{user.id}/products") + assert response.status_code == status.HTTP_200_OK + data = response.json() + + product_item = next((p for p in data["items"] if p["id"] == setup_data["product"].id), None) + assert product_item is not None + assert product_item["owner_username"] == user.username diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 00000000..e8c47a17 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1 @@ +"""Integration-test tier guidance.""" diff --git a/backend/tests/integration/core/__init__.py b/backend/tests/integration/core/__init__.py new file mode 100644 index 00000000..cc47c93c --- /dev/null +++ b/backend/tests/integration/core/__init__.py @@ -0,0 +1 @@ +"""Integration tests for core functionality.""" diff --git a/backend/tests/integration/core/test_database_operations.py b/backend/tests/integration/core/test_database_operations.py new file mode 100644 index 00000000..5ecdf2f2 --- /dev/null +++ b/backend/tests/integration/core/test_database_operations.py @@ -0,0 +1,38 @@ +"""Focused integration checks for database-backed relationship behavior.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import QueryableAttribute + +from app.api.background_data.models import Taxonomy +from tests.factories.models import CategoryFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.db + + +async def test_taxonomy_categories_relationship_loads_children( + db_session: AsyncSession, + db_taxonomy: Taxonomy, +) -> None: + """A taxonomy should load all persisted child categories via ORM relationships.""" + await CategoryFactory.create_async(db_session, name="Cat A", taxonomy_id=db_taxonomy.id) + await CategoryFactory.create_async(db_session, name="Cat B", taxonomy_id=db_taxonomy.id) + + stmt = ( + select(Taxonomy) + .where(Taxonomy.id == db_taxonomy.id) + .options(selectinload(cast("QueryableAttribute[Any]", Taxonomy.categories))) + ) + result = await db_session.execute(stmt) + taxonomy = result.scalar_one() + + assert {category.name for category in taxonomy.categories} >= {"Cat A", "Cat B"} diff --git a/backend/tests/integration/core/test_fastapi_cache.py b/backend/tests/integration/core/test_fastapi_cache.py new file mode 100644 index 00000000..8ed5c721 --- /dev/null +++ b/backend/tests/integration/core/test_fastapi_cache.py @@ -0,0 +1,230 @@ +"""Integration tests for endpoint caching with the shared cache wrapper.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import pytest +from cashews.backends import memory as memory_backend +from fastapi import APIRouter, Depends, FastAPI +from httpx import ASGITransport, AsyncClient + +from app.core.cache import ( + cache, + clear_cache_namespace, + close_fastapi_cache, + init_fastapi_cache, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +EXPIRE_60 = 60 +EXPIRE_1 = 1 + + +@pytest.fixture +async def cache_app() -> AsyncGenerator[FastAPI]: + """Create a minimal FastAPI app with in-memory caching configured.""" + await close_fastapi_cache() + init_fastapi_cache(None) + + app = FastAPI() + router = APIRouter() + call_counts = {"cached_endpoint": 0} + + @router.get("/cached-endpoint") + @cache(expire=EXPIRE_60) + async def cached_endpoint(value: str) -> dict: + call_counts["cached_endpoint"] += 1 + return {"result": f"processed_{value}", "call_count": call_counts["cached_endpoint"]} + + app.include_router(router) + + try: + yield app + finally: + await close_fastapi_cache() + + +class TestFastAPICacheIntegration: + """Integration tests for cached FastAPI endpoints.""" + + async def test_cache_hit_on_second_request(self, cache_app: FastAPI) -> None: + """Test that second request returns cached response with HIT header.""" + async with AsyncClient(transport=ASGITransport(app=cache_app), base_url="http://test") as client: + response1 = await client.get("/cached-endpoint?value=test") + assert response1.status_code == 200 + assert response1.json() == {"result": "processed_test", "call_count": 1} + + response2 = await client.get("/cached-endpoint?value=test") + assert response2.status_code == 200 + assert response2.json() == {"result": "processed_test", "call_count": 1} + + async def test_different_params_different_cache(self, cache_app: FastAPI) -> None: + """Test that different parameters create separate cache entries.""" + async with AsyncClient(transport=ASGITransport(app=cache_app), base_url="http://test") as client: + response1 = await client.get("/cached-endpoint?value=test1") + assert response1.status_code == 200 + assert response1.json()["call_count"] == 1 + + response2 = await client.get("/cached-endpoint?value=test2") + assert response2.status_code == 200 + assert response2.json()["call_count"] == 2 + + response3 = await client.get("/cached-endpoint?value=test1") + assert response3.status_code == 200 + assert response3.json()["call_count"] == 1 + + async def test_cache_ttl_expiration(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that cache entries expire after TTL.""" + fake_now = {"value": 1_700_000_000.0} + + def _fake_time() -> float: + return fake_now["value"] + + monkeypatch.setattr(memory_backend.time, "time", _fake_time) + await close_fastapi_cache() + init_fastapi_cache(None) + + app = FastAPI() + router = APIRouter() + call_count = {"short_ttl": 0} + + @router.get("/short-ttl") + @cache(expire=EXPIRE_1) + async def short_ttl_endpoint(value: str) -> dict: + call_count["short_ttl"] += 1 + return {"result": f"processed_{value}", "call_count": call_count["short_ttl"]} + + app.include_router(router) + + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response1 = await client.get("/short-ttl?value=test") + assert response1.json()["call_count"] == 1 + + response2 = await client.get("/short-ttl?value=test") + assert response2.json()["call_count"] == 1 + + fake_now["value"] += 1.1 + + response3 = await client.get("/short-ttl?value=test") + assert response3.json()["call_count"] == 2 + finally: + await close_fastapi_cache() + + async def test_session_exclusion_from_cache_key(self) -> None: + """Dependencies should not affect the cache key when request params are unchanged.""" + await close_fastapi_cache() + init_fastapi_cache(None) + + app = FastAPI() + router = APIRouter() + call_count = {"cached_with_dependency": 0} + + async def get_context() -> dict[str, str]: + return {"request_id": "123"} + + @router.get("/cached-with-dependency", dependencies=[Depends(get_context)]) + @cache(expire=EXPIRE_60) + async def cached_with_dependency(value: str) -> dict: + call_count["cached_with_dependency"] += 1 + return {"result": f"processed_{value}", "call_count": call_count["cached_with_dependency"]} + + app.include_router(router) + + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response1 = await client.get("/cached-with-dependency?value=test") + assert response1.status_code == 200 + assert response1.json()["result"] == "processed_test" + assert response1.json()["call_count"] == 1 + + response2 = await client.get("/cached-with-dependency?value=test") + assert response2.status_code == 200 + assert response2.json()["call_count"] == 1 + finally: + await close_fastapi_cache() + + async def test_concurrent_requests_same_endpoint(self, cache_app: FastAPI) -> None: + """Concurrent requests should all succeed even before the cache warms.""" + async with AsyncClient(transport=ASGITransport(app=cache_app), base_url="http://test") as client: + responses = await asyncio.gather( + client.get("/cached-endpoint?value=concurrent"), + client.get("/cached-endpoint?value=concurrent"), + client.get("/cached-endpoint?value=concurrent"), + ) + + assert all(r.status_code == 200 for r in responses) + call_counts = [r.json()["call_count"] for r in responses] + assert 1 in call_counts + + async def test_cache_different_endpoints_separate(self) -> None: + """Different endpoints should maintain separate cache entries.""" + await close_fastapi_cache() + init_fastapi_cache(None) + + app = FastAPI() + router = APIRouter() + call_count = {"endpoint1": 0, "endpoint2": 0} + + @router.get("/endpoint1") + @cache(expire=EXPIRE_60) + async def endpoint1() -> dict: + call_count["endpoint1"] += 1 + return {"endpoint": "1", "call_count": call_count["endpoint1"]} + + @router.get("/endpoint2") + @cache(expire=EXPIRE_60) + async def endpoint2() -> dict: + call_count["endpoint2"] += 1 + return {"endpoint": "2", "call_count": call_count["endpoint2"]} + + app.include_router(router) + + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response1 = await client.get("/endpoint1") + assert response1.json()["call_count"] == 1 + + response2 = await client.get("/endpoint2") + assert response2.json()["call_count"] == 1 + + response3 = await client.get("/endpoint1") + assert response3.json()["call_count"] == 1 + finally: + await close_fastapi_cache() + + async def test_cache_clear_namespace(self) -> None: + """Clearing a namespace should invalidate matching cached endpoints.""" + await close_fastapi_cache() + init_fastapi_cache(None) + + app = FastAPI() + router = APIRouter() + call_count = {"namespaced": 0} + + @router.get("/namespaced") + @cache(expire=EXPIRE_60, namespace="test-namespace") + async def namespaced_endpoint(value: str) -> dict: + call_count["namespaced"] += 1 + return {"result": value, "call_count": call_count["namespaced"]} + + app.include_router(router) + + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response1 = await client.get("/namespaced?value=test") + assert response1.json()["call_count"] == 1 + + response2 = await client.get("/namespaced?value=test") + assert response2.json()["call_count"] == 1 + + await clear_cache_namespace("test-namespace") + + response3 = await client.get("/namespaced?value=test") + assert response3.json()["call_count"] == 2 + finally: + await close_fastapi_cache() diff --git a/backend/tests/integration/core/test_file_cleanup_manager.py b/backend/tests/integration/core/test_file_cleanup_manager.py new file mode 100644 index 00000000..3bfcd7ce --- /dev/null +++ b/backend/tests/integration/core/test_file_cleanup_manager.py @@ -0,0 +1,94 @@ +"""Integration tests for the scheduled file cleanup manager.""" + +from __future__ import annotations + +import os +import time +import uuid +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy import text + +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.services.manager import FileCleanupManager +from app.core.config import settings +from app.core.images import thumbnail_path_for +from tests.factories.models import ProductFactory, ProductTypeFactory + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + + +class TestFileCleanupManager: + """Scheduled cleanup manager behavior.""" + + async def test_run_once_preserves_referenced_image_thumbnails( + self, + db_session: AsyncSession, + db_superuser: User, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """run_once keeps thumbnails for referenced images while deleting orphaned files.""" + file_storage = tmp_path / "files" + image_storage = tmp_path / "images" + file_storage.mkdir() + image_storage.mkdir() + + referenced_image = image_storage / "kept.jpg" + referenced_image.write_bytes(b"image") + referenced_thumbnail = thumbnail_path_for(referenced_image, 200) + referenced_thumbnail.write_bytes(b"thumb") + orphan_thumbnail = image_storage / "orphan_thumb_200.webp" + orphan_thumbnail.write_bytes(b"orphan") + + old_mtime = time.time() - 7200 + for path in (referenced_image, referenced_thumbnail, orphan_thumbnail): + os.utime(path, (old_mtime, old_mtime)) + + monkeypatch.setattr(settings, "file_storage_path", file_storage) + monkeypatch.setattr(settings, "image_storage_path", image_storage) + monkeypatch.setattr(settings, "file_cleanup_min_file_age_minutes", 30) + monkeypatch.setattr(settings, "file_cleanup_dry_run", False) + + product_type = await ProductTypeFactory.create_async(session=db_session) + product = await ProductFactory.create_async( + session=db_session, + owner_id=db_superuser.id, + product_type_id=product_type.id, + ) + + await db_session.execute( + cast( + "Any", + text( + """ + INSERT INTO image (id, filename, file, parent_type, parent_id) + VALUES (:id, :filename, :file, :parent_type, :parent_id) + """ + ), + ), + params={ + "id": uuid.UUID("11111111-1111-1111-1111-111111111111"), + "filename": "kept.jpg", + "file": "kept.jpg", + "parent_type": MediaParentType.PRODUCT.name, + "parent_id": product.id, + }, + ) + await db_session.commit() + + def session_factory() -> AsyncSession: + return db_session + + manager = FileCleanupManager(session_factory) + await manager.run_once() + + assert referenced_image.exists() + assert referenced_thumbnail.exists() + assert not orphan_thumbnail.exists() diff --git a/backend/tests/integration/core/test_lifespan.py b/backend/tests/integration/core/test_lifespan.py new file mode 100644 index 00000000..d6e9212d --- /dev/null +++ b/backend/tests/integration/core/test_lifespan.py @@ -0,0 +1,293 @@ +"""Integration tests for the FastAPI application lifespan. + +Verifies that startup initialises app.state correctly and that shutdown +calls the expected close methods. External services (Redis, email checker) +are mocked so these tests run without any real infrastructure. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import CloseError + +from app.core.config import settings +from app.core.database import async_engine +from app.core.runtime import AppServices +from app.main import app, lifespan + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture(autouse=True) +def _reset_app_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None]: + """Start each lifespan test with a clean app.state.""" + uploads_path = tmp_path / "uploads" + file_storage_path = uploads_path / "files" + image_storage_path = uploads_path / "images" + + monkeypatch.setattr(settings, "uploads_path", uploads_path) + monkeypatch.setattr(settings, "file_storage_path", file_storage_path) + monkeypatch.setattr(settings, "image_storage_path", image_storage_path) + + app.state.services = AppServices() + yield + app.state.services = AppServices() + + +class TestLifespan: + """Application lifespan startup and shutdown.""" + + async def test_startup_sets_redis_on_app_state(self) -> None: + """After startup, app.state.redis must be populated.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis) as mock_init_redis, + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + assert isinstance(app.state.services, AppServices) + assert app.state.services.redis is mock_redis + mock_init_redis.assert_awaited_once() + + async def test_startup_sets_email_checker_on_app_state(self) -> None: + """After startup, app.state.email_checker must be populated.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker) as mock_init_checker, + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + assert app.state.services.email_checker is mock_email_checker + mock_init_checker.assert_awaited_once_with(mock_redis) + + async def test_startup_initializes_storage_on_app_state(self) -> None: + """After startup, storage must be ensured and state marked ready.""" + with ( + patch("app.core.lifecycle.init_redis"), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker"), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + patch("app.core.lifecycle.ensure_storage_directories") as mock_ensure, + patch("app.core.lifecycle.mount_static_directories") as mock_mount, + patch("app.core.lifecycle.register_favicon_route") as mock_favicon, + ): + async with lifespan(app): + assert app.state.services.storage_ready is True + + mock_ensure.assert_called_once() + mock_mount.assert_called_once_with(app) + mock_favicon.assert_called_once_with(app) + + async def test_shutdown_closes_email_checker(self) -> None: + """On shutdown, email_checker.close() must be awaited.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + pass # allow shutdown to run + + mock_email_checker.close.assert_awaited_once() + + async def test_shutdown_closes_redis(self) -> None: + """On shutdown, close_redis() must be called with the Redis instance.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis") as mock_close_redis, + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + pass + + mock_close_redis.assert_awaited_once_with(mock_redis) + + async def test_shutdown_tolerates_email_checker_close_error(self) -> None: + """A RuntimeError from email_checker.close() must not prevent shutdown from completing.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + mock_email_checker.close.side_effect = RuntimeError("checker gone") + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis") as mock_close_redis, + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + pass # shutdown runs even if close() raised + + # Redis must still be closed despite the earlier error + mock_close_redis.assert_awaited_once() + + async def test_shutdown_tolerates_redis_close_error(self) -> None: + """A ConnectionError from close_redis() must not propagate out of the lifespan.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis", side_effect=ConnectionError("redis gone")), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + # Must not raise + async with lifespan(app): + pass + + async def test_shutdown_tolerates_file_cleanup_manager_cancellation(self) -> None: + """A cancelled file cleanup manager close must not prevent shutdown.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + mock_file_cleanup_manager = MagicMock() + mock_file_cleanup_manager.initialize = AsyncMock() + mock_file_cleanup_manager.close = AsyncMock(side_effect=asyncio.CancelledError()) + mock_http_client = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + patch("app.core.lifecycle.FileCleanupManager", return_value=mock_file_cleanup_manager), + patch("app.core.lifecycle.create_http_client", return_value=mock_http_client), + ): + async with lifespan(app): + pass + + mock_file_cleanup_manager.close.assert_awaited_once() + mock_http_client.aclose.assert_awaited_once() + + async def test_shutdown_tolerates_http_client_close_error(self) -> None: + """A CloseError from the shared HTTP client must not prevent shutdown.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + mock_file_cleanup_manager = MagicMock() + mock_file_cleanup_manager.initialize = AsyncMock() + mock_file_cleanup_manager.close = AsyncMock() + mock_http_client = AsyncMock() + mock_http_client.aclose.side_effect = CloseError("client gone") + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + patch("app.core.lifecycle.FileCleanupManager", return_value=mock_file_cleanup_manager), + patch("app.core.lifecycle.create_http_client", return_value=mock_http_client), + ): + async with lifespan(app): + pass + + mock_file_cleanup_manager.close.assert_awaited_once() + mock_http_client.aclose.assert_awaited_once() + + async def test_startup_initializes_telemetry(self) -> None: + """Startup should invoke telemetry initialization with the shared async engine.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry") as mock_init_telemetry, + patch("app.core.lifecycle.close_fastapi_cache"), + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry"), + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + pass + + mock_init_telemetry.assert_called_once_with(app, async_engine) + + async def test_shutdown_shuts_down_telemetry(self) -> None: + """Shutdown should uninstrument telemetry even when no exporter is enabled.""" + mock_redis = MagicMock() + mock_email_checker = AsyncMock() + + with ( + patch("app.core.lifecycle.init_redis", return_value=mock_redis), + patch("app.core.lifecycle.init_blocking_redis", return_value=None), + patch("app.core.lifecycle.init_email_checker", return_value=mock_email_checker), + patch("app.core.lifecycle.init_fastapi_cache"), + patch("app.core.lifecycle.init_telemetry"), + patch("app.core.lifecycle.close_fastapi_cache") as mock_close_fastapi_cache, + patch("app.core.lifecycle.close_redis"), + patch("app.core.lifecycle.shutdown_telemetry") as mock_shutdown_telemetry, + patch("app.main.cleanup_logging"), + ): + async with lifespan(app): + pass + + mock_close_fastapi_cache.assert_awaited_once() + mock_shutdown_telemetry.assert_called_once_with(app) diff --git a/backend/tests/integration/core/test_logging.py b/backend/tests/integration/core/test_logging.py new file mode 100644 index 00000000..ced0c854 --- /dev/null +++ b/backend/tests/integration/core/test_logging.py @@ -0,0 +1,63 @@ +"""Tests for the application logging configuration.""" + +import logging +from typing import TYPE_CHECKING + +from app.core.config import Environment +from app.core.logging import InterceptHandler, configure_loguru_handlers + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +def test_standard_logging_intercepted() -> None: + """Verify that standard logging messages are captured by loguru.""" + assert any(isinstance(handler, InterceptHandler) for handler in logging.root.handlers) + + +def test_noisy_loggers_configured() -> None: + """Verify that noisy loggers like uvicorn and sqlalchemy are propagated to root.""" + noisy_logger = logging.getLogger("sqlalchemy.engine") + assert noisy_logger.propagate is True + assert len(noisy_logger.handlers) == 0 + + +def test_configure_loguru_handlers_dev_environment(mocker: MockerFixture, tmp_path: Path) -> None: + """Verify that DEV keeps a synchronous, human-readable console sink.""" + mock_add = mocker.patch("loguru.logger.add") + mocker.patch("app.core.logging.settings.environment", new=Environment.DEV) + + configure_loguru_handlers(tmp_path, "DEBUG") + + assert mock_add.call_count == 1 + for call in mock_add.call_args_list: + assert call.kwargs.get("enqueue") is False + assert call.kwargs.get("serialize") is False + + +def test_configure_loguru_handlers_prod_environment(mocker: MockerFixture, tmp_path: Path) -> None: + """Verify that PROD enables a queued JSON console sink.""" + mock_add = mocker.patch("loguru.logger.add") + mocker.patch("app.core.logging.settings.environment", new=Environment.PROD) + + configure_loguru_handlers(tmp_path, "INFO") + + assert mock_add.call_count == 1 + for call in mock_add.call_args_list: + assert call.kwargs.get("enqueue") is True + assert call.kwargs.get("serialize") is True + + +def test_configure_loguru_handlers_staging_environment(mocker: MockerFixture, tmp_path: Path) -> None: + """Verify that STAGING matches PROD console logging behavior.""" + mock_add = mocker.patch("loguru.logger.add") + mocker.patch("app.core.logging.settings.environment", new=Environment.STAGING) + + configure_loguru_handlers(tmp_path, "INFO") + + assert mock_add.call_count == 1 + for call in mock_add.call_args_list: + assert call.kwargs.get("enqueue") is True + assert call.kwargs.get("serialize") is True diff --git a/backend/tests/integration/core/test_migrations.py b/backend/tests/integration/core/test_migrations.py new file mode 100644 index 00000000..87943f39 --- /dev/null +++ b/backend/tests/integration/core/test_migrations.py @@ -0,0 +1,99 @@ +"""Tests for Alembic migration correctness.""" + +import logging +from typing import TYPE_CHECKING + +import pytest +from alembic import command + +if TYPE_CHECKING: + from alembic.config import Config + + from tests.fixtures.migrations import MigrationHelper + +logger = logging.getLogger(__name__) + +# All tables expected to exist after upgrade head. +# Table names are the lowercase class names defined on each model. +EXPECTED_TABLES = { + # Auth + "user", + "oauthaccount", + "organization", + # Background data + "taxonomy", + "category", + "material", + "producttype", + "categorymateriallink", + "categoryproducttypelink", + # Data collection + "product", + "materialproductlink", + # File storage + "file", + "image", + "video", + # Newsletter + "newslettersubscriber", +} + + +@pytest.mark.migration +def test_all_expected_tables_exist(migration_helper: MigrationHelper) -> None: + """Every domain table must be present after upgrade head. + + This catches migrations that were written but never applied, or table + renames that were not reflected in a new migration. + """ + for table in EXPECTED_TABLES: + assert migration_helper.table_exists(table), f"Expected table '{table}' not found in schema" + + +@pytest.mark.migration +def test_user_table_has_required_columns(migration_helper: MigrationHelper) -> None: + """Core user columns must be present; guards against accidental column drops.""" + columns = set(migration_helper.get_table_columns("user")) + required = {"id", "email", "hashed_password", "is_active", "is_superuser", "created_at", "updated_at"} + missing = required - columns + assert not missing, f"user table is missing columns: {missing}" + + +@pytest.mark.migration +def test_oauthaccount_foreign_key_to_user(migration_helper: MigrationHelper) -> None: + """Oauthaccount must have a FK back to the user table.""" + constraints = migration_helper.get_table_constraints("oauthaccount") + fk_tables = {fk["referred_table"] for fk in constraints["fk"]} + assert "user" in fk_tables, "oauthaccount is missing its FK to the user table" + + +@pytest.mark.migration +def test_category_foreign_key_to_taxonomy(migration_helper: MigrationHelper) -> None: + """Category must have a FK back to the taxonomy table.""" + constraints = migration_helper.get_table_constraints("category") + fk_tables = {fk["referred_table"] for fk in constraints["fk"]} + assert "taxonomy" in fk_tables, "category is missing its FK to the taxonomy table" + + +@pytest.mark.migration +def test_alembic_version_at_head(migration_helper: MigrationHelper) -> None: + """alembic_version table must exist and hold a revision (i.e. head was reached).""" + revision = migration_helper.current_revision() + assert revision is not None, "No revision recorded; migrations may not have run" + + +@pytest.mark.migration +def test_migrations_downgrade_upgrade(relab_alembic_config: Config) -> None: + """Migration downgrade/upgrade cycle must succeed without error.""" + command.downgrade(relab_alembic_config, "-1") + command.upgrade(relab_alembic_config, "+1") + + +@pytest.mark.migration +def test_alembic_autogenerate_is_clean(relab_alembic_config: Config) -> None: + """Alembic autogenerate should detect no pending schema changes after head. + + This is the test-suite equivalent of `alembic check`: if ORM metadata has + drifted from the migration history, this assertion fails. + """ + command.check(relab_alembic_config) diff --git a/backend/tests/integration/db/README.md b/backend/tests/integration/db/README.md new file mode 100644 index 00000000..4b678e4d --- /dev/null +++ b/backend/tests/integration/db/README.md @@ -0,0 +1,7 @@ +# Integration DB Tests + +Use this tier for database-backed tests that exercise ORM models, CRUD, queries, and migrations without going through HTTP routing. + +- Allowed fixtures: `db_session`, seed/data fixtures, factories +- Not required by default: `api_client`, app lifespan, routing stack +- Goal: verify persistence and query behavior with a real database diff --git a/backend/tests/integration/db/__init__.py b/backend/tests/integration/db/__init__.py new file mode 100644 index 00000000..3acc02f8 --- /dev/null +++ b/backend/tests/integration/db/__init__.py @@ -0,0 +1 @@ +"""Database-backed integration test tier.""" diff --git a/backend/tests/integration/db/test_auth_user_crud.py b/backend/tests/integration/db/test_auth_user_crud.py new file mode 100644 index 00000000..f5764d30 --- /dev/null +++ b/backend/tests/integration/db/test_auth_user_crud.py @@ -0,0 +1,118 @@ +"""Integration tests for auth user CRUD functions. + +Tests validate_user_create and get_user_by_username directly against a real +database session so we exercise the actual SQL queries, not mocked DB calls. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock + +import pytest + +from app.api.auth.crud.users import get_user_by_username, validate_user_create +from app.api.auth.exceptions import DisposableEmailError, UserNameAlreadyExistsError +from app.api.auth.models import OAuthAccount, User +from app.api.auth.schemas import OrganizationCreate, UserCreate, UserCreateWithOrganization +from app.api.auth.services.user_database import UserDatabaseAsync +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +pytestmark = pytest.mark.db + + +def _make_user_db(db_session: AsyncSession) -> UserDatabaseAsync: + """Build a UserDatabaseAsync wired to the test session.""" + return UserDatabaseAsync(db_session, User, OAuthAccount) + + +class TestValidateUserCreate: + """validate_user_create enforces username uniqueness, disposable-email, and org rules.""" + + async def test_returns_user_create_unchanged_when_valid(self, db_session: AsyncSession) -> None: + """No conflicts or checks → returns the same UserCreate unchanged.""" + user_db = _make_user_db(db_session) + user_create = UserCreate(email="fresh@example.com", password="ValidPass1") + + result = await validate_user_create(user_db, user_create) + + assert isinstance(result, UserCreate) + assert result.email == user_create.email + + async def test_raises_when_username_already_taken(self, db_session: AsyncSession) -> None: + """Duplicate username must raise UserNameAlreadyExistsError.""" + await UserFactory.create_async(db_session, email="first@example.com", username="taken_name") + user_db = _make_user_db(db_session) + user_create = UserCreate( + email="second@example.com", + password="ValidPass1", + username="taken_name", + ) + + with pytest.raises(UserNameAlreadyExistsError): + await validate_user_create(user_db, user_create) + + async def test_allows_null_username(self, db_session: AsyncSession) -> None: + """username=None skips uniqueness check entirely.""" + user_db = _make_user_db(db_session) + user_create = UserCreate(email="anon@example.com", password="ValidPass1", username=None) + + result = await validate_user_create(user_db, user_create) + + assert result.username is None + + async def test_raises_for_disposable_email(self, db_session: AsyncSession) -> None: + """A disposable email flagged by the checker must raise DisposableEmailError.""" + user_db = _make_user_db(db_session) + user_create = UserCreate(email="burner@disposable.com", password="ValidPass1") + + mock_checker = AsyncMock() + mock_checker.is_disposable.return_value = True + + with pytest.raises(DisposableEmailError): + await validate_user_create(user_db, user_create, email_checker=mock_checker) + + async def test_skips_disposable_check_when_checker_is_none(self, db_session: AsyncSession) -> None: + """No email_checker → disposable check is skipped, validation passes.""" + user_db = _make_user_db(db_session) + user_create = UserCreate(email="burner@disposable.com", password="ValidPass1") + + result = await validate_user_create(user_db, user_create, email_checker=None) + + assert result.email == user_create.email + + async def test_converts_user_create_with_org_to_user_create(self, db_session: AsyncSession) -> None: + """UserCreateWithOrganization must be reduced to a plain UserCreate (org handled post-creation).""" + user_db = _make_user_db(db_session) + user_create = UserCreateWithOrganization( + email="orgfounder@example.com", + password="ValidPass1", + organization=OrganizationCreate(name="CircularTech", location="Berlin"), + ) + + result = await validate_user_create(user_db, user_create) + + assert isinstance(result, UserCreate) + assert not isinstance(result, UserCreateWithOrganization) + assert result.email == "orgfounder@example.com" + + +class TestGetUserByUsername: + """get_user_by_username returns the user or raises ValueError.""" + + async def test_returns_user_when_found(self, db_session: AsyncSession) -> None: + """Existing username → returns the matching User instance.""" + user = await UserFactory.create_async(db_session, email="user@example.com", username="find_me") + + result = await get_user_by_username(db_session, "find_me") + + assert result.id == user.id + assert result.username == "find_me" + + async def test_raises_when_username_not_found(self, db_session: AsyncSession) -> None: + """Non-existent username → raises ValueError with descriptive message.""" + with pytest.raises(ValueError, match="not found"): + await get_user_by_username(db_session, "ghost_user") diff --git a/backend/tests/integration/db/test_rpi_cam_crud.py b/backend/tests/integration/db/test_rpi_cam_crud.py new file mode 100644 index 00000000..6b20f8a4 --- /dev/null +++ b/backend/tests/integration/db/test_rpi_cam_crud.py @@ -0,0 +1,104 @@ +"""Integration tests for RPi Cam plugin CRUD operations.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.plugins.rpi_cam.crud import create_camera, update_camera +from app.api.plugins.rpi_cam.models import Camera, CameraCredentialStatus +from app.api.plugins.rpi_cam.schemas import CameraCreate, CameraUpdate, RelayPublicKeyJWK + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + +pytestmark = pytest.mark.db +TEST_CAMERA_NAME = "Test Camera" +TEST_CAMERA_DESC = "Test Description" +TEST_OLD_NAME = "Old Name" +TEST_NEW_NAME = "New Name" +PUBLIC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "y": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "kid": "key-12345", +} +KEY_ID = "key-12345" +NEW_KEY_ID = "key-67890" + + +def require_uuid(value: UUID | None) -> UUID: + """Narrow optional UUID values produced by Pydantic models.""" + assert value is not None + return value + + +def build_camera(*, owner_id: UUID, name: str = TEST_OLD_NAME) -> Camera: + """Build a camera for CRUD tests.""" + return Camera(name=name, owner_id=owner_id, relay_public_key_jwk=PUBLIC_JWK, relay_key_id=KEY_ID) + + +async def test_create_camera(db_session: AsyncSession, db_superuser: User) -> None: + """Test creating a new camera entry with device public key metadata.""" + owner_id = require_uuid(db_superuser.id) + camera_in = CameraCreate( + name=TEST_CAMERA_NAME, + description=TEST_CAMERA_DESC, + relay_public_key_jwk=RelayPublicKeyJWK(**PUBLIC_JWK), + relay_key_id=KEY_ID, + ) + + camera = await create_camera(db_session, camera_in, owner_id) + + assert camera.name == TEST_CAMERA_NAME + assert camera.description == TEST_CAMERA_DESC + assert camera.relay_public_key_jwk == PUBLIC_JWK + assert camera.relay_key_id == KEY_ID + assert camera.owner_id == owner_id + + db_camera = await db_session.get(Camera, camera.id) + assert db_camera is not None + assert db_camera.name == TEST_CAMERA_NAME + + +async def test_update_camera(db_session: AsyncSession, db_superuser: User) -> None: + """Test updating mutable camera metadata and credential status.""" + owner_id = require_uuid(db_superuser.id) + camera = build_camera(owner_id=owner_id) + db_session.add(camera) + await db_session.commit() + await db_session.refresh(camera) + + update_data = CameraUpdate(name=TEST_NEW_NAME, relay_credential_status=CameraCredentialStatus.REVOKED) + + updated_camera = await update_camera(db_session, camera, update_data) + + assert updated_camera.name == TEST_NEW_NAME + assert updated_camera.relay_credential_status == CameraCredentialStatus.REVOKED + + await db_session.refresh(camera) + assert camera.name == TEST_NEW_NAME + assert camera.relay_credential_status == CameraCredentialStatus.REVOKED + + +async def test_update_camera_applies_validated_owner_transfer() -> None: + """CRUD applies an owner change once the router has already validated it.""" + mock_session = AsyncMock() + mock_session.add = MagicMock() + camera = build_camera(owner_id=uuid.uuid4()) + new_owner_id = uuid.uuid4() + update_data = CameraUpdate.model_validate({"owner_id": new_owner_id, "relay_key_id": NEW_KEY_ID}) + + updated_camera = await update_camera(mock_session, camera, update_data, new_owner_id=new_owner_id) + + assert updated_camera.owner_id == new_owner_id + assert updated_camera.relay_key_id == NEW_KEY_ID + mock_session.commit.assert_awaited_once() diff --git a/backend/tests/integration/db/test_rpi_cam_preview_thumbnail.py b/backend/tests/integration/db/test_rpi_cam_preview_thumbnail.py new file mode 100644 index 00000000..28de8d67 --- /dev/null +++ b/backend/tests/integration/db/test_rpi_cam_preview_thumbnail.py @@ -0,0 +1,46 @@ +"""Tests for preview-thumbnail URL helpers.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import pytest + +from app.api.plugins.rpi_cam.services import get_preview_thumbnail_urls_per_camera +from app.core.config import settings + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.db + + +async def test_preview_thumbnail_helper_returns_public_url_when_file_exists( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The preview-thumbnail helper should expose deterministic upload URLs.""" + camera_id = uuid.uuid4() + monkeypatch.setattr(settings, "image_storage_path", tmp_path) + path = tmp_path / "rpi-cam-preview" / f"{camera_id}.jpg" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"preview-bytes") + + result = get_preview_thumbnail_urls_per_camera([camera_id]) + + expected_mtime = int(path.stat().st_mtime) + assert result[camera_id] == f"/uploads/images/rpi-cam-preview/{camera_id}.jpg?v={expected_mtime}" + + +async def test_preview_thumbnail_helper_returns_none_when_file_is_missing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing preview-thumbnail files should produce ``None`` entries.""" + camera_id = uuid.uuid4() + monkeypatch.setattr(settings, "image_storage_path", tmp_path) + + result = get_preview_thumbnail_urls_per_camera([camera_id]) + + assert result[camera_id] is None diff --git a/backend/tests/integration/flows/README.md b/backend/tests/integration/flows/README.md new file mode 100644 index 00000000..396c7cbc --- /dev/null +++ b/backend/tests/integration/flows/README.md @@ -0,0 +1,5 @@ +Flow tests cover a small number of multi-step scenarios that cross feature boundaries. + +Use this tier only when a single test needs to prove an end-to-end journey such as authenticate -> mutate -> fetch, or camera setup -> record -> persist. + +Keep these tests sparse and slower than the unit and API tiers on purpose. diff --git a/backend/tests/integration/flows/__init__.py b/backend/tests/integration/flows/__init__.py new file mode 100644 index 00000000..ec4afb3f --- /dev/null +++ b/backend/tests/integration/flows/__init__.py @@ -0,0 +1 @@ +"""Integration flows for the RELAB backend.""" diff --git a/backend/tests/integration/flows/conftest.py b/backend/tests/integration/flows/conftest.py new file mode 100644 index 00000000..091f1e73 --- /dev/null +++ b/backend/tests/integration/flows/conftest.py @@ -0,0 +1 @@ +"""Flow integration-test fixture loading.""" diff --git a/backend/tests/integration/flows/test_auth_flows.py b/backend/tests/integration/flows/test_auth_flows.py new file mode 100644 index 00000000..52356284 --- /dev/null +++ b/backend/tests/integration/flows/test_auth_flows.py @@ -0,0 +1,267 @@ +"""Integration tests for complete authentication flows. + +These tests cover complete user journeys from registration through login, +session management, refresh tokens, and logout. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch +from uuid import UUID + +import pytest +from fastapi import status +from sqlalchemy import select + +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate +from app.api.auth.services import refresh_token_service + +if TYPE_CHECKING: + from httpx import AsyncClient + from redis.asyncio import Redis + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.flow + +# Constants for test values +FLOW_TEST_EMAIL = "flowtest@example.com" +FLOW_TEST_USERNAME = "flowtest" +FLOW_TEST_PASSWORD = "SecurePassword123!" +MULTI_DEVICE_EMAIL = "multidevice@example.com" +MULTI_DEVICE_USERNAME = "multidevice" +LOGOUT_ALL_EMAIL = "logoutall@example.com" +LOGOUT_ALL_USERNAME = "logoutall" +TRACKING_TEST_EMAIL = "trackingtest@example.com" +TRACKING_TEST_USERNAME = "trackingtest" +COOKIE_FLOW_EMAIL = "cookie_flow@example.com" +COOKIE_FLOW_USERNAME = "cookie_flow" +TEST_USER_ID = UUID("11111111-1111-4111-8111-111111111111") +TEST_SESSION_ID = "test-session-456" +TEST_IP = "192.168.1.1" +UA_MOBILE = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0)" +UA_DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0" + + +async def get_user_by_email(db_session: AsyncSession, email: str) -> User | None: + """Get a user from the database by email.""" + statement = select(User).where(User.email == email) + result = await db_session.execute(statement) + return result.scalars().first() + + +class TestCompleteAuthFlow: + """Test complete authentication flow from registration to logout.""" + + async def test_full_bearer_auth_flow( + self, api_client: AsyncClient, mock_redis_dependency: Redis, db_session: AsyncSession + ) -> None: + """Test complete bearer auth flow: register -> login -> refresh -> logout.""" + # Step 1: Register a new user + register_data = { + "email": FLOW_TEST_EMAIL, + "password": FLOW_TEST_PASSWORD, + "username": FLOW_TEST_USERNAME, + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_override: + mock_override.return_value = UserCreate( + email=register_data["email"], + password=register_data["password"], + username=register_data["username"], + ) + register_response = await api_client.post("/auth/register", json=register_data) + + assert register_response.status_code == status.HTTP_201_CREATED, "Registration failed" + + # Fetch user from database to verify registration + user = await get_user_by_email(db_session, register_data["email"]) + assert user is not None, "User not found in database after registration" + + # Step 2: Login with bearer authentication + login_data = { + "username": register_data["email"], + "password": register_data["password"], + } + login_response = await api_client.post("/auth/bearer/login", data=login_data) + + assert login_response.status_code == status.HTTP_200_OK, "Login failed, skipping integration test" + + # FastAPI-Users bearer auth might return token or empty response + # Refresh token is set as httpOnly cookie via on_after_login + login_result = login_response.json() if login_response.text else {} + + # Get access token from response + access_token = login_result.get("access_token") + + # Get refresh token from cookies + refresh_token = login_response.cookies.get("refresh_token") + + # Verify tokens are present + assert access_token is not None + assert refresh_token is not None + + # Step 5: Refresh the access token + refresh_data = {"refresh_token": refresh_token} + refresh_response = await api_client.post("/auth/refresh", json=refresh_data) + assert refresh_response.status_code == status.HTTP_200_OK + refresh_result = refresh_response.json() + new_access_token = refresh_result["access_token"] + assert new_access_token is not None + assert new_access_token != access_token # Should be a new token + + # Step 6: Logout through the custom auth route so the refresh cookie is blacklisted too. + logout_response = await api_client.post( + "/auth/logout", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert logout_response.status_code == status.HTTP_204_NO_CONTENT + + # Verify token is now blacklisted in Redis + is_blacklisted = await mock_redis_dependency.exists(f"auth:rt_blacklist:{refresh_token}") + assert is_blacklisted + + # Step 7: Try to use blacklisted token (should fail) + retry_refresh = await api_client.post("/auth/refresh", json=refresh_data) + assert retry_refresh.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_login_tracking( + self, api_client: AsyncClient, mock_redis_dependency: Redis, db_session: AsyncSession + ) -> None: + """Test that login tracking (last_login_at, last_login_ip) is updated.""" + del mock_redis_dependency + # Step 1: Register user + register_data = { + "email": TRACKING_TEST_EMAIL, + "password": FLOW_TEST_PASSWORD, + "username": TRACKING_TEST_USERNAME, + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_override: + mock_override.return_value = UserCreate( + email=register_data["email"], + password=register_data["password"], + username=register_data["username"], + ) + register_response = await api_client.post("/auth/register", json=register_data) + + assert register_response.status_code == status.HTTP_201_CREATED + + # Fetch user from database to get ID (registration response doesn't include it) + user = await get_user_by_email(db_session, register_data["email"]) + assert user is not None, "User not found in database after registration" + + # Verify user doesn't have login tracking yet + assert user.last_login_at is None + + # Step 2: Login + login_data = {"username": register_data["email"], "password": register_data["password"]} + login_response = await api_client.post("/auth/bearer/login", data=login_data) + + assert login_response.status_code == status.HTTP_200_OK + + # Step 3: Verify login tracking was updated + # Clear session cache to ensure we get fresh data from DB + db_session.expire_all() + user_after = await get_user_by_email(db_session, register_data["email"]) + assert user_after is not None + assert user_after.last_login_at is not None, "last_login_at was not updated" + + async def test_cookie_auth_flow(self, api_client: AsyncClient, mock_redis_dependency: Redis) -> None: + """Test cookie-based authentication flow.""" + del mock_redis_dependency + # Step 1: Register user + register_data = { + "email": COOKIE_FLOW_EMAIL, + "password": FLOW_TEST_PASSWORD, + "username": COOKIE_FLOW_USERNAME, + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_override: + mock_override.return_value = UserCreate( + email=register_data["email"], + password=register_data["password"], + username=register_data["username"], + ) + register_response = await api_client.post("/auth/register", json=register_data) + + assert register_response.status_code == status.HTTP_201_CREATED + + # Step 2: Login with cookie transport + login_data = {"username": register_data["email"], "password": register_data["password"]} + login_response = await api_client.post("/auth/cookie/login", data=login_data) + + assert login_response.status_code == status.HTTP_204_NO_CONTENT, "Cookie login failed" + + # Verify cookies were set + cookies = login_response.cookies + assert len(cookies) > 0 or "set-cookie" in login_response.headers + + # Step 3: Access protected endpoint using cookies + + # Step 4: Logout (clear cookies) + await api_client.post("/auth/cookie/logout") + + +class TestErrorHandling: + """Test error handling in authentication flows.""" + + async def test_refresh_with_expired_token(self, api_client: AsyncClient, mock_redis_dependency: Redis) -> None: + """Test refreshing with an expired token returns 401.""" + # Create a refresh token manually and then delete it (simulate expiry) + user_id = TEST_USER_ID + + token = await refresh_token_service.create_refresh_token(mock_redis_dependency, user_id) + + # Delete the token (simulate expiry) + await mock_redis_dependency.delete(f"auth:rt:{token}") + + # Try to refresh + refresh_data = {"refresh_token": token} + response = await api_client.post("/auth/refresh", json=refresh_data) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_concurrent_logout_and_refresh(self, api_client: AsyncClient, mock_redis_dependency: Redis) -> None: + """Test handling of concurrent logout and refresh operations.""" + del mock_redis_dependency + # Register and login + register_data = { + "email": "concurrent@example.com", + "password": FLOW_TEST_PASSWORD, + "username": "concurrent", + } + + with patch("app.api.auth.routers.register.validate_user_create") as mock_override: + mock_override.return_value = UserCreate( + email=register_data["email"], + password=register_data["password"], + username=register_data["username"], + ) + await api_client.post("/auth/register", json=register_data) + + login_data = {"username": register_data["email"], "password": register_data["password"]} + login_response = await api_client.post("/auth/bearer/login", data=login_data) + + assert login_response.status_code == status.HTTP_200_OK + + # Get tokens from response and cookies + login_result = login_response.json() if login_response.text else {} + access_token = login_result.get("access_token") + refresh_token = login_response.cookies.get("refresh_token") + assert refresh_token is not None, "No refresh token in cookies" + + # Logout via the custom route so the refresh token cookie is blacklisted. + logout_response = await api_client.post( + "/auth/logout", + headers={"Authorization": f"Bearer {access_token}"} if access_token else {}, + ) + assert logout_response.status_code == status.HTTP_204_NO_CONTENT + + # Try to refresh immediately after logout + refresh_data = {"refresh_token": refresh_token} + refresh_response = await api_client.post("/auth/refresh", json=refresh_data) + + assert refresh_response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/backend/tests/integration/flows/test_newsletter_flow.py b/backend/tests/integration/flows/test_newsletter_flow.py new file mode 100644 index 00000000..51514b35 --- /dev/null +++ b/backend/tests/integration/flows/test_newsletter_flow.py @@ -0,0 +1,102 @@ +"""Integration tests for newsletter subscription flows.""" + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import status +from sqlalchemy import select + +from app.api.newsletter.models import NewsletterSubscriber +from app.api.newsletter.utils.tokens import JWTType, create_jwt_token + +if TYPE_CHECKING: + from collections.abc import Generator + + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.flow + +# Constants for test values +FLOW_EMAIL = "integration_flow@example.com" +IS_CONFIRMED = "is_confirmed" + + +@pytest.fixture +def mock_send_subscription_email() -> Generator[AsyncMock]: + """Fixture to mock newsletter subscription email sending.""" + with patch("app.api.newsletter.routers.send_newsletter_subscription_email", new_callable=AsyncMock) as mock: + yield mock + + +@pytest.fixture +def mock_send_unsubscription_email() -> Generator[AsyncMock]: + """Fixture to mock newsletter unsubscription request email sending.""" + with patch( + "app.api.newsletter.routers.send_newsletter_unsubscription_request_email", new_callable=AsyncMock + ) as mock: + yield mock + + +async def test_newsletter_subscription_lifecycle( + api_client: AsyncClient, + db_session: AsyncSession, + mock_send_subscription_email: AsyncMock, + mock_send_unsubscription_email: AsyncMock, +) -> None: + """Test the full lifecycle of a newsletter subscription. + + Lifecycle: + 1. Subscribe + 2. Confirm + 3. Request Unsubscribe + 4. Unsubscribe + """ + # 1. Subscribe + response = await api_client.post("/newsletter/subscribe", json=FLOW_EMAIL) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["email"] == FLOW_EMAIL + # Check is_confirmed if present in response + if IS_CONFIRMED in data: + assert data[IS_CONFIRMED] is False + + # Verify DB state + stmt = select(NewsletterSubscriber).where(NewsletterSubscriber.email == FLOW_EMAIL) + result = await db_session.execute(stmt) + subscriber = result.scalar_one_or_none() + assert subscriber is not None + assert subscriber.is_confirmed is False + + mock_send_subscription_email.assert_called_once() + + # 2. Confirm + # Manually generate token as we can't easily intercept the one sent in email in this test setup + token = create_jwt_token(FLOW_EMAIL, JWTType.NEWSLETTER_CONFIRMATION) + + response = await api_client.post("/newsletter/confirm", json=token) + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_confirmed"] is True + + # Verify DB state + await db_session.refresh(subscriber) + assert subscriber.is_confirmed is True + + # 3. Request Unsubscribe + response = await api_client.post("/newsletter/request-unsubscribe", json=FLOW_EMAIL) + assert response.status_code == status.HTTP_200_OK + + mock_send_unsubscription_email.assert_called_once() + + # 4. Unsubscribe + unsubscribe_token = create_jwt_token(FLOW_EMAIL, JWTType.NEWSLETTER_UNSUBSCRIBE) + + response = await api_client.post("/newsletter/unsubscribe", json=unsubscribe_token) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify DB state + result = await db_session.execute(stmt) + subscriber = result.scalar_one_or_none() + assert subscriber is None diff --git a/backend/tests/integration/flows/test_rpi_cam_flow.py b/backend/tests/integration/flows/test_rpi_cam_flow.py new file mode 100644 index 00000000..51c9c670 --- /dev/null +++ b/backend/tests/integration/flows/test_rpi_cam_flow.py @@ -0,0 +1,123 @@ +"""Integration tests for RPi Cam plugin flows.""" + +from typing import TYPE_CHECKING + +import pytest +from fastapi import status + +if TYPE_CHECKING: + from httpx import AsyncClient + + from app.api.auth.models import User + + +pytestmark = pytest.mark.flow + +# Constants for test values +CAM_NAME = "Integration Camera" +CAM_DESC = "Testing constraints" +UPDATED_CAM_NAME = "Updated Camera Name" +DUPLICATE_CAM_NAME = "Duplicate Name Camera" +INVALID_CAM_NAME = "Invalid Camera" +PUBLIC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "y": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "kid": "integration-key-1", +} +KEY_ID = "integration-key-1" + + +def build_camera_payload(name: str = CAM_NAME, description: str | None = CAM_DESC) -> dict[str, object]: + """Build a WebSocket-only camera create payload.""" + return { + "name": name, + "description": description, + "relay_public_key_jwk": PUBLIC_JWK, + "relay_key_id": KEY_ID, + } + + +async def test_camera_lifecycle_and_constraints(api_client_superuser: AsyncClient, db_superuser: User) -> None: + """Test the lifecycle of a camera and DB constraints. + + Steps: + 1. Create Camera + 2. Read Camera + 3. Update Camera + 4. Delete Camera + 5. Verify Owner Constraints + """ + # 1. Create Camera + camera_data = build_camera_payload() + + response = await api_client_superuser.post("/plugins/rpi-cam/cameras", json=camera_data) + + assert response.status_code == status.HTTP_201_CREATED + created_camera = response.json() + camera_id = created_camera["id"] + + assert created_camera["name"] == camera_data["name"] + assert created_camera["relay_key_id"] == KEY_ID + assert created_camera["relay_credential_status"] == "active" + assert created_camera["owner_id"] == str(db_superuser.id) + + # 2. Read Camera (List and Detail) + response = await api_client_superuser.get(f"/plugins/rpi-cam/cameras/{camera_id}") + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == camera_id + + # 3. Update Camera + update_data = {"name": UPDATED_CAM_NAME} + response = await api_client_superuser.patch(f"/plugins/rpi-cam/cameras/{camera_id}", json=update_data) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == UPDATED_CAM_NAME + + # 4. Delete Camera + response = await api_client_superuser.delete(f"/plugins/rpi-cam/cameras/{camera_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify deletion + response = await api_client_superuser.get(f"/plugins/rpi-cam/cameras/{camera_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_current_user_camera_alias_routes(api_client_superuser: AsyncClient) -> None: + """The user-scoped camera alias should expose the same CRUD flow.""" + camera_data = build_camera_payload() + + response = await api_client_superuser.post("/users/me/cameras", json=camera_data) + assert response.status_code == status.HTTP_201_CREATED + camera_id = response.json()["id"] + + response = await api_client_superuser.get("/users/me/cameras") + assert response.status_code == status.HTTP_200_OK + + response = await api_client_superuser.get(f"/users/me/cameras/{camera_id}") + assert response.status_code == status.HTTP_200_OK + + response = await api_client_superuser.delete(f"/users/me/cameras/{camera_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + +async def test_camera_unique_constraints(api_client_superuser: AsyncClient) -> None: + """Test unique constraints if any.""" + camera_data = build_camera_payload(name=DUPLICATE_CAM_NAME) + + # First camera + response = await api_client_superuser.post("/plugins/rpi-cam/cameras", json=camera_data) + assert response.status_code == status.HTTP_201_CREATED + + # Second camera + response = await api_client_superuser.post("/plugins/rpi-cam/cameras", json=camera_data) + assert response.status_code == status.HTTP_201_CREATED + + +async def test_camera_required_fields(api_client_superuser: AsyncClient) -> None: + """Test API structure validation for required fields.""" + camera_data = { + "name": INVALID_CAM_NAME, + } + response = await api_client_superuser.post("/plugins/rpi-cam/cameras", json=camera_data) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/backend/tests/integration/models/__init__.py b/backend/tests/integration/models/__init__.py new file mode 100644 index 00000000..fcb78a92 --- /dev/null +++ b/backend/tests/integration/models/__init__.py @@ -0,0 +1 @@ +"""Integration tests for authentication models.""" diff --git a/backend/tests/integration/models/test_auth_models.py b/backend/tests/integration/models/test_auth_models.py new file mode 100644 index 00000000..dbf059b2 --- /dev/null +++ b/backend/tests/integration/models/test_auth_models.py @@ -0,0 +1,79 @@ +"""Integration tests for auth model persistence and constraints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.api.auth.models import OrganizationRole +from tests.factories.models import OrganizationFactory, UserFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +pytestmark = pytest.mark.db + +TEST_EMAIL = "test@example.com" +TEST_USERNAME = "testuser" + + +async def test_email_uniqueness_is_enforced(db_session: AsyncSession) -> None: + """The database must reject duplicate email addresses.""" + await UserFactory.create_async(db_session, email="unique@example.com", hashed_password="hashed1") + + with pytest.raises(IntegrityError, match="unique"): + await UserFactory.create_async(db_session, email="unique@example.com", hashed_password="hashed2") + + +async def test_username_uniqueness_ignores_null_values(db_session: AsyncSession) -> None: + """Usernames should be unique when present, but nullable usernames remain allowed.""" + await UserFactory.create_async( + db_session, + email="named@example.com", + username="uniqueuser", + hashed_password="hashed1", + ) + await UserFactory.create_async(db_session, email="null1@example.com", hashed_password="hashed2", username=None) + await UserFactory.create_async(db_session, email="null2@example.com", hashed_password="hashed3", username=None) + + with pytest.raises(IntegrityError, match="unique"): + await UserFactory.create_async( + db_session, + email="named2@example.com", + username="uniqueuser", + hashed_password="hashed4", + ) + + +async def test_user_can_join_and_leave_organization(db_session: AsyncSession) -> None: + """Users should be able to gain and lose organization membership cleanly.""" + owner = await UserFactory.create_async(db_session, email="owner@example.com", hashed_password="hashed") + organization = await OrganizationFactory.create_async(db_session, name="Test Org", owner_id=owner.id) + user = await UserFactory.create_async( + db_session, + email=TEST_EMAIL, + hashed_password="hashed", + organization_id=organization.id, + organization_role=OrganizationRole.MEMBER, + ) + + assert user.organization_id == organization.id + assert user.organization_role == OrganizationRole.MEMBER + + user.organization_id = None + user.organization_role = None + db_session.add(user) + await db_session.flush() + await db_session.refresh(user) + + assert user.organization_id is None + assert user.organization_role is None + + +def test_organization_role_enum_values_match_storage_strings() -> None: + """Enum values should stay aligned with the stored string values.""" + assert OrganizationRole.OWNER.value == "owner" + assert OrganizationRole.MEMBER.value == "member" diff --git a/backend/tests/integration/models/test_background_data_models.py b/backend/tests/integration/models/test_background_data_models.py new file mode 100644 index 00000000..beff4efb --- /dev/null +++ b/backend/tests/integration/models/test_background_data_models.py @@ -0,0 +1,96 @@ +"""Integration tests for background-data persistence and relationships.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import QueryableAttribute + +from app.api.background_data.models import Category, Taxonomy +from tests.factories.models import ( + CategoryFactory, + CategoryMaterialLinkFactory, + CategoryProductTypeLinkFactory, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.background_data.models import Material, ProductType + + +pytestmark = pytest.mark.db + + +async def test_deleting_taxonomy_cascades_categories(db_session: AsyncSession, db_taxonomy: Taxonomy) -> None: + """Deleting a taxonomy should remove its categories.""" + category = await CategoryFactory.create_async(db_session, name="Test Category", taxonomy_id=db_taxonomy.id) + category_id = category.id + + await db_session.delete(db_taxonomy) + await db_session.flush() + + assert await db_session.get(Category, category_id) is None + + +async def test_category_requires_taxonomy(db_session: AsyncSession) -> None: + """Categories should fail without a taxonomy foreign key.""" + category = CategoryFactory.build(name="Invalid Category") + db_session.add(category) + + with pytest.raises(IntegrityError, match="taxonomy_id"): + await db_session.flush() + + +async def test_category_hierarchy_loads_subcategories(db_session: AsyncSession, db_taxonomy: Taxonomy) -> None: + """Category trees should retain parent-child relationships.""" + parent = await CategoryFactory.create_async(db_session, name="Metals", taxonomy_id=db_taxonomy.id) + await CategoryFactory.create_async( + db_session, + name="Ferrous", + taxonomy_id=db_taxonomy.id, + supercategory_id=parent.id, + ) + await db_session.refresh(parent) + + assert parent.subcategories is not None + assert [subcategory.name for subcategory in parent.subcategories] == ["Ferrous"] + + +async def test_material_and_product_type_links_round_trip( + db_session: AsyncSession, + db_category: Category, + db_material: Material, + db_product_type: ProductType, +) -> None: + """Many-to-many links for categories should remain queryable from both sides.""" + await CategoryMaterialLinkFactory.create_async( + db_session, + category_id=db_category.id, + material_id=db_material.id, + ) + await CategoryProductTypeLinkFactory.create_async( + db_session, + category_id=db_category.id, + product_type_id=db_product_type.id, + ) + + stmt = ( + select(Category) + .where(Category.id == db_category.id) + .options( + selectinload(cast("QueryableAttribute[Any]", Category.materials)), + selectinload(cast("QueryableAttribute[Any]", Category.product_types)), + ) + ) + result = await db_session.execute(stmt) + category = result.scalar_one() + + assert category.materials is not None + assert category.product_types is not None + assert [material.id for material in category.materials] == [db_material.id] + assert [product_type.id for product_type in category.product_types] == [db_product_type.id] diff --git a/backend/tests/integration/models/test_data_collection_models.py b/backend/tests/integration/models/test_data_collection_models.py new file mode 100644 index 00000000..ac82984c --- /dev/null +++ b/backend/tests/integration/models/test_data_collection_models.py @@ -0,0 +1,68 @@ +"""Integration tests for product persistence, hierarchy, and ownership behavior.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import insert +from sqlalchemy.exc import IntegrityError + +from app.api.data_collection.models.product import Product +from tests.factories.models import MaterialFactory, ProductFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.auth.models import User + + +pytestmark = pytest.mark.db + + +async def test_product_requires_owner(db_session: AsyncSession) -> None: + """Products without an owner should fail the database constraint.""" + with pytest.raises(IntegrityError): + await db_session.execute(insert(Product).values(name="Orphan Product", owner_id=None)) + + +async def test_product_hierarchy_links_parent_and_child(db_session: AsyncSession, db_superuser: User) -> None: + """Parent and child products should preserve the hierarchy fields.""" + parent = await ProductFactory.create_async( + db_session, + owner_id=db_superuser.id, + name="Parent Product", + parent_id=None, + product_type_id=None, + ) + child = await ProductFactory.create_async( + db_session, + owner_id=db_superuser.id, + name="Component", + parent_id=parent.id, + amount_in_parent=2, + product_type_id=None, + ) + await db_session.refresh(child) + + assert child.parent_id == parent.id + assert child.amount_in_parent == 2 + assert child.is_base_product is False + assert child.parent is not None + + +async def test_product_bom_and_owner_relationships_are_accessible(db_session: AsyncSession, db_superuser: User) -> None: + """Owner and BOM relationships should remain available after persistence.""" + await MaterialFactory.create_async(db_session, name="Steel") + product = await ProductFactory.create_async( + db_session, + owner_id=db_superuser.id, + name="Owned Product", + bill_of_materials=[], + product_type_id=None, + ) + await db_session.refresh(product) + + assert product.owner is not None + assert product.owner.id == db_superuser.id + assert product.bill_of_materials == [] diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py deleted file mode 100644 index 7444eb17..00000000 --- a/backend/tests/test_main.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Main test module for the application.""" - -from typing import TYPE_CHECKING - -from fastapi.testclient import TestClient - -if TYPE_CHECKING: - from httpx import Response - - -def test_read_units(client: TestClient) -> None: - """Test the units endpoint.""" - response: Response = client.get("/units") - assert response.status_code == 200 - assert response.json() == ["kg", "g", "m", "cm"] - - -def test_read_items(client: TestClient) -> None: - """Test the items endpoint.""" - response: Response = client.get("/file-storage/videos") - assert response.status_code == 200 - assert response.json() == [] diff --git a/backend/tests/unit/README.md b/backend/tests/unit/README.md new file mode 100644 index 00000000..ac320807 --- /dev/null +++ b/backend/tests/unit/README.md @@ -0,0 +1,11 @@ +# Unit Tests + +Use this tier for isolated fast tests only. + +- No Docker or database startup +- No real app lifespan unless the behavior is fully mocked and still isolated +- Prefer local stubs, parametrization, and function-based tests +- Prefer small behavior-focused files over domain-sized catch-all modules +- Patch the owning module seam directly + - example: patch `product_commands` or `product_tree_queries`, not a broad legacy facade +- Keep helper modules private and local to one test area when possible; avoid recreating package-level test registries diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 00000000..ea3f8b92 --- /dev/null +++ b/backend/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" diff --git a/backend/tests/unit/auth/__init__.py b/backend/tests/unit/auth/__init__.py new file mode 100644 index 00000000..def088ab --- /dev/null +++ b/backend/tests/unit/auth/__init__.py @@ -0,0 +1 @@ +"""Initialization for authentication unit tests.""" diff --git a/backend/tests/unit/auth/_org_crud_support.py b/backend/tests/unit/auth/_org_crud_support.py new file mode 100644 index 00000000..d0f8eaba --- /dev/null +++ b/backend/tests/unit/auth/_org_crud_support.py @@ -0,0 +1,22 @@ +"""Shared helpers for organization CRUD unit tests.""" + +from __future__ import annotations + +import uuid + +from app.api.auth.models import OrganizationRole, User +from tests.factories.models import UserFactory + + +def make_user( + organization_id: uuid.UUID | None = None, + organization_role: OrganizationRole | None = None, + *, + is_superuser: bool = False, +) -> User: + """Build a user with organization fields configured for org CRUD tests.""" + user = UserFactory.build(id=uuid.uuid4(), is_superuser=is_superuser) + user.organization_id = organization_id + user.organization_role = organization_role + user.organization = None + return user diff --git a/backend/tests/unit/auth/test_auth_utils.py b/backend/tests/unit/auth/test_auth_utils.py new file mode 100644 index 00000000..80569acd --- /dev/null +++ b/backend/tests/unit/auth/test_auth_utils.py @@ -0,0 +1,306 @@ +"""Unit tests for authentication utilities.""" +# spell-checker: ignore hget, hset, mailinator +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists +from redis.exceptions import ConnectionError as RedisConnectionError + +from app.api.auth.schemas import UserCreate +from app.api.auth.services.email_checker import EmailChecker, load_local_disposable_domains +from app.api.auth.services.programmatic_user_crud import create_user +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from pathlib import Path + + from sqlalchemy.ext.asyncio import AsyncSession + +# Constants for test values +PW_TOO_SHORT = "Too short" +PASSWORD_INVALID_MSG = f"Password is invalid: {PW_TOO_SHORT}" + + +# Allow private method access for testing purposes +@pytest.fixture +def mock_redis() -> AsyncMock: + """Fixture for a mock Redis client.""" + return AsyncMock() + + +class TestEmailChecker: + """Tests for the EmailChecker utility.""" + + async def test_init_without_redis(self) -> None: + """Test initialization without Redis client.""" + checker = EmailChecker(redis_client=None) + + with patch( + "app.api.auth.services.email_checker.load_local_disposable_domains", + return_value={"temp-mail.org"}, + ): + await checker.initialize() + + assert checker._initialized is True + assert checker._domains == {"temp-mail.org"} + + await checker.close() + + async def test_init_with_redis(self, mock_redis: AsyncMock) -> None: + """Test initialization with Redis client when domains don't exist in cache.""" + mock_redis.exists = AsyncMock(return_value=False) + mock_pipe = MagicMock() + mock_pipe.execute = AsyncMock() + mock_redis.pipeline = MagicMock(return_value=mock_pipe) + checker = EmailChecker(redis_client=mock_redis) + + with patch( + "app.api.auth.services.email_checker.load_local_disposable_domains", + return_value={"mailinator.com", "temp-mail.org"}, + ): + await checker.initialize() + + assert checker._initialized is True + mock_redis.exists.assert_called_once_with("temp_domains") + mock_pipe.delete.assert_called_once() + mock_pipe.hset.assert_called_once() + + await checker.close() + + async def test_init_with_redis_cached(self, mock_redis: AsyncMock) -> None: + """Test initialization with Redis client when domains already exist in cache.""" + mock_redis.exists = AsyncMock(return_value=True) + checker = EmailChecker(redis_client=mock_redis) + + with patch( + "app.api.auth.services.email_checker.load_local_disposable_domains", + return_value={"temp-mail.org"}, + ): + await checker.initialize() + + assert checker._initialized is True + mock_redis.exists.assert_called_once_with("temp_domains") + mock_redis.hset.assert_not_awaited() + + await checker.close() + + async def test_refresh_domains_success(self) -> None: + """Test successful domain refresh.""" + checker = EmailChecker(redis_client=None) + checker._initialized = True + + with patch.object( + checker, + "_fetch_remote_domains", + AsyncMock(return_value={"mailinator.com", "temp-mail.org"}), + ): + await checker.run_once() + + assert checker._domains == {"mailinator.com", "temp-mail.org"} + + async def test_refresh_domains_failure(self, mock_redis: AsyncMock) -> None: + """Test domain refresh failure handles exceptions gracefully.""" + checker = EmailChecker(redis_client=mock_redis) + checker._initialized = True + + with patch.object(checker, "_fetch_remote_domains", AsyncMock(side_effect=RuntimeError("Refresh failed"))): + await checker.run_once() + + mock_redis.hset.assert_not_awaited() + + def test_load_local_disposable_domains(self, tmp_path: Path) -> None: + """Local fallback files should ignore comments and blank lines.""" + domains_file = tmp_path / "domains.txt" + domains_file.write_text("# comment\nTemp-Mail.org\n\nmailinator.com\n", encoding="utf-8") + + assert load_local_disposable_domains(domains_file) == {"mailinator.com", "temp-mail.org"} + + async def test_is_disposable_true(self) -> None: + """Test identifying disposable email.""" + checker = EmailChecker(redis_client=None) + checker._initialized = True + checker._domains = {"temp-mail.org"} + + result = await checker.is_disposable("test@temp-mail.org") + + assert result is True + + async def test_is_disposable_false(self) -> None: + """Test identifying non-disposable email.""" + checker = EmailChecker(redis_client=None) + checker._initialized = True + checker._domains = {"temp-mail.org"} + + result = await checker.is_disposable("user@example.com") + + assert result is False + + async def test_is_disposable_redis(self, mock_redis: AsyncMock) -> None: + """Test disposable check via Redis.""" + mock_redis.hget = AsyncMock(return_value=b"1") + checker = EmailChecker(redis_client=mock_redis) + checker._initialized = True + + result = await checker.is_disposable("test@temp-mail.org") + + assert result is True + mock_redis.hget.assert_awaited_once_with("temp_domains", "temp-mail.org") + + async def test_is_disposable_error_fail_open(self, mock_redis: AsyncMock) -> None: + """Test error handling during check returns False (fail open).""" + mock_redis.hget = AsyncMock(side_effect=RedisConnectionError("Redis down")) + checker = EmailChecker(redis_client=mock_redis) + checker._initialized = True + + result = await checker.is_disposable("user@example.com") + + assert result is False + + async def test_is_disposable_not_initialized(self) -> None: + """Test check when checker is not initialized.""" + checker = EmailChecker(redis_client=None) + + result = await checker.is_disposable("user@example.com") + + assert result is False + + async def test_close_cancels_task(self) -> None: + """Test close cancels the refresh task.""" + checker = EmailChecker(redis_client=None) + checker._initialized = True + + mock_task = cast("Any", asyncio.Future()) + mock_task.set_result(None) + mock_task.cancel = MagicMock() + + checker._task = mock_task + + await checker.close() + + mock_task.cancel.assert_called_once() + assert checker._initialized is False + + +class TestProgrammaticUserCrud: + """Tests for programmatic user CRUD operations.""" + + @pytest.fixture + def user_create(self) -> UserCreate: + """Fixture for UserCreate schema.""" + return UserCreate(email="test@example.com", password="password123") + + @pytest.fixture + def mock_user_manager(self) -> AsyncMock: + """Fixture for a mock user manager.""" + return AsyncMock() + + async def test_create_user_success( + self, mock_session: AsyncSession, user_create: UserCreate, mock_user_manager: AsyncMock + ) -> None: + """Test successful user creation.""" + expected_user = UserFactory.build(email=user_create.email, hashed_password="hashed") + mock_user_manager.create.return_value = expected_user + + # Mock the context manager + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_user_manager + mock_context.__aexit__.return_value = None + + with patch( + "app.api.auth.services.programmatic_user_crud.get_chained_async_user_manager_context", + return_value=mock_context, + ): + user = await create_user(mock_session, user_create, send_registration_email=False) + + assert user == expected_user + mock_user_manager.create.assert_called_once_with(user_create) + + async def test_create_user_with_email( + self, mock_session: AsyncSession, user_create: UserCreate, mock_user_manager: AsyncMock + ) -> None: + """Test user creation with verification email.""" + expected_user = UserFactory.build(email=user_create.email, hashed_password="hashed") + mock_user_manager.create.return_value = expected_user + mock_user_manager.request_verify = AsyncMock() + + # Mock the context manager + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_user_manager + mock_context.__aexit__.return_value = None + + with patch( + "app.api.auth.services.programmatic_user_crud.get_chained_async_user_manager_context", + return_value=mock_context, + ): + user = await create_user(mock_session, user_create, send_registration_email=True) + + assert user == expected_user + mock_user_manager.create.assert_called_once_with(user_create) + + # Verify request_verify was called with user + mock_user_manager.request_verify.assert_called_once_with(expected_user) + + async def test_create_user_can_skip_breach_check( + self, mock_session: AsyncSession, user_create: UserCreate, mock_user_manager: AsyncMock + ) -> None: + """Programmatic bootstrap flows can disable the network breach check.""" + expected_user = UserFactory.build(email=user_create.email, hashed_password="hashed") + mock_user_manager.create.return_value = expected_user + + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_user_manager + mock_context.__aexit__.return_value = None + + with patch( + "app.api.auth.services.programmatic_user_crud.get_chained_async_user_manager_context", + return_value=mock_context, + ): + user = await create_user(mock_session, user_create, skip_breach_check=True) + + assert user == expected_user + assert mock_user_manager.skip_breach_check is True + mock_user_manager.create.assert_called_once_with(user_create) + + async def test_create_user_already_exists( + self, mock_session: AsyncSession, user_create: UserCreate, mock_user_manager: AsyncMock + ) -> None: + """Test user creation when user already exists.""" + mock_user_manager.create.side_effect = UserAlreadyExists() + + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_user_manager + mock_context.__aexit__.return_value = None + + with patch( + "app.api.auth.services.programmatic_user_crud.get_chained_async_user_manager_context", + return_value=mock_context, + ): + with pytest.raises(UserAlreadyExists) as exc: + await create_user(mock_session, user_create) + + assert f"User with email {user_create.email} already exists" in str(exc.value) + + async def test_create_user_invalid_password( + self, mock_session: AsyncSession, user_create: UserCreate, mock_user_manager: AsyncMock + ) -> None: + """Test user creation with invalid password.""" + mock_user_manager.create.side_effect = InvalidPasswordException(reason=PW_TOO_SHORT) + + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_user_manager + mock_context.__aexit__.return_value = None + + with patch( + "app.api.auth.services.programmatic_user_crud.get_chained_async_user_manager_context", + return_value=mock_context, + ): + with pytest.raises(InvalidPasswordException) as exc: + await create_user(mock_session, user_create) + + assert PASSWORD_INVALID_MSG in str(exc.value) diff --git a/backend/tests/unit/auth/test_config.py b/backend/tests/unit/auth/test_config.py new file mode 100644 index 00000000..5dbd2755 --- /dev/null +++ b/backend/tests/unit/auth/test_config.py @@ -0,0 +1,108 @@ +"""Unit tests for the auth module configuration.""" + +from pydantic import SecretStr + +from app.api.auth.config import AuthSettings + + +class TestAuthSettingsDefaults: + """AuthSettings should produce safe, predictable defaults when no env file is present.""" + + def test_oauth_redirect_lists_default_empty(self) -> None: + """OAuth redirect path/native allowlists default empty.""" + settings = AuthSettings() + assert settings.oauth_allowed_redirect_paths == [] + assert settings.oauth_allowed_native_redirect_uris == [] + + def test_email_defaults_to_username(self) -> None: + """Resolved sender fields should fall back to the SMTP username when omitted.""" + settings = AuthSettings( + email_username="noreply@example.com", + email_from="", + email_reply_to="", + ) + assert settings.email.sender is not None + assert settings.email.reply_to is not None + assert settings.email.sender.email == "noreply@example.com" + assert settings.email.reply_to.email == "noreply@example.com" + + def test_email_parsing_reuses_sender_when_reply_to_is_omitted(self) -> None: + """Parsed sender/reply-to values should share the same fallback logic.""" + settings = AuthSettings( + email_username="smtp@example.com", + email_from="Reverse Engineering Lab ", + email_reply_to="", + ) + assert settings.email.sender is not None + assert settings.email.reply_to is not None + assert settings.email.sender.name == "Reverse Engineering Lab" + assert settings.email.sender.email == "noreply@example.com" + assert settings.email.reply_to == settings.email.sender + + def test_email_recipient_uses_shared_parser(self) -> None: + """Recipients should be parsed through the shared email config.""" + settings = AuthSettings() + recipient = settings.email.recipient("person@example.com") + + assert recipient.name == "person" + assert recipient.email == "person@example.com" + + def test_token_ttl_defaults(self) -> None: + """Token TTL defaults encode the expected business rules.""" + settings = AuthSettings() + assert settings.access_token_ttl_seconds == 60 * 15 # 15 min + assert settings.oauth_state_token_ttl_seconds == 60 * 10 # 10 min + assert settings.reset_password_token_ttl_seconds == 60 * 60 # 1 h + assert settings.verification_token_ttl_seconds == 60 * 60 * 24 # 1 day + assert settings.newsletter_unsubscription_token_ttl_seconds == 60 * 60 * 24 * 30 # 30 days + + def test_session_defaults(self) -> None: + """Session and refresh-token defaults are sensible.""" + settings = AuthSettings() + assert settings.refresh_token_expire_days == 30 + assert settings.session_id_length == 32 + + def test_rate_limit_defaults(self) -> None: + """Rate limiting defaults are enabled with conservative values.""" + settings = AuthSettings() + assert settings.rate_limit_login_attempts_per_minute == 3 + assert settings.rate_limit_register_attempts_per_hour == 5 + assert settings.rate_limit_password_reset_attempts_per_hour == 3 + + def test_youtube_api_scopes_default(self) -> None: + """YouTube API scopes default to the expected list of four scopes.""" + settings = AuthSettings() + assert len(settings.youtube_api_scopes) == 4 + assert all(s.startswith("https://www.googleapis.com/auth/youtube") for s in settings.youtube_api_scopes) + + +class TestAuthSettingsOverrides: + """AuthSettings should accept constructor-level overrides for all fields.""" + + def test_secrets_can_be_set_via_constructor(self) -> None: + """Secrets supplied in __init__ are stored and retrievable.""" + secret = "my-test-jwt-secret" + settings = AuthSettings(fastapi_users_secret=SecretStr(secret)) + assert settings.fastapi_users_secret.get_secret_value() == secret + + def test_oauth_redirect_paths_can_be_set(self) -> None: + """OAuth allowed paths can be configured via constructor.""" + paths = ["/auth/callback", "/oauth/complete"] + settings = AuthSettings(oauth_allowed_redirect_paths=paths) + assert settings.oauth_allowed_redirect_paths == paths + + def test_rate_limit_can_be_overridden(self) -> None: + """Rate limiting parameters accept custom values.""" + settings = AuthSettings(rate_limit_login_attempts_per_minute=10, rate_limit_password_reset_attempts_per_hour=8) + assert settings.rate_limit_login_attempts_per_minute == 10 + assert settings.rate_limit_password_reset_attempts_per_hour == 8 + + def test_explicit_email_from_and_reply_to_are_preserved(self) -> None: + """Explicit sender overrides should win over the username fallback.""" + settings = AuthSettings( + email_username="smtp@example.com", + email_from="Sender ", + email_reply_to="reply@example.com", + ) + assert settings.email_from == "Sender " + assert settings.email_reply_to == "reply@example.com" diff --git a/backend/tests/unit/auth/test_context_managers.py b/backend/tests/unit/auth/test_context_managers.py new file mode 100644 index 00000000..dc204c44 --- /dev/null +++ b/backend/tests/unit/auth/test_context_managers.py @@ -0,0 +1,54 @@ +"""Unit tests for programmatic auth user-manager context wiring.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from app.api.auth.services.programmatic_user_crud import get_chained_async_user_manager_context + + +class TestGetChainedAsyncUserManagerContext: + """Tests for get_chained_async_user_manager_context.""" + + async def test_uses_provided_session(self) -> None: + """Test that a provided session is used directly.""" + mock_session = AsyncMock() + mock_user_db = MagicMock() + mock_user_manager = MagicMock() + + with ( + patch("app.api.auth.services.programmatic_user_crud.get_async_user_db_context") as mock_db_ctx, + patch("app.api.auth.services.programmatic_user_crud.get_async_user_manager_context") as mock_mgr_ctx, + ): + mock_db_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_user_db) + mock_db_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + mock_mgr_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_user_manager) + mock_mgr_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + + async with get_chained_async_user_manager_context(session=mock_session) as user_manager: + assert user_manager == mock_user_manager + + mock_db_ctx.assert_called_once_with(mock_session) + + async def test_creates_session_when_not_provided(self) -> None: + """Test that a new session is created when none is provided.""" + mock_db_session = AsyncMock() + mock_user_db = MagicMock() + mock_user_manager = MagicMock() + + with ( + patch("app.api.auth.services.programmatic_user_crud.async_session_context") as mock_session_ctx, + patch("app.api.auth.services.programmatic_user_crud.get_async_user_db_context") as mock_db_ctx, + patch("app.api.auth.services.programmatic_user_crud.get_async_user_manager_context") as mock_mgr_ctx, + ): + mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db_session) + mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + mock_db_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_user_db) + mock_db_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + mock_mgr_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_user_manager) + mock_mgr_ctx.return_value.__aexit__ = AsyncMock(return_value=False) + + async with get_chained_async_user_manager_context() as user_manager: + assert user_manager == mock_user_manager + + mock_session_ctx.assert_called_once() diff --git a/backend/tests/unit/auth/test_exceptions.py b/backend/tests/unit/auth/test_exceptions.py new file mode 100644 index 00000000..1ef73d90 --- /dev/null +++ b/backend/tests/unit/auth/test_exceptions.py @@ -0,0 +1,281 @@ +"""Tests for authentication exceptions module. + +Tests validate exception hierarchy, HTTP status codes, message formatting, +and the handle_organization_integrity_error function. +""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi import status +from fastapi_users.router.common import ErrorCode +from sqlalchemy.exc import IntegrityError + +from app.api.auth.exceptions import ( + AlreadyMemberError, + AuthCRUDError, + DisposableEmailError, + InvalidOAuthProviderError, + OAuthAccountAlreadyLinkedError, + OAuthAccountNotLinkedError, + OAuthEmailUnavailableError, + OAuthInactiveUserHTTPError, + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, + OAuthStateDecodeError, + OAuthStateExpiredError, + OAuthUserAlreadyExistsHTTPError, + OrganizationHasMembersError, + OrganizationNameExistsError, + RefreshTokenInvalidError, + RefreshTokenNotFoundError, + RefreshTokenRevokedError, + RefreshTokenUserInactiveError, + RegistrationInvalidPasswordHTTPError, + RegistrationUnexpectedHTTPError, + RegistrationUserAlreadyExistsHTTPError, + UserDoesNotOwnOrgError, + UserHasNoOrgError, + UserIsNotMemberError, + UserNameAlreadyExistsError, + UserOwnershipError, + UserOwnsOrgError, + handle_organization_integrity_error, +) +from app.api.common.exceptions import APIError + +_USER_ID = uuid4() +_ORG_ID = uuid4() + + +class TestAuthCRUDErrorHierarchy: + """Test the exception class hierarchy.""" + + def test_auth_crud_error_is_not_api_error(self) -> None: + """Verify AuthCRUDError stays a marker mixin, while subclasses inherit APIError via concrete families.""" + assert not issubclass(AuthCRUDError, APIError) + + def test_user_ownership_error_is_api_error_not_auth_crud(self) -> None: + """Verify UserOwnershipError inherits from APIError directly, not AuthCRUDError.""" + assert issubclass(UserOwnershipError, APIError) + assert not issubclass(UserOwnershipError, AuthCRUDError) + + +@pytest.mark.parametrize( + ("exception_cls", "kwargs", "expected_status", "expected_fragments"), + [ + # UserNameAlreadyExistsError + ( + UserNameAlreadyExistsError, + {"username": "jean.dupont"}, + status.HTTP_409_CONFLICT, + ["jean.dupont", "already taken"], + ), + # AlreadyMemberError -- personal vs admin phrasing + (AlreadyMemberError, {}, status.HTTP_409_CONFLICT, ["You already belong"]), + (AlreadyMemberError, {"user_id": _USER_ID}, status.HTTP_409_CONFLICT, [str(_USER_ID), "already belongs"]), + ( + AlreadyMemberError, + {"user_id": _USER_ID, "details": "Active member since Jan 2024"}, + status.HTTP_409_CONFLICT, + [str(_USER_ID), "Active member"], + ), + # UserOwnsOrgError + (UserOwnsOrgError, {}, status.HTTP_409_CONFLICT, ["You own an organization"]), + (UserOwnsOrgError, {"user_id": _USER_ID}, status.HTTP_409_CONFLICT, [str(_USER_ID), "owns an organization"]), + ( + UserOwnsOrgError, + {"user_id": _USER_ID, "details": "Transfer ownership first"}, + status.HTTP_409_CONFLICT, + [str(_USER_ID), "Transfer ownership"], + ), + # UserHasNoOrgError + (UserHasNoOrgError, {}, status.HTTP_404_NOT_FOUND, ["You do not belong"]), + (UserHasNoOrgError, {"user_id": _USER_ID}, status.HTTP_404_NOT_FOUND, [str(_USER_ID), "does not belong"]), + ( + UserHasNoOrgError, + {"user_id": _USER_ID, "details": "Must join before uploading"}, + status.HTTP_404_NOT_FOUND, + [str(_USER_ID), "Must join"], + ), + # UserIsNotMemberError + (UserIsNotMemberError, {}, status.HTTP_403_FORBIDDEN, ["You do not belong to this organization"]), + (UserIsNotMemberError, {"user_id": _USER_ID}, status.HTTP_403_FORBIDDEN, [str(_USER_ID), "does not belong"]), + ( + UserIsNotMemberError, + {"user_id": _USER_ID, "organization_id": _ORG_ID}, + status.HTTP_403_FORBIDDEN, + [str(_USER_ID), str(_ORG_ID)], + ), + ( + UserIsNotMemberError, + {"user_id": _USER_ID, "organization_id": _ORG_ID, "details": "Membership denied"}, + status.HTTP_403_FORBIDDEN, + [str(_USER_ID), str(_ORG_ID), "Membership denied"], + ), + # UserDoesNotOwnOrgError + (UserDoesNotOwnOrgError, {}, status.HTTP_403_FORBIDDEN, ["You do not own"]), + ( + UserDoesNotOwnOrgError, + {"user_id": _USER_ID, "details": "Owner privileges required"}, + status.HTTP_403_FORBIDDEN, + [str(_USER_ID), "Owner privileges"], + ), + # OrganizationHasMembersError + ( + OrganizationHasMembersError, + {}, + status.HTTP_409_CONFLICT, + ["has members and cannot be deleted", "Transfer ownership"], + ), + ( + OrganizationHasMembersError, + {"organization_id": _ORG_ID}, + status.HTTP_409_CONFLICT, + [str(_ORG_ID), "cannot be deleted"], + ), + # OrganizationNameExistsError + (OrganizationNameExistsError, {}, status.HTTP_409_CONFLICT, ["Organization with this name already exists"]), + (OrganizationNameExistsError, {"msg": "Duplicate: TU Berlin Lab"}, status.HTTP_409_CONFLICT, ["TU Berlin Lab"]), + # DisposableEmailError + ( + DisposableEmailError, + {"email": "temp@guerrillamail.com"}, + status.HTTP_400_BAD_REQUEST, + ["temp@guerrillamail.com", "disposable", "not allowed"], + ), + # InvalidOAuthProviderError + ( + InvalidOAuthProviderError, + {"provider": "discord"}, + status.HTTP_400_BAD_REQUEST, + ["Invalid OAuth provider", "discord"], + ), + # OAuthAccountNotLinkedError + ( + OAuthAccountNotLinkedError, + {"provider": "google"}, + status.HTTP_404_NOT_FOUND, + ["OAuth account not linked", "google"], + ), + # RefreshToken errors + (RefreshTokenNotFoundError, {}, status.HTTP_401_UNAUTHORIZED, ["Refresh token not found"]), + (RefreshTokenInvalidError, {}, status.HTTP_401_UNAUTHORIZED, ["Invalid or expired refresh token"]), + (RefreshTokenRevokedError, {}, status.HTTP_401_UNAUTHORIZED, ["Token has been revoked"]), + (RefreshTokenUserInactiveError, {}, status.HTTP_401_UNAUTHORIZED, ["User not found or inactive"]), + ], + ids=lambda v: v.__name__ if isinstance(v, type) else "", +) +def test_api_error_status_and_message( + exception_cls: type[APIError], + kwargs: dict, + expected_status: int, + expected_fragments: list[str], +) -> None: + """Each APIError subclass produces the correct HTTP status and message.""" + error = exception_cls(**kwargs) + assert error.http_status_code == expected_status + for fragment in expected_fragments: + assert fragment in error.message, f"Expected '{fragment}' in '{error.message}'" + + +def test_user_ownership_error_message() -> None: + """UserOwnershipError includes model name, user_id, and model_id.""" + mock_model = Mock() + mock_model.model_label = "Product" + + user_id = uuid4() + model_id = uuid4() + error = UserOwnershipError(model_type=mock_model, model_id=model_id, user_id=user_id) + + assert error.http_status_code == status.HTTP_403_FORBIDDEN + assert "Product" in error.message + assert str(user_id) in error.message + assert str(model_id) in error.message + assert "does not own" in error.message.lower() + + +@pytest.mark.parametrize( + ("error_cls", "kwargs", "expected_status", "expected_detail"), + [ + (OAuthStateDecodeError, {}, 400, ErrorCode.ACCESS_TOKEN_DECODE_ERROR), + (OAuthStateExpiredError, {}, 400, ErrorCode.ACCESS_TOKEN_ALREADY_EXPIRED), + (OAuthInvalidStateError, {}, 400, ErrorCode.OAUTH_INVALID_STATE), + (OAuthInvalidRedirectURIError, {}, 400, "Invalid redirect_uri"), + (OAuthEmailUnavailableError, {}, 400, ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL), + (OAuthUserAlreadyExistsHTTPError, {}, 400, ErrorCode.OAUTH_USER_ALREADY_EXISTS), + (OAuthInactiveUserHTTPError, {}, 400, ErrorCode.LOGIN_BAD_CREDENTIALS), + (OAuthAccountAlreadyLinkedError, {}, 400, "This account is already linked to another user."), + (RegistrationUserAlreadyExistsHTTPError, {}, 409, "already exists"), + (RegistrationInvalidPasswordHTTPError, {"reason": "score below threshold"}, 400, "Password validation failed"), + (RegistrationUnexpectedHTTPError, {}, 500, "An unexpected error occurred during registration"), + ], + ids=lambda v: v.__name__ if isinstance(v, type) else "", +) +def test_http_error_adapter( + error_cls: type, + kwargs: dict, + expected_status: int, + expected_detail: str | ErrorCode, +) -> None: + """OAuth and registration HTTP error adapters preserve stable status codes and details.""" + error = error_cls(**kwargs) + assert error.status_code == expected_status + if isinstance(expected_detail, str): + assert expected_detail in error.detail + else: + assert error.detail == expected_detail + + +class TestExceptionInheritanceChain: + """Tests for verifying the complete exception inheritance chain.""" + + def test_all_auth_crud_errors_inherit_from_api_error(self) -> None: + """Verify all AuthCRUDError subclasses ultimately inherit from APIError.""" + crud_error_subclasses = [ + UserNameAlreadyExistsError, + AlreadyMemberError, + UserOwnsOrgError, + UserHasNoOrgError, + UserIsNotMemberError, + UserDoesNotOwnOrgError, + OrganizationHasMembersError, + OrganizationNameExistsError, + DisposableEmailError, + ] + + for error_class in crud_error_subclasses: + assert issubclass(error_class, APIError), f"{error_class.__name__} must inherit from APIError" + + def test_exception_can_be_caught_as_api_error(self) -> None: + """Verify exceptions can be caught as APIError.""" + with pytest.raises(APIError): + raise UserNameAlreadyExistsError(username="test") + + def test_exception_can_be_caught_as_auth_crud_error(self) -> None: + """Verify AuthCRUDError subclasses can be caught as AuthCRUDError.""" + with pytest.raises(AuthCRUDError): + raise UserNameAlreadyExistsError(username="test") + + +class TestHandleOrganizationIntegrityError: + """Tests for handle_organization_integrity_error.""" + + def test_raises_org_name_exists_on_unique_violation(self) -> None: + """Test that unique violation raises OrganizationNameExistsError.""" + mock_orig = Mock() + mock_orig.pgcode = "23505" + e = IntegrityError("statement", {}, mock_orig) + + with pytest.raises(OrganizationNameExistsError): + handle_organization_integrity_error(e, "creating") + + def test_raises_internal_server_error_on_other_db_error(self) -> None: + """Test that non-unique violations raise InternalServerError.""" + mock_orig = Mock() + mock_orig.pgcode = "23503" # Foreign key violation + e = IntegrityError("statement", {}, mock_orig) + + with pytest.raises(APIError, match="Internal server error"): + handle_organization_integrity_error(e, "creating") diff --git a/backend/tests/unit/auth/test_frontend.py b/backend/tests/unit/auth/test_frontend.py new file mode 100644 index 00000000..d11278d0 --- /dev/null +++ b/backend/tests/unit/auth/test_frontend.py @@ -0,0 +1,20 @@ +"""Unit tests for the auth frontend routes.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from fastapi.responses import RedirectResponse + +from app.api.auth.routers.frontend import login_page, router + + +class TestLoginPage: + """Tests for the login page route.""" + + async def test_logged_in_user_redirects_to_safe_target(self) -> None: + """Logged-in users should always be redirected to the index page.""" + response = await login_page(MagicMock(), MagicMock(), next_page="https://evil.test") + + assert isinstance(response, RedirectResponse) + assert response.headers["location"] == str(router.url_path_for("index")) diff --git a/backend/tests/unit/auth/test_oauth_associate.py b/backend/tests/unit/auth/test_oauth_associate.py new file mode 100644 index 00000000..3f9adfbf --- /dev/null +++ b/backend/tests/unit/auth/test_oauth_associate.py @@ -0,0 +1,285 @@ +"""Unit tests for the OAuth account-association callback handler. + +Focuses on the security-sensitive branches of +:meth:`CustomOAuthAssociateRouterBuilder._get_callback_handler`: + +* state/sub mismatch → reject (CSRF / session fixation) +* provider returns no email → reject +* OAuth account already linked to a different user → reject +* same-user re-associate → in-place token update (idempotent) +* frontend redirect path → success redirect is returned +""" +# ruff: noqa: SLF001 — the private callback/authorize handlers are the subject under test + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest + +if TYPE_CHECKING: + from collections.abc import Mapping + +from app.api.auth.exceptions import ( + OAuthAccountAlreadyLinkedError, + OAuthEmailUnavailableError, + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, +) +from app.api.auth.services.oauth.associate import CustomOAuthAssociateRouterBuilder +from app.api.auth.services.oauth_utils import CSRF_TOKEN_KEY + +STATE_SECRET = "test-state-secret-at-least-32-bytes-long-for-hmac-sha256" + + +def _make_builder() -> tuple[CustomOAuthAssociateRouterBuilder, MagicMock, MagicMock]: + """Return (builder, oauth_client_mock, user_schema_mock) so tests can poke the mocks with correct typing.""" + oauth_client = MagicMock() + oauth_client.name = "google" + user_schema = MagicMock() + builder = CustomOAuthAssociateRouterBuilder( + oauth_client=cast("Any", oauth_client), + authenticator=cast("Any", MagicMock()), + user_schema=cast("Any", user_schema), + state_secret=STATE_SECRET, + ) + return builder, oauth_client, user_schema + + +def _user(user_id: UUID | None = None) -> MagicMock: + user = MagicMock() + user.id = user_id or uuid4() + return user + + +def _session_returning(existing: object | None) -> MagicMock: + """Build an AsyncSession mock whose `execute().scalars().first()` yields `existing`.""" + session = MagicMock() + scalars = MagicMock() + scalars.first.return_value = existing + result = MagicMock() + result.scalars.return_value = scalars + session.execute = AsyncMock(return_value=result) + return session + + +def _user_manager_for(session: MagicMock) -> MagicMock: + um = MagicMock() + um.user_db = MagicMock() + um.user_db.session = session + um.user_db.update_oauth_account = AsyncMock() + um.oauth_associate_callback = AsyncMock() + return um + + +def _patch_verify_state(builder: CustomOAuthAssociateRouterBuilder, state_data: dict[str, str]) -> None: + cast("Any", builder).verify_state = MagicMock(return_value=state_data) + + +def _access_token_state(token: Mapping[str, object]) -> Any: # noqa: ANN401 — the tuple type is an opaque httpx-oauth alias + """Return ``(token, state)`` cast to the opaque OAuth2Token alias the handler expects.""" + return cast("Any", (token, "state")) + + +class TestCallbackHandlerSecurityBranches: + """Cover the security-sensitive branches of _get_callback_handler.""" + + async def test_state_sub_mismatch_rejected(self) -> None: + """A state token whose `sub` doesn't match the current user must be rejected (session-fixation guard).""" + builder, _, _ = _make_builder() + user = _user() + _patch_verify_state(builder, {"sub": str(uuid4()), CSRF_TOKEN_KEY: "csrf"}) + + with pytest.raises(OAuthInvalidStateError): + await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state({"access_token": "x"}), + cast("Any", _user_manager_for(MagicMock())), + ) + + async def test_missing_email_rejected(self) -> None: + """Providers that return no email must not be linkable.""" + builder, oauth_client, _ = _make_builder() + user = _user() + oauth_client.get_id_email = AsyncMock(return_value=("account-id", None)) + _patch_verify_state(builder, {"sub": str(user.id), CSRF_TOKEN_KEY: "csrf"}) + + with pytest.raises(OAuthEmailUnavailableError): + await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state({"access_token": "x"}), + cast("Any", _user_manager_for(MagicMock())), + ) + + async def test_existing_link_to_other_user_rejected(self) -> None: + """If the OAuth account_id already belongs to another user, reject (prevents takeover).""" + builder, oauth_client, _ = _make_builder() + user = _user() + oauth_client.get_id_email = AsyncMock(return_value=("account-id", "new@example.com")) + existing_account = SimpleNamespace(user_id=uuid4()) # different user + _patch_verify_state(builder, {"sub": str(user.id), CSRF_TOKEN_KEY: "csrf"}) + + session = _session_returning(existing_account) + with pytest.raises(OAuthAccountAlreadyLinkedError): + await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state({"access_token": "x"}), + cast("Any", _user_manager_for(session)), + ) + + async def test_same_user_reassociate_updates_token_in_place(self) -> None: + """Re-running associate for the same user upgrades the stored token (scope upgrade flow).""" + builder, oauth_client, user_schema = _make_builder() + user = _user() + oauth_client.get_id_email = AsyncMock(return_value=("account-id", "me@example.com")) + existing_account = SimpleNamespace(user_id=user.id) + _patch_verify_state(builder, {"sub": str(user.id), CSRF_TOKEN_KEY: "csrf"}) + + session = _session_returning(existing_account) + um = _user_manager_for(session) + um.user_db.update_oauth_account.return_value = user + user_schema.model_validate = MagicMock(return_value="validated-user") + + token = {"access_token": "new-access", "expires_at": 1234, "refresh_token": "new-refresh"} + result = await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state(token), + cast("Any", um), + ) + + um.user_db.update_oauth_account.assert_awaited_once() + args = um.user_db.update_oauth_account.await_args + assert args.args[1] is existing_account + assert args.args[2] == { + "access_token": "new-access", + "expires_at": 1234, + "refresh_token": "new-refresh", + } + # No INSERT-style associate when updating in place + um.oauth_associate_callback.assert_not_called() + assert result == "validated-user" + + async def test_new_account_invokes_associate_callback(self) -> None: + """A never-seen OAuth account_id triggers the INSERT-style associate_callback path.""" + builder, oauth_client, user_schema = _make_builder() + user = _user() + oauth_client.get_id_email = AsyncMock(return_value=("account-id", "me@example.com")) + _patch_verify_state(builder, {"sub": str(user.id), CSRF_TOKEN_KEY: "csrf"}) + + session = _session_returning(None) + um = _user_manager_for(session) + um.oauth_associate_callback.return_value = user + user_schema.model_validate = MagicMock(return_value="validated-user") + + token = {"access_token": "at", "expires_at": 9, "refresh_token": "rt"} + result = await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state(token), + cast("Any", um), + ) + + um.oauth_associate_callback.assert_awaited_once() + um.user_db.update_oauth_account.assert_not_called() + assert result == "validated-user" + + async def test_frontend_redirect_returns_redirect_response(self) -> None: + """If the state carries a frontend_redirect_uri, the response is a redirect, not a user payload.""" + builder, oauth_client, _ = _make_builder() + user = _user() + oauth_client.get_id_email = AsyncMock(return_value=("account-id", "me@example.com")) + _patch_verify_state( + builder, + { + "sub": str(user.id), + CSRF_TOKEN_KEY: "csrf", + "frontend_redirect_uri": "https://relab.example/ok", + }, + ) + session = _session_returning(None) + um = _user_manager_for(session) + um.oauth_associate_callback.return_value = user + + token = {"access_token": "at"} + result = await builder._get_callback_handler( + cast("Any", MagicMock()), + cast("Any", user), + _access_token_state(token), + cast("Any", um), + ) + + # _create_success_redirect returns a starlette RedirectResponse + assert result.status_code in (302, 307) + assert "relab.example/ok" in result.headers["location"] + assert "success=true" in result.headers["location"] + + +class TestAuthorizeHandler: + """Cover the authorize-endpoint handler's redirect-URI validation.""" + + async def test_authorize_without_redirect_uri(self) -> None: + """Without a ?redirect_uri= param, the authorize handler returns the provider URL and sets a CSRF cookie.""" + builder, oauth_client, _ = _make_builder() + builder.redirect_url = "https://api.example/cb" + oauth_client.get_authorization_url = AsyncMock(return_value="https://provider.example/auth?x=1") + user = _user() + request = MagicMock() + request.query_params = {} + response = MagicMock() + + result = await builder._get_authorize_handler( + cast("Any", request), cast("Any", response), cast("Any", user), scopes=None + ) + + assert result.authorization_url == "https://provider.example/auth?x=1" + # CSRF cookie was set with a random token + response.set_cookie.assert_called_once() + + async def test_authorize_rejects_disallowed_redirect_uri(self) -> None: + """An attacker-supplied redirect_uri outside the allowlist must be rejected.""" + builder, oauth_client, _ = _make_builder() + builder.redirect_url = "https://api.example/cb" + oauth_client.get_authorization_url = AsyncMock() + # Force the allowlist check to reject. + cast("Any", builder)._is_allowed_frontend_redirect = MagicMock(return_value=False) + user = _user() + request = MagicMock() + request.query_params = {"redirect_uri": "https://evil.example/"} + response = MagicMock() + + with pytest.raises(OAuthInvalidRedirectURIError): + await builder._get_authorize_handler( + cast("Any", request), cast("Any", response), cast("Any", user), scopes=None + ) + oauth_client.get_authorization_url.assert_not_called() + + async def test_authorize_embeds_allowed_frontend_redirect_in_state(self) -> None: + """An allowed redirect_uri gets embedded into the state token for later use in the callback.""" + builder, oauth_client, _ = _make_builder() + builder.redirect_url = "https://api.example/cb" + oauth_client.get_authorization_url = AsyncMock(return_value="https://provider.example/auth") + cast("Any", builder)._is_allowed_frontend_redirect = MagicMock(return_value=True) + user = _user() + request = MagicMock() + request.query_params = {"redirect_uri": "https://relab.example/ok"} + response = MagicMock() + + await builder._get_authorize_handler( + cast("Any", request), cast("Any", response), cast("Any", user), scopes=["openid"] + ) + + # The state token is opaque here, but we can assert get_authorization_url was called + # with it — non-empty — and scopes propagated. + call = oauth_client.get_authorization_url.await_args + assert call is not None + assert call.args[0] == "https://api.example/cb" + assert isinstance(call.args[1], str) + assert len(call.args[1]) > 20 + assert call.args[2] == ["openid"] diff --git a/backend/tests/unit/auth/test_oauth_clients.py b/backend/tests/unit/auth/test_oauth_clients.py new file mode 100644 index 00000000..a4c045d0 --- /dev/null +++ b/backend/tests/unit/auth/test_oauth_clients.py @@ -0,0 +1,40 @@ +"""Unit tests for OAuth client scope separation. + +Tests verify our wiring (which client has which scopes, which client the router uses). +URL construction by httpx_oauth itself is not tested. +""" + +from __future__ import annotations + +from httpx_oauth.clients.google import BASE_SCOPES as GOOGLE_BASE_SCOPES + +from app.api.auth.config import settings +from app.api.auth.routers import oauth as oauth_router_module +from app.api.auth.services.oauth import ( + GOOGLE_YOUTUBE_SCOPES, + google_oauth_client, + google_youtube_oauth_client, +) + + +def test_google_login_client_uses_base_scopes_only() -> None: + """Ensure the standard Google login client stays on the minimal login scope set.""" + youtube_scopes = set(settings.youtube_api_scopes or []) + base_scopes = google_oauth_client.base_scopes or [] + assert google_oauth_client.base_scopes == GOOGLE_BASE_SCOPES + assert youtube_scopes.isdisjoint(base_scopes) + + +def test_google_youtube_client_extends_login_scopes() -> None: + """Ensure the plugin-only YouTube client keeps the elevated scope set separate.""" + youtube_scopes = set(settings.youtube_api_scopes or []) + base_scopes = google_youtube_oauth_client.base_scopes or [] + assert google_youtube_oauth_client.base_scopes == GOOGLE_YOUTUBE_SCOPES + assert set(GOOGLE_BASE_SCOPES).issubset(base_scopes) + assert youtube_scopes.issubset(base_scopes) + + +def test_login_router_wiring_uses_standard_google_client() -> None: + """Ensure the auth router is wired to the normal Google login client, not the YouTube client.""" + assert oauth_router_module.google_oauth_client is google_oauth_client + assert oauth_router_module.google_oauth_client is not google_youtube_oauth_client diff --git a/backend/tests/unit/auth/test_oauth_router.py b/backend/tests/unit/auth/test_oauth_router.py new file mode 100644 index 00000000..5df73ade --- /dev/null +++ b/backend/tests/unit/auth/test_oauth_router.py @@ -0,0 +1,36 @@ +"""Unit tests for OAuth router wiring.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import HttpUrl + +from app.api.auth.routers.oauth import _public_callback_url + +if TYPE_CHECKING: + import pytest + + +def test_public_callback_url_uses_configured_backend_base(monkeypatch: pytest.MonkeyPatch) -> None: + """Public OAuth callbacks should be built from the configured backend URL.""" + monkeypatch.setattr( + "app.api.auth.routers.oauth.core_settings.backend_api_url", + HttpUrl("https://api-test.cml-relab.org"), + ) + + assert _public_callback_url("/auth/oauth/google/associate/callback") == ( + "https://api-test.cml-relab.org/auth/oauth/google/associate/callback" + ) + + +def test_public_callback_url_normalizes_slashes(monkeypatch: pytest.MonkeyPatch) -> None: + """Callback URL construction should be stable regardless of input slashes.""" + monkeypatch.setattr( + "app.api.auth.routers.oauth.core_settings.backend_api_url", + HttpUrl("https://api-test.cml-relab.org/"), + ) + + assert _public_callback_url("auth/oauth/google/session/callback") == ( + "https://api-test.cml-relab.org/auth/oauth/google/session/callback" + ) diff --git a/backend/tests/unit/auth/test_oauth_token_router.py b/backend/tests/unit/auth/test_oauth_token_router.py new file mode 100644 index 00000000..fe1f09eb --- /dev/null +++ b/backend/tests/unit/auth/test_oauth_token_router.py @@ -0,0 +1,139 @@ +"""Unit tests for Google ID token verification logic in the PKCE token-exchange router.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from jwt import ExpiredSignatureError, InvalidTokenError + +from app.api.auth.exceptions import ( + OAuthEmailUnavailableError, + OAuthStateDecodeError, + OAuthStateExpiredError, +) +from app.api.auth.routers.oauth_token import _verify_google_id_token + +if TYPE_CHECKING: + from collections.abc import Iterator + from contextlib import AbstractContextManager + +_VALID_PAYLOAD = { + "sub": "google-user-123", + "email": "user@example.com", + "email_verified": True, + "iss": "https://accounts.google.com", + "exp": 9_999_999_999, +} + + +def _mock_signing_key() -> MagicMock: + key = MagicMock() + key.key = "mock-rsa-key" + return key + + +def _patched( + payload: dict | None = None, + *, + side_effect: type[Exception] | None = None, +) -> AbstractContextManager[None]: + """Context manager that patches all three external dependencies of _verify_google_id_token.""" + mock_key = _mock_signing_key() + + jwks_patch = patch( + "app.api.auth.routers.oauth_token._google_jwks_client.get_signing_key_from_jwt", + return_value=mock_key, + ) + if side_effect: + decode_patch = patch("app.api.auth.routers.oauth_token.jwt.decode", side_effect=side_effect) + else: + decode_patch = patch("app.api.auth.routers.oauth_token.jwt.decode", return_value=payload) + + client_id_mock = MagicMock() + client_id_mock.get_secret_value.return_value = "test-client-id" + settings_patch = patch( + "app.api.auth.routers.oauth_token.auth_settings.google_oauth_client_id", + new=client_id_mock, + ) + + @contextlib.contextmanager + def _ctx() -> Iterator[None]: + with jwks_patch, decode_patch, settings_patch: + yield + + return _ctx() + + +class TestVerifyGoogleIdToken: + """Unit tests for _verify_google_id_token.""" + + def test_valid_token_returns_payload(self) -> None: + """A valid ID token with a verified email and known issuer should return its claims.""" + with _patched(_VALID_PAYLOAD): + result = _verify_google_id_token("mock-token") + + assert result == _VALID_PAYLOAD + + def test_accounts_google_com_issuer_is_accepted(self) -> None: + """The scheme-less issuer 'accounts.google.com' is also valid.""" + payload = {**_VALID_PAYLOAD, "iss": "accounts.google.com"} + + with _patched(payload): + result = _verify_google_id_token("mock-token") + + assert result["iss"] == "accounts.google.com" + + def test_expired_token_raises_state_expired_error(self) -> None: + """jwt.ExpiredSignatureError from PyJWT should surface as OAuthStateExpiredError.""" + with _patched(side_effect=ExpiredSignatureError), pytest.raises(OAuthStateExpiredError): + _verify_google_id_token("expired-token") + + def test_invalid_token_raises_state_decode_error(self) -> None: + """Any other JWT validation failure should surface as OAuthStateDecodeError.""" + with _patched(side_effect=InvalidTokenError), pytest.raises(OAuthStateDecodeError): + _verify_google_id_token("bad-token") + + def test_wrong_issuer_raises_state_decode_error(self) -> None: + """A payload whose 'iss' is not in the Google issuer set should be rejected.""" + payload = {**_VALID_PAYLOAD, "iss": "https://evil.example.com"} + + with _patched(payload), pytest.raises(OAuthStateDecodeError): + _verify_google_id_token("wrong-issuer-token") + + def test_missing_issuer_raises_state_decode_error(self) -> None: + """A payload with no 'iss' field should be rejected.""" + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != "iss"} + + with _patched(payload), pytest.raises(OAuthStateDecodeError): + _verify_google_id_token("no-issuer-token") + + def test_unverified_email_raises_email_unavailable_error(self) -> None: + """A token where email_verified is False should raise OAuthEmailUnavailableError.""" + payload = {**_VALID_PAYLOAD, "email_verified": False} + + with _patched(payload), pytest.raises(OAuthEmailUnavailableError): + _verify_google_id_token("unverified-email-token") + + def test_missing_email_verified_raises_email_unavailable_error(self) -> None: + """A payload without an email_verified claim should be treated as unverified.""" + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != "email_verified"} + + with _patched(payload), pytest.raises(OAuthEmailUnavailableError): + _verify_google_id_token("no-email-verified-token") + + def test_unconfigured_client_id_raises_503(self) -> None: + """If the Google OAuth client ID is not set, the endpoint should be unavailable.""" + empty_client_id = MagicMock() + empty_client_id.get_secret_value.return_value = "" + + with ( + patch("app.api.auth.routers.oauth_token.auth_settings.google_oauth_client_id", new=empty_client_id), + pytest.raises(HTTPException) as exc_info, + ): + _verify_google_id_token("any-token") + + assert exc_info.value.status_code == 503 diff --git a/backend/tests/unit/auth/test_oauth_utils.py b/backend/tests/unit/auth/test_oauth_utils.py new file mode 100644 index 00000000..52aed157 --- /dev/null +++ b/backend/tests/unit/auth/test_oauth_utils.py @@ -0,0 +1,48 @@ +"""Unit tests for OAuth token and cookie helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Response + +from app.api.auth.services.oauth_utils import OAuthCookieSettings, generate_state_token, set_csrf_cookie + +if TYPE_CHECKING: + import pytest + + +def test_generate_state_token_uses_configured_default_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """OAuth state JWTs should default to the configured 10-minute lifetime.""" + captured: dict[str, object] = {} + + def fake_generate_jwt(data: dict[str, str], secret: str, lifetime_seconds: int) -> str: + captured["data"] = data + captured["secret"] = secret + captured["lifetime_seconds"] = lifetime_seconds + return "jwt-token" + + monkeypatch.setattr("app.api.auth.services.oauth_utils.auth_settings.oauth_state_token_ttl_seconds", 600) + monkeypatch.setattr("app.api.auth.services.oauth_utils.generate_jwt", fake_generate_jwt) + + token = generate_state_token({"csrftoken": "csrf"}, "test-secret") + + assert token == "jwt-token" + assert captured["secret"] == "test-secret" + assert captured["lifetime_seconds"] == 600 + assert captured["data"] == { + "csrftoken": "csrf", + "aud": "fastapi-users:oauth-state", + } + + +def test_set_csrf_cookie_uses_configured_state_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """OAuth CSRF cookies should expire on the same timeline as state JWTs.""" + monkeypatch.setattr("app.api.auth.services.oauth_utils.auth_settings.oauth_state_token_ttl_seconds", 600) + + response = Response() + set_csrf_cookie(response, OAuthCookieSettings(secure=False), "csrf-token") + + set_cookie_headers = response.headers.getlist("set-cookie") + assert len(set_cookie_headers) == 1 + assert "Max-Age=600" in set_cookie_headers[0] diff --git a/backend/tests/unit/auth/test_org_crud_lifecycle.py b/backend/tests/unit/auth/test_org_crud_lifecycle.py new file mode 100644 index 00000000..79e30f69 --- /dev/null +++ b/backend/tests/unit/auth/test_org_crud_lifecycle.py @@ -0,0 +1,218 @@ +"""Behavior-focused tests for organization lifecycle CRUD.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch, sentinel + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.api.auth.crud.organizations import ( + create_organization, + delete_organization_as_owner, + force_delete_organization, + get_organizations, + update_user_organization, +) +from app.api.auth.exceptions import ( + AlreadyMemberError, + OrganizationHasMembersError, + OrganizationNameExistsError, + UserDoesNotOwnOrgError, + UserIsNotMemberError, +) +from app.api.auth.models import Organization, OrganizationRole +from app.api.auth.schemas import OrganizationCreate, OrganizationReadPublic, OrganizationUpdate +from tests.factories.models import OrganizationFactory +from tests.unit.auth._org_crud_support import make_user + + +class TestCreateOrganization: + """Test organization creation.""" + + async def test_create_organization_success(self, mock_session: AsyncMock) -> None: + """Test that a user can create an org and that they become the owner of the new org.""" + owner = make_user() + org_create = OrganizationCreate(name="My Org") + + result = await create_organization(mock_session, org_create, owner) + + assert isinstance(result, Organization) + assert result.name == "My Org" + assert result.owner_id == owner.id + mock_session.add.assert_called() + mock_session.commit.assert_called_once() + + async def test_create_organization_already_member_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot create an org if they are already a member of another org.""" + owner = make_user(organization_id=uuid.uuid4(), organization_role=OrganizationRole.MEMBER) + org_create = OrganizationCreate(name="New Org") + + with pytest.raises(AlreadyMemberError): + await create_organization(mock_session, org_create, owner) + + +class TestGetOrganizations: + """Test getting organizations.""" + + async def test_get_organizations_uses_paginated_helper(self, mock_session: AsyncMock) -> None: + """Test that the paginate_select helper is used to return a paginated list of orgs.""" + with patch( + "app.api.auth.crud.organizations.paginate_select", + new=AsyncMock(return_value=sentinel.page), + ) as mock_get_paginated: + result = await get_organizations(mock_session, read_schema=OrganizationReadPublic) + + assert result == sentinel.page + mock_get_paginated.assert_awaited_once() + + +class TestUpdateUserOrganization: + """Test updating organization details as a user with permissions to update the org (owner or superuser).""" + + async def test_update_organization_name_success(self, mock_session: AsyncMock) -> None: + """Test that an org owner can update the name of their org.""" + org = OrganizationFactory.build(name="Old Name") + org_update = OrganizationUpdate(name="New Name") + + with patch("app.api.auth.crud.organizations.require_model", new=AsyncMock(return_value=org)): + result = await update_user_organization(mock_session, org, org_update) + + assert result.name == "New Name" + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_transfer_ownership_success(self, mock_session: AsyncMock) -> None: + """Test that an org owner can transfer ownership to another member and that the roles are updated.""" + current_owner = make_user(organization_role=OrganizationRole.OWNER) + new_owner = make_user(organization_role=OrganizationRole.MEMBER) + org = OrganizationFactory.build(owner_id=current_owner.id) + org.owner = current_owner + org.members = [current_owner, new_owner] + current_owner.organization = org + new_owner.organization = org + + org_update = OrganizationUpdate(name=org.name, owner_id=new_owner.id) + + with patch("app.api.auth.crud.organizations.require_model", new=AsyncMock(return_value=org)): + result = await update_user_organization(mock_session, org, org_update) + + assert result.owner_id == new_owner.id + assert current_owner.organization_role == OrganizationRole.MEMBER + assert new_owner.organization_role == OrganizationRole.OWNER + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_transfer_ownership_to_non_member_raises(self, mock_session: AsyncMock) -> None: + """Test that an org owner cannot transfer ownership to a user who is not a member of the org.""" + current_owner = make_user(organization_role=OrganizationRole.OWNER) + non_member = make_user() + org = OrganizationFactory.build(owner_id=current_owner.id) + org.owner = current_owner + org.members = [current_owner] + current_owner.organization = org + non_member.organization = None + + org_update = OrganizationUpdate(name=org.name, owner_id=non_member.id) + + with ( + patch("app.api.auth.crud.organizations.require_model", new=AsyncMock(return_value=org)), + pytest.raises(UserIsNotMemberError), + ): + await update_user_organization(mock_session, org, org_update) + + +class TestDeleteOrganizationAsOwner: + """Test deleting an organization as the owner. + + The owner should only be able to delete the org if there are no other members, + otherwise they would be leaving orphaned members without an org. + """ + + async def test_delete_no_org_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot delete an org if they are not currently part of any org.""" + user = make_user() + user.organization = None + + with pytest.raises(UserDoesNotOwnOrgError): + await delete_organization_as_owner(mock_session, user) + + async def test_delete_not_owner_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot delete an org if they are a member but not the owner.""" + org = OrganizationFactory.build() + object.__setattr__(org, "members", [MagicMock()]) + user = make_user(organization_role=OrganizationRole.MEMBER) + user.organization = org + + with pytest.raises(UserDoesNotOwnOrgError): + await delete_organization_as_owner(mock_session, user) + + async def test_delete_with_multiple_members_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot delete an org if they are the owner but there are other members in the org.""" + org = OrganizationFactory.build() + object.__setattr__(org, "members", [MagicMock(), MagicMock()]) + user = make_user(organization_role=OrganizationRole.OWNER) + user.organization = org + + with pytest.raises(OrganizationHasMembersError): + await delete_organization_as_owner(mock_session, user) + + async def test_delete_success(self, mock_session: AsyncMock) -> None: + """Test that an org owner can delete their org if there are no other members.""" + org = MagicMock() + org.members = [MagicMock()] + user = make_user(organization_role=OrganizationRole.OWNER) + user.organization = org + + await delete_organization_as_owner(mock_session, user) + + mock_session.delete.assert_called_once_with(org) + mock_session.commit.assert_called_once() + + +class TestForceDeleteOrganization: + """Test force deleting an organization. + + This should succeed regardless of the number of members and without requiring an owner user context. + It would be used by admins to delete orgs that are in a bad state or that they want to remove for any reason. + """ + + async def test_force_delete_success(self, mock_session: AsyncMock) -> None: + """Test that an org can be force deleted regardless of the number of members or user context.""" + org_id = uuid.uuid4() + org = OrganizationFactory.build(id=org_id) + + with patch("app.api.auth.crud.organizations.require_model", return_value=org): + await force_delete_organization(mock_session, org_id) + + mock_session.delete.assert_called_once_with(org) + mock_session.commit.assert_called_once() + + +class TestOrganizationIntegrityErrors: + """Test that integrity errors are properly translated into user-friendly errors for org creation and updates.""" + + async def test_create_organization_unique_name_raises(self, mock_session: AsyncMock) -> None: + """Test that a unique constraint violation on org name during creation raises an OrganizationNameExistsError.""" + owner = make_user() + org_create = OrganizationCreate(name="My Org") + + mock_orig = MagicMock() + mock_orig.pgcode = "23505" + mock_session.flush = AsyncMock(side_effect=IntegrityError("stmt", {}, mock_orig)) + + with pytest.raises(OrganizationNameExistsError): + await create_organization(mock_session, org_create, owner) + + async def test_update_organization_unique_name_raises(self, mock_session: AsyncMock) -> None: + """Test that a unique constraint violation on org name during update raises an OrganizationNameExistsError.""" + org = OrganizationFactory.build(name="Old Name") + org_update = OrganizationUpdate(name="Conflict Name") + + mock_orig = MagicMock() + mock_orig.pgcode = "23505" + mock_session.flush = AsyncMock(side_effect=IntegrityError("stmt", {}, mock_orig)) + + with pytest.raises(OrganizationNameExistsError): + await update_user_organization(mock_session, org, org_update) diff --git a/backend/tests/unit/auth/test_org_crud_members.py b/backend/tests/unit/auth/test_org_crud_members.py new file mode 100644 index 00000000..c1693913 --- /dev/null +++ b/backend/tests/unit/auth/test_org_crud_members.py @@ -0,0 +1,90 @@ +"""Behavior-focused tests for organization member listing.""" + +from __future__ import annotations + +import uuid +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch, sentinel + +import pytest + +from app.api.auth.crud.organizations import get_organization_members +from app.api.auth.exceptions import UserIsNotMemberError +from app.api.auth.models import Organization, User +from app.api.auth.schemas import UserReadPublic +from tests.unit.auth._org_crud_support import make_user + + +class TestGetOrganizationMembers: + """Test getting organization members.""" + + async def test_get_members_non_member_raises(self, mock_session: AsyncMock) -> None: + """Test that a not found error is raised if the user is not a member of the organization.""" + org_id = uuid.uuid4() + user = make_user(organization_id=uuid.uuid4()) + + with pytest.raises(UserIsNotMemberError): + await get_organization_members(mock_session, org_id, user) + + async def test_get_members_success_as_member(self, mock_session: AsyncMock) -> None: + """Test that a user can get the list of members for their organization.""" + org_id = uuid.uuid4() + user = make_user(organization_id=org_id) + mock_members = [MagicMock(), MagicMock()] + mock_org = MagicMock() + mock_org.members = mock_members + + with patch("app.api.auth.crud.organizations.require_model", return_value=mock_org): + result = await get_organization_members(mock_session, org_id, user) + + assert result == mock_members + + async def test_get_members_success_as_superuser(self, mock_session: AsyncMock) -> None: + """Test that a superuser can get the list of members for any org, even if they are not a member themselves.""" + org_id = uuid.uuid4() + user = make_user(is_superuser=True) + mock_org = MagicMock() + mock_org.members = [MagicMock()] + + with patch("app.api.auth.crud.organizations.require_model", return_value=mock_org): + result = await get_organization_members(mock_session, org_id, user) + + members = cast("list[User]", result) + assert len(members) == 1 + + async def test_get_members_paginated_success(self, mock_session: AsyncMock) -> None: + """Test that a user can get a paginated list of members for their organization.""" + org_id = uuid.uuid4() + user = make_user(organization_id=org_id) + + with ( + patch( + "app.api.auth.crud.organizations.require_model", + new=AsyncMock(return_value=MagicMock()), + ) as mock_require_model, + patch( + "app.api.auth.crud.organizations.page_organization_members", + new=AsyncMock(return_value=sentinel.page), + ) as mock_get_paginated, + ): + result = await get_organization_members( + mock_session, + org_id, + user, + paginate=True, + read_schema=UserReadPublic, + ) + + assert result == sentinel.page + mock_require_model.assert_awaited_once_with( + mock_session, + Organization, + org_id, + loaders=None, + read_schema=None, + ) + mock_get_paginated.assert_awaited_once_with( + mock_session, + org_id, + read_schema=UserReadPublic, + ) diff --git a/backend/tests/unit/auth/test_org_crud_membership.py b/backend/tests/unit/auth/test_org_crud_membership.py new file mode 100644 index 00000000..762c71b0 --- /dev/null +++ b/backend/tests/unit/auth/test_org_crud_membership.py @@ -0,0 +1,93 @@ +"""Behavior-focused tests for organization membership changes.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.auth.crud.organizations import leave_organization, user_join_organization +from app.api.auth.exceptions import AlreadyMemberError, UserHasNoOrgError, UserOwnsOrgError +from app.api.auth.models import OrganizationRole +from tests.factories.models import OrganizationFactory +from tests.unit.auth._org_crud_support import make_user + + +class TestUserJoinOrganization: + """Test user joining an organization.""" + + async def test_join_success(self, mock_session: AsyncMock) -> None: + """Test that a user can join an org as a member when they are not currently part of any org.""" + org = OrganizationFactory.build() + user = make_user() + + result = await user_join_organization(mock_session, org, user) + + assert result.organization_role == OrganizationRole.MEMBER + assert result.organization_id == org.id + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_join_already_owner_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot join an org if they are already the owner of another org.""" + org = OrganizationFactory.build() + user = make_user(organization_id=org.id, organization_role=OrganizationRole.OWNER) + user.organization = org + org.members = [user, MagicMock()] + + with pytest.raises(UserOwnsOrgError): + await user_join_organization(mock_session, org, user) + + async def test_join_already_member_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot join an org if they are already a member of another org.""" + org = OrganizationFactory.build() + user = make_user(organization_id=uuid.uuid4(), organization_role=OrganizationRole.MEMBER) + + with pytest.raises(AlreadyMemberError): + await user_join_organization(mock_session, org, user) + + async def test_owner_can_join_new_org_when_old_org_has_no_other_members(self, mock_session: AsyncMock) -> None: + """Test that a user can join a new org if they currently own an org but there are no other members.""" + current_org = OrganizationFactory.build() + target_org = OrganizationFactory.build() + user = make_user(organization_id=current_org.id, organization_role=OrganizationRole.OWNER) + user.organization = current_org + current_org.members = [user] + + result = await user_join_organization(mock_session, target_org, user) + + assert result.organization_id == target_org.id + assert result.organization_role == OrganizationRole.MEMBER + mock_session.execute.assert_awaited_once() + mock_session.flush.assert_awaited_once() + mock_session.commit.assert_awaited_once() + + +class TestLeaveOrganization: + """Test user leaving an organization.""" + + async def test_leave_success(self, mock_session: AsyncMock) -> None: + """Test that a user can leave their org and have their organization_id and organization_role set to None.""" + user = make_user(organization_id=uuid.uuid4(), organization_role=OrganizationRole.MEMBER) + + result = await leave_organization(mock_session, user) + + assert result.organization_id is None + assert result.organization_role is None + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_leave_no_org_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot leave an org if they are not currently part of any org.""" + user = make_user() + + with pytest.raises(UserHasNoOrgError): + await leave_organization(mock_session, user) + + async def test_leave_as_owner_raises(self, mock_session: AsyncMock) -> None: + """Test that a user cannot leave an org if they are the owner, even if there are other members.""" + user = make_user(organization_id=uuid.uuid4(), organization_role=OrganizationRole.OWNER) + + with pytest.raises(UserOwnsOrgError): + await leave_organization(mock_session, user) diff --git a/backend/tests/unit/auth/test_org_registration.py b/backend/tests/unit/auth/test_org_registration.py new file mode 100644 index 00000000..d3f44013 --- /dev/null +++ b/backend/tests/unit/auth/test_org_registration.py @@ -0,0 +1,117 @@ +"""Unit tests for add_user_role_in_organization_after_registration. + +Tests the three branches: + 1. No org data in request → user returned unchanged + 2. organization dict in request → org created, user set as OWNER + 3. organization_id in request → user assigned as MEMBER of existing org +""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +from app.api.auth.crud.users import add_user_role_in_organization_after_registration +from app.api.auth.models import OrganizationRole, User +from tests.factories.models import UserFactory + + +def _make_user() -> User: + user = UserFactory.build( + id=uuid.uuid4(), + email="u@example.com", + hashed_password="hashed", + ) + user.organization_id = None + user.organization_role = None + return user + + +def _make_user_db(session: AsyncMock) -> MagicMock: + user_db = MagicMock() + user_db.session = session + return user_db + + +def _make_request(body: dict) -> MagicMock: + request = MagicMock() + request.json = AsyncMock(return_value=body) + return request + + +class TestAddUserRoleInOrganization: + """add_user_role_in_organization_after_registration branch coverage.""" + + async def test_no_org_data_returns_user_unchanged(self, mock_session: AsyncMock) -> None: + """When the request body has no org fields, the user is returned without modification.""" + session = mock_session + user = _make_user() + user_db = _make_user_db(session) + request = _make_request({}) + + result = await add_user_role_in_organization_after_registration(user_db, user, request) + + assert result is user + assert result.organization_id is None + assert result.organization_role is None + session.add.assert_not_called() + session.commit.assert_not_called() + + async def test_organization_dict_creates_org_and_sets_owner_role(self, mock_session: AsyncMock) -> None: + """When 'organization' dict is in request body, a new org is created and user becomes OWNER.""" + session = mock_session + user = _make_user() + user_db = _make_user_db(session) + org_data = {"name": "CircularTech", "location": "Berlin"} + request = _make_request({"organization": org_data}) + + with patch("app.api.auth.crud.users.Organization") as mock_org: + mock_org_instance = MagicMock() + mock_org_instance.id = uuid.uuid4() + mock_org.return_value = mock_org_instance + + result = await add_user_role_in_organization_after_registration(user_db, user, request) + + mock_org.assert_called_once_with(**org_data, owner_id=user.id) + session.add.assert_any_call(mock_org_instance) + session.flush.assert_awaited_once() + assert result.organization_role == OrganizationRole.OWNER + assert result.organization_id == mock_org_instance.id + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(user) + + async def test_organization_id_sets_member_role(self, mock_session: AsyncMock) -> None: + """When 'organization_id' is in request body, user is added as MEMBER (no org created).""" + session = mock_session + user = _make_user() + user_db = _make_user_db(session) + org_id = uuid.uuid4() + request = _make_request({"organization_id": str(org_id)}) + + result = await add_user_role_in_organization_after_registration(user_db, user, request) + + assert result.organization_role == OrganizationRole.MEMBER + assert result.organization_id == str(org_id) + # No Organization created; flush should not have been called for org creation + session.flush.assert_not_awaited() + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(user) + + async def test_organization_dict_takes_priority_over_organization_id(self, mock_session: AsyncMock) -> None: + """If both 'organization' and 'organization_id' are present, the dict branch wins.""" + session = mock_session + user = _make_user() + user_db = _make_user_db(session) + org_id = uuid.uuid4() + request = _make_request({"organization": {"name": "MyOrg"}, "organization_id": str(org_id)}) + + with patch("app.api.auth.crud.users.Organization") as mock_org: + mock_org_instance = MagicMock() + mock_org_instance.id = uuid.uuid4() + mock_org.return_value = mock_org_instance + + result = await add_user_role_in_organization_after_registration(user_db, user, request) + + # Should have taken the org-creation branch, not the member branch + assert result.organization_role == OrganizationRole.OWNER + mock_org.assert_called_once() diff --git a/backend/tests/unit/auth/test_password_validator.py b/backend/tests/unit/auth/test_password_validator.py new file mode 100644 index 00000000..5258ffef --- /dev/null +++ b/backend/tests/unit/auth/test_password_validator.py @@ -0,0 +1,131 @@ +"""Unit tests for password validation helpers.""" +# spell-checker: ignore alicewonder, hibp, zxcvbn + +from __future__ import annotations + +import hashlib +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi_users import InvalidPasswordException +from httpx import HTTPError +from pydantic import SecretStr + +from app.api.auth.services.password_validator import ( + check_pwned_password, + validate_password, +) + + +async def test_check_pwned_password_uses_hibp_range_prefix_and_matches_suffix() -> None: + """The HIBP breach lookup should use the SHA-1 range API and parse the matching suffix.""" + http_client = AsyncMock() + response = Mock() + response.text = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:42\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:1" + response.raise_for_status = Mock() + http_client.get.return_value = response + + breach_count = await check_pwned_password("password", http_client) + + assert breach_count == 42 + http_client.get.assert_awaited_once_with( + "https://api.pwnedpasswords.com/range/5BAA6", + headers={"Add-Padding": "true"}, + timeout=5.0, + ) + + +async def test_check_pwned_password_returns_zero_when_not_found() -> None: + """A suffix not present in the range response means not breached.""" + http_client = AsyncMock() + response = Mock() + response.text = "0000000000000000000000000000000000A:1\n0000000000000000000000000000000000B:2" + response.raise_for_status = Mock() + http_client.get.return_value = response + + assert await check_pwned_password("password", http_client) == 0 + + +async def test_check_pwned_password_fails_open_on_http_error() -> None: + """If HIBP is unreachable we must not block registrations (fail-open policy).""" + http_client = AsyncMock() + http_client.get.side_effect = HTTPError("unreachable") + + assert await check_pwned_password("password", http_client) == 0 + + +# ── validate_password ──────────────────────────────────────────────────────── + + +_STRONG = "correct-horse-battery-staple-v42" # test fixture, not a real secret + + +async def test_validate_password_accepts_strong() -> None: + """A strong password that meets all criteria should be accepted without exception.""" + await validate_password(_STRONG, email="alice@example.com", skip_breach_check=True) + + +async def test_validate_password_accepts_secretstr() -> None: + """validate_password should accept SecretStr and unwrap it for validation.""" + await validate_password(SecretStr(_STRONG), email="alice@example.com", skip_breach_check=True) + + +async def test_validate_password_rejects_short() -> None: + """The password must be at least 8 characters long.""" + with pytest.raises(InvalidPasswordException) as exc: + await validate_password("short", email="a@b.c", skip_breach_check=True) + assert "8 characters" in exc.value.reason + + +async def test_validate_password_rejects_email_in_password() -> None: + """The password must not contain the e-mail address as a substring.""" + with pytest.raises(InvalidPasswordException) as exc: + await validate_password( + "prefix-alice@example.com-suffix", + email="alice@example.com", + skip_breach_check=True, + ) + assert "e-mail" in exc.value.reason + + +async def test_validate_password_rejects_username_in_password() -> None: + """The password must not contain the username as a substring.""" + with pytest.raises(InvalidPasswordException) as exc: + await validate_password( + "xxxx-alicewonder-xxxx", + email="a@b.c", + username="alicewonder", + skip_breach_check=True, + ) + assert "username" in exc.value.reason + + +async def test_validate_password_rejects_weak_password_with_feedback() -> None: + """A well-known weak password (zxcvbn score 0) must be rejected with feedback.""" + with pytest.raises(InvalidPasswordException) as exc: + await validate_password("password123", email="a@b.c", skip_breach_check=True) + assert "too weak" in exc.value.reason + + +async def test_validate_password_rejects_breached() -> None: + """When HIBP reports the password as breached the call must raise.""" + http_client = AsyncMock() + response = Mock() + # Craft a response that echoes back the test password's SHA-1 suffix so the + # range-match path fires. + pwd = "correct-horse-battery-staple-pwnd-42" + sha1 = hashlib.sha1(pwd.encode(), usedforsecurity=False).hexdigest().upper() + response.text = f"{sha1[5:]}:9999" + response.raise_for_status = Mock() + http_client.get.return_value = response + + with pytest.raises(InvalidPasswordException) as exc: + await validate_password(pwd, email="alice@example.com", http_client=http_client, skip_breach_check=False) + assert "data breach" in exc.value.reason + + +async def test_validate_password_skips_breach_check_when_disabled() -> None: + """skip_breach_check=True must not invoke the http client.""" + http_client = AsyncMock() + await validate_password(_STRONG, email="a@b.c", http_client=http_client, skip_breach_check=True) + http_client.get.assert_not_called() diff --git a/backend/tests/unit/auth/test_rate_limiter.py b/backend/tests/unit/auth/test_rate_limiter.py new file mode 100644 index 00000000..0e8d8815 --- /dev/null +++ b/backend/tests/unit/auth/test_rate_limiter.py @@ -0,0 +1,182 @@ +"""Unit tests for the custom rate limiter.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +from fastapi import Request + +from app.api.auth.services.rate_limiter import ( + Limiter, + RateLimitExceededError, + _find_request, + rate_limit_exceeded_handler, +) + + +def _make_request() -> MagicMock: + """Return a ``MagicMock`` that passes ``isinstance(…, Request)`` checks.""" + return MagicMock(spec=Request) + + +# --------------------------------------------------------------------------- +# _find_request +# --------------------------------------------------------------------------- + + +class TestFindRequest: + """Tests for the internal function that looks for a Request object in the arguments of a rate-limited endpoint.""" + + def test_finds_request_in_args(self) -> None: + """When a Request object is present in the positional arguments, it should be returned.""" + req = _make_request() + assert _find_request((req,), {}) is req + + def test_finds_request_in_kwargs(self) -> None: + """When a Request object is present in the keyword arguments, it should be returned.""" + req = _make_request() + assert _find_request((), {"request": req}) is req + + def test_returns_none_when_absent(self) -> None: + """When no Request object is present in either args or kwargs, the function should return None.""" + assert _find_request(("a", 1), {"key": "val"}) is None + + +# --------------------------------------------------------------------------- +# RateLimitExceededError +# --------------------------------------------------------------------------- + + +class TestRateLimitExceededError: + """Tests for the custom exception that is raised when a rate limit is exceeded.""" + + def test_default_detail(self) -> None: + """When no custom message is provided, the detail should be "Rate limit exceeded".""" + exc = RateLimitExceededError() + assert exc.detail == "Rate limit exceeded" + assert str(exc) == "Rate limit exceeded" + + def test_custom_detail(self) -> None: + """You can provide a custom detail message when raising the exception.""" + exc = RateLimitExceededError("custom") + assert exc.detail == "custom" + + +# --------------------------------------------------------------------------- +# rate_limit_exceeded_handler +# --------------------------------------------------------------------------- + + +class TestRateLimitExceededHandler: + """Tests for the exception handler that converts a RateLimitExceededError into an HTTP response.""" + + def test_returns_429(self) -> None: + """The handler should return a 429 Too Many Requests status code.""" + resp = rate_limit_exceeded_handler(MagicMock(), RateLimitExceededError()) + assert resp.status_code == 429 + + def test_body_contains_detail(self) -> None: + """The response body should include the error detail message.""" + resp = rate_limit_exceeded_handler(MagicMock(), RateLimitExceededError("nope")) + body = json.loads(bytes(resp.body)) + assert body["detail"] == "nope" + + +# --------------------------------------------------------------------------- +# Limiter +# --------------------------------------------------------------------------- + + +@pytest.fixture +def limiter() -> Limiter: + """Limiter backed by an in-memory storage (no Redis needed).""" + return Limiter( + key_func=lambda _: "test-key", + storage_uri="memory://", + strategy="fixed-window", + enabled=True, + ) + + +class TestLimiter: + """Tests for the Limiter class that enforces rate limits on FastAPI endpoints.""" + + async def test_allows_requests_under_limit(self, limiter: Limiter) -> None: + """When the number of requests is within the defined limit, they should be allowed to proceed.""" + + @limiter.limit("5/minute") + async def endpoint(_request: Request) -> str: + return "ok" + + req = _make_request() + for _ in range(5): + assert await endpoint(req) == "ok" + + async def test_raises_when_limit_exceeded(self, limiter: Limiter) -> None: + """When the number of requests exceeds the defined limit, a RateLimitExceededError should be raised.""" + + @limiter.limit("2/minute") + async def endpoint(_request: Request) -> str: + return "ok" + + req = _make_request() + await endpoint(req) + await endpoint(req) + + with pytest.raises(RateLimitExceededError): + await endpoint(req) + + async def test_disabled_limiter_skips_check(self) -> None: + """When the limiter is not enabled it should not enforce any limits and should allow all requests.""" + disabled = Limiter( + key_func=lambda _: "key", + storage_uri="memory://", + enabled=False, + ) + + @disabled.limit("1/minute") + async def endpoint(_request: Request) -> str: + return "ok" + + req = _make_request() + # Should never raise even though limit is 1/min + for _ in range(10): + assert await endpoint(req) == "ok" + + async def test_different_keys_have_separate_limits(self) -> None: + """When different keys are used, they should be rate limited separately.""" + call_count = 0 + + def key_func(request: Request) -> str: + return request.headers.get("X-Client-ID", "default") + + lim = Limiter(key_func=key_func, storage_uri="memory://", enabled=True) + + @lim.limit("1/minute") + async def endpoint(_request: Request) -> str: + nonlocal call_count + call_count += 1 + return "ok" + + req_a = _make_request() + req_a.headers.get.return_value = "client-a" + + req_b = _make_request() + req_b.headers.get.return_value = "client-b" + + await endpoint(req_a) + await endpoint(req_b) + assert call_count == 2 # Both succeed with their own bucket + + async def test_no_request_arg_skips_check(self, limiter: Limiter) -> None: + """When no Request is found in args, the limiter should not block.""" + + @limiter.limit("1/minute") + async def endpoint(data: str) -> str: + return data + + # No Request object passed — limiter has nothing to key on, so it passes through + assert await endpoint("hello") == "hello" + assert await endpoint("hello") == "hello" diff --git a/backend/tests/unit/auth/test_refresh_token_service.py b/backend/tests/unit/auth/test_refresh_token_service.py new file mode 100644 index 00000000..6051300c --- /dev/null +++ b/backend/tests/unit/auth/test_refresh_token_service.py @@ -0,0 +1,274 @@ +"""Unit tests for refresh token service.""" +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import pytest +from fakeredis.aioredis import FakeRedis + +from app.api.auth.config import settings +from app.api.auth.exceptions import RefreshTokenInvalidError, RefreshTokenRevokedError +from app.api.auth.services import refresh_token_service +from app.api.auth.services.refresh_token_service import ( + blacklist_token, + create_refresh_token, + rotate_refresh_token, + verify_refresh_token, +) + +if TYPE_CHECKING: + from redis.asyncio import Redis + +# Constants for test values to avoid magic value warnings +# Renamed to avoid S105 while keeping meaningful names +TOKEN_VAL_INVALID = "invalid" +TOKEN_VAL_REVOKED = "revoked" +TOKEN_LENGTH = 64 +TTL_MARGIN = 10 +TTL_ABS_MARGIN = 5 + + +class TestRefreshTokenService: + """Tests for refresh token service functions.""" + + async def test_create_refresh_token(self, redis_client: Redis) -> None: + """Token is stored in Redis under the correct key with user_id in the payload.""" + user_id = uuid.uuid4() + token = await create_refresh_token(redis_client, user_id) + + assert len(token) == TOKEN_LENGTH + assert isinstance(token, str) + + stored_data = await redis_client.get(f"auth:rt:{token}") + assert stored_data is not None + assert ( + str(user_id) in stored_data.decode("utf-8") + if isinstance(stored_data, bytes) + else str(user_id) in stored_data + ) + + async def test_verify_refresh_token_success(self, redis_client: Redis) -> None: + """Test verifying a valid refresh token.""" + user_id = uuid.uuid4() + token = await create_refresh_token(redis_client, user_id) + + result = await verify_refresh_token(redis_client, token) + + assert result == user_id + + async def test_verify_refresh_token_not_found(self, redis_client: Redis) -> None: + """Test verifying a non-existent token raises 401.""" + with pytest.raises(RefreshTokenInvalidError) as exc_info: + await verify_refresh_token(redis_client, "nonexistent-token-123456789012345678901234567890") + + assert exc_info.value.http_status_code == 401 + assert TOKEN_VAL_INVALID in exc_info.value.message.lower() + + async def test_verify_refresh_token_blacklisted(self, redis_client: Redis) -> None: + """Test verifying a blacklisted token raises 401.""" + user_id = uuid.uuid4() + token = await create_refresh_token(redis_client, user_id) + await blacklist_token(redis_client, token) + + with pytest.raises(RefreshTokenRevokedError) as exc_info: + await verify_refresh_token(redis_client, token) + + assert exc_info.value.http_status_code == 401 + assert TOKEN_VAL_REVOKED in exc_info.value.message.lower() + + async def test_blacklist_token(self, redis_client: Redis) -> None: + """Test blacklisting a refresh token.""" + user_id = uuid.uuid4() + token = await create_refresh_token(redis_client, user_id) + + # Verify token exists and is valid + result = await verify_refresh_token(redis_client, token) + assert result == user_id + + # Blacklist the token + await blacklist_token(redis_client, token) + + # Token should be blacklisted + is_blacklisted = await redis_client.exists(f"auth:rt_blacklist:{token}") + assert is_blacklisted + + # Original token data should be deleted + stored_data = await redis_client.get(f"auth:rt:{token}") + assert stored_data is None + + # Verify token is now invalid + with pytest.raises((RefreshTokenInvalidError, RefreshTokenRevokedError)): + await verify_refresh_token(redis_client, token) + + async def test_rotate_refresh_token(self, redis_client: Redis) -> None: + """Test rotating a refresh token (create new, blacklist old).""" + user_id = uuid.uuid4() + old_token = await create_refresh_token(redis_client, user_id) + + # Rotate the token + new_token = await rotate_refresh_token(redis_client, old_token) + + # New token should be different + assert new_token != old_token + assert len(new_token) == TOKEN_LENGTH + + # New token should be valid + result = await verify_refresh_token(redis_client, new_token) + assert result == user_id + + # Old token should be blacklisted + is_blacklisted = await redis_client.exists(f"auth:rt_blacklist:{old_token}") + assert is_blacklisted + + # Old token should be invalid + with pytest.raises((RefreshTokenInvalidError, RefreshTokenRevokedError)): + await verify_refresh_token(redis_client, old_token) + + async def test_multiple_tokens_per_user(self, redis_client: Redis) -> None: + """Test that a user can have multiple active refresh tokens (multi-device).""" + user_id = uuid.uuid4() + token_1 = await create_refresh_token(redis_client, user_id) + token_2 = await create_refresh_token(redis_client, user_id) + + # Both tokens should be valid + await verify_refresh_token(redis_client, token_1) + await verify_refresh_token(redis_client, token_2) + + async def test_token_expiry_ttl(self, redis_client: Redis) -> None: + """Test that tokens have correct TTL set.""" + user_id = uuid.uuid4() + token = await create_refresh_token(redis_client, user_id) + + # Check TTL on token data + token_ttl = await redis_client.ttl(f"auth:rt:{token}") + expected_ttl = settings.refresh_token_expire_days * 24 * 60 * 60 + + # TTL should be close to expected (within 5 seconds) + assert abs(token_ttl - expected_ttl) < TTL_ABS_MARGIN + + +# Private method access is needed for testing in-memory fallback behavior +class TestRefreshTokenServiceInMemory: + """Tests for refresh token service in-memory fallback (redis=None).""" + + async def test_create_refresh_token_in_memory(self) -> None: + """Test creating a token with no Redis stores it in memory.""" + refresh_token_service._memory_tokens.clear() + user_id = uuid.uuid4() + + token = await create_refresh_token(None, user_id) + + assert isinstance(token, str) + assert len(token) == TOKEN_LENGTH + assert token in refresh_token_service._memory_tokens + stored_user_id, _expire = refresh_token_service._memory_tokens[token] + assert stored_user_id == str(user_id) + + refresh_token_service._memory_tokens.clear() + + async def test_verify_refresh_token_in_memory_success(self) -> None: + """Test verifying a valid in-memory token returns the correct user ID.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + user_id = uuid.uuid4() + + token = await create_refresh_token(None, user_id) + result = await verify_refresh_token(None, token) + + assert result == user_id + + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + async def test_verify_refresh_token_in_memory_not_found(self) -> None: + """Test that verifying a missing in-memory token raises 401.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + with pytest.raises(RefreshTokenInvalidError) as exc_info: + await verify_refresh_token(None, "nonexistent-token") + + assert exc_info.value.http_status_code == 401 + assert TOKEN_VAL_INVALID in exc_info.value.message.lower() + + async def test_verify_refresh_token_in_memory_blacklisted(self) -> None: + """Test that a blacklisted in-memory token raises 401.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + user_id = uuid.uuid4() + + token = await create_refresh_token(None, user_id) + await blacklist_token(None, token) + + with pytest.raises(RefreshTokenRevokedError) as exc_info: + await verify_refresh_token(None, token) + + assert exc_info.value.http_status_code == 401 + assert TOKEN_VAL_REVOKED in exc_info.value.message.lower() + + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + async def test_blacklist_token_in_memory(self) -> None: + """Test blacklisting an in-memory token removes it and adds to blacklist.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + user_id = uuid.uuid4() + + token = await create_refresh_token(None, user_id) + assert token in refresh_token_service._memory_tokens + + await blacklist_token(None, token) + + assert token not in refresh_token_service._memory_tokens + assert token in refresh_token_service._memory_blacklist + + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + async def test_blacklist_token_in_memory_with_explicit_ttl(self) -> None: + """Test blacklisting with explicit TTL uses provided value.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + user_id = uuid.uuid4() + + token = await create_refresh_token(None, user_id) + await blacklist_token(None, token, ttl_seconds=3600) + + assert token in refresh_token_service._memory_blacklist + + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + async def test_blacklist_nonexistent_token_in_memory(self) -> None: + """Test blacklisting a nonexistent in-memory token uses default TTL.""" + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + await blacklist_token(None, "nonexistent-token") + + assert "nonexistent-token" in refresh_token_service._memory_blacklist + + refresh_token_service._memory_tokens.clear() + refresh_token_service._memory_blacklist.clear() + + async def test_blacklist_token_redis_expired_defaults_ttl(self) -> None: + """Test that blacklisting with Redis uses default TTL when token already expired.""" + redis = FakeRedis(decode_responses=True, version=7) + user_id = uuid.uuid4() + + token = await create_refresh_token(redis, user_id) + # Delete the token to simulate expiry + await redis.delete(f"auth:rt:{token}") + + # Blacklisting should still work using the default 3600 TTL + await blacklist_token(redis, token) + + bl_key = f"auth:rt_blacklist:{token}" + assert await redis.exists(bl_key) + ttl = await redis.ttl(bl_key) + assert ttl > 0 + await redis.aclose() diff --git a/backend/tests/unit/auth/test_user_manager.py b/backend/tests/unit/auth/test_user_manager.py new file mode 100644 index 00000000..4afa53b3 --- /dev/null +++ b/backend/tests/unit/auth/test_user_manager.py @@ -0,0 +1,97 @@ +"""Unit tests for username/email login resolution in the UserManager service.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_users.manager import BaseUserManager + +from app.api.auth.services.user_manager import UserManager + + +def _make_credentials(username: str, password: str = "testpassword") -> OAuth2PasswordRequestForm: # noqa: S107 + form = MagicMock(spec=OAuth2PasswordRequestForm) + form.username = username + form.password = password + return form + + +def _make_manager(mock_user: MagicMock | None = None) -> tuple[UserManager, AsyncMock]: + """Return a UserManager with a mocked user_db.session.""" + mock_scalars = MagicMock() + mock_scalars.unique.return_value.one_or_none.return_value = mock_user + + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + + mock_session = MagicMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + mock_user_db = MagicMock() + mock_user_db.session = mock_session + + manager = UserManager.__new__(UserManager) + manager.user_db = mock_user_db + + return manager, mock_session + + +class TestAuthenticateUsernameResolution: + """UserManager.authenticate resolves usernames to email before delegating to the parent.""" + + async def test_email_input_skips_db_lookup(self) -> None: + """When credentials contain '@', no DB query is made and the email is passed through unchanged.""" + manager, mock_session = _make_manager() + credentials = _make_credentials("user@example.com") + + with patch.object(BaseUserManager, "authenticate", new_callable=AsyncMock) as mock_super: + mock_super.return_value = None + await manager.authenticate(credentials) + + mock_session.execute.assert_not_called() + mock_super.assert_called_once_with(credentials) + assert credentials.username == "user@example.com" + + async def test_username_found_replaces_with_email(self) -> None: + """When a user is found by username, credentials.username is replaced with their email.""" + mock_user = MagicMock() + mock_user.email = "resolved@example.com" + + manager, mock_session = _make_manager(mock_user=mock_user) + credentials = _make_credentials("myusername") + + with patch.object(BaseUserManager, "authenticate", new_callable=AsyncMock) as mock_super: + mock_super.return_value = mock_user + await manager.authenticate(credentials) + + mock_session.execute.assert_called_once() + assert credentials.username == "resolved@example.com" + mock_super.assert_called_once_with(credentials) + + async def test_username_not_found_passes_original(self) -> None: + """When no user matches the username, credentials are passed unchanged to the parent.""" + manager, mock_session = _make_manager(mock_user=None) + credentials = _make_credentials("nonexistent_user") + + with patch.object(BaseUserManager, "authenticate", new_callable=AsyncMock) as mock_super: + mock_super.return_value = None + await manager.authenticate(credentials) + + mock_session.execute.assert_called_once() + assert credentials.username == "nonexistent_user" + mock_super.assert_called_once_with(credentials) + + async def test_returns_parent_result(self) -> None: + """Authenticate returns whatever the parent authenticate returns.""" + mock_user = MagicMock() + mock_user.email = "found@example.com" + + manager, _ = _make_manager(mock_user=mock_user) + credentials = _make_credentials("someuser") + + with patch.object(BaseUserManager, "authenticate", new_callable=AsyncMock) as mock_super: + mock_super.return_value = mock_user + result = await manager.authenticate(credentials) + + assert result is mock_user diff --git a/backend/tests/unit/background_data/__init__.py b/backend/tests/unit/background_data/__init__.py new file mode 100644 index 00000000..bf3d7c17 --- /dev/null +++ b/backend/tests/unit/background_data/__init__.py @@ -0,0 +1 @@ +"""Unit tests for background data module.""" diff --git a/backend/tests/unit/background_data/test_background_data_crud_ops.py b/backend/tests/unit/background_data/test_background_data_crud_ops.py new file mode 100644 index 00000000..bed08a94 --- /dev/null +++ b/backend/tests/unit/background_data/test_background_data_crud_ops.py @@ -0,0 +1,227 @@ +"""CRUD-operation tests for background data domain modules.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from app.api.background_data.crud.categories import delete_category, update_category +from app.api.background_data.crud.materials import ( + add_categories_to_material, + create_material, + delete_material, + update_material, +) +from app.api.background_data.crud.product_types import ( + add_categories_to_product_type, + create_product_type, + delete_product_type, + update_product_type, +) +from app.api.background_data.crud.taxonomies import create_taxonomy, delete_taxonomy, update_taxonomy +from app.api.background_data.models import Material, ProductType, Taxonomy, TaxonomyDomain +from app.api.background_data.schemas import ( + CategoryUpdate, + MaterialCreate, + MaterialUpdate, + ProductTypeCreate, + ProductTypeUpdate, + TaxonomyCreate, + TaxonomyUpdate, +) +from tests.factories.models import CategoryFactory, MaterialFactory, ProductTypeFactory, TaxonomyFactory + + +def _make_session() -> AsyncMock: + session = AsyncMock() + session.add = MagicMock() + session.flush = AsyncMock() + session.commit = AsyncMock() + session.refresh = AsyncMock() + session.delete = AsyncMock() + session.execute = AsyncMock() + return session + + +class TestCategoryCrud: + """Cover category CRUD helpers.""" + + async def test_update_category_name(self) -> None: + """Updates a category name and persists the change.""" + session = _make_session() + db_category = CategoryFactory.build(id=1, name="Old Name") + category_update = CategoryUpdate(name="New Name") + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_category): + result = await update_category(session, 1, category_update) + + assert result.name == "New Name" + session.add.assert_called_once() + session.commit.assert_called_once() + + async def test_delete_category_success(self) -> None: + """Deletes a category and commits the removal.""" + session = _make_session() + db_category = CategoryFactory.build(id=1) + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_category): + await delete_category(session, 1) + + session.delete.assert_called_once_with(db_category) + session.commit.assert_called_once() + + +class TestTaxonomyCrud: + """Cover taxonomy CRUD helpers.""" + + async def test_create_taxonomy_simple(self) -> None: + """Creates a taxonomy from a minimal payload.""" + session = _make_session() + taxonomy_create = TaxonomyCreate( + name="EN 45554 Repairability Scoring", domains={TaxonomyDomain.PRODUCTS}, version="1.0" + ) + + result = await create_taxonomy(session, taxonomy_create) + + assert isinstance(result, Taxonomy) + assert result.name == "EN 45554 Repairability Scoring" + + async def test_update_taxonomy_name(self) -> None: + """Updates a taxonomy name.""" + session = _make_session() + db_taxonomy = TaxonomyFactory.build(id=10, name="Old Name") + taxonomy_update = TaxonomyUpdate(name="New Name") + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_taxonomy): + result = await update_taxonomy(session, 10, taxonomy_update) + + assert result.name == "New Name" + session.commit.assert_called_once() + + async def test_delete_taxonomy_success(self) -> None: + """Deletes a taxonomy and commits the change.""" + session = _make_session() + db_taxonomy = TaxonomyFactory.build(id=10) + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_taxonomy): + await delete_taxonomy(session, 10) + + session.delete.assert_called_once_with(db_taxonomy) + session.commit.assert_called_once() + + +class TestMaterialCrud: + """Cover material CRUD helpers.""" + + async def test_create_material_simple(self) -> None: + """Creates a material from a minimal payload.""" + session = _make_session() + material_create = MaterialCreate(name="Aluminum") + + result = await create_material(session, material_create) + + assert isinstance(result, Material) + assert result.name == "Aluminum" + + async def test_update_material_name(self) -> None: + """Updates a material name.""" + session = _make_session() + db_material = MaterialFactory.build(id=1, name="Old Material") + material_update = MaterialUpdate(name="New Material") + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_material): + result = await update_material(session, 1, material_update) + + assert result.name == "New Material" + session.commit.assert_called_once() + + async def test_delete_material_success(self) -> None: + """Deletes a material and its related assets.""" + session = _make_session() + db_material = MaterialFactory.build(id=1) + + with ( + patch("app.api.background_data.crud.materials.require_model", return_value=db_material), + patch("app.api.background_data.crud.materials.delete_all_material_files"), + patch("app.api.background_data.crud.materials.delete_all_material_images"), + ): + await delete_material(session, 1) + + session.delete.assert_called_once_with(db_material) + session.commit.assert_called_once() + + async def test_add_categories_to_material_creates_first_link(self) -> None: + """Creates category links when a material has none yet.""" + session = _make_session() + db_material = MaterialFactory.build(id=1) + db_material.categories = [] + db_categories = [CategoryFactory.build(id=1)] + + with ( + patch("app.api.background_data.crud.shared.require_model", return_value=db_material), + patch("app.api.background_data.crud.shared.require_models", return_value=db_categories), + patch("app.api.background_data.crud.materials.validate_category_taxonomy_domains", new=AsyncMock()), + patch("app.api.background_data.crud.shared.add_links", new=AsyncMock()) as mock_create_links, + ): + result = await add_categories_to_material(session, 1, {1}) + + assert result == db_categories + mock_create_links.assert_awaited_once() + + +class TestProductTypeCrud: + """Cover product type CRUD helpers.""" + + async def test_add_categories_to_product_type_creates_first_link(self) -> None: + """Creates category links when a product type has none yet.""" + session = _make_session() + db_product_type = ProductTypeFactory.build(id=1) + db_product_type.categories = [] + db_categories = [CategoryFactory.build(id=1)] + + with ( + patch("app.api.background_data.crud.shared.require_model", return_value=db_product_type), + patch("app.api.background_data.crud.shared.require_models", return_value=db_categories), + patch("app.api.background_data.crud.product_types.validate_category_taxonomy_domains", new=AsyncMock()), + patch("app.api.background_data.crud.shared.add_links", new=AsyncMock()) as mock_create_links, + ): + result = await add_categories_to_product_type(session, 1, {1}) + + assert result == db_categories + mock_create_links.assert_awaited_once() + + async def test_create_product_type_simple(self) -> None: + """Creates a product type from a minimal payload.""" + session = _make_session() + pt_create = ProductTypeCreate(name="Laptop") + + result = await create_product_type(session, pt_create) + + assert isinstance(result, ProductType) + assert result.name == "Laptop" + + async def test_update_product_type(self) -> None: + """Updates a product type name.""" + session = _make_session() + db_pt = ProductTypeFactory.build(id=1, name="Old Type") + pt_update = ProductTypeUpdate(name="New Type") + + with patch("app.api.background_data.crud.shared.require_model", return_value=db_pt): + result = await update_product_type(session, 1, pt_update) + + assert result.name == "New Type" + session.commit.assert_called_once() + + async def test_delete_product_type(self) -> None: + """Deletes a product type and its related assets.""" + session = _make_session() + db_pt = ProductTypeFactory.build(id=1) + + with ( + patch("app.api.background_data.crud.product_types.require_model", return_value=db_pt), + patch("app.api.background_data.crud.product_types.delete_all_product_type_files"), + patch("app.api.background_data.crud.product_types.delete_all_product_type_images"), + ): + await delete_product_type(session, 1) + + session.delete.assert_called_once_with(db_pt) + session.commit.assert_called_once() diff --git a/backend/tests/unit/background_data/test_background_data_filters.py b/backend/tests/unit/background_data/test_background_data_filters.py new file mode 100644 index 00000000..555fdf95 --- /dev/null +++ b/backend/tests/unit/background_data/test_background_data_filters.py @@ -0,0 +1,80 @@ +"""Unit tests for background_data filter helper functions (SQL-clause level, no DB required).""" +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import pytest +from sqlalchemy import Table +from sqlalchemy.dialects import postgresql + +from app.api.background_data.filters import CategoryFilter, MaterialFilter, ProductTypeFilter +from app.api.background_data.models import Category, Material, ProductType +from app.api.common.search_utils import TSVectorSearchMixin + +if TYPE_CHECKING: + from sqlalchemy.sql.elements import ClauseElement + + +def _table(model: type) -> Table: + """Return the SQLAlchemy Table for a table model.""" + return cast("Table", vars(model)["__table__"]) + + +def _sql(clause: ClauseElement) -> str: + """Compile a clause to a SQL string using the PostgreSQL dialect.""" + return str(clause.compile(dialect=postgresql.dialect())) + + +# ruff: noqa : SLF001, ARG002 # We are testing the _search_vector_col and _trigram_cols methods directly and in a parameterized way +@pytest.mark.parametrize( + ("filter_cls", "table_name"), + [ + pytest.param(MaterialFilter, "material", id="material"), + pytest.param(ProductTypeFilter, "producttype", id="product_type"), + pytest.param(CategoryFilter, "category", id="category"), + ], +) +class TestFilterSearchVector: + """Verify each Filter's _search_vector_col / _trigram_cols point at the correct model columns.""" + + def test_search_vector_col_references_model(self, filter_cls: type[TSVectorSearchMixin], table_name: str) -> None: + """The compiled search-vector column SQL must reference the model's table.""" + sql = _sql(filter_cls._search_vector_col()) + assert table_name in sql.lower() + + def test_trigram_cols_contains_name(self, filter_cls: type[TSVectorSearchMixin], table_name: str) -> None: + """The trigram columns must include a 'name' field.""" + cols = filter_cls._trigram_cols() + assert len(cols) >= 1 + assert "name" in _sql(cols[0]).lower() + + def test_filter_has_no_search_model_fields(self, filter_cls: type[TSVectorSearchMixin], table_name: str) -> None: + """search_model_fields must be absent so fastapi-filter doesn't generate ILIKE queries.""" + assert not getattr(filter_cls.Constants, "search_model_fields", None) + + +@pytest.mark.parametrize( + ("model_cls", "expected_fields"), + [ + pytest.param(Material, ["name", "description", "source"], id="material"), + pytest.param(ProductType, ["name", "description"], id="product_type"), + pytest.param(Category, ["name", "description"], id="category"), + ], +) +class TestSearchVectorModel: + """Verify each model declares a computed search_vector covering the expected fields.""" + + def test_search_vector_is_computed(self, model_cls: type, expected_fields: list[str]) -> None: + """The search_vector column must be a computed (generated) column.""" + col = _table(model_cls).c.search_vector + assert col.computed is not None + + def test_search_vector_covers_expected_fields(self, model_cls: type, expected_fields: list[str]) -> None: + """The computed expression must reference every field that should be searchable.""" + computed = _table(model_cls).c.search_vector.computed + assert computed is not None + sql = str(computed.sqltext) + for field in expected_fields: + assert field in sql diff --git a/backend/tests/unit/background_data/test_background_data_validation.py b/backend/tests/unit/background_data/test_background_data_validation.py new file mode 100644 index 00000000..2aa1ceae --- /dev/null +++ b/backend/tests/unit/background_data/test_background_data_validation.py @@ -0,0 +1,174 @@ +"""Validation-focused tests for background data CRUD helpers.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.api.background_data.crud.categories import ( + get_category_trees, + validate_category_creation, + validate_category_taxonomy_domains, +) +from app.api.background_data.models import Category, Taxonomy, TaxonomyDomain +from app.api.common.exceptions import BadRequestError +from tests.factories.models import CategoryFactory, TaxonomyFactory + + +def _make_session() -> AsyncMock: + session = AsyncMock() + session.execute = AsyncMock() + return session + + +class TestCategoryValidation: + """Cover category creation validation helpers.""" + + async def test_validate_category_creation_with_supercategory(self, mock_session: AsyncMock) -> None: + """Accepts a matching supercategory within the same taxonomy.""" + category_create = AsyncMock() + category_create.taxonomy_id = 99 + super_category = CategoryFactory.build(id=1, taxonomy_id=10, name="Super") + + with patch("app.api.background_data.crud.categories.require_model", return_value=super_category) as mock_get: + result_id, result_cat = await validate_category_creation( + mock_session, category_create, taxonomy_id=10, supercategory_id=1 + ) + + assert result_id == 10 + assert result_cat == super_category + mock_get.assert_called_with(mock_session, Category, 1) + + async def test_validate_category_creation_supercategory_mismatch(self, mock_session: AsyncMock) -> None: + """Rejects a supercategory from a different taxonomy.""" + category_create = AsyncMock() + super_category = CategoryFactory.build(id=1, taxonomy_id=10, name="Super") + + with ( + patch("app.api.background_data.crud.categories.require_model", return_value=super_category), + pytest.raises(BadRequestError, match="does not belong to taxonomy with id"), + ): + await validate_category_creation(mock_session, category_create, taxonomy_id=20, supercategory_id=1) + + async def test_validate_category_creation_top_level(self, mock_session: AsyncMock) -> None: + """Allows top-level categories when a taxonomy is provided.""" + category_create = AsyncMock() + category_create.taxonomy_id = 10 + mock_taxonomy = TaxonomyFactory.build(id=10, name="Tax") + + with patch("app.api.background_data.crud.categories.require_model", return_value=mock_taxonomy) as mock_get: + result_id, result_cat = await validate_category_creation( + mock_session, category_create, taxonomy_id=None, supercategory_id=None + ) + + assert result_id == 10 + assert result_cat is None + mock_get.assert_called_with(mock_session, Taxonomy, 10) + + async def test_validate_category_creation_missing_taxonomy(self, mock_session: AsyncMock) -> None: + """Rejects category creation without any taxonomy id.""" + category_create = AsyncMock() + category_create.taxonomy_id = None + + with pytest.raises(BadRequestError, match="Taxonomy ID is required"): + await validate_category_creation(mock_session, category_create, taxonomy_id=None, supercategory_id=None) + + +class TestTaxonomyDomainValidation: + """Cover taxonomy-domain validation helpers.""" + + async def test_validate_domains_success(self, mock_session: AsyncMock) -> None: + """Accepts categories whose taxonomies include the expected domain.""" + category_ids = {1, 2} + expected_domain = TaxonomyDomain.PRODUCTS + cat1 = CategoryFactory.build(id=1, taxonomy=TaxonomyFactory.build(domains={TaxonomyDomain.PRODUCTS})) + cat2 = CategoryFactory.build( + id=2, + taxonomy=TaxonomyFactory.build(domains={TaxonomyDomain.PRODUCTS, TaxonomyDomain.MATERIALS}), + ) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [cat1, cat2] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_session.execute = AsyncMock(return_value=mock_result) + + await validate_category_taxonomy_domains(mock_session, category_ids, expected_domain) + + async def test_validate_domains_missing_category(self, mock_session: AsyncMock) -> None: + """Raises when some requested categories are missing.""" + category_ids = {1, 2} + expected_domain = TaxonomyDomain.PRODUCTS + cat1 = CategoryFactory.build(id=1, taxonomy=TaxonomyFactory.build(domains={TaxonomyDomain.PRODUCTS})) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [cat1] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_session.execute = AsyncMock(return_value=mock_result) + + with pytest.raises(BadRequestError, match="not found"): + await validate_category_taxonomy_domains(mock_session, category_ids, expected_domain) + + async def test_validate_domains_invalid_domain(self, mock_session: AsyncMock) -> None: + """Raises when category taxonomies do not allow the target domain.""" + category_ids = {1} + expected_domain = TaxonomyDomain.PRODUCTS + cat1 = CategoryFactory.build(id=1, taxonomy=TaxonomyFactory.build(domains={TaxonomyDomain.MATERIALS})) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [cat1] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_session.execute = AsyncMock(return_value=mock_result) + + with pytest.raises(BadRequestError, match="belong to taxonomies outside of domains"): + await validate_category_taxonomy_domains(mock_session, category_ids, expected_domain) + + +class TestGetCategoryTrees: + """Cover category tree retrieval helpers.""" + + async def test_raises_when_both_ids_provided(self) -> None: + """Rejects requests that mix taxonomy and supercategory filters.""" + session = _make_session() + with pytest.raises(BadRequestError, match="not both"): + await get_category_trees(session, supercategory_id=1, taxonomy_id=2) + + async def test_returns_top_level_categories(self) -> None: + """Returns top-level categories when no filters are provided.""" + session = _make_session() + cat = CategoryFactory.build(id=1) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [cat] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + result = await get_category_trees(session) + assert result == [cat] + + async def test_filters_by_taxonomy_id(self) -> None: + """Filters category trees by taxonomy id.""" + session = _make_session() + cat = CategoryFactory.build(id=1, taxonomy_id=10) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [cat] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + + with patch("app.api.background_data.crud.categories.require_model"): + result = await get_category_trees(session, taxonomy_id=10) + assert result == [cat] + + async def test_filters_by_supercategory_id(self) -> None: + """Filters category trees by supercategory id.""" + session = _make_session() + child_cat = CategoryFactory.build(id=2, supercategory_id=1) + mock_scalars = MagicMock() + mock_scalars.all.return_value = [child_cat] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + + with patch("app.api.background_data.crud.categories.require_model"): + result = await get_category_trees(session, supercategory_id=1) + assert result == [child_cat] diff --git a/backend/tests/unit/background_data/test_schemas.py b/backend/tests/unit/background_data/test_schemas.py new file mode 100644 index 00000000..e71969da --- /dev/null +++ b/backend/tests/unit/background_data/test_schemas.py @@ -0,0 +1,30 @@ +"""Unit tests for background data schemas (no database required). + +Covers business-rule constraints. Pydantic roundtrip and optional-field behavior is not tested. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.api.background_data.models import TaxonomyDomain +from app.api.background_data.schemas import MaterialCreate, TaxonomyCreate + + +def test_taxonomy_name_min_length() -> None: + """Taxonomy name must be at least 2 characters.""" + with pytest.raises(ValidationError) as exc_info: + TaxonomyCreate(name="A", version="1.0", domains={TaxonomyDomain.MATERIALS}) + + errors = exc_info.value.errors() + assert any(e["loc"][0] == "name" for e in errors) + + +def test_material_negative_density_rejected() -> None: + """Material density must be greater than zero (business constraint).""" + with pytest.raises(ValidationError) as exc_info: + MaterialCreate(name="Invalid alloy", density_kg_m3=-100.0) + + errors = exc_info.value.errors() + assert any(e["loc"][0] == "density_kg_m3" for e in errors) diff --git a/backend/tests/unit/common/__init__.py b/backend/tests/unit/common/__init__.py new file mode 100644 index 00000000..cef45765 --- /dev/null +++ b/backend/tests/unit/common/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the common module.""" diff --git a/backend/tests/unit/common/test_associations.py b/backend/tests/unit/common/test_associations.py new file mode 100644 index 00000000..fc53556d --- /dev/null +++ b/backend/tests/unit/common/test_associations.py @@ -0,0 +1,67 @@ +"""Unit tests for association CRUD utilities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.background_data.models import CategoryMaterialLink +from app.api.common.crud.associations import add_links, require_link +from app.api.common.exceptions import BadRequestError + + +class TestRequireLink: + """Tests for require_link.""" + + async def test_returns_link_when_found(self, mock_session: AsyncMock) -> None: + """Existing association rows should be returned.""" + mock_link = MagicMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_link + mock_session.execute = AsyncMock(return_value=mock_result) + + result = await require_link( + mock_session, + CategoryMaterialLink, + 1, + 2, + CategoryMaterialLink.material_id, + CategoryMaterialLink.category_id, + ) + + assert result == mock_link + + async def test_raises_bad_request_error_when_not_found(self, mock_session: AsyncMock) -> None: + """Missing association rows should raise a client-safe error.""" + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute = AsyncMock(return_value=mock_result) + + with pytest.raises(BadRequestError, match="not found"): + await require_link( + mock_session, + CategoryMaterialLink, + 1, + 2, + CategoryMaterialLink.material_id, + CategoryMaterialLink.category_id, + ) + + +class TestAddLinks: + """Tests for add_links.""" + + async def test_creates_links_for_all_ids(self, mock_session: AsyncMock) -> None: + """Bulk link creation should create one association row per dependent ID.""" + await add_links( + mock_session, + 1, + CategoryMaterialLink.material_id, + {10, 20, 30}, + CategoryMaterialLink.category_id, + MagicMock, + ) + + mock_session.add_all.assert_called_once() + assert len(mock_session.add_all.call_args[0][0]) == 3 diff --git a/backend/tests/unit/common/test_config.py b/backend/tests/unit/common/test_config.py new file mode 100644 index 00000000..5b39afbe --- /dev/null +++ b/backend/tests/unit/common/test_config.py @@ -0,0 +1,44 @@ +"""Unit tests for the common API configuration.""" + +from app.__version__ import version +from app.api.common.config import APISettings + + +class TestAPISettingsOpenAPIDocs: + """APISettings should expose correctly structured OpenAPI metadata.""" + + def test_public_docs_title(self) -> None: + """Public docs use the expected human-readable API title.""" + settings = APISettings() + assert "Reverse Engineering Lab" in settings.public_docs.title + + def test_public_docs_version_matches_package(self) -> None: + """Docs version is kept in sync with the installed package version.""" + settings = APISettings() + assert settings.public_docs.version == version + + def test_public_docs_license_is_agpl(self) -> None: + """Public docs reference the AGPL-3.0 license.""" + settings = APISettings() + assert "Affero" in settings.public_docs.license_info["name"] + assert settings.public_docs.license_info["url"].startswith("https://") + + def test_public_docs_tag_groups_are_non_empty(self) -> None: + """Public docs define at least one tag group.""" + settings = APISettings() + assert len(settings.public_docs.x_tag_groups) > 0 + + def test_full_docs_adds_admin_tag_group(self) -> None: + """Full (internal) docs include an Admin tag group absent from public docs.""" + settings = APISettings() + public_group_names = {g["name"] for g in settings.public_docs.x_tag_groups} + full_group_names = {g["name"] for g in settings.full_docs.x_tag_groups} + assert "Admin" in full_group_names + assert "Admin" not in public_group_names + + def test_full_docs_is_superset_of_public_docs(self) -> None: + """All public tag groups are present in full docs.""" + settings = APISettings() + full_names = {g["name"] for g in settings.full_docs.x_tag_groups} + for group in settings.public_docs.x_tag_groups: + assert group["name"] in full_names diff --git a/backend/tests/unit/common/test_exceptions.py b/backend/tests/unit/common/test_exceptions.py new file mode 100644 index 00000000..41fcd6a7 --- /dev/null +++ b/backend/tests/unit/common/test_exceptions.py @@ -0,0 +1,151 @@ +"""Unit tests for common exception handlers.""" + +from __future__ import annotations + +import json +from typing import cast +from unittest.mock import MagicMock, patch + +from fastapi import status + +from app.api.auth.services.rate_limiter import RateLimitExceededError, rate_limit_exceeded_handler +from app.api.common.exceptions import ( + APIError, + InternalServerError, + ServiceUnavailableError, +) +from app.api.common.routers.exceptions import create_exception_handler + + +class TestCreateExceptionHandler: + """Tests for create_exception_handler.""" + + async def test_api_error_without_details(self) -> None: + """Test that APIError without details returns correct JSON response.""" + handler = create_exception_handler() + mock_request = MagicMock() + mock_request.state.request_id = "req-123" + exc = APIError("Not found") + exc.http_status_code = status.HTTP_404_NOT_FOUND + + with patch("app.api.common.routers.exceptions.logger"): + response = await handler(mock_request, exc) + + assert response.status_code == 404 + + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Not found" + assert body["request_id"] == "req-123" + assert body["code"] == "APIError" + assert "errors" not in body + + async def test_api_error_with_details(self) -> None: + """Test that APIError with details includes them in response (line 30).""" + handler = create_exception_handler() + mock_request = MagicMock() + mock_request.state.request_id = "req-456" + exc = APIError("Bad input", details="field value is wrong") + exc.http_status_code = status.HTTP_400_BAD_REQUEST + + with patch("app.api.common.routers.exceptions.logger"): + response = await handler(mock_request, exc) + + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Bad input" + assert body["errors"] == "field value is wrong" + + async def test_server_error_logs_at_error_level(self) -> None: + """Test that 5xx errors are logged with opt(exception=True).error (line 37).""" + handler = create_exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) + mock_request = MagicMock() + mock_request.state.request_id = "req-500" + exc = RuntimeError("Something broke") + + mock_logger = MagicMock() + mock_logger.opt.return_value = mock_logger + with patch("app.api.common.routers.exceptions.logger", mock_logger): + response = await handler(mock_request, exc) + + assert response.status_code == 500 + mock_logger.opt.assert_called_once_with(exception=True) + mock_logger.error.assert_called_once() + + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Internal server error" + assert body["request_id"] == "req-500" + + async def test_400_error_logs_at_warning_level(self) -> None: + """Test that 4xx (non-404) errors are logged at warning level.""" + handler = create_exception_handler(status.HTTP_400_BAD_REQUEST) + mock_request = MagicMock() + mock_request.state.request_id = "req-400" + exc = ValueError("Bad value") + + mock_logger = MagicMock() + with patch("app.api.common.routers.exceptions.logger", mock_logger): + response = await handler(mock_request, exc) + + assert response.status_code == 400 + mock_logger.warning.assert_called_once() + + async def test_internal_api_error_uses_safe_message_and_custom_log_message(self) -> None: + """Test that APIError subclasses can hide internal details from the client.""" + handler = create_exception_handler() + mock_request = MagicMock() + mock_request.state.request_id = "req-internal" + exc = InternalServerError(log_message="Database invariant failed for category link") + + mock_logger = MagicMock() + mock_logger.opt.return_value = mock_logger + with patch("app.api.common.routers.exceptions.logger", mock_logger): + response = await handler(mock_request, exc) + + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Internal server error" + mock_logger.error.assert_called_once_with("InternalServerError: Database invariant failed for category link") + + +class TestRateLimitExceededHandler: + """Tests for rate_limit_exceeded_handler.""" + + def test_returns_429_with_detail(self) -> None: + """Test that handler returns a 429 JSON response.""" + mock_request = MagicMock() + exc = RateLimitExceededError() + + response = rate_limit_exceeded_handler(mock_request, exc) + + assert response.status_code == 429 + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Rate limit exceeded" + assert body["status"] == 429 + + def test_custom_detail_message(self) -> None: + """Test that a custom detail message is forwarded.""" + mock_request = MagicMock() + exc = RateLimitExceededError("Too many login attempts") + + response = rate_limit_exceeded_handler(mock_request, exc) + + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Too many login attempts" + assert body["code"] == "RateLimitExceeded" + + +class TestSharedExceptionFamilies: + """Tests for shared common exception families exercising the full response path.""" + + async def test_service_unavailable_error_with_details_is_exposed(self) -> None: + """ServiceUnavailableError (503) includes message and details in the response body.""" + handler = create_exception_handler() + mock_request = MagicMock() + mock_request.state.request_id = "req-503" + exc = ServiceUnavailableError("Temporarily unavailable", details="redis offline") + + with patch("app.api.common.routers.exceptions.logger"): + response = await handler(mock_request, exc) + + assert response.status_code == 503 + body = json.loads(cast("bytes", response.body)) + assert body["detail"] == "Temporarily unavailable" + assert body["errors"] == "redis offline" diff --git a/backend/tests/unit/common/test_health.py b/backend/tests/unit/common/test_health.py new file mode 100644 index 00000000..97d2e2f9 --- /dev/null +++ b/backend/tests/unit/common/test_health.py @@ -0,0 +1,175 @@ +"""Unit tests for health check endpoints.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.exc import SQLAlchemyError + +from app.api.common.routers.health import ( + HEALTHY_STATUS, + UNHEALTHY_STATUS, + check_database, + check_redis, + healthy_check, + perform_health_checks, + unhealthy_check, +) +from app.core.runtime import AppServices + + +class TestHealthCheckHelpers: + """Tests for health check helper functions.""" + + def test_healthy_check_returns_correct_payload(self) -> None: + """Test that healthy_check returns the expected dict.""" + result = healthy_check("database") + assert result == {"component": "database", "status": HEALTHY_STATUS} + + def test_unhealthy_check_returns_correct_payload(self) -> None: + """Test that unhealthy_check includes status and error.""" + result = unhealthy_check("database", "db connection refused") + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "database" + assert result["error"] == "db connection refused" + + +class TestCheckDatabase: + """Tests for check_database function.""" + + async def test_database_healthy(self) -> None: + """Test healthy result when DB returns SELECT 1.""" + mock_conn = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one.return_value = 1 + mock_conn.execute = AsyncMock(return_value=mock_result) + + mock_engine_ctx = AsyncMock() + mock_engine_ctx.__aenter__ = AsyncMock(return_value=mock_conn) + mock_engine_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch("app.api.common.routers.health.async_engine") as mock_engine: + mock_engine.connect.return_value = mock_engine_ctx + result = await check_database() + + assert result["status"] == HEALTHY_STATUS + assert result["component"] == "database" + + async def test_database_unexpected_result(self) -> None: + """Test unhealthy result when SELECT 1 returns wrong value.""" + mock_conn = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one.return_value = 0 # Wrong value + mock_conn.execute = AsyncMock(return_value=mock_result) + + mock_engine_ctx = AsyncMock() + mock_engine_ctx.__aenter__ = AsyncMock(return_value=mock_conn) + mock_engine_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch("app.api.common.routers.health.async_engine") as mock_engine: + mock_engine.connect.return_value = mock_engine_ctx + result = await check_database() + + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "database" + assert "unexpected" in result["error"].lower() + + async def test_database_connection_error(self) -> None: + """Test unhealthy result when DB raises an exception.""" + mock_engine_ctx = AsyncMock() + mock_engine_ctx.__aenter__ = AsyncMock(side_effect=SQLAlchemyError("connection refused")) + mock_engine_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch("app.api.common.routers.health.async_engine") as mock_engine: + mock_engine.connect.return_value = mock_engine_ctx + result = await check_database() + + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "database" + assert result["error"] == "Database connection failed" + + +class TestCheckRedis: + """Tests for check_redis function.""" + + async def test_redis_healthy(self) -> None: + """Test healthy result when Redis ping succeeds.""" + mock_redis = AsyncMock() + request = MagicMock() + request.app.state.services = AppServices(redis=mock_redis) + + with patch("app.api.common.routers.health.ping_redis", return_value=True): + result = await check_redis(request) + + assert result["status"] == HEALTHY_STATUS + assert result["component"] == "redis" + + async def test_redis_ping_returns_false(self) -> None: + """Test unhealthy result when ping returns False.""" + mock_redis = AsyncMock() + request = MagicMock() + request.app.state.services = AppServices(redis=mock_redis) + + with patch("app.api.common.routers.health.ping_redis", return_value=False): + result = await check_redis(request) + + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "redis" + assert "False" in result["error"] + + async def test_redis_not_initialized(self) -> None: + """Test unhealthy result when Redis client is not set.""" + request = MagicMock() + request.app.state.services = AppServices() + + result = await check_redis(request) + + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "redis" + assert "not initialized" in result["error"] + + async def test_redis_connection_error(self) -> None: + """Test unhealthy result when Redis raises an exception.""" + mock_redis = AsyncMock() + request = MagicMock() + request.app.state.services = AppServices(redis=mock_redis) + + with patch("app.api.common.routers.health.ping_redis", side_effect=OSError("connection refused")): + result = await check_redis(request) + + assert result["status"] == UNHEALTHY_STATUS + assert result["component"] == "redis" + assert result["error"] == "Redis connection failed" + + +class TestPerformHealthChecks: + """Tests for perform_health_checks.""" + + async def test_all_healthy(self) -> None: + """Test that all checks pass through correctly.""" + request = MagicMock() + healthy = {"status": HEALTHY_STATUS} + + with ( + patch("app.api.common.routers.health.check_database", return_value=healthy), + patch("app.api.common.routers.health.check_redis", return_value=healthy), + ): + result = await perform_health_checks(request) + + assert result["database"]["status"] == HEALTHY_STATUS + assert result["redis"]["status"] == HEALTHY_STATUS + + async def test_one_unhealthy(self) -> None: + """Test that an unhealthy check is included in results.""" + request = MagicMock() + healthy = {"status": HEALTHY_STATUS} + unhealthy = {"status": UNHEALTHY_STATUS, "error": "down"} + + with ( + patch("app.api.common.routers.health.check_database", return_value=healthy), + patch("app.api.common.routers.health.check_redis", return_value=unhealthy), + ): + result = await perform_health_checks(request) + + assert result["database"]["status"] == HEALTHY_STATUS + assert result["redis"]["status"] == UNHEALTHY_STATUS diff --git a/backend/tests/unit/common/test_model_base.py b/backend/tests/unit/common/test_model_base.py new file mode 100644 index 00000000..6b6c20ae --- /dev/null +++ b/backend/tests/unit/common/test_model_base.py @@ -0,0 +1,26 @@ +"""Unit tests for shared model base helpers.""" + +from __future__ import annotations + +from app.api.common.models.base import camel_to_capital, get_model_label_plural, pluralize_camel_name + + +class TestModelNamingHelpers: + """Tests for shared API model naming helpers.""" + + def test_pluralization_handles_existing_backend_model_names(self) -> None: + """Pluralization should support common backend resource names.""" + assert pluralize_camel_name("Category") == "Categories" + assert pluralize_camel_name("Taxonomy") == "Taxonomies" + assert pluralize_camel_name("ProductType") == "ProductTypes" + + def test_pluralization_handles_y_suffix_edge_cases(self) -> None: + """Words ending in vowel+y should not be pluralized as ``ies``.""" + assert pluralize_camel_name("Key") == "Keys" + assert camel_to_capital("ProductType") == "Product Type" + + def test_model_label_plural_uses_human_readable_plural(self) -> None: + """Plural helper should produce display-ready labels.""" + product_type_model = type("ProductType", (), {}) + + assert get_model_label_plural(product_type_model) == "Product Types" diff --git a/backend/tests/unit/common/test_mutable_model_properties.py b/backend/tests/unit/common/test_mutable_model_properties.py new file mode 100644 index 00000000..15b8c2ca --- /dev/null +++ b/backend/tests/unit/common/test_mutable_model_properties.py @@ -0,0 +1,71 @@ +"""Unit tests for mutable computed/model properties.""" + +from __future__ import annotations + +import uuid + +from app.api.auth.models import OrganizationRole, User +from app.api.common.models.enums import Unit +from app.api.data_collection.models.product import MaterialProductLink, Product +from tests.factories.models import ProductFactory + + +def test_user_organization_owner_property_tracks_mutations() -> None: + """Derived role flags should reflect the latest field value.""" + user = User( + email="user@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_role=None, + ) + + assert user.is_organization_owner is False + + user.organization_role = OrganizationRole.OWNER + + assert user.is_organization_owner is True + + +def test_physical_properties_volume_tracks_dimension_updates() -> None: + """Computed volume should not retain stale cached values after mutation.""" + product = ProductFactory.build(height_cm=2, width_cm=3, depth_cm=4, owner_id=uuid.uuid4()) + + assert product.volume_cm3 == 24 + + product.depth_cm = 5 + + assert product.volume_cm3 == 30 + + +def test_product_derived_flags_track_parent_and_component_updates() -> None: + """Product convenience flags should reflect the current graph state.""" + product = Product( + id=1, + name="Chair", + owner_id=uuid.uuid4(), + first_image_id=None, + parent_id=None, + components=[], + bill_of_materials=[MaterialProductLink(material_id=1, product_id=1, quantity=1, unit=Unit.GRAM)], + ) + + assert product.is_base_product is True + assert product.is_leaf_node is True + + product.parent_id = 99 + product.components = [ + Product( + id=2, + name="Leg", + owner_id=uuid.uuid4(), + first_image_id=None, + parent_id=1, + amount_in_parent=1, + bill_of_materials=[MaterialProductLink(material_id=2, product_id=2, quantity=1, unit=Unit.GRAM)], + ) + ] + + assert product.is_base_product is False + assert product.is_leaf_node is False diff --git a/backend/tests/unit/common/test_openapi_examples.py b/backend/tests/unit/common/test_openapi_examples.py new file mode 100644 index 00000000..3de4079d --- /dev/null +++ b/backend/tests/unit/common/test_openapi_examples.py @@ -0,0 +1,66 @@ +"""Unit tests for OpenAPI example helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from app.api.common.openapi_examples import openapi_example, openapi_examples + + +class TestOpenAPIExamples: + """Tests for shared OpenAPI example helper functions.""" + + def test_openapi_example_builds_value_only_payload(self) -> None: + """A basic example should include the value unchanged.""" + result = openapi_example({"id": 1}) + + assert result == {"value": {"id": 1}} + + def test_openapi_example_includes_summary_and_description(self) -> None: + """Optional metadata should be included when provided.""" + result = openapi_example( + "camera-token", + summary="Example token", + description="Returned by the provisioning email", + ) + + assert result["value"] == "camera-token" + assert result["summary"] == "Example token" + assert result["description"] == "Returned by the provisioning email" + + def test_openapi_examples_returns_named_example_mapping(self) -> None: + """Named examples should be preserved for FastAPI OpenAPI wiring.""" + result = openapi_examples( + basic=openapi_example([1, 2, 3], summary="Bulk IDs"), + empty=openapi_example([]), + ) + + assert set(result) == {"basic", "empty"} + assert result["basic"]["value"] == [1, 2, 3] + assert result["basic"]["summary"] == "Bulk IDs" + assert result["empty"]["value"] == [] + + def test_api_modules_do_not_inline_large_example_literals(self) -> None: + """API modules should keep example payloads centralized instead of inlining large literals.""" + backend_root = Path(__file__).resolve().parents[2] + api_root = backend_root / "app" / "api" + + forbidden_snippets = ( + "openapi_examples={", + "examples=[", + 'json_schema_extra={"examples": [', + 'json_schema_extra={"examples": {', + ) + + offenders: list[str] = [] + for path in sorted(api_root.rglob("*.py")): + if path.name == "examples.py": + continue + contents = path.read_text() + offenders.extend( + f"{path.relative_to(backend_root)} -> {snippet}" + for snippet in forbidden_snippets + if snippet in contents + ) + + assert offenders == [] diff --git a/backend/tests/unit/common/test_ownership_validation.py b/backend/tests/unit/common/test_ownership_validation.py new file mode 100644 index 00000000..6f8451ef --- /dev/null +++ b/backend/tests/unit/common/test_ownership_validation.py @@ -0,0 +1,108 @@ +"""Unit tests for get_user_owned_object ownership enforcement.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.exceptions import UserOwnershipError +from app.api.common.crud.exceptions import ModelNotFoundError +from app.api.common.ownership import get_user_owned_object + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +class TestGetUserOwnedObject: + """get_user_owned_object enforces owner scoping and maps failures cleanly.""" + + async def test_success_returns_object_and_filters_by_default_owner_fk(self, mocker: MockerFixture) -> None: + """Happy path: returned object matches and the default FK is owner_id.""" + user_id = uuid4() + model_id = uuid4() + expected = MagicMock() + expected.owner_id = user_id + statement = MagicMock() + statement.where.return_value = statement + mocker.patch("app.api.common.ownership.select", return_value=statement) + execute_result = MagicMock() + execute_result.scalars.return_value.unique.return_value.one_or_none.return_value = expected + db = AsyncMock(spec=AsyncSession) + db.execute.return_value = execute_result + mock_model = MagicMock() + mock_model.id = MagicMock() + mock_model.owner_id = MagicMock() + + result = await get_user_owned_object(db=db, model=mock_model, model_id=model_id, owner_id=user_id) + + assert result is expected + db.execute.assert_awaited_once() + + async def test_success_respects_custom_owner_fk(self, mocker: MockerFixture) -> None: + """Custom owner FK names should be checked and queried consistently.""" + user_id = uuid4() + model_id = uuid4() + expected = MagicMock() + expected.created_by_id = user_id + statement = MagicMock() + statement.where.return_value = statement + mocker.patch("app.api.common.ownership.select", return_value=statement) + execute_result = MagicMock() + execute_result.scalars.return_value.unique.return_value.one_or_none.return_value = expected + db = AsyncMock(spec=AsyncSession) + db.execute.return_value = execute_result + mock_model = MagicMock() + mock_model.id = MagicMock() + mock_model.created_by_id = MagicMock() + + result = await get_user_owned_object( + db=db, model=mock_model, model_id=model_id, owner_id=user_id, user_fk="created_by_id" + ) + + assert result is expected + + async def test_ownership_error_raises_user_ownership_error(self, mocker: MockerFixture) -> None: + """Mismatched owner IDs are translated to UserOwnershipError (403, correct message).""" + user_id = uuid4() + model_id = uuid4() + existing = MagicMock() + existing.owner_id = uuid4() + statement = MagicMock() + statement.where.return_value = statement + mocker.patch("app.api.common.ownership.select", return_value=statement) + execute_result = MagicMock() + execute_result.scalars.return_value.unique.return_value.one_or_none.return_value = existing + db = AsyncMock(spec=AsyncSession) + db.execute.return_value = execute_result + mock_model = MagicMock() + mock_model.model_label = "Product" + + with pytest.raises(UserOwnershipError) as exc_info: + await get_user_owned_object(db=db, model=mock_model, model_id=model_id, owner_id=user_id) + + err = exc_info.value + assert err.http_status_code == 403 + assert str(user_id) in err.message + assert str(model_id) in err.message + assert "Product" in err.message + + async def test_missing_object_raises_model_not_found(self, mocker: MockerFixture) -> None: + """Missing owned objects should surface as ModelNotFoundError.""" + user_id = uuid4() + model_id = uuid4() + statement = MagicMock() + statement.where.return_value = statement + mocker.patch("app.api.common.ownership.select", return_value=statement) + execute_result = MagicMock() + execute_result.scalars.return_value.unique.return_value.one_or_none.return_value = None + db = AsyncMock(spec=AsyncSession) + db.execute.return_value = execute_result + mock_model = MagicMock() + mock_model.model_label = "Product" + + with pytest.raises(ModelNotFoundError): + await get_user_owned_object(db=db, model=mock_model, model_id=model_id, owner_id=user_id) diff --git a/backend/tests/unit/common/test_query_crud.py b/backend/tests/unit/common/test_query_crud.py new file mode 100644 index 00000000..603fd11f --- /dev/null +++ b/backend/tests/unit/common/test_query_crud.py @@ -0,0 +1,67 @@ +"""Unit tests for common query/loading/scoped CRUD helpers.""" + +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import select + +from app.api.background_data.models import Material +from app.api.common.crud.exceptions import CRUDConfigurationError +from app.api.common.crud.filtering import filter_has_values +from app.api.common.crud.loading import apply_loader_profile +from app.api.common.crud.query import require_model + + +class TestFilterHasValues: + """Tests for active fastapi-filter detection.""" + + def test_returns_false_when_all_none(self) -> None: + """Inactive filters should be skipped when every value is None.""" + mock_filter = MagicMock() + mock_filter.__dict__ = {"name": None, "id": None} + + assert filter_has_values(mock_filter) is False + + def test_returns_true_when_value_set(self) -> None: + """Filters with at least one concrete value should be applied.""" + mock_filter = MagicMock() + mock_filter.__dict__ = {"name": "test", "id": None} + + assert filter_has_values(mock_filter) is True + + +class TestRequireModel: + """Tests for model lookup error paths.""" + + async def test_raises_crud_configuration_error_for_model_without_id(self) -> None: + """Models without an id attribute should fail before querying.""" + session = AsyncMock() + + class NoIdModel: + pass + + with pytest.raises(CRUDConfigurationError, match="does not have an id field"): + await require_model(session, cast("type[Any]", NoIdModel), 1) + + +class TestQueryConstruction: + """Tests for query filtering and relationship loading.""" + + def test_does_not_apply_noload_without_read_schema(self) -> None: + """Loader profiles should leave statements unchanged without explicit loaders.""" + statement = select(Material) + + updated_statement = apply_loader_profile(statement, Material) + + assert str(updated_statement) == str(statement) + + def test_accepts_explicit_base_statement(self) -> None: + """Explicit SQLAlchemy statements should remain stable through loader application.""" + statement = select(Material).where(Material.id == 1) + + updated_statement = apply_loader_profile(statement, Material) + + assert str(updated_statement) == str(statement) diff --git a/backend/tests/unit/common/test_schema_base.py b/backend/tests/unit/common/test_schema_base.py new file mode 100644 index 00000000..c951801e --- /dev/null +++ b/backend/tests/unit/common/test_schema_base.py @@ -0,0 +1,45 @@ +"""Unit tests for shared schema base helpers.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from app.api.common.schemas.base import IntIdReadSchemaWithTimeStamp + + +class ExampleReadSchema(IntIdReadSchemaWithTimeStamp): + """Concrete test schema using the shared read base.""" + + name: str + + +class TestBaseReadSchemaWithTimeStamp: + """Tests for common read-schema behavior.""" + + def test_model_validate_reads_attributes_from_objects(self) -> None: + """Read schemas should accept attribute-based ORM-like inputs by default.""" + + class ExampleORMRow: + id = 1 + name = "example" + created_at = datetime(2026, 3, 30, 10, 11, 12, tzinfo=UTC) + updated_at = datetime(2026, 3, 30, 10, 12, 13, tzinfo=UTC) + + result = ExampleReadSchema.model_validate(ExampleORMRow()) + + assert result.id == 1 + assert result.name == "example" + + def test_model_dump_serializes_timestamps_with_z_suffix(self) -> None: + """Timestamp serializer should emit stable UTC ``Z`` strings.""" + result = ExampleReadSchema( + id=1, + name="example", + created_at=datetime(2026, 3, 30, 10, 11, 12, tzinfo=UTC), + updated_at=datetime(2026, 3, 30, 10, 12, 13, tzinfo=UTC), + ) + + dumped = result.model_dump() + + assert dumped["created_at"] == "2026-03-30T10:11:12Z" + assert dumped["updated_at"] == "2026-03-30T10:12:13Z" diff --git a/backend/tests/unit/common/test_schema_field_mixins.py b/backend/tests/unit/common/test_schema_field_mixins.py new file mode 100644 index 00000000..52f3ddae --- /dev/null +++ b/backend/tests/unit/common/test_schema_field_mixins.py @@ -0,0 +1,94 @@ +"""Unit tests for pure Pydantic schema field mixins.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import cast + +import pytest +from pydantic import ValidationError + +from app.api.auth.services.privacy import redact_product_owner +from app.api.background_data.schemas import CategoryReadAsSubCategory, ProductTypeRead, TaxonomyRead +from app.api.common.models.enums import Unit +from app.api.common.schemas.associations import MaterialProductLinkCreateWithinProduct +from app.api.common.schemas.base import MaterialRead, ProductRead +from app.api.data_collection.models.product import Product +from app.api.data_collection.schemas import ProductCreateBaseProduct + + +def test_read_schemas_validate_from_attribute_objects_without_orm_bases() -> None: + """Read schemas should validate ORM-like attribute objects via pure Pydantic field mixins.""" + + class MaterialRow: + id = 1 + name = "Steel" + description = "Alloy" + source = "DIN" + density_kg_m3 = 7850.0 + is_crm = False + + class ProductTypeRow: + id = 2 + name = "Chair" + description = "Seating" + + class CategoryRow: + id = 3 + name = "Metals" + description = "Raw materials" + external_id = "cat-1" + + class TaxonomyRow: + id = 4 + name = "Materials" + version = "2026" + description = "Taxonomy" + domains = frozenset({"materials"}) + source = "doi:test" + created_at = datetime(2026, 3, 30, 10, 11, 12, tzinfo=UTC) + updated_at = datetime(2026, 3, 30, 10, 12, 13, tzinfo=UTC) + + class ProductRow: + id = 5 + name = "Office Chair" + description = "Chair" + brand = "Brand" + model = "M1" + dismantling_notes = None + dismantling_time_start = datetime(2026, 3, 29, 10, 11, 12, tzinfo=UTC) + dismantling_time_end = datetime(2026, 3, 29, 10, 12, 13, tzinfo=UTC) + owner_id = "4f4b34bc-4b3d-4324-a58f-8fb59428df2a" + created_at = datetime(2026, 3, 30, 10, 11, 12, tzinfo=UTC) + updated_at = datetime(2026, 3, 30, 10, 12, 13, tzinfo=UTC) + product_type_id = 2 + owner_username = "simon" + thumbnail_url = None + parent_id = None + amount_in_parent = None + + assert MaterialRead.model_validate(MaterialRow()).name == "Steel" + assert ProductTypeRead.model_validate(ProductTypeRow()).name == "Chair" + assert CategoryReadAsSubCategory.model_validate(CategoryRow()).external_id == "cat-1" + assert TaxonomyRead.model_validate(TaxonomyRow()).domains == {"materials"} + assert ProductRead.model_validate(ProductRow()).owner_username == "simon" + + +def test_redact_product_owner_noops_on_serialized_schema() -> None: + """Privacy redaction should safely no-op if a schema object is passed by mistake.""" + product = ProductRead(id=1, name="Office Chair", owner_username="simon") + + redact_product_owner(cast("Product", product), None) + + assert product.owner_username == "simon" + + +def test_product_create_schema_still_validates_end_after_start() -> None: + """Timestamp validation should now live in schema validation, not the ORM base.""" + with pytest.raises(ValidationError): + ProductCreateBaseProduct( + name="Chair", + dismantling_time_start=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + dismantling_time_end=datetime(2026, 3, 30, 9, 0, tzinfo=UTC), + bill_of_materials=[MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1, unit=Unit.GRAM)], + ) diff --git a/backend/tests/unit/common/test_search_utils.py b/backend/tests/unit/common/test_search_utils.py new file mode 100644 index 00000000..8ad5ef41 --- /dev/null +++ b/backend/tests/unit/common/test_search_utils.py @@ -0,0 +1,93 @@ +"""Unit tests for the shared tsvector search utilities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import literal_column, select +from sqlalchemy.dialects import postgresql + +from app.api.common.search_utils import apply_ts_rank_ordering, build_text_search_clause + +if TYPE_CHECKING: + from sqlalchemy.sql.elements import ClauseElement + + +def _sql(clause: ClauseElement) -> str: + """Compile a clause to a SQL string using the PostgreSQL dialect.""" + return str(clause.compile(dialect=postgresql.dialect())) + + +_SEARCH_VECTOR = literal_column("material.search_vector") +_NAME_COL = literal_column("material.name") +_DESC_COL = literal_column("material.description") + + +class TestBuildTextSearchClause: + """Tests for build_text_search_clause.""" + + def test_tsvector_only_produces_one_condition(self) -> None: + """No trigram fields → single tsvector @@ tsquery condition.""" + clause = build_text_search_clause("test", _SEARCH_VECTOR) + sql = _sql(clause) + assert "@@" in sql + assert " OR " not in sql.upper() + + def test_one_trigram_field_produces_two_conditions(self) -> None: + """One trigram field → tsvector condition + one trigram condition.""" + clause = build_text_search_clause("test", _SEARCH_VECTOR, _NAME_COL) + assert len(list(clause.clauses)) == 2 + + def test_two_trigram_fields_produce_three_conditions(self) -> None: + """Two trigram fields → tsvector condition + two trigram conditions.""" + clause = build_text_search_clause("test", _SEARCH_VECTOR, _NAME_COL, _DESC_COL) + assert len(list(clause.clauses)) == 3 + + def test_contains_tsvector_match_operator(self) -> None: + """Clause should contain the tsvector match operator (@@).""" + sql = _sql(build_text_search_clause("hello", _SEARCH_VECTOR, _NAME_COL)) + assert "@@" in sql + + def test_uses_websearch_to_tsquery(self) -> None: + """Clause should use websearch_to_tsquery for the tsquery.""" + sql = _sql(build_text_search_clause("hello world", _SEARCH_VECTOR)) + assert "websearch_to_tsquery" in sql + + def test_trigram_operator_present_for_given_field(self) -> None: + """Clause should contain the trigram operator (%) for the name field.""" + sql = _sql(build_text_search_clause("hello", _SEARCH_VECTOR, _NAME_COL)) + assert "%" in sql + assert "material.name" in sql.lower() + + def test_absent_field_not_in_sql(self) -> None: + """If a trigram field isn't given, it shouldn't appear in the SQL.""" + sql = _sql(build_text_search_clause("hello", _SEARCH_VECTOR, _NAME_COL)) + assert "material.description" not in sql.lower() + + def test_search_lowercased_for_trigram(self) -> None: + """Trigram comparisons use lower() to normalise case.""" + sql = _sql(build_text_search_clause("Hello", _SEARCH_VECTOR, _NAME_COL)) + assert "lower" in sql.lower() + + def test_conditions_combined_with_or(self) -> None: + """Multiple conditions are OR-combined, not AND.""" + sql = _sql(build_text_search_clause("x", _SEARCH_VECTOR, _NAME_COL)) + assert " OR " in sql.upper() + + +class TestApplyTsRankOrdering: + """Tests for apply_ts_rank_ordering.""" + + def test_adds_rank_column_and_orders_by_it(self) -> None: + """Rank is added to the select list and used as the ORDER BY target. + + This satisfies Postgres' rule that ORDER BY expressions under + SELECT DISTINCT must appear in the select list. + """ + base = select(_NAME_COL) + sql = _sql(apply_ts_rank_ordering(base, _SEARCH_VECTOR, "hello world")) + assert "ts_rank" in sql.lower() + assert "websearch_to_tsquery" in sql + assert "ts_rank_score" in sql + assert "ORDER BY" in sql.upper() + assert "DESC" in sql.upper() diff --git a/backend/tests/unit/common/test_utils.py b/backend/tests/unit/common/test_utils.py new file mode 100644 index 00000000..bf82b2c4 --- /dev/null +++ b/backend/tests/unit/common/test_utils.py @@ -0,0 +1,46 @@ +"""Unit tests for common utilities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from app.api.common.exceptions import APIError + +if TYPE_CHECKING: + from typing import Never + + +class TestAPIError: + """Test custom API error exception.""" + + def test_api_error_creation_message_only(self) -> None: + """Test that APIError can be created with just a message.""" + error = APIError(message="Test error") + assert error.message == "Test error" + assert error.details is None + assert str(error) == "Test error" + + def test_api_error_creation_with_details(self) -> None: + """Test that APIError can be created with both message and details.""" + error = APIError(message="Validation failed", details="Field 'email' is invalid") + assert error.message == "Validation failed" + assert error.details == "Field 'email' is invalid" + + def test_api_error_default_status_code(self) -> None: + """Test that the default HTTP status code for APIError is 500.""" + assert APIError(message="x").http_status_code == 500 + + def test_api_error_subclass_custom_status_code(self) -> None: + """Test that a subclass of APIError can define a custom HTTP status code.""" + + class NotFoundError(APIError): + http_status_code = 404 + + assert NotFoundError(message="not found").http_status_code == 404 + + def test_api_error_is_raisable(self) -> Never: + """Test that APIError can be raised and caught.""" + with pytest.raises(APIError, match="Test"): + raise APIError(message="Test") diff --git a/backend/tests/unit/conftest.py b/backend/tests/unit/conftest.py new file mode 100644 index 00000000..e4029de8 --- /dev/null +++ b/backend/tests/unit/conftest.py @@ -0,0 +1,51 @@ +"""Shared fixtures for unit tests (no database required).""" +# spell-checker: ignore fixturenames + +from __future__ import annotations + +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_FORBIDDEN_UNIT_FIXTURES = { + "async_engine", + "db_session", + "relab_alembic_config", + "test_database_name", +} + + +@pytest.fixture +def mock_session() -> AsyncMock: + """Async database session mock with common SQLAlchemy methods. + + Synchronous methods (add, add_all) use MagicMock; async methods use AsyncMock. + Tests can further configure return values on this mock as needed. + """ + session = AsyncMock() + # Synchronous in SQLAlchemy + session.add = MagicMock() + session.add_all = MagicMock() + return session + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Fail fast when unit tests accidentally depend on DB-backed fixtures.""" + violations: list[str] = [] + + for item in items: + item_path = Path(str(item.fspath)) + if "tests/unit/" not in item_path.as_posix(): + continue + + fixture_names = cast("list[str]", getattr(item, "fixturenames", [])) + forbidden = sorted(_FORBIDDEN_UNIT_FIXTURES.intersection(fixture_names)) + if forbidden: + violations.append(f"{item.nodeid}: forbidden fixtures in unit tier: {', '.join(forbidden)}") + + if violations: + msg = "\n".join(violations) + error_message = f"Unit tests must stay database-free.\n{msg}" + raise pytest.UsageError(error_message) diff --git a/backend/tests/unit/core/__init__.py b/backend/tests/unit/core/__init__.py new file mode 100644 index 00000000..c3dcf900 --- /dev/null +++ b/backend/tests/unit/core/__init__.py @@ -0,0 +1 @@ +"""Core unit tests.""" diff --git a/backend/tests/unit/core/test_cache.py b/backend/tests/unit/core/test_cache.py new file mode 100644 index 00000000..de908c0e --- /dev/null +++ b/backend/tests/unit/core/test_cache.py @@ -0,0 +1,126 @@ +"""Unit tests for cache utilities.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.responses import HTMLResponse + +from app.core.cache import ( + HTMLCoder, + _backend, + _cache_state, + clear_cache_namespace, + close_fastapi_cache, + init_fastapi_cache, +) + + +class TestHTMLCoder: + """Tests for HTMLCoder encode/decode.""" + + def test_encode_html_response(self) -> None: + """Test encoding an HTMLResponse extracts body and metadata.""" + response = HTMLResponse(content="

Hello

", status_code=200) + encoded = HTMLCoder.encode(response) + + decoded = json.loads(encoded) + assert decoded["type"] == "HTMLResponse" + assert decoded["body"] == "

Hello

" + assert decoded["status_code"] == 200 + + def test_encode_dict(self) -> None: + """Test encoding a regular dict uses JSON.""" + data = {"key": "value", "count": 42} + encoded = HTMLCoder.encode(data) + + decoded = json.loads(encoded) + assert decoded == data + + def test_decode_html_response(self) -> None: + """Test decoding reconstructs an HTMLResponse.""" + payload = json.dumps( + {"type": "HTMLResponse", "body": "

Hi

", "status_code": 200, "media_type": "text/html", "headers": {}} + ).encode("utf-8") + + result = HTMLCoder.decode(payload) + + assert isinstance(result, HTMLResponse) + + def test_decode_regular_data(self) -> None: + """Test decoding regular JSON data returns the original value.""" + data = {"key": "value"} + encoded = json.dumps(data).encode("utf-8") + + result = HTMLCoder.decode(encoded) + + assert result == data + + def test_decode_string_input(self) -> None: + """Test decoding handles string (not bytes) input.""" + data = [1, 2, 3] + encoded_str = json.dumps(data) + + result = HTMLCoder.decode(encoded_str) + + assert result == data + + +class TestInitFastapiCache: + """Tests for init_fastapi_cache.""" + + def test_init_with_redis_client(self) -> None: + """Test cache init uses Redis backend when redis_client is provided.""" + redis_client = MagicMock() + + with patch("app.core.cache.settings") as mock_settings, patch.object(_backend, "setup") as mock_setup: + mock_settings.enable_caching = True + mock_settings.cache_url = "redis://cache" + with patch.dict(_cache_state, {"initialized": False}): + init_fastapi_cache(redis_client) + + mock_setup.assert_called_once_with("redis://cache") + + def test_init_without_redis_uses_in_memory(self) -> None: + """Test cache init falls back to in-memory when redis_client is None.""" + with patch("app.core.cache.settings") as mock_settings, patch.object(_backend, "setup") as mock_setup: + mock_settings.enable_caching = True + with patch.dict(_cache_state, {"initialized": False}): + init_fastapi_cache(None) + + mock_setup.assert_called_once_with("mem://") + + def test_init_caching_disabled_uses_in_memory(self) -> None: + """Test that when caching is disabled, InMemoryBackend is used.""" + with patch("app.core.cache.settings") as mock_settings, patch.object(_backend, "setup") as mock_setup: + mock_settings.enable_caching = False + mock_settings.environment = "testing" + with patch.dict(_cache_state, {"initialized": False}): + init_fastapi_cache(None) + + mock_setup.assert_called_once_with("mem://") + + async def test_close_fastapi_cache(self) -> None: + """Closing the shared cache should close the backend when initialized.""" + with ( + patch.dict(_cache_state, {"initialized": True}), + patch.object(_backend, "close", AsyncMock()) as mock_close, + ): + await close_fastapi_cache() + + mock_close.assert_awaited_once() + + +class TestClearCacheNamespace: + """Tests for clear_cache_namespace.""" + + async def test_clear_cache_namespace(self) -> None: + """Test that clear_cache_namespace clears keys under the namespace prefix.""" + with ( + patch("app.core.cache.settings") as mock_settings, + patch.object(_backend, "delete_match", AsyncMock()) as mock_delete, + ): + mock_settings.cache.prefix = "test-cache" + + await clear_cache_namespace("test-namespace") + + mock_delete.assert_awaited_once_with("test-cache:test-namespace:*") diff --git a/backend/tests/unit/core/test_config.py b/backend/tests/unit/core/test_config.py new file mode 100644 index 00000000..1991db8e --- /dev/null +++ b/backend/tests/unit/core/test_config.py @@ -0,0 +1,241 @@ +"""Unit tests for application configuration. + +Tests CORS settings, host allowlists, and environment file resolution. +""" +# spell-checker: ignore PGSSL + +from typing import TYPE_CHECKING + +import pytest +from pydantic import HttpUrl, SecretStr +from pydantic_core import ValidationError +from sqlalchemy.engine import make_url + +from app.api.auth.config import AuthSettings +from app.api.plugins.rpi_cam.config import RPiCamSettings +from app.core.config import DEFAULT_CORS_ORIGIN_REGEX, CoreSettings, Environment +from app.core.env import get_env_file + +if TYPE_CHECKING: + from pathlib import Path + + +class TestCoreSettingsCors: + """Test CORS configuration behavior in CoreSettings.""" + + def test_allowed_origins_dev_normalizes_frontend_urls(self) -> None: + """DEV environment normalizes frontend URLs to scheme+host (no trailing slash).""" + settings = CoreSettings( + environment=Environment.DEV, + frontend_web_url=HttpUrl("http://localhost:3000/"), + frontend_app_url=HttpUrl("http://localhost:8081/"), + ) + assert settings.allowed_origins == ["http://localhost:3000", "http://localhost:8081"] + + def test_allowed_origins_staging_are_normalized(self) -> None: + """Staging origins should match browser Origin format (no trailing slash).""" + settings = CoreSettings( + environment=Environment.STAGING, + backend_api_url=HttpUrl("https://api-test.cml-relab.org/"), + frontend_web_url=HttpUrl("https://web-test.cml-relab.org/"), + frontend_app_url=HttpUrl("https://app-test.cml-relab.org/"), + cors_origin_regex=None, + postgres_password=SecretStr("test-password"), + redis_password=SecretStr("test-password"), + superuser_password=SecretStr("test-password"), + superuser_email="test@example.com", + ) + + assert settings.allowed_origins == [ + "https://web-test.cml-relab.org", + "https://app-test.cml-relab.org", + ] + + def test_allowed_hosts_dev_defaults(self) -> None: + """DEV environment should trust all hosts (Docker/Testcontainers convenience).""" + settings = CoreSettings(environment=Environment.DEV) + assert settings.allowed_hosts == ["*"] + + def test_allowed_hosts_derive_from_backend_api_url(self) -> None: + """Trusted hosts should derive from backend_api_url in non-DEV environments.""" + settings = CoreSettings( + environment=Environment.STAGING, + backend_api_url=HttpUrl("https://api-test.cml-relab.org"), + frontend_web_url=HttpUrl("https://web-test.cml-relab.org/"), + frontend_app_url=HttpUrl("https://app-test.cml-relab.org/"), + cors_origin_regex=None, + postgres_password=SecretStr("test-password"), + redis_password=SecretStr("test-password"), + superuser_password=SecretStr("test-password"), + superuser_email="test@example.com", + ) + + assert settings.allowed_hosts == [ + "api-test.cml-relab.org", + "127.0.0.1", + "localhost", + ] + + def test_staging_rejects_cors_regex(self) -> None: + """Staging/production should reject the permissive dev CORS regex.""" + with pytest.raises(ValidationError, match="CORS_ORIGIN_REGEX must not be set in production/staging"): + CoreSettings( + environment=Environment.STAGING, + backend_api_url=HttpUrl("https://api-test.cml-relab.org"), + cors_origin_regex=DEFAULT_CORS_ORIGIN_REGEX, + postgres_password=SecretStr("test-password"), + redis_password=SecretStr("test-password"), + superuser_password=SecretStr("test-password"), + superuser_email="test@example.com", + ) + + def test_production_requires_non_default_secrets(self) -> None: + """Production config should fail fast when required secrets are missing.""" + with pytest.raises(ValidationError, match="Production security check failed"): + CoreSettings(environment=Environment.PROD) + + def test_request_body_limit_default_is_one_mebibyte(self) -> None: + """Non-upload request bodies should default to a conservative 1 MiB cap.""" + settings = CoreSettings(environment=Environment.DEV) + assert settings.request_body_limit_bytes == 1024 * 1024 + + def test_otel_is_disabled_by_default(self) -> None: + """Telemetry is opt-in: no endpoint configured means OTEL is off.""" + settings = CoreSettings(environment=Environment.DEV) + assert settings.otel_enabled is False + assert settings.otel_exporter_otlp_endpoint is None + + def test_build_database_url_preserves_reserved_password_characters(self) -> None: + """Database URL construction should safely encode reserved password characters.""" + settings = CoreSettings( + environment=Environment.DEV, + database_host="database.internal", + database_port=5432, + postgres_user="relab_user", + postgres_password=SecretStr("p@ss:word/with?chars"), + ) + + url = settings.build_database_url("asyncpg", "relab_db") + parsed = make_url(url) + + assert parsed.drivername == "postgresql+asyncpg" + assert parsed.username == "relab_user" + assert parsed.password == "p@ss:word/with?chars" + assert parsed.host == "database.internal" + assert parsed.port == 5432 + assert parsed.database == "relab_db" + + def test_sync_database_url_disables_ssl_by_default(self) -> None: + """Sync DB URLs should explicitly disable SSL for the internal Postgres service.""" + settings = CoreSettings( + environment=Environment.DEV, + postgres_password=SecretStr("test-password"), + ) + parsed = make_url(settings.sync_database_url) + assert parsed.query["sslmode"] == "disable" + + def test_async_database_connect_args_disable_ssl_by_default(self) -> None: + """Async DB connections should not inherit accidental PGSSL* env vars by default.""" + settings = CoreSettings(environment=Environment.DEV) + assert settings.async_database_connect_args == {"ssl": False} + + def test_async_database_connect_args_enable_ssl_when_configured(self) -> None: + """Async DB SSL should remain configurable for deployments that need it.""" + settings = CoreSettings( + environment=Environment.PROD, + database_ssl=True, + backend_api_url=HttpUrl("https://api.cml-relab.org/"), + frontend_web_url=HttpUrl("https://cml-relab.org/"), + frontend_app_url=HttpUrl("https://app.cml-relab.org/"), + postgres_password=SecretStr("test-password"), + redis_password=SecretStr("test-password"), + superuser_password=SecretStr("test-password"), + superuser_email="test@example.com", + ) + assert settings.async_database_connect_args == {"ssl": True} + + def test_production_requires_https_origins(self) -> None: + """Production-like environments should use HTTPS for external URLs.""" + with pytest.raises(ValidationError, match="BACKEND_API_URL must use https"): + CoreSettings( + environment=Environment.PROD, + backend_api_url=HttpUrl("http://api.cml-relab.org"), + frontend_web_url=HttpUrl("https://cml-relab.org"), + frontend_app_url=HttpUrl("https://app.cml-relab.org"), + postgres_password=SecretStr("test-password"), + redis_password=SecretStr("test-password"), + superuser_password=SecretStr("test-password"), + superuser_email="test@example.com", + ) + + def test_otel_enabled_tracks_endpoint(self) -> None: + """Telemetry is enabled iff an exporter endpoint is configured.""" + settings = CoreSettings(environment=Environment.DEV) + assert settings.otel_enabled is False + + settings = CoreSettings( + environment=Environment.DEV, + otel_exporter_otlp_endpoint="http://otel.internal:4318/v1/traces", + ) + assert settings.otel_enabled is True + + +class TestModuleSettingsValidation: + """Test non-core module settings that should fail fast on bad config.""" + + def test_auth_settings_require_secrets_in_production(self) -> None: + """Auth settings should reject blank prod/staging secrets and email config.""" + with pytest.raises(ValidationError, match="Auth settings validation failed"): + AuthSettings( + environment=Environment.PROD, + fastapi_users_secret=SecretStr(""), + newsletter_secret=SecretStr(""), + google_oauth_client_id=SecretStr(""), + google_oauth_client_secret=SecretStr(""), + github_oauth_client_id=SecretStr(""), + github_oauth_client_secret=SecretStr(""), + email_host="", + email_username="", + email_password=SecretStr(""), + email_from="", + email_reply_to="", + ) + + def test_rpi_cam_secret_must_be_valid_fernet_key(self) -> None: + """Plugin secret should be validated as a Fernet key when provided.""" + with pytest.raises(ValidationError): + RPiCamSettings(environment=Environment.DEV, rpi_cam_plugin_secret="not-a-fernet-key") + + +class TestGetEnvFile: + """get_env_file() should return the correct .env path for each ENVIRONMENT value.""" + + def test_dev_maps_to_development_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """DEV environment should map to .env.dev.""" + monkeypatch.setenv("ENVIRONMENT", "dev") + assert get_env_file(tmp_path) == tmp_path / ".env.dev" + + def test_staging_maps_to_staging_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """STAGING environment should map to .env.staging.""" + monkeypatch.setenv("ENVIRONMENT", "staging") + assert get_env_file(tmp_path) == tmp_path / ".env.staging" + + def test_prod_maps_to_production_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """PROD environment should map to .env.prod.""" + monkeypatch.setenv("ENVIRONMENT", "prod") + assert get_env_file(tmp_path) == tmp_path / ".env.prod" + + def test_testing_maps_to_test_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """TESTING environment should map to .env.test.""" + monkeypatch.setenv("ENVIRONMENT", "testing") + assert get_env_file(tmp_path) == tmp_path / ".env.test" + + def test_defaults_to_development_when_unset(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """When ENVIRONMENT is unset, it should default to DEV and map to .env.dev.""" + monkeypatch.delenv("ENVIRONMENT", raising=False) + assert get_env_file(tmp_path) == tmp_path / ".env.dev" + + def test_unknown_environment_uses_name_directly(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An unrecognised value falls back to .env. so custom envs work.""" + monkeypatch.setenv("ENVIRONMENT", "ci") + assert get_env_file(tmp_path) == tmp_path / ".env.ci" diff --git a/backend/tests/unit/core/test_fastapi_cache.py b/backend/tests/unit/core/test_fastapi_cache.py new file mode 100644 index 00000000..2c09c7af --- /dev/null +++ b/backend/tests/unit/core/test_fastapi_cache.py @@ -0,0 +1,67 @@ +"""Unit tests for fastapi-cache key builder.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.cache import key_builder_excluding_dependencies + +if TYPE_CHECKING: + from collections.abc import Callable + + import pytest_mock + + +def _make_func(name: str = "test_func") -> Callable[..., Any]: + def f() -> None: + pass + + f.__module__ = "test.module" + f.__name__ = name + return f + + +class TestKeyBuilderExcludingDependencies: + """The custom key builder excludes AsyncSession (and other injected deps) from the key.""" + + def test_same_args_produce_same_key_with_namespace_prefix(self) -> None: + """Identical args → identical key; key starts with the namespace.""" + func = _make_func() + key1 = key_builder_excluding_dependencies(func, namespace="ns", args=(), kwargs={"p": "v"}) + key2 = key_builder_excluding_dependencies(func, namespace="ns", args=(), kwargs={"p": "v"}) + assert key1 == key2 + assert key1.startswith("ns:") + + def test_excludes_async_session(self, mocker: pytest_mock.MockerFixture) -> None: + """Different AsyncSession instances must not affect the cache key.""" + func = _make_func() + s1 = mocker.Mock(spec=AsyncSession) + s2 = mocker.Mock(spec=AsyncSession) + + key1 = key_builder_excluding_dependencies(func, namespace="ns", args=(), kwargs={"session": s1, "q": "x"}) + key2 = key_builder_excluding_dependencies(func, namespace="ns", args=(), kwargs={"session": s2, "q": "x"}) + + assert key1 == key2 + + def test_non_excluded_params_differentiate_keys(self, mocker: pytest_mock.MockerFixture) -> None: + """Changing a non-session kwarg must produce a different key.""" + func = _make_func() + session = mocker.Mock(spec=AsyncSession) + + key1 = key_builder_excluding_dependencies( + func, namespace="ns", args=(), kwargs={"session": session, "filter": "active"} + ) + key2 = key_builder_excluding_dependencies( + func, namespace="ns", args=(), kwargs={"session": session, "filter": "inactive"} + ) + + assert key1 != key2 + + def test_handles_none_kwargs(self) -> None: + """None kwargs should not crash the builder.""" + func = _make_func() + key = key_builder_excluding_dependencies(func, namespace="ns", args=(), kwargs=None) + assert isinstance(key, str) + assert key.startswith("ns:") diff --git a/backend/tests/unit/core/test_image_processing.py b/backend/tests/unit/core/test_image_processing.py new file mode 100644 index 00000000..e0d5b0a9 --- /dev/null +++ b/backend/tests/unit/core/test_image_processing.py @@ -0,0 +1,431 @@ +"""Unit tests for image processing utilities.""" +# spell-checker: ignore getexif, GPSIFD + +import io +from pathlib import Path +from typing import cast + +import piexif +import pytest +from anyio import Path as AnyIOPath +from fastapi import UploadFile +from PIL import Image as PILImage +from starlette.datastructures import Headers + +from app.core.images import ( + ALLOWED_IMAGE_MIME_TYPES, + MAX_IMAGE_DIMENSION, + THUMBNAIL_WIDTHS, + apply_exif_orientation, + delete_thumbnails, + generate_thumbnails, + process_image_for_storage, + resize_image, + strip_sensitive_exif, + thumbnail_path_for, + validate_image_dimensions, + validate_image_file, + validate_image_mime_type, +) +from app.core.images.exif import _clean_exif_bytes + + +@pytest.fixture +def sample_image(tmp_path: Path) -> Path: + """Create a sample image for testing.""" + image_path = tmp_path / "test_image.png" + img = PILImage.new("RGB", (400, 200), color="red") + img.save(image_path) + return image_path + + +@pytest.fixture +def jpeg_image(tmp_path: Path) -> Path: + """Create a sample JPEG image for testing.""" + image_path = tmp_path / "test_image.jpg" + img = PILImage.new("RGB", (400, 200), color="blue") + img.save(image_path, format="JPEG") + return image_path + + +def _make_jpeg_with_exif( + path: Path, width: int, height: int, orientation: int | None = None, *, gps: bool = False +) -> Path: + """Save a JPEG with optional EXIF orientation and GPS tags.""" + img = PILImage.new("RGB", (width, height), color="green") + exif_dict: dict = {"0th": {}, "Exif": {}, "1st": {}, "thumbnail": None} + if orientation is not None: + exif_dict["0th"][piexif.ImageIFD.Orientation] = orientation + if gps: + exif_dict["GPS"] = {piexif.GPSIFD.GPSLatitudeRef: b"N"} + exif_bytes = piexif.dump(exif_dict) + img.save(path, format="JPEG", exif=exif_bytes) + return path + + +def _make_upload_file(content_type: str) -> UploadFile: + """Create a minimal UploadFile for MIME type validation tests.""" + return UploadFile(file=io.BytesIO(b""), filename="test.bin", headers=Headers({"content-type": content_type})) + + +# --------------------------------------------------------------------------- +# resize_image +# --------------------------------------------------------------------------- + + +def test_resize_image_width(sample_image: Path) -> None: + """Test resizing by width only.""" + resized_bytes = resize_image(sample_image, width=100) + + with PILImage.open(io.BytesIO(resized_bytes)) as img: + assert img.width == 100 + assert img.height == 50 # Aspect ratio 2:1 maintained + + +def test_resize_image_height(sample_image: Path) -> None: + """Test resizing by height only.""" + resized_bytes = resize_image(sample_image, height=100) + + with PILImage.open(io.BytesIO(resized_bytes)) as img: + assert img.height == 100 + assert img.width == 200 # Aspect ratio 2:1 maintained + + +def test_resize_image_both(sample_image: Path) -> None: + """Test resizing by both width and height.""" + resized_bytes = resize_image(sample_image, width=50, height=50) + + with PILImage.open(io.BytesIO(resized_bytes)) as img: + assert img.width == 50 + assert img.height == 50 + + +def test_resize_image_none(sample_image: Path) -> None: + """Test resizing with neither width nor height (should return original size).""" + resized_bytes = resize_image(sample_image) + + with PILImage.open(io.BytesIO(resized_bytes)) as img: + assert img.width == 400 + assert img.height == 200 + + +def test_resize_image_not_found() -> None: + """Test error when image file is not found.""" + with pytest.raises(FileNotFoundError): + resize_image(Path("non_existent.png"), width=100) + + +def test_resize_image_accepts_anyio_path(sample_image: Path) -> None: + """Resize should work with anyio.Path without touching async exists().""" + async_path = AnyIOPath(str(sample_image)) + + resized_bytes = resize_image(cast("Path", async_path), width=100) + + with PILImage.open(io.BytesIO(resized_bytes)) as img: + assert img.width == 100 + assert img.height == 50 + + +# --------------------------------------------------------------------------- +# validate_image_dimensions +# --------------------------------------------------------------------------- + + +def test_validate_dimensions_accepts_valid_images() -> None: + """Images within the limit (and exactly at the limit) should not raise.""" + validate_image_dimensions(PILImage.new("RGB", (100, 100))) + validate_image_dimensions(PILImage.new("RGB", (MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION))) + + +def test_validate_dimensions_exceeds_width() -> None: + """Images exceeding max width should raise ValueError.""" + img = PILImage.new("RGB", (MAX_IMAGE_DIMENSION + 1, 100)) + with pytest.raises(ValueError, match="exceed the maximum"): + validate_image_dimensions(img) + + +def test_validate_dimensions_exceeds_height() -> None: + """Images exceeding max height should raise ValueError.""" + img = PILImage.new("RGB", (100, MAX_IMAGE_DIMENSION + 1)) + with pytest.raises(ValueError, match="exceed the maximum"): + validate_image_dimensions(img) + + +def test_validate_dimensions_custom_limit() -> None: + """Custom max_dimension parameter should be respected.""" + img = PILImage.new("RGB", (500, 500)) + with pytest.raises(ValueError, match="exceed the maximum"): + validate_image_dimensions(img, max_dimension=400) + + +# --------------------------------------------------------------------------- +# image upload validation +# --------------------------------------------------------------------------- + + +def test_validate_image_mime_type_accepts_allowed_types() -> None: + """Allowed MIME types should pass through unchanged.""" + for mime_type in ALLOWED_IMAGE_MIME_TYPES: + file = _make_upload_file(mime_type) + assert validate_image_mime_type(file) == file + + +def test_validate_image_mime_type_rejects_disallowed_type() -> None: + """Disallowed MIME types should raise ValueError.""" + file = _make_upload_file("text/plain") + + with pytest.raises(ValueError, match="Invalid file type"): + validate_image_mime_type(file) + + +def test_validate_image_file_accepts_valid_image() -> None: + """A real image byte stream should be accepted.""" + buf = io.BytesIO() + PILImage.new("RGB", (10, 10), color="red").save(buf, format="PNG") + + validate_image_file(buf) + + +def test_validate_image_file_rejects_invalid_image() -> None: + """Non-image bytes should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid image file"): + validate_image_file(io.BytesIO(b"not an image")) + + +# --------------------------------------------------------------------------- +# apply_exif_orientation +# --------------------------------------------------------------------------- + + +def test_apply_exif_orientation_noop_for_un_rotated(tmp_path: Path) -> None: + """Images with orientation=1 or no orientation tag should not be transformed.""" + for path in [ + _make_jpeg_with_exif(tmp_path / "normal.jpg", 400, 200, orientation=1), + (tmp_path / "no_exif.jpg"), + ]: + if not path.exists(): + PILImage.new("RGB", (400, 200), color="red").save(path, format="JPEG") + with PILImage.open(path) as img: + corrected = apply_exif_orientation(img) + assert corrected.width == 400 + assert corrected.height == 200 + + +@pytest.mark.parametrize("orientation", [6, 8]) +def test_apply_exif_orientation_90deg_swaps_dimensions(tmp_path: Path, orientation: int) -> None: + """Orientations 6 (90° CW) and 8 (90° CCW) should both swap width and height.""" + path = _make_jpeg_with_exif(tmp_path / f"orient{orientation}.jpg", 100, 200, orientation=orientation) + + with PILImage.open(path) as img: + corrected = apply_exif_orientation(img) + + assert corrected.width == 200 + assert corrected.height == 100 + + +def test_apply_exif_orientation_3_preserves_dimensions(tmp_path: Path) -> None: + """Orientation 3 (180°) should preserve width and height.""" + path = _make_jpeg_with_exif(tmp_path / "orient3.jpg", 400, 200, orientation=3) + + with PILImage.open(path) as img: + corrected = apply_exif_orientation(img) + + assert corrected.width == 400 + assert corrected.height == 200 + + +# --------------------------------------------------------------------------- +# strip_sensitive_exif +# --------------------------------------------------------------------------- + + +def test_strip_sensitive_exif_removes_orientation(tmp_path: Path) -> None: + """Orientation tag should be stripped (callers must apply it first).""" + path = _make_jpeg_with_exif(tmp_path / "oriented.jpg", 100, 100, orientation=6) + + with PILImage.open(path) as img: + assert img.getexif().get(0x0112) is not None + strip_sensitive_exif(img) + assert img.getexif().get(0x0112) is None + + +def test_clean_exif_bytes_returns_none_for_piexif_dump_bug(monkeypatch: pytest.MonkeyPatch) -> None: + """Malformed EXIF values from camera firmware should not crash image processing.""" + + def _fake_load(_: bytes) -> dict[str, object]: + return { + "0th": {33434: 0.041551}, + "Exif": {}, + "GPS": {}, + "1st": {}, + "thumbnail": None, + } + + monkeypatch.setattr(piexif, "load", _fake_load) + + assert _clean_exif_bytes(b"broken-exif") is None + + +# --------------------------------------------------------------------------- +# process_image_for_storage +# --------------------------------------------------------------------------- + + +def test_process_image_not_found() -> None: + """Should raise FileNotFoundError for a missing file.""" + with pytest.raises(FileNotFoundError): + process_image_for_storage(Path("non_existent.jpg")) + + +def test_process_image_accepts_anyio_path(tmp_path: Path) -> None: + """Process should work with anyio.Path without touching async exists().""" + path = tmp_path / "anyio.jpg" + PILImage.new("RGB", (100, 100), color="green").save(path, format="JPEG") + async_path = AnyIOPath(str(path)) + + process_image_for_storage(cast("Path", async_path)) + + with PILImage.open(path) as result: + assert result.size == (100, 100) + + +def test_process_image_dimension_guard(tmp_path: Path) -> None: + """Images exceeding MAX_IMAGE_DIMENSION should raise ValueError.""" + path = tmp_path / "huge.jpg" + PILImage.new("RGB", (MAX_IMAGE_DIMENSION + 1, 100)).save(path, format="JPEG") + + with pytest.raises(ValueError, match="exceed the maximum"): + process_image_for_storage(path) + + +def test_process_image_strips_gps(tmp_path: Path) -> None: + """GPS data should be absent from the saved file after processing.""" + path = _make_jpeg_with_exif(tmp_path / "gps.jpg", 100, 100, gps=True) + + process_image_for_storage(path) + + with PILImage.open(path) as result: + exif_bytes = result.info.get("exif") + if exif_bytes: + exif_dict = piexif.load(exif_bytes) + assert exif_dict.get("GPS") == {} + + +def test_process_image_applies_orientation_and_strips_tag(tmp_path: Path) -> None: + """Orientation should be baked into pixels and the orientation tag removed.""" + # 100w x 200h tagged orientation 6 → after processing: 200w x 100h, no orientation tag + path = _make_jpeg_with_exif(tmp_path / "orient.jpg", 100, 200, orientation=6) + + process_image_for_storage(path) + + with PILImage.open(path) as result: + assert result.width == 200 + assert result.height == 100 + assert result.getexif().get(0x0112) is None + + +def test_process_image_normal_orientation_unchanged(tmp_path: Path) -> None: + """Images with no orientation issue should preserve their dimensions.""" + path = _make_jpeg_with_exif(tmp_path / "normal.jpg", 400, 200, orientation=1) + + process_image_for_storage(path) + + with PILImage.open(path) as result: + assert result.width == 400 + assert result.height == 200 + + +# --------------------------------------------------------------------------- +# thumbnail_path_for +# --------------------------------------------------------------------------- + + +def test_thumbnail_path_for(tmp_path: Path) -> None: + """Should return the expected derivative path.""" + image_path = tmp_path / "abc123_photo.jpg" + result = thumbnail_path_for(image_path, 200) + assert result == tmp_path / "abc123_photo_thumb_200.webp" + + +# --------------------------------------------------------------------------- +# generate_thumbnails +# --------------------------------------------------------------------------- + + +@pytest.fixture +def large_image(tmp_path: Path) -> Path: + """Create a 2000x1000 image suitable for thumbnail generation.""" + path = tmp_path / "large.jpg" + PILImage.new("RGB", (2000, 1000), color="blue").save(path, format="JPEG") + return path + + +def test_generate_thumbnails_creates_standard_sizes(large_image: Path) -> None: + """Should create WebP thumbnails for all standard widths smaller than the original.""" + generated = generate_thumbnails(large_image) + + expected_widths = [w for w in THUMBNAIL_WIDTHS if w < 2000] + assert len(generated) == len(expected_widths) + + for w in expected_widths: + thumb = thumbnail_path_for(large_image, w) + assert thumb.exists() + with PILImage.open(thumb) as img: + assert img.format == "WEBP" + assert img.width == w + # Aspect ratio maintained (2:1) + assert img.height == w // 2 + + +def test_generate_thumbnails_skips_larger_than_original(tmp_path: Path) -> None: + """Should skip thumbnail widths that exceed the original image width.""" + path = tmp_path / "small.jpg" + PILImage.new("RGB", (150, 100), color="red").save(path, format="JPEG") + + generated = generate_thumbnails(path) + + assert generated == [] + for w in THUMBNAIL_WIDTHS: + assert not thumbnail_path_for(path, w).exists() + + +def test_generate_thumbnails_custom_widths(large_image: Path) -> None: + """Should respect custom width tuples.""" + generated = generate_thumbnails(large_image, widths=(300, 600)) + + assert len(generated) == 2 + with PILImage.open(thumbnail_path_for(large_image, 300)) as img: + assert img.width == 300 + with PILImage.open(thumbnail_path_for(large_image, 600)) as img: + assert img.width == 600 + + +def test_generate_thumbnails_not_found() -> None: + """Should raise FileNotFoundError for a missing source image.""" + with pytest.raises(FileNotFoundError): + generate_thumbnails(Path("nonexistent.jpg")) + + +# --------------------------------------------------------------------------- +# delete_thumbnails +# --------------------------------------------------------------------------- + + +def test_delete_thumbnails_removes_generated_files(large_image: Path) -> None: + """Should remove all generated thumbnail files.""" + generate_thumbnails(large_image) + + # Verify they exist first + for w in THUMBNAIL_WIDTHS: + if w < 2000: + assert thumbnail_path_for(large_image, w).exists() + + delete_thumbnails(large_image) + + for w in THUMBNAIL_WIDTHS: + assert not thumbnail_path_for(large_image, w).exists() + + +def test_delete_thumbnails_noop_when_none_exist(large_image: Path) -> None: + """Should not raise when no thumbnails exist.""" + delete_thumbnails(large_image) # Should not raise diff --git a/backend/tests/unit/core/test_logging.py b/backend/tests/unit/core/test_logging.py new file mode 100644 index 00000000..201648bb --- /dev/null +++ b/backend/tests/unit/core/test_logging.py @@ -0,0 +1,10 @@ +"""Unit tests for logging helpers.""" + +from __future__ import annotations + +from app.core.logging import sanitize_log_value + + +def test_sanitize_log_value_strips_newlines() -> None: + """sanitize_log_value should neutralize log-breaking line separators.""" + assert sanitize_log_value("first\nsecond\rthird") == "first second third" diff --git a/backend/tests/unit/core/test_model_registry.py b/backend/tests/unit/core/test_model_registry.py new file mode 100644 index 00000000..d6444d36 --- /dev/null +++ b/backend/tests/unit/core/test_model_registry.py @@ -0,0 +1,149 @@ +"""Tests for SQLAlchemy mapper configuration. + +Two levels: + +1. ``TestMapperWithRegistry``: calls ``load_models()`` first (the + current safety-net). These must always pass. + +2. ``TestModuleIsolation``: imports each model module in a *subprocess* + so the Python process starts fresh, then calls ``configure_mappers()`` + without the registry helper. This documents which modules are + self-contained and which still depend on the registry. +""" +# ruff: noqa: PLC0415 # We are intentionally testing that these models have mappers configured after load_models(). + +import importlib +import multiprocessing as mp +import traceback + +import pytest +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import configure_mappers + +from app.core.model_registry import load_models + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_isolated(import_paths: tuple[str, ...]) -> tuple[bool, str]: + """Import modules in a subprocess, then call ``configure_mappers()`` once.""" + ctx = mp.get_context("spawn") + result_queue: mp.Queue = ctx.Queue() + + process = ctx.Process(target=_worker, args=(import_paths, result_queue)) + process.start() + process.join(timeout=15) + if process.is_alive(): + process.terminate() + process.join() + return False, "Timed out while configuring mappers" + + if not result_queue.empty(): + return result_queue.get() + + if process.exitcode == 0: + return True, "" + + modules = ", ".join(import_paths) + return False, f"worker exited with code {process.exitcode} while checking: {modules}" + + +def _worker(import_paths: tuple[str, ...], queue: mp.Queue) -> None: + try: + for import_path in import_paths: + importlib.import_module(import_path) + + configure_mappers() + except ( + ImportError, + AttributeError, + NameError, + RuntimeError, + TypeError, + ValueError, + AssertionError, + SQLAlchemyError, + ) as exc: + lines = [line for line in traceback.format_exc().splitlines() if line.strip()] + message = lines[-1] if lines else str(exc) + queue.put((False, message)) + else: + queue.put((True, "")) + + +# --------------------------------------------------------------------------- +# 1. Tests that use the registry (must always pass) +# --------------------------------------------------------------------------- + + +# These imports are intentionally inside the test to verify that they work after load_models() has run +class TestMapperWithRegistry: + """Mapper configuration succeeds when load_models() has run.""" + + def test_load_models_imports_without_error(self) -> None: + """Test that load_models() can be called without error.""" + load_models() + + def test_configure_mappers_resolves_all_relationships(self) -> None: + """Test that configure_mappers() succeeds after calling load_models().""" + load_models() + configure_mappers() + + def test_all_expected_table_models_are_registered(self) -> None: + """Test that all expected models are registered and have mappers after load_models().""" + from sqlalchemy.orm import class_mapper + + load_models() + configure_mappers() + + from app.api.auth.models import Organization, User + from app.api.background_data.models import Category, Material, ProductType, Taxonomy + from app.api.data_collection.models.product import ( + MaterialProductLink, + Product, + ) + from app.api.file_storage.models import File, Image, Video + from app.api.newsletter.models import NewsletterSubscriber + from app.api.plugins.rpi_cam.models import Camera + + for model in [ + User, + Organization, + Taxonomy, + Category, + Material, + ProductType, + Product, + MaterialProductLink, + File, + Image, + Video, + NewsletterSubscriber, + Camera, + ]: + mapper = class_mapper(model) + assert mapper is not None, f"{model.__name__} has no SQLAlchemy mapper" + + +# --------------------------------------------------------------------------- +# 2. Isolation tests: document per-module self-sufficiency +# --------------------------------------------------------------------------- + + +class TestModuleIsolation: + """Each test imports exactly one top-level model module in a clean process.""" + + @pytest.mark.slow + def test_model_modules_are_self_contained(self) -> None: + """Model modules should configure mappers without depending on the registry helper.""" + ok, msg = _run_isolated( + ( + "app.api.auth.models", + "app.api.background_data.models", + "app.api.data_collection.models.product", + "app.api.file_storage.models", + ) + ) + assert ok, msg diff --git a/backend/tests/unit/core/test_redis.py b/backend/tests/unit/core/test_redis.py new file mode 100644 index 00000000..98356427 --- /dev/null +++ b/backend/tests/unit/core/test_redis.py @@ -0,0 +1,56 @@ +"""Unit tests for Redis helper utilities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.core.redis import delete_redis_key, get_redis_value, require_redis, set_redis_value + + +class TestRedisHelpers: + """Test shared Redis helper functions.""" + + async def test_get_redis_value_success(self) -> None: + """get_redis_value returns the stored string value.""" + redis_client = AsyncMock() + redis_client.get.return_value = "value" + + result = await get_redis_value(redis_client, "key") + + assert result == "value" + redis_client.get.assert_awaited_once_with("key") + + async def test_get_redis_value_failure_returns_none(self) -> None: + """get_redis_value returns None when Redis raises.""" + redis_client = AsyncMock() + redis_client.get.side_effect = TimeoutError("boom") + + result = await get_redis_value(redis_client, "key") + + assert result is None + + async def test_set_redis_value_success(self) -> None: + """set_redis_value returns True when the write succeeds.""" + redis_client = AsyncMock() + + result = await set_redis_value(redis_client, "key", "value", ex=60) + + assert result is True + redis_client.set.assert_awaited_once_with("key", "value", ex=60) + + async def test_delete_redis_key_success(self) -> None: + """delete_redis_key returns True when the delete succeeds.""" + redis_client = AsyncMock() + + result = await delete_redis_key(redis_client, "key") + + assert result is True + redis_client.delete.assert_awaited_once_with("key") + + def test_require_redis_raises_when_missing(self) -> None: + """require_redis should raise an HTTPException when Redis is unavailable.""" + with pytest.raises(HTTPException, match=r"Redis is required for this operation\."): + require_redis(None) diff --git a/backend/tests/unit/core/test_request_id.py b/backend/tests/unit/core/test_request_id.py new file mode 100644 index 00000000..fa12f7e5 --- /dev/null +++ b/backend/tests/unit/core/test_request_id.py @@ -0,0 +1,71 @@ +"""Unit tests for request ID middleware.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.core.middleware.request_id import REQUEST_ID_HEADER, register_request_id_middleware + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def _create_test_app() -> FastAPI: + app = FastAPI() + register_request_id_middleware(app) + + @app.get("/ping") + async def ping() -> dict[str, str]: + return {"status": "ok"} + + return app + + +async def test_request_id_middleware_generates_response_header(mocker: MockerFixture) -> None: + """Requests without an ID should receive a generated request ID.""" + bind_logger = MagicMock() + bind_logger.info = MagicMock() + bind_mock = mocker.patch("app.core.middleware.request_id.loguru_logger.bind", return_value=bind_logger) + + app = _create_test_app() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/ping") + + assert response.status_code == 200 + assert REQUEST_ID_HEADER in response.headers + assert response.headers[REQUEST_ID_HEADER] + + request_log_calls = [call.kwargs for call in bind_mock.call_args_list if "request_id" in call.kwargs] + assert len(request_log_calls) == 1 + + bind_kwargs = request_log_calls[0] + assert bind_kwargs["request_id"] == response.headers[REQUEST_ID_HEADER] + assert bind_kwargs["http_method"] == "GET" + assert bind_kwargs["http_path"] == "/ping" + assert bind_kwargs["http_status_code"] == 200 + assert bind_kwargs["http_latency_ms"] >= 0 + bind_logger.info.assert_called_once_with("HTTP request completed") + + +async def test_request_id_middleware_preserves_incoming_header(mocker: MockerFixture) -> None: + """Requests with an ID should echo the same request ID back to callers.""" + bind_logger = MagicMock() + bind_logger.info = MagicMock() + bind_mock = mocker.patch("app.core.middleware.request_id.loguru_logger.bind", return_value=bind_logger) + + app = _create_test_app() + request_id = "frontend-request-123" + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/ping", headers={REQUEST_ID_HEADER: request_id}) + + assert response.status_code == 200 + assert response.headers[REQUEST_ID_HEADER] == request_id + request_log_calls = [call.kwargs for call in bind_mock.call_args_list if "request_id" in call.kwargs] + assert len(request_log_calls) == 1 + assert request_log_calls[0]["request_id"] == request_id diff --git a/backend/tests/unit/core/test_request_size.py b/backend/tests/unit/core/test_request_size.py new file mode 100644 index 00000000..d6d4c7a2 --- /dev/null +++ b/backend/tests/unit/core/test_request_size.py @@ -0,0 +1,75 @@ +"""Unit tests for global request body size middleware.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from fastapi import FastAPI, Request +from httpx import ASGITransport, AsyncClient + +from app.core.middleware.request_size import register_request_size_limit_middleware + +if TYPE_CHECKING: + import pytest + + +def _create_test_app() -> FastAPI: + app = FastAPI() + register_request_size_limit_middleware(app) + + @app.post("/echo") + async def echo(request: Request) -> dict[str, object]: + payload = await request.json() + return {"payload": payload} + + @app.post("/multipart") + async def multipart_probe(request: Request) -> dict[str, str]: + return {"content_type": request.headers.get("content-type", "")} + + return app + + +async def test_request_size_limit_accepts_small_json(monkeypatch: pytest.MonkeyPatch) -> None: + """JSON requests under the limit should pass through unchanged.""" + monkeypatch.setattr("app.core.middleware.request_size.settings.request_body_limit_bytes", 64) + app = _create_test_app() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/echo", json={"ok": "yes"}) + + assert response.status_code == 200 + assert response.json() == {"payload": {"ok": "yes"}} + + +async def test_request_size_limit_rejects_large_json(monkeypatch: pytest.MonkeyPatch) -> None: + """JSON requests over the limit should receive a 413 response.""" + monkeypatch.setattr("app.core.middleware.request_size.settings.request_body_limit_bytes", 32) + app = _create_test_app() + body = json.dumps({"payload": "x" * 40}).encode() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/echo", + content=body, + headers={"content-type": "application/json", "content-length": str(len(body))}, + ) + + assert response.status_code == 413 + assert response.json()["detail"]["message"] == "Request body too large. Maximum size: 32 bytes" + + +async def test_request_size_limit_skips_multipart_requests(monkeypatch: pytest.MonkeyPatch) -> None: + """Multipart requests should remain governed by route-specific upload validation.""" + monkeypatch.setattr("app.core.middleware.request_size.settings.request_body_limit_bytes", 8) + app = _create_test_app() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/multipart", + content=b"x" * 128, + headers={"content-type": "multipart/form-data; boundary=test-boundary"}, + ) + + assert response.status_code == 200 + assert response.json()["content_type"].startswith("multipart/form-data") diff --git a/backend/tests/unit/core/test_telemetry.py b/backend/tests/unit/core/test_telemetry.py new file mode 100644 index 00000000..10e37efa --- /dev/null +++ b/backend/tests/unit/core/test_telemetry.py @@ -0,0 +1,123 @@ +"""Unit tests for optional OpenTelemetry bootstrap.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from fastapi import FastAPI + +from app.core.observability.telemetry import init_telemetry, shutdown_telemetry + +if TYPE_CHECKING: + import pytest + + +class _FakeResource: + @staticmethod + def create(attributes: dict[str, object]) -> dict[str, object]: + return attributes + + +class _FakeTracerProvider: + def __init__(self, *, resource: dict[str, object]) -> None: + self.resource = resource + self.processors: list[object] = [] + self.shutdown = MagicMock() + + def add_span_processor(self, processor: object) -> None: + self.processors.append(processor) + + +def _build_fake_otel_modules( + fastapi_instrumentor: MagicMock, + sqlalchemy_instrumentor: MagicMock, + httpx_instrumentor: MagicMock, +) -> dict[str, object]: + """Build a dict of fake sys.modules entries for OpenTelemetry packages.""" + trace_module = SimpleNamespace(set_tracer_provider=MagicMock()) + return { + "opentelemetry": SimpleNamespace(trace=trace_module), + "opentelemetry.trace": trace_module, + "opentelemetry.sdk": SimpleNamespace(), + "opentelemetry.sdk.resources": SimpleNamespace(Resource=_FakeResource), + "opentelemetry.sdk.trace": SimpleNamespace(TracerProvider=_FakeTracerProvider), + "opentelemetry.sdk.trace.export": SimpleNamespace( + BatchSpanProcessor=lambda exporter: ("batch", exporter), + ), + "opentelemetry.exporter": SimpleNamespace(), + "opentelemetry.exporter.otlp": SimpleNamespace(), + "opentelemetry.exporter.otlp.proto": SimpleNamespace(), + "opentelemetry.exporter.otlp.proto.http": SimpleNamespace(), + "opentelemetry.exporter.otlp.proto.http.trace_exporter": SimpleNamespace( + OTLPSpanExporter=lambda **kwargs: ("otlp", kwargs), + ), + "opentelemetry.instrumentation": SimpleNamespace(), + "opentelemetry.instrumentation.fastapi": SimpleNamespace( + FastAPIInstrumentor=lambda: fastapi_instrumentor, + ), + "opentelemetry.instrumentation.sqlalchemy": SimpleNamespace( + SQLAlchemyInstrumentor=lambda: sqlalchemy_instrumentor, + ), + "opentelemetry.instrumentation.httpx": SimpleNamespace( + HTTPXClientInstrumentor=lambda: httpx_instrumentor, + ), + } + + +def test_init_telemetry_returns_false_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Disabled telemetry should not import or instrument anything.""" + app = FastAPI() + async_engine = MagicMock() + + monkeypatch.setattr("app.core.observability.telemetry.settings.otel_exporter_otlp_endpoint", None) + + assert init_telemetry(app, async_engine) is False + + +def test_init_telemetry_instruments_app_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Enabled telemetry should set up the tracer provider and instrumentors.""" + app = FastAPI() + async_engine = MagicMock() + fastapi_instrumentor = MagicMock() + sqlalchemy_instrumentor = MagicMock() + httpx_instrumentor = MagicMock() + + monkeypatch.setattr( + "app.core.observability.telemetry.settings.otel_exporter_otlp_endpoint", "http://otel:4318/v1/traces" + ) + monkeypatch.setattr("app.core.observability.telemetry.settings.environment", "testing") + + fake_modules = _build_fake_otel_modules(fastapi_instrumentor, sqlalchemy_instrumentor, httpx_instrumentor) + trace_module = fake_modules["opentelemetry.trace"] + assert isinstance(trace_module, SimpleNamespace) + + with patch.dict(sys.modules, fake_modules): + assert init_telemetry(app, async_engine) is True + + trace_module.set_tracer_provider.assert_called_once() + fastapi_instrumentor.instrument_app.assert_called_once_with(app) + sqlalchemy_instrumentor.instrument.assert_called_once_with(engine=async_engine.sync_engine) + httpx_instrumentor.instrument.assert_called_once_with() + + shutdown_telemetry(app) + + fastapi_instrumentor.uninstrument_app.assert_called_once_with(app) + sqlalchemy_instrumentor.uninstrument.assert_called_once_with() + httpx_instrumentor.uninstrument.assert_called_once_with() + + +def test_init_telemetry_returns_false_when_dependencies_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing optional telemetry dependencies should fail closed, not crash startup.""" + app = FastAPI() + async_engine = MagicMock() + + monkeypatch.setattr( + "app.core.observability.telemetry.settings.otel_exporter_otlp_endpoint", "http://otel:4318/v1/traces" + ) + + # Setting a module to None in sys.modules causes ImportError on import + with patch.dict(sys.modules, {"opentelemetry": None}): + assert init_telemetry(app, async_engine) is False diff --git a/backend/tests/unit/data_collection/__init__.py b/backend/tests/unit/data_collection/__init__.py new file mode 100644 index 00000000..a8490745 --- /dev/null +++ b/backend/tests/unit/data_collection/__init__.py @@ -0,0 +1 @@ +"""Unit tests for data collection module.""" diff --git a/backend/tests/unit/data_collection/test_crud.py b/backend/tests/unit/data_collection/test_crud.py new file mode 100644 index 00000000..fd511f4e --- /dev/null +++ b/backend/tests/unit/data_collection/test_crud.py @@ -0,0 +1,262 @@ +"""Unit tests for data collection CRUD operations.""" +# spell-checker: ignore Bosch, Combi, Makita + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.common.models.enums import Unit +from app.api.common.schemas.associations import ( + MaterialProductLinkCreateWithinProduct, + MaterialProductLinkCreateWithinProductAndMaterial, + MaterialProductLinkUpdate, +) +from app.api.data_collection.crud.material_links import ( + add_material_to_product, + add_materials_to_product, + remove_materials_from_product, + update_material_within_product, +) +from app.api.data_collection.crud.products import ( + create_component, + create_product, + delete_product, + get_product_trees, + update_product, +) +from app.api.data_collection.exceptions import ( + MaterialIDRequiredError, + ProductOwnerRequiredError, +) +from app.api.data_collection.models.product import Product +from app.api.data_collection.schemas import ( + ComponentCreateWithComponents, + ProductCreateWithComponents, + ProductUpdate, +) +from app.api.file_storage.schemas import VideoCreateWithinProduct +from tests.factories.models import ProductFactory + +BRAND_BOSCH = "bosch" +BRAND_MAKITA = "makita" + + +@pytest.fixture +def mock_session() -> AsyncMock: + """Fixture for an AsyncSession mock.""" + session = AsyncMock(spec=AsyncSession) + # add and add_all are synchronous methods in SQLAlchemy + session.add = MagicMock() + session.add_all = MagicMock() + session.delete = AsyncMock() + + # For execute/exec mocking + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [BRAND_BOSCH, BRAND_MAKITA] + mock_result.all.return_value = [BRAND_BOSCH, BRAND_MAKITA] + session.execute.return_value = mock_result + # session.execute already set above with mock_result + + return session + + +class TestProductCrud: + """Tests for product CRUD operations.""" + + async def test_create_product_success(self, mock_session: AsyncMock) -> None: + """Test successful product creation.""" + owner_id = uuid4() + # Product must have at least one material or component + product_create = ProductCreateWithComponents( + name="Makita DHP486 Combi Drill", + product_type_id=1, + components=[], + bill_of_materials=[MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1.0, unit=Unit.KILOGRAM)], + ) + + with ( + patch("app.api.data_collection.crud.product_commands.require_models"), + patch("app.api.data_collection.crud.product_commands.recompute_user_stats"), + ): + result = await create_product(mock_session, product_create, owner_id) + + assert isinstance(result, Product) + assert result.name == "Makita DHP486 Combi Drill" + assert result.owner_id == owner_id + + mock_session.add.assert_called() + assert mock_session.commit.call_count >= 1 + + async def test_get_product_trees(self, mock_session: AsyncMock) -> None: + """Test retrieving product trees.""" + with patch("app.api.data_collection.crud.product_tree_queries.require_model"): + # Setup mock_session to return results for exec().all() + mock_scalars = MagicMock() + mock_scalars.all.return_value = ["Product 1"] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_session.execute = AsyncMock(return_value=mock_result) + + res = await get_product_trees(mock_session, parent_id=1, product_filter=MagicMock()) + assert res == ["Product 1"] + + async def test_update_product_success(self, mock_session: AsyncMock) -> None: + """Test successful product update.""" + product_id = 1 + product_update = ProductUpdate(name="Bosch GSR 18V-90 C") + + db_product = ProductFactory.build(id=product_id, name="Bosch PSR 1800 LI-2") + + with ( + patch("app.api.data_collection.crud.product_commands.require_model", return_value=db_product), + patch("app.api.data_collection.crud.product_commands.require_models", return_value=[]), + patch("app.api.data_collection.crud.product_commands.recompute_user_stats"), + ): + result = await update_product(mock_session, product_id, product_update) + assert result.name == "Bosch GSR 18V-90 C" + assert mock_session.add.call_count >= 1 + assert mock_session.commit.call_count >= 1 + + async def test_delete_product_success(self, mock_session: AsyncMock) -> None: + """Test successful product deletion.""" + product_id = 1 + db_product = ProductFactory.build(id=product_id) + + with ( + patch("app.api.data_collection.crud.product_commands.require_model", return_value=db_product), + patch("app.api.data_collection.crud.product_commands.delete_all_product_files"), + patch("app.api.data_collection.crud.product_commands.delete_all_product_images"), + patch("app.api.data_collection.crud.product_commands.recompute_user_stats"), + ): + await delete_product(mock_session, product_id) + mock_session.delete.assert_called_once_with(db_product) + assert mock_session.commit.call_count >= 1 + + async def test_create_component_success(self, mock_session: AsyncMock) -> None: + """Test successful component creation.""" + owner_id = uuid4() + parent_product = ProductFactory.build(id=1, owner_id=owner_id) + + comp_create = ComponentCreateWithComponents( + name="Comp", + product_type_id=1, + amount_in_parent=1, + weight_g=1, + components=[ + ComponentCreateWithComponents( + name="Subcomp", + product_type_id=1, + amount_in_parent=1, + bill_of_materials=[MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1)], + ) + ], + videos=[VideoCreateWithinProduct.model_validate({"url": "http://ok.com", "title": "Vid"})], + bill_of_materials=[MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1)], + ) + + with patch("app.api.data_collection.crud.product_commands.require_models"): + res = await create_component(mock_session, comp_create, parent_product) + assert res.name == "Comp" + assert res.owner_id == owner_id + + async def test_create_product_tree_requires_owner(self, mock_session: AsyncMock) -> None: + """The shared tree helper should reject creation attempts without an owner id.""" + product_create = ProductCreateWithComponents( + name="Makita DHP486 Combi Drill", + bill_of_materials=[MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1.0, unit=Unit.KILOGRAM)], + ) + + with pytest.raises(ProductOwnerRequiredError, match="owner_id must be set"): + await create_product(mock_session, product_create, owner_id=None) + + +class TestBillOfMaterialsCrud: + """Tests for bill of materials CRUD operations.""" + + async def test_add_materials_to_product_success(self, mock_session: AsyncMock) -> None: + """Test successful batch addition of materials to product.""" + product = ProductFactory.build(id=1) + product.bill_of_materials = [] + with ( + patch("app.api.data_collection.crud.shared.require_model", return_value=product), + patch("app.api.data_collection.crud.shared.require_models"), + ): + links = [MaterialProductLinkCreateWithinProduct(material_id=1, quantity=1)] + res = await add_materials_to_product(mock_session, 1, links) + assert len(res) == 1 + mock_session.add_all.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_add_material_to_product_success(self, mock_session: AsyncMock) -> None: + """Test adding material to product.""" + product_id = 1 + material_id = 10 + link_create = MaterialProductLinkCreateWithinProductAndMaterial(quantity=5.0) + + db_product = ProductFactory.build(id=product_id, name="Bosch IXO 7 Screwdriver") + db_product.bill_of_materials = [] + + with ( + patch("app.api.data_collection.crud.shared.require_model", return_value=db_product), + patch("app.api.data_collection.crud.shared.require_models"), + patch("app.api.data_collection.crud.material_links.add_materials_to_product") as mock_add_batch, + ): + expected_link = MagicMock() + mock_add_batch.return_value = [expected_link] + + result = await add_material_to_product( + mock_session, product_id, material_link=link_create, material_id=material_id + ) + + assert result == expected_link + mock_add_batch.assert_called_once() + + async def test_add_material_missing_id(self, mock_session: AsyncMock) -> None: + """Test error when material ID is missing.""" + link_create = MaterialProductLinkCreateWithinProductAndMaterial(quantity=5.0) + + with pytest.raises(MaterialIDRequiredError, match="Material ID is required"): + await add_material_to_product(mock_session, product_id=1, material_link=link_create, material_id=None) + + async def test_update_material_within_product_success(self, mock_session: AsyncMock) -> None: + """Test successful update of material within product.""" + with ( + patch("app.api.data_collection.crud.shared.require_model"), + patch("app.api.data_collection.crud.material_links.require_link") as mock_link, + ): + mock_link_obj = MagicMock() + mock_link.return_value = mock_link_obj + + await update_material_within_product(mock_session, 1, 1, MaterialProductLinkUpdate(quantity=2)) + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + async def test_remove_materials_from_product(self, mock_session: AsyncMock) -> None: + """Test removal of materials from product.""" + product_id = 1 + material_ids = {10, 20} + + db_product = ProductFactory.build(id=product_id) + link1 = MagicMock(material_id=10, id=10) + link2 = MagicMock(material_id=20, id=20) + object.__setattr__(db_product, "bill_of_materials", [link1, link2]) + + mock_scalars = MagicMock() + mock_scalars.all.return_value = [link1, link2] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_session.execute = AsyncMock(return_value=mock_result) + + with ( + patch("app.api.data_collection.crud.shared.require_model", return_value=db_product), + patch("app.api.data_collection.crud.shared.require_models"), + ): + await remove_materials_from_product(mock_session, product_id, material_ids) + mock_session.execute.assert_called_once() + # Should have deleted each material link + assert mock_session.delete.call_count == 2 + mock_session.commit.assert_called_once() diff --git a/backend/tests/unit/data_collection/test_data_collection_filters.py b/backend/tests/unit/data_collection/test_data_collection_filters.py new file mode 100644 index 00000000..089b23e5 --- /dev/null +++ b/backend/tests/unit/data_collection/test_data_collection_filters.py @@ -0,0 +1,146 @@ +"""Tests for data_collection and shared search filter helper functions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlalchemy.sql.elements import ClauseElement + +from sqlalchemy import select +from sqlalchemy.dialects import postgresql + +from app.api.data_collection.filters import ProductFilter, get_brand_search_statement +from app.api.data_collection.models.product import Product + + +def _sql(clause: ClauseElement) -> str: + """Compile a clause to a SQL string using the PostgreSQL dialect.""" + return str(clause.compile(dialect=postgresql.dialect())) + + +class TestGetBrandSearchStatement: + """Tests for the get_brand_search_statement function.""" + + def test_filters_null_brands(self) -> None: + """Test that null brands are filtered out.""" + sql = _sql(get_brand_search_statement()) + assert "IS NOT NULL" in sql.upper() + + def test_returns_distinct_results(self) -> None: + """Test that distinct results are returned.""" + sql = _sql(get_brand_search_statement()) + assert "DISTINCT" in sql.upper() + + def test_no_search_omits_tsvector_clause(self) -> None: + """Test that no search query omits the tsvector clause.""" + sql = _sql(get_brand_search_statement()) + assert "websearch_to_tsquery" not in sql + + def test_with_search_adds_where_clause(self) -> None: + """Test that a search query adds a WHERE clause using websearch_to_tsquery.""" + sql = _sql(get_brand_search_statement(search="nike")) + assert "websearch_to_tsquery" in sql + + def test_search_strips_whitespace(self) -> None: + """Test that search queries are stripped of leading/trailing whitespace.""" + stripped = _sql(get_brand_search_statement(search="nike")) + padded = _sql(get_brand_search_statement(search=" nike ")) + assert stripped == padded + + def test_default_order_is_asc(self) -> None: + """Test that the default order is ascending.""" + sql = _sql(get_brand_search_statement()) + assert "DESC" not in sql.upper() + + def test_order_desc(self) -> None: + """Test that ordering can be set to descending.""" + sql = _sql(get_brand_search_statement(order="desc")) + assert "DESC" in sql.upper() + + def test_order_asc_explicit(self) -> None: + """Test that ordering can be explicitly set to ascending.""" + sql_default = _sql(get_brand_search_statement()) + sql_asc = _sql(get_brand_search_statement(order="asc")) + assert sql_default == sql_asc + + +class TestProductFilterRankSort: + """Tests for ProductFilter's relevance-rank ordering behaviour. + + 'rank'/'- rank' is stripped at construction time (model_validator mode="before") + before fastapi-filter's validate_order_by sees the list. When the list becomes + empty/None after stripping, _apply_rank_ordering treats it as "apply ts_rank". + """ + + def _build(self, search: str | None, order_by: list[str] | None) -> ProductFilter: + return ProductFilter(search=search, order_by=order_by) + + def _filter_sql(self, search: str | None, order_by: list[str] | None) -> str: + """Build a ProductFilter, apply filter + sort, and return the compiled SQL.""" + f = self._build(search, order_by) + stmt = f.filter(select(Product)) + stmt = f.sort(stmt) + return _sql(stmt) + + # ── Construction-time stripping ─────────────────────────────────────────── + + def test_rank_stripped_from_order_by_list(self) -> None: + """order_by=['rank'] (list form) must become None after model_validator strips it.""" + f = self._build("chair", ["rank"]) + assert f.order_by is None + + def test_rank_stripped_from_order_by_string(self) -> None: + """order_by='rank' (raw string, as FastAPI delivers query params) is also stripped.""" + # Bypass _build helper to pass a raw string, simulating the FastAPI query-param path. + f = ProductFilter.model_validate({"search": "chair", "order_by": "rank"}) + assert f.order_by is None + + def test_negative_rank_stripped(self) -> None: + """order_by=['-rank'] must also be stripped to None.""" + f = self._build("chair", ["-rank"]) + assert f.order_by is None + + def test_rank_among_other_fields_is_removed(self) -> None: + """Only 'rank' is stripped; other fields survive.""" + f = self._build("chair", ["rank", "-created_at"]) + assert f.order_by == ["-created_at"] + + def test_rank_string_among_other_fields_is_removed(self) -> None: + """String form 'rank,-created_at' → only '-created_at' survives.""" + f = ProductFilter.model_validate({"order_by": "rank,-created_at"}) + assert f.order_by == ["-created_at"] + + def test_valid_order_by_unchanged(self) -> None: + """A valid order_by with no 'rank' should be left unchanged.""" + f = self._build(None, ["-created_at"]) + assert f.order_by == ["-created_at"] + + # ── SQL ordering ────────────────────────────────────────────────────────── + + def test_rank_ordering_applied_when_search_and_no_order_by(self) -> None: + """ts_rank ordering is the default when searching without an explicit sort.""" + sql = self._filter_sql("chair", None) + assert "ts_rank" in sql.lower() + + def test_rank_ordering_applied_when_order_by_was_rank(self) -> None: + """order_by=['rank'] strips to None, which triggers ts_rank ordering.""" + sql = self._filter_sql("chair", ["rank"]) + assert "ts_rank" in sql.lower() + + def test_rank_ordering_not_applied_when_explicit_field_given(self) -> None: + """An explicit field sort must suppress ts_rank ordering.""" + sql = self._filter_sql("chair", ["-created_at"]) + assert "created_at" in sql.lower() + assert "ts_rank" not in sql.lower() + + def test_search_where_clause_always_present(self) -> None: + """The tsvector WHERE clause is always added when search is set, regardless of sort.""" + for order in (None, ["rank"], ["-created_at"], ["name"]): + sql = self._filter_sql("test", order) + assert "websearch_to_tsquery" in sql, f"Missing WHERE clause for order_by={order}" + + def test_no_rank_ordering_when_no_search(self) -> None: + """ts_rank ordering must not appear when there is no search term.""" + sql = self._filter_sql(None, None) + assert "ts_rank" not in sql.lower() diff --git a/backend/tests/unit/data_collection/test_product_logic.py b/backend/tests/unit/data_collection/test_product_logic.py new file mode 100644 index 00000000..6a0a9e7a --- /dev/null +++ b/backend/tests/unit/data_collection/test_product_logic.py @@ -0,0 +1,149 @@ +"""Unit tests for product model logic.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest + +from app.api.data_collection.validators import validate_product +from tests.factories.models import MaterialProductLinkFactory, ProductFactory + +# Constants for test values to avoid magic value warnings +AMOUNT_IN_PARENT_5 = 5 +ERR_MIN_CONTENT = "must have at least one material or one component" +ERR_MISSING_AMOUNT = "must have amount_in_parent set" + +_VALIDATE_PRODUCT = validate_product + + +class TestProductLogic: + """Tests for product model business logic like cycle detection and validation.""" + + def test_thumbnail_url_uses_first_image_id(self) -> None: + """Test that thumbnail_url is derived from the mapped first image ID.""" + product = ProductFactory.build(id=1, owner_id=uuid4(), bill_of_materials=[MaterialProductLinkFactory.build()]) + first_image_id = UUID("12345678-1234-5678-1234-567812345678") + object.__setattr__(product, "first_image_id", first_image_id) + + assert product.thumbnail_url == f"/images/{first_image_id}/resized?width=200" + + def test_thumbnail_url_is_none_without_first_image_id(self) -> None: + """Test that thumbnail_url is None when no first image is available.""" + product = ProductFactory.build(id=1, owner_id=uuid4(), bill_of_materials=[MaterialProductLinkFactory.build()]) + object.__setattr__(product, "first_image_id", None) + + assert product.thumbnail_url is None + + def test_has_cycles_no_cycle(self) -> None: + """Test that a valid tree has no cycles.""" + # A -> B -> C + c = ProductFactory.build(id=uuid4(), components=[]) + b = ProductFactory.build(id=uuid4(), components=[c]) + a = ProductFactory.build(id=uuid4(), components=[b]) + + assert a.has_cycles() is False + + def test_has_cycles_direct_cycle(self) -> None: + """Test detection of a product containing itself.""" + a = ProductFactory.build(id=uuid4()) + a.components = [a] # Direct cycle + + assert a.has_cycles() is True + + def test_has_cycles_indirect_cycle(self) -> None: + """Test detection of an indirect cycle A -> B -> A.""" + a = ProductFactory.build(id=uuid4()) + b = ProductFactory.build(id=uuid4(), components=[a]) + a.components = [b] + + assert a.has_cycles() is True + + def test_components_resolve_to_materials_valid(self) -> None: + """Test that validation passes when all leaves have materials.""" + # A -> B (Material) + # -> C (Material) + + # Leaf B + link_b = MaterialProductLinkFactory.build() + b = ProductFactory.build(id=uuid4(), components=[], bill_of_materials=[link_b]) + + # Leaf C + link_c = MaterialProductLinkFactory.build() + c = ProductFactory.build(id=uuid4(), components=[], bill_of_materials=[link_c]) + + # Root A + a = ProductFactory.build(id=uuid4(), components=[b, c]) + + assert a.components_resolve_to_materials() is True + + def test_components_resolve_to_materials_invalid(self) -> None: + """Test that validation fails when a leaf has no materials.""" + # A -> B (No Material) + + b = ProductFactory.build(id=uuid4(), components=[], bill_of_materials=[]) + a = ProductFactory.build(id=uuid4(), components=[b]) + + assert a.components_resolve_to_materials() is False + + def test_validate_product_base_valid(self) -> None: + """Test validation of a valid base product.""" + # Base product (no parent_id) must have content + link = MaterialProductLinkFactory.build() + + # Should not raise + p = ProductFactory.build( + name="Valid Base", owner_id=uuid4(), bill_of_materials=[link], parent_id=None, amount_in_parent=None + ) + _VALIDATE_PRODUCT(p) + + def test_validate_product_base_invalid_no_content(self) -> None: + """Test validation fails for base product with no content.""" + p = ProductFactory.build( + name="Empty Base", + owner_id=uuid4(), + bill_of_materials=[], + components=[], + parent_id=None, + amount_in_parent=None, + ) + + with pytest.raises(ValueError, match=ERR_MIN_CONTENT): + _VALIDATE_PRODUCT(p) + + def test_validate_product_intermediate_valid(self) -> None: + """Test validation of a valid intermediate product.""" + link = MaterialProductLinkFactory.build() + + # Intermediate product + p = ProductFactory.build( + name="Valid Intermediate", + owner_id=uuid4(), + bill_of_materials=[link], + parent_id=uuid4(), # Has parent + amount_in_parent=AMOUNT_IN_PARENT_5, + ) + _VALIDATE_PRODUCT(p) + + def test_validate_product_intermediate_missing_amount(self) -> None: + """Test validation fails for intermediate product without amount.""" + link = MaterialProductLinkFactory.build() + + p = ProductFactory.build( + name="No Amount Intermediate", + owner_id=uuid4(), + bill_of_materials=[link], + parent_id=uuid4(), + amount_in_parent=None, # Missing + ) + + with pytest.raises(ValueError, match=ERR_MISSING_AMOUNT): + _VALIDATE_PRODUCT(p) + + def test_validate_cycle_detection_on_init(self) -> None: + """Test that cycles are detected during validation.""" + a = ProductFactory.build(id=uuid4()) + a.components = [a] + + with pytest.raises(ValueError, match="Cycle detected"): + _VALIDATE_PRODUCT(a) diff --git a/backend/tests/unit/data_collection/test_schemas.py b/backend/tests/unit/data_collection/test_schemas.py new file mode 100644 index 00000000..31b506b1 --- /dev/null +++ b/backend/tests/unit/data_collection/test_schemas.py @@ -0,0 +1,244 @@ +"""Tests for data collection schema validation. + +Covers custom validators, computed properties, and business-rule constraints. +Pydantic built-in behavior (required fields, optional defaults, roundtrip) is not tested. +""" +# spell-checker: ignore KALLAX + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from pydantic import BaseModel, ValidationError + +from app.api.data_collection.schemas import ( + ProductCreateBaseProduct, + ProductReadWithRelationships, + ValidDateTime, + ensure_timezone, + not_too_old, +) +from app.api.file_storage.models import MediaParentType +from app.api.file_storage.schemas import ImageRead + + +def _validate_model[T: BaseModel](schema: type[T], data: object) -> T: + """Validate schema data without unpacking loosely typed dicts.""" + return schema.model_validate(data) + + +class TestValidatorsCommon: + """Tests for custom validators used across schemas.""" + + def test_ensure_timezone_with_aware_datetime(self) -> None: + """Verify ensure_timezone accepts timezone-aware datetime.""" + dt = datetime.now(UTC) + result = ensure_timezone(dt) + assert result == dt + assert result.tzinfo is not None + + def test_ensure_timezone_rejects_naive_datetime(self) -> None: + """Verify ensure_timezone rejects naive datetime.""" + dt = datetime.now(UTC).replace(tzinfo=None) + with pytest.raises(ValueError, match="timezone"): + ensure_timezone(dt) + + def test_not_too_old_recent_datetime(self) -> None: + """Verify not_too_old accepts recent datetime.""" + dt = datetime.now(UTC) - timedelta(days=30) + result = not_too_old(dt) + assert result == dt + + def test_not_too_old_rejects_old_datetime(self) -> None: + """Verify not_too_old rejects datetime older than 365 days.""" + dt = datetime.now(UTC) - timedelta(days=366) + with pytest.raises(ValueError, match="365"): + not_too_old(dt) + + def test_not_too_old_accepts_boundary_date(self) -> None: + """Verify not_too_old accepts datetime within 365 days.""" + dt = datetime.now(UTC) - timedelta(days=364) + result = not_too_old(dt) + assert result == dt + + def test_not_too_old_with_custom_delta(self) -> None: + """Verify not_too_old respects custom time delta.""" + old_dt = datetime.now(UTC) - timedelta(days=61) + with pytest.raises(ValueError, match="in past"): + not_too_old(old_dt, time_delta=timedelta(days=30)) + + +@pytest.mark.parametrize( + ("schema_cls", "field", "max_len"), + [ + (ProductCreateBaseProduct, "name", 100), + (ProductCreateBaseProduct, "description", 500), + (ProductCreateBaseProduct, "brand", 100), + (ProductCreateBaseProduct, "model", 100), + (ProductCreateBaseProduct, "dismantling_notes", 500), + (ProductCreateBaseProduct, "recyclability_observation", 500), + (ProductCreateBaseProduct, "recyclability_comment", 100), + ], + ids=lambda v: v if isinstance(v, str) else "", +) +def test_field_max_length_enforced(schema_cls: type[BaseModel], field: str, max_len: int) -> None: + """Business-rule max-length constraints reject inputs that are too long.""" + base = {"name": "Bosch IXO 7 Screwdriver"} + # Exactly at limit should pass + data_ok = {**base, field: "a" * max_len} + result = _validate_model(schema_cls, data_ok) + assert len(getattr(result, field)) == max_len + + # One over should fail + data_bad = {**base, field: "a" * (max_len + 1)} + with pytest.raises(ValidationError): + _validate_model(schema_cls, data_bad) + + +def test_product_name_min_length() -> None: + """Product name must be at least 2 characters.""" + with pytest.raises(ValidationError): + _validate_model(ProductCreateBaseProduct, {"name": "A"}) + + +class TestProductTimeValidation: + """Tests for dismantling time custom validators.""" + + def test_dismantling_time_start_must_be_in_past(self) -> None: + """Verify dismantling_time_start rejects future datetimes.""" + future = datetime.now(UTC) + timedelta(days=1) + with pytest.raises(ValidationError): + _validate_model(ProductCreateBaseProduct, {"name": "IKEA KALLAX Shelf", "dismantling_time_start": future}) + + def test_dismantling_time_end_must_be_after_start(self) -> None: + """Verify dismantling_time_end must be after dismantling_time_start.""" + start = datetime.now(UTC) - timedelta(hours=2) + end = start - timedelta(hours=1) + with pytest.raises(ValidationError): + _validate_model( + ProductCreateBaseProduct, + {"name": "IKEA KALLAX Shelf", "dismantling_time_start": start, "dismantling_time_end": end}, + ) + + +def test_product_list_fields_default_to_empty() -> None: + """Videos and bill_of_materials default to empty lists.""" + product = _validate_model(ProductCreateBaseProduct, {"name": "Dyson V15 Detect"}) + assert product.videos == [] + assert product.bill_of_materials == [] + + +def test_product_brand_lowercased() -> None: + """Brand field is normalized to lowercase.""" + product = _validate_model( + ProductCreateBaseProduct, + {"name": "Cordless Drill", "brand": "Bosch"}, + ) + assert product.brand == "bosch" + + +class TestValidDatetimeType: + """Tests for ValidDateTime custom type.""" + + def test_valid_recent_past_datetime(self) -> None: + """Verify ValidDateTime accepts recent past datetime.""" + dt = datetime.now(UTC) - timedelta(days=30) + + class TestModel(BaseModel): + event_time: ValidDateTime + + model = TestModel(event_time=dt) + assert model.event_time == dt + + def test_valid_datetime_rejects_future(self) -> None: + """Verify ValidDateTime rejects future datetime.""" + dt = datetime.now(UTC) + timedelta(hours=1) + + class TestModel(BaseModel): + event_time: ValidDateTime + + with pytest.raises(ValidationError): + TestModel(event_time=dt) + + def test_valid_datetime_requires_timezone(self) -> None: + """Verify ValidDateTime requires timezone-aware datetime.""" + dt = datetime.now(UTC).replace(tzinfo=None) + + class TestModel(BaseModel): + event_time: ValidDateTime + + with pytest.raises(ValidationError): + TestModel(event_time=dt) + + def test_valid_datetime_rejects_too_old(self) -> None: + """Verify ValidDateTime rejects datetime older than 365 days.""" + dt = datetime.now(UTC) - timedelta(days=400) + + class TestModel(BaseModel): + event_time: ValidDateTime + + with pytest.raises(ValidationError): + TestModel(event_time=dt) + + +def test_product_read_thumbnail_url_with_images() -> None: + """Thumbnail URL is computed from the first image.""" + image1 = _validate_model( + ImageRead, + { + "id": uuid4(), + "filename": "front-panel.png", + "image_url": "/uploads/images/front-panel.png", + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + "parent_id": 1, + "parent_type": MediaParentType.PRODUCT, + }, + ) + image2 = _validate_model( + ImageRead, + { + "id": uuid4(), + "filename": "pcb-detail.png", + "image_url": "/uploads/images/pcb-detail.png", + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + "parent_id": 1, + "parent_type": MediaParentType.PRODUCT, + }, + ) + + product = _validate_model( + ProductReadWithRelationships, + { + "id": 1, + "name": "Bosch PSB 1800 LI-2", + "owner_id": uuid4(), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + "dismantling_time_start": datetime.now(UTC), + "images": [image1, image2], + }, + ) + + assert product.thumbnail_url == "/uploads/images/front-panel.png" + + +def test_product_read_thumbnail_url_without_images() -> None: + """Thumbnail URL is None when no images are present.""" + product = _validate_model( + ProductReadWithRelationships, + { + "id": 1, + "name": "Bosch PSB 1800 LI-2", + "owner_id": uuid4(), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + "dismantling_time_start": datetime.now(UTC), + "images": [], + }, + ) + + assert product.thumbnail_url is None diff --git a/backend/tests/unit/emails/__init__.py b/backend/tests/unit/emails/__init__.py new file mode 100644 index 00000000..d04ba407 --- /dev/null +++ b/backend/tests/unit/emails/__init__.py @@ -0,0 +1 @@ +"""Tests related to email functionality.""" diff --git a/backend/tests/unit/emails/test_email_config.py b/backend/tests/unit/emails/test_email_config.py new file mode 100644 index 00000000..3ee167fa --- /dev/null +++ b/backend/tests/unit/emails/test_email_config.py @@ -0,0 +1,23 @@ +"""Tests for auth email configuration.""" + +from pydantic import EmailStr, TypeAdapter + +from app.api.auth.config import settings as auth_settings +from app.api.auth.services.emails import email_conf + + +def test_email_config_uses_a_valid_sender_address() -> None: + """The mail config should always expose a valid sender address.""" + sender = email_conf.MAIL_FROM + + assert sender + assert TypeAdapter(EmailStr).validate_python(sender) == sender + + +def test_email_config_reuses_the_parsed_sender_name() -> None: + """The mail config should reuse the shared parsed sender config.""" + sender = auth_settings.email.sender + + assert sender is not None + assert sender.email == email_conf.MAIL_FROM + assert sender.name == email_conf.MAIL_FROM_NAME diff --git a/backend/tests/unit/emails/test_programmatic_emails.py b/backend/tests/unit/emails/test_programmatic_emails.py new file mode 100644 index 00000000..7afb57fd --- /dev/null +++ b/backend/tests/unit/emails/test_programmatic_emails.py @@ -0,0 +1,258 @@ +"""Tests for programmatic email sending functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse + +import pytest +from faker import Faker +from fastapi import BackgroundTasks + +from app.api.auth.config import settings as auth_settings +from app.api.auth.services.emails import ( + generate_token_link, + send_post_verification_email, + send_registration_email, + send_reset_password_email, + send_verification_email, +) +from app.core.config import settings as core_settings + +if TYPE_CHECKING: + from collections.abc import Callable + from unittest.mock import AsyncMock + +fake = Faker("en_US") + +# Constants for magic values +DOUBLE_SLASH = "//" +PROTO_SEP = "://" + + +@pytest.fixture +def email_data() -> dict[str, str]: + """Return common data for email tests.""" + return { + "email": fake.email(), + "username": fake.user_name(), + "token": fake.uuid4(), + } + + +### Token Link Generation Tests ### +def test_generate_token_link_default_base_url() -> None: + """Test token link generation with default base URL from core settings.""" + token = fake.uuid4() + route = "/verify" + + link = generate_token_link(token, route) + + parsed = urlparse(link) + query_params = parse_qs(parsed.query) + + assert link.startswith(str(core_settings.frontend_app_url)) + assert parsed.path == route + assert query_params["token"] == [token] + + +def test_generate_token_link_custom_base_url() -> None: + """Test token link generation with custom base URL.""" + token = fake.uuid4() + route = "/reset-password" + custom_base_url = fake.url() + + link = generate_token_link(token, route, base_url=custom_base_url) + + parsed = urlparse(link) + query_params = parse_qs(parsed.query) + + assert link.startswith(custom_base_url) + assert parsed.path == route + assert query_params["token"] == [token] + + +def test_generate_token_link_with_trailing_slash() -> None: + """Test that token links are generated correctly regardless of trailing slashes.""" + token = fake.uuid4() + route = "/verify" + base_url_with_slash = f"{fake.url()}//" + + link = generate_token_link(token, route, base_url=base_url_with_slash) + + # Should not have double slashes + assert DOUBLE_SLASH not in link.split(PROTO_SEP)[1] + # Should still have the correct route + assert urlparse(link).path == route + + +### Registration Email Tests ### +async def test_send_registration_email(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test registration email is sent.""" + await send_registration_email(email_data["email"], email_data["username"], email_data["token"]) + mock_email_sending.assert_called_once() + + +async def test_send_registration_email_sets_reply_to(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test registration emails include the configured reply-to address.""" + await send_registration_email(email_data["email"], email_data["username"], email_data["token"]) + + await_args = mock_email_sending.await_args + reply_to = auth_settings.email.reply_to + + assert await_args is not None + message = await_args.args[0] + assert message.reply_to + assert reply_to is not None + assert message.reply_to[0] == reply_to + + +async def test_send_registration_email_no_username(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test registration email works without username.""" + await send_registration_email(email_data["email"], None, email_data["token"]) + mock_email_sending.assert_called_once() + + +async def test_send_registration_email_with_background_tasks( + email_data: dict[str, str], mock_email_sending: AsyncMock +) -> None: + """Test registration email queues task instead of sending immediately.""" + background_tasks = MagicMock(spec=BackgroundTasks) + + await send_registration_email( + email_data["email"], email_data["username"], email_data["token"], background_tasks=background_tasks + ) + + # When background_tasks is provided, it should queue, not send + background_tasks.add_task.assert_called_once() + mock_email_sending.assert_not_called() + + +### Password Reset Email Tests ### +async def test_send_reset_password_email(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test password reset email is sent.""" + await send_reset_password_email(email_data["email"], email_data["username"], email_data["token"]) + mock_email_sending.assert_called_once() + + +async def test_send_reset_password_email_with_background_tasks( + email_data: dict[str, str], mock_email_sending: AsyncMock +) -> None: + """Test password reset email queues task when background_tasks provided.""" + background_tasks = MagicMock(spec=BackgroundTasks) + + await send_reset_password_email( + email_data["email"], email_data["username"], email_data["token"], background_tasks=background_tasks + ) + + background_tasks.add_task.assert_called_once() + mock_email_sending.assert_not_called() + + +### Verification Email Tests ### +async def test_send_verification_email(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test verification email is sent.""" + await send_verification_email(email_data["email"], email_data["username"], email_data["token"]) + mock_email_sending.assert_called_once() + + +async def test_send_verification_email_with_background_tasks( + email_data: dict[str, str], mock_email_sending: AsyncMock +) -> None: + """Test verification email queues task when background_tasks provided.""" + background_tasks = MagicMock(spec=BackgroundTasks) + + await send_verification_email( + email_data["email"], email_data["username"], email_data["token"], background_tasks=background_tasks + ) + + background_tasks.add_task.assert_called_once() + mock_email_sending.assert_not_called() + + +### Post-Verification Email Tests ### +async def test_send_post_verification_email(email_data: dict[str, str], mock_email_sending: AsyncMock) -> None: + """Test post-verification email is sent.""" + await send_post_verification_email(email_data["email"], email_data["username"]) + mock_email_sending.assert_called_once() + + +async def test_send_post_verification_email_no_username( + email_data: dict[str, str], mock_email_sending: AsyncMock +) -> None: + """Test post-verification email works without username.""" + await send_post_verification_email(email_data["email"], None) + mock_email_sending.assert_called_once() + + +async def test_send_post_verification_email_with_background_tasks( + email_data: dict[str, str], mock_email_sending: AsyncMock +) -> None: + """Test post-verification email queues task when background_tasks provided.""" + background_tasks = MagicMock(spec=BackgroundTasks) + + await send_post_verification_email(email_data["email"], email_data["username"], background_tasks=background_tasks) + + background_tasks.add_task.assert_called_once() + mock_email_sending.assert_not_called() + + +### Parametrized Integration Tests ### +@pytest.mark.parametrize( + ("email_func", "needs_token"), + [ + (send_registration_email, True), + (send_reset_password_email, True), + (send_verification_email, True), + (send_post_verification_email, False), + ], +) +async def test_all_email_functions_send_emails( + email_data: dict[str, str], + mock_email_sending: AsyncMock, + email_func: Callable[..., Any], + *, + needs_token: bool, +) -> None: + """Test that all email functions successfully send emails.""" + # Call function with appropriate arguments + if needs_token: + await email_func(email_data["email"], email_data["username"], email_data["token"]) + else: + await email_func(email_data["email"], email_data["username"]) + + # Verify email was sent + mock_email_sending.assert_called_once() + + +@pytest.mark.parametrize( + ("email_func", "needs_token"), + [ + (send_registration_email, True), + (send_reset_password_email, True), + (send_verification_email, True), + (send_post_verification_email, False), + ], +) +async def test_all_email_functions_support_background_tasks( + email_data: dict[str, str], + mock_email_sending: AsyncMock, + email_func: Callable[..., Any], + *, + needs_token: bool, +) -> None: + """Test that all email functions support background tasks.""" + background_tasks = MagicMock(spec=BackgroundTasks) + + # Call function with background tasks + if needs_token: + await email_func( + email_data["email"], email_data["username"], email_data["token"], background_tasks=background_tasks + ) + else: + await email_func(email_data["email"], email_data["username"], background_tasks=background_tasks) + + # Verify task was queued, not sent immediately + background_tasks.add_task.assert_called_once() + mock_email_sending.assert_not_called() diff --git a/backend/tests/unit/file_storage/__init__.py b/backend/tests/unit/file_storage/__init__.py new file mode 100644 index 00000000..e6372607 --- /dev/null +++ b/backend/tests/unit/file_storage/__init__.py @@ -0,0 +1 @@ +"""Unit tests for file storage module.""" diff --git a/backend/tests/unit/file_storage/test_cleanup.py b/backend/tests/unit/file_storage/test_cleanup.py new file mode 100644 index 00000000..44b7877f --- /dev/null +++ b/backend/tests/unit/file_storage/test_cleanup.py @@ -0,0 +1,226 @@ +"""Unit tests for the file cleanup logic.""" + +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +from app.api.file_storage.services.cleanup import ( + cleanup_unreferenced_files, + get_files_on_disk, + get_referenced_files, + get_unreferenced_files, +) +from app.core.config import settings + +if TYPE_CHECKING: + import pytest + +# --------------------------------------------------------------------------- +# get_referenced_files +# --------------------------------------------------------------------------- + + +async def test_get_referenced_files_empty() -> None: + """Returns empty set when no files or images exist in DB.""" + session = AsyncMock() + mock_scalars = MagicMock() + mock_scalars.all.return_value = [] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + + result = await get_referenced_files(session) + + assert result == set() + + +async def test_get_referenced_files_with_paths(tmp_path: Path) -> None: + """Returns resolved paths for all files and images with a path attribute.""" + session = AsyncMock() + + fake_file = MagicMock() + fake_file.file.path = str(tmp_path / "upload.txt") + fake_image = MagicMock() + fake_image.file.path = str(tmp_path / "image.jpg") + + mock_files_scalars = MagicMock() + mock_files_scalars.all.return_value = [fake_file] + mock_files_result = MagicMock() + mock_files_result.scalars.return_value = mock_files_scalars + mock_images_scalars = MagicMock() + mock_images_scalars.all.return_value = [fake_image] + mock_images_result = MagicMock() + mock_images_result.scalars.return_value = mock_images_scalars + session.execute.side_effect = [mock_files_result, mock_images_result] + + result = await get_referenced_files(session) + + assert (tmp_path / "upload.txt").resolve() in result + assert (tmp_path / "image.jpg").resolve() in result + + +async def test_get_referenced_files_skips_none_entries() -> None: + """None entries (no file attached) are silently skipped.""" + session = AsyncMock() + mock_scalars = MagicMock() + mock_scalars.all.return_value = [None] + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + session.execute.return_value = mock_result + + result = await get_referenced_files(session) + + assert result == set() + + +# --------------------------------------------------------------------------- +# get_files_on_disk +# --------------------------------------------------------------------------- + + +async def test_get_files_on_disk_returns_old_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Files old enough to exceed the grace period are included.""" + file_storage = tmp_path / "files" + image_storage = tmp_path / "images" + file_storage.mkdir() + image_storage.mkdir() + + old_file = file_storage / "old.txt" + old_file.write_text("old") + old_mtime = time.time() - 7200 # 2 hours ago + os.utime(old_file, (old_mtime, old_mtime)) + + monkeypatch.setattr(settings, "file_storage_path", file_storage) + monkeypatch.setattr(settings, "image_storage_path", image_storage) + monkeypatch.setattr(settings, "file_cleanup_min_file_age_minutes", 30) + + result = await get_files_on_disk() + + assert old_file.resolve() in result + + +async def test_get_files_on_disk_excludes_recent_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Files newer than the grace period are excluded (Time-of-Check to Time-of-Use protection).""" + file_storage = tmp_path / "files" + image_storage = tmp_path / "images" + file_storage.mkdir() + image_storage.mkdir() + + new_file = file_storage / "new.txt" + new_file.write_text("new") + # mtime defaults to now; well within any grace period + + monkeypatch.setattr(settings, "file_storage_path", file_storage) + monkeypatch.setattr(settings, "image_storage_path", image_storage) + monkeypatch.setattr(settings, "file_cleanup_min_file_age_minutes", 30) + + result = await get_files_on_disk() + + assert new_file.resolve() not in result + + +async def test_get_files_on_disk_missing_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Non-existent storage directories are silently skipped.""" + monkeypatch.setattr(settings, "file_storage_path", tmp_path / "does_not_exist") + monkeypatch.setattr(settings, "image_storage_path", tmp_path / "also_missing") + + result = await get_files_on_disk() + + assert result == set() + + +# --------------------------------------------------------------------------- +# get_unreferenced_files / cleanup_unreferenced_files (mocked helpers) +# --------------------------------------------------------------------------- + +_REF_PATH = Path("/uploads/files/referenced.txt").resolve() +_UNREF_PATH = Path("/uploads/files/unreferenced.txt").resolve() +_REF_IMAGE_PATH = Path("/uploads/images/referenced.jpg").resolve() +_REF_IMAGE_THUMB_PATH = Path("/uploads/images/referenced_thumb_200.webp").resolve() + + +async def test_get_unreferenced_files_returns_delta() -> None: + """get_unreferenced_files returns disk files that are not referenced in DB.""" + session = MagicMock() + + with ( + patch("app.api.file_storage.services.cleanup.get_referenced_files", new=AsyncMock(return_value={_REF_PATH})), + patch( + "app.api.file_storage.services.cleanup.get_files_on_disk", + new=AsyncMock(return_value={_REF_PATH, _UNREF_PATH}), + ), + ): + result = await get_unreferenced_files(session) + + assert result == [_UNREF_PATH] + + +async def test_get_unreferenced_files_preserves_generated_thumbnails() -> None: + """Derived thumbnails for referenced images are not treated as unreferenced.""" + session = MagicMock() + + with ( + patch( + "app.api.file_storage.services.cleanup.get_referenced_files", + new=AsyncMock(return_value={_REF_IMAGE_PATH, _REF_IMAGE_THUMB_PATH}), + ), + patch( + "app.api.file_storage.services.cleanup.get_files_on_disk", + new=AsyncMock(return_value={_REF_IMAGE_PATH, _REF_IMAGE_THUMB_PATH}), + ), + ): + result = await get_unreferenced_files(session) + + assert result == [] + + +async def test_cleanup_dry_run_does_not_delete(tmp_path: Path) -> None: + """dry_run=True logs but does not delete anything.""" + target = tmp_path / "stale.txt" + target.write_text("stale") + session = MagicMock() + + with patch( + "app.api.file_storage.services.cleanup.get_unreferenced_files", + new=AsyncMock(return_value=[target]), + ): + deleted = await cleanup_unreferenced_files(session, dry_run=True) + + assert deleted == [target] + assert target.exists(), "dry_run must not delete the file" + + +async def test_cleanup_force_deletes_files(tmp_path: Path) -> None: + """dry_run=False deletes each unreferenced file.""" + target = tmp_path / "stale.txt" + target.write_text("stale") + session = MagicMock() + + with patch( + "app.api.file_storage.services.cleanup.get_unreferenced_files", + new=AsyncMock(return_value=[target]), + ): + deleted = await cleanup_unreferenced_files(session, dry_run=False) + + assert deleted == [target] + assert not target.exists(), "file should have been deleted" + + +async def test_cleanup_continues_after_delete_error(tmp_path: Path) -> None: + """A failed deletion is logged and does not abort remaining files.""" + good = tmp_path / "good.txt" + good.write_text("good") + missing = tmp_path / "missing.txt" # does not exist; unlink will raise OSError + session = MagicMock() + + with patch( + "app.api.file_storage.services.cleanup.get_unreferenced_files", + new=AsyncMock(return_value=[missing, good]), + ): + deleted = await cleanup_unreferenced_files(session, dry_run=False) + + assert missing in deleted + assert good in deleted + assert not good.exists(), "the successful deletion should still happen" diff --git a/backend/tests/unit/file_storage/test_custom_types.py b/backend/tests/unit/file_storage/test_custom_types.py new file mode 100644 index 00000000..974ae89d --- /dev/null +++ b/backend/tests/unit/file_storage/test_custom_types.py @@ -0,0 +1,83 @@ +"""Test custom logic of file storage types.""" + +import io +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from app.api.file_storage.models.storage_filesystem import FileSystemStorage +from app.main import ensure_storage_directories + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +def test_custom_storage_is_lazy_about_creating_directories(tmp_path: Path) -> None: + """Test that FileSystemStorage does not create the storage directory until a file is written.""" + storage_dir = tmp_path / "files" + + # Ensure directory does not exist before instantiation + assert not storage_dir.exists() + + storage = FileSystemStorage(path=str(storage_dir)) + + # Instantiation should NOT create the directory + assert not storage_dir.exists() + + # Writing a file should create the directory and write the file + data = b"hello" + storage.write(file=io.BytesIO(data), name="greeting.txt") + + written_path = storage_dir / "greeting.txt" + assert storage_dir.exists() + assert written_path.exists() + assert written_path.read_bytes() == data + + +def test_custom_storage_calls_mkdir_on_each_write(tmp_path: Path, mocker: MockerFixture) -> None: + """Test that FileSystemStorage triggers directory creation on each write.""" + storage_dir = tmp_path / "files_once" + + # Mock Path.mkdir and Path.open so we can count calls without touching disk. + mock_mkdir = mocker.patch("pathlib.Path.mkdir") + mocker.patch("pathlib.Path.open", mocker.mock_open()) + + storage = FileSystemStorage(path=str(storage_dir)) + + # First write should call mkdir + storage.write(file=io.BytesIO(b"first"), name="1.txt") + assert mock_mkdir.call_count == 1 + + # Second write to same storage should call mkdir again + storage.write(file=io.BytesIO(b"second"), name="2.txt") + assert mock_mkdir.call_count == 2 + + # New storage instance with SAME path should still call mkdir + storage2 = FileSystemStorage(path=str(storage_dir)) + storage2.write(file=io.BytesIO(b"third"), name="3.txt") + assert mock_mkdir.call_count == 3 + + +def test_ensure_storage_directories_creates_expected_paths(mocker: MockerFixture) -> None: + """Test that startup storage directory creation calls mkdir for both paths.""" + mock_mkdir = mocker.patch("pathlib.Path.mkdir") + mock_named_tempfile = mocker.patch("tempfile.NamedTemporaryFile") + + ensure_storage_directories() + + # Should call mkdir for file and image paths + assert mock_mkdir.call_count == 2 + assert mock_named_tempfile.call_count == 2 + + +def test_ensure_storage_directories_raises_when_storage_path_is_not_writable(mocker: MockerFixture) -> None: + """Test that startup fails fast when a storage path exists but is not writable.""" + mocker.patch("pathlib.Path.mkdir") + mock_named_tempfile = mocker.patch("tempfile.NamedTemporaryFile") + mock_named_tempfile.side_effect = PermissionError("permission denied") + + with pytest.raises(RuntimeError, match="Storage path is not writable"): + ensure_storage_directories() diff --git a/backend/tests/unit/file_storage/test_media_crud.py b/backend/tests/unit/file_storage/test_media_crud.py new file mode 100644 index 00000000..ff5dfe61 --- /dev/null +++ b/backend/tests/unit/file_storage/test_media_crud.py @@ -0,0 +1,173 @@ +"""Behavior-focused tests for file and image CRUD entrypoints.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import UploadFile + +from app.api.file_storage.crud.media_queries import create_file, create_image, delete_file, delete_image +from app.api.file_storage.exceptions import ModelFileNotFoundError, UploadTooLargeError +from app.api.file_storage.models import File, Image, MediaParentType +from app.api.file_storage.schemas import FileCreate, ImageCreateInternal +from tests.factories.models import ProductFactory + +TEST_FILE_DESC = "Test file" +TEST_FILENAME = "test.txt" +TEST_IMAGE_DESC = "Test image" +IMAGE_FILENAME = "image.png" +FAKE_PATH = "/fake/path/test.txt" +FAKE_IMAGE_PATH = "/fake/path/test.png" +CONTENT_TYPE_PNG = "image/png" +MB = 1024 * 1024 + + +class TestFileStorageCrud: + """Test CRUD operations for generic files.""" + + async def test_create_file_success(self, mock_session: AsyncMock) -> None: + """Creates a file record for a valid upload.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = TEST_FILENAME + mock_file.size = 1024 + mock_file.file = BytesIO(b"test content") + + file_create = FileCreate( + file=mock_file, description=TEST_FILE_DESC, parent_id=1, parent_type=MediaParentType.PRODUCT + ) + + with ( + patch("app.api.file_storage.crud.support_queries.parent_model_for_type") as mock_parent_model, + patch("app.api.file_storage.crud.support_queries.require_model"), + patch("app.api.file_storage.crud.support_services._get_file_storage") as mock_get_storage, + ): + mock_parent_model.return_value = MagicMock() + mock_storage = mock_get_storage.return_value + mock_storage.write_upload = AsyncMock(return_value="stored_test.txt") + + result = await create_file(mock_session, file_create) + + assert isinstance(result, File) + assert result.description == TEST_FILE_DESC + assert result.filename == TEST_FILENAME + assert result.parent_type == MediaParentType.PRODUCT + assert result.parent_id == 1 + + async def test_create_file_rejects_oversized_upload(self, mock_session: AsyncMock) -> None: + """Rejects file uploads above the size limit.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = TEST_FILENAME + mock_file.size = 51 * MB + mock_file.file = BytesIO(b"") + + file_create = FileCreate( + file=mock_file, description=TEST_FILE_DESC, parent_id=1, parent_type=MediaParentType.PRODUCT + ) + + with pytest.raises(UploadTooLargeError, match="Maximum size: 50 MB"): + await create_file(mock_session, file_create) + + async def test_delete_file_success(self, mock_session: AsyncMock) -> None: + """Deletes a stored file and its database record.""" + file_id = uuid4() + mock_db_file = MagicMock(spec=File) + mock_db_file.file.path = FAKE_PATH + + with ( + patch("app.api.file_storage.crud.support_services.require_model", return_value=mock_db_file), + patch("app.api.file_storage.crud.support_services.delete_file_from_storage") as mock_delete_from_storage, + ): + await delete_file(mock_session, file_id) + + mock_session.delete.assert_called_once_with(mock_db_file) + mock_delete_from_storage.assert_called_once_with(Path(FAKE_PATH)) + + +class TestImageStorageCrud: + """Test CRUD operations for image files.""" + + async def test_create_image_internal_success(self, mock_session: AsyncMock) -> None: + """Creates an image record for a valid upload.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = IMAGE_FILENAME + mock_file.content_type = CONTENT_TYPE_PNG + mock_file.size = 1024 + mock_file.file = BytesIO(b"fake image bytes") + mock_file.path = None + + image_create = ImageCreateInternal( + file=mock_file, description=TEST_IMAGE_DESC, parent_id=1, parent_type=MediaParentType.PRODUCT + ) + + with ( + patch( + "app.api.file_storage.crud.support_queries.require_model", + return_value=ProductFactory.build(id=1, owner_id=uuid4(), first_image_id=uuid4()), + ), + patch("app.api.file_storage.crud.support_services._get_image_storage") as mock_get_storage, + ): + mock_storage = mock_get_storage.return_value + mock_storage.write_image_upload = AsyncMock(return_value="stored_image.png") + result = await create_image(mock_session, image_create) + + assert isinstance(result, Image) + assert result.description == TEST_IMAGE_DESC + assert result.filename == IMAGE_FILENAME + + async def test_create_image_rejects_oversized_upload(self, mock_session: AsyncMock) -> None: + """Rejects image uploads above the size limit.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = IMAGE_FILENAME + mock_file.content_type = CONTENT_TYPE_PNG + mock_file.size = 11 * MB + mock_file.file = BytesIO(b"") + + image_create = ImageCreateInternal( + file=mock_file, description=TEST_IMAGE_DESC, parent_id=1, parent_type=MediaParentType.PRODUCT + ) + + with pytest.raises(UploadTooLargeError, match="Maximum size: 10 MB"): + await create_image(mock_session, image_create) + + async def test_delete_image_success(self, mock_session: AsyncMock) -> None: + """Deletes a stored image and its database record.""" + image_id = uuid4() + mock_db_image = MagicMock(spec=Image) + mock_db_image.file.path = FAKE_IMAGE_PATH + + with ( + patch("app.api.file_storage.crud.support_services.require_model", return_value=mock_db_image), + patch( + "app.api.file_storage.crud.support_services.delete_image_from_storage", + new=AsyncMock(), + ) as mock_delete_image, + ): + await delete_image(mock_session, image_id) + mock_session.delete.assert_called_once_with(mock_db_image) + mock_delete_image.assert_awaited_once_with(Path(FAKE_IMAGE_PATH)) + + async def test_delete_image_cleans_thumbnails_when_original_is_missing(self, mock_session: AsyncMock) -> None: + """Cleans up derived image files when the original file record is missing.""" + image_id = uuid4() + mock_db_image = MagicMock(spec=Image) + mock_db_image.file.path = FAKE_IMAGE_PATH + mock_session.get.return_value = mock_db_image + + with ( + patch( + "app.api.file_storage.crud.support_services.require_model", + side_effect=ModelFileNotFoundError(Image, image_id), + ), + patch( + "app.api.file_storage.crud.support_services.delete_image_from_storage", + new=AsyncMock(), + ) as mock_delete_image, + ): + await delete_image(mock_session, image_id) + + mock_session.delete.assert_called_once_with(mock_db_image) + mock_delete_image.assert_awaited_once_with(Path(FAKE_IMAGE_PATH)) diff --git a/backend/tests/unit/file_storage/test_parent_media_crud.py b/backend/tests/unit/file_storage/test_parent_media_crud.py new file mode 100644 index 00000000..ea4103a9 --- /dev/null +++ b/backend/tests/unit/file_storage/test_parent_media_crud.py @@ -0,0 +1,85 @@ +"""Behavior-focused tests for parent-scoped media CRUD.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import UploadFile + +from app.api.data_collection.models.product import Product +from app.api.file_storage.crud.parent_media import ParentMediaCrud +from app.api.file_storage.exceptions import ParentStorageOwnershipError +from app.api.file_storage.models import Image, MediaParentType +from app.api.file_storage.schemas import ImageCreateInternal + +TEST_FILE_DESC = "Test file" +TEST_FILENAME = "test.txt" +CONTENT_TYPE_PNG = "image/png" + + +class TestParentStorageCrud: + """Test parent-scoped storage operations.""" + + async def test_create_rejects_parent_scope_mismatch(self, mock_session: AsyncMock) -> None: + """Test that creating an item with a parent ID that doesn't match the expected parent scope raises an error.""" + operations = ParentMediaCrud( + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=Image, + storage_service=MagicMock(create=AsyncMock(), delete=AsyncMock()), + ) + + image_create = ImageCreateInternal( + file=MagicMock(spec=UploadFile, filename=TEST_FILENAME, size=1024, content_type=CONTENT_TYPE_PNG), + description=TEST_FILE_DESC, + parent_id=2, + parent_type=MediaParentType.MATERIAL, + ) + + with pytest.raises(ValueError, match="Parent ID mismatch"): + await operations.create(mock_session, 1, image_create) + + async def test_delete_removes_db_record_when_storage_file_is_missing(self, mock_session: AsyncMock) -> None: + """Test that deleting an item removes the database record even if the storage file is missing.""" + storage_service = MagicMock() + storage_service.delete = AsyncMock() + operations = ParentMediaCrud( + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=Image, + storage_service=storage_service, + ) + + item_id = uuid4() + db_item = MagicMock(spec=Image) + db_item.parent_id = 1 + + with patch( + "app.api.file_storage.crud.parent_media.get_parent_owned_storage_item", + new=AsyncMock(return_value=db_item), + ): + await operations.delete(mock_session, 1, item_id) + + storage_service.delete.assert_awaited_once_with(mock_session, item_id) + + async def test_get_by_id_raises_not_found_for_wrong_parent(self, mock_session: AsyncMock) -> None: + """Test a not found error is raised if the item exists but is not owned by the specified parent.""" + operations = ParentMediaCrud( + parent_model=Product, + parent_type=MediaParentType.PRODUCT, + storage_model=Image, + storage_service=MagicMock(create=AsyncMock(), delete=AsyncMock()), + ) + + item_id = uuid4() + + with ( + patch( + "app.api.file_storage.crud.parent_media.get_parent_owned_storage_item", + new=AsyncMock(side_effect=ParentStorageOwnershipError(Image, item_id, Product, 1)), + ), + pytest.raises(ParentStorageOwnershipError, match="not found for"), + ): + await operations.get_by_id(mock_session, 1, item_id) diff --git a/backend/tests/unit/file_storage/test_presentation.py b/backend/tests/unit/file_storage/test_presentation.py new file mode 100644 index 00000000..db51726f --- /dev/null +++ b/backend/tests/unit/file_storage/test_presentation.py @@ -0,0 +1,110 @@ +"""Unit tests for file storage read-model and path helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast +from uuid import uuid4 + +from app.api.file_storage.crud.support_paths import storage_item_exists +from app.api.file_storage.models import File, Image, MediaParentType +from app.api.file_storage.models.storage_types import FileType, ImageType +from app.api.file_storage.schemas import FileReadWithinParent, ImageRead, ImageReadWithinParent +from app.core.config import settings + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_file_read_within_parent_model_validate_returns_public_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """File read schema should build a public URL when the file exists.""" + storage_root = tmp_path / "files" + monkeypatch.setattr(settings, "file_storage_path", storage_root) + stored_dir = storage_root / "tests" + stored_dir.mkdir(parents=True, exist_ok=True) + stored_file = stored_dir / "example.txt" + stored_file.write_bytes(b"hello") + + file = File( + id=uuid4(), + filename="example.txt", + file=cast("FileType", SimpleNamespace(path=str(stored_file))), + parent_type=MediaParentType.PRODUCT, + parent_id=1, + ) + + read_model = FileReadWithinParent.model_validate(file) + + assert storage_item_exists(file) is True + assert read_model.file_url == f"/uploads/files/{stored_file.relative_to(storage_root)}" + + +def test_image_read_within_parent_model_validate_returns_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Image read schema should build image and thumbnail URLs.""" + storage_root = tmp_path / "images" + monkeypatch.setattr(settings, "image_storage_path", storage_root) + stored_dir = storage_root / "tests" + stored_dir.mkdir(parents=True, exist_ok=True) + stored_file = stored_dir / "example.png" + stored_file.write_bytes(b"hello") + image_id = uuid4() + + image = Image( + id=image_id, + filename="example.png", + file=cast("ImageType", SimpleNamespace(path=str(stored_file))), + parent_type=MediaParentType.PRODUCT, + parent_id=1, + ) + + read_model = ImageReadWithinParent.model_validate(image) + + assert read_model.image_url == f"/uploads/images/{stored_file.relative_to(storage_root)}" + assert read_model.thumbnail_url == f"/images/{image_id}/resized?width=200" + + +def test_image_read_model_validate_from_orm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Image read schemas should validate raw ORM rows with derived URLs.""" + storage_root = tmp_path / "images" + monkeypatch.setattr(settings, "image_storage_path", storage_root) + stored_dir = storage_root / "tests" + stored_dir.mkdir(parents=True, exist_ok=True) + stored_file = stored_dir / "example.png" + stored_file.write_bytes(b"hello") + image_id = uuid4() + + image = Image( + id=image_id, + filename="example.png", + file=cast("ImageType", SimpleNamespace(path=str(stored_file))), + parent_type=MediaParentType.PRODUCT, + parent_id=1, + ) + + read_model = ImageRead.model_validate(image) + + assert read_model.image_url == f"/uploads/images/{stored_file.relative_to(storage_root)}" + assert read_model.thumbnail_url == f"/images/{image_id}/resized?width=200" + assert read_model.parent_id == 1 + assert read_model.parent_type == MediaParentType.PRODUCT + + +def test_missing_storage_file_returns_no_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Read schema should omit URLs when the backing file is missing.""" + storage_root = tmp_path / "files" + monkeypatch.setattr(settings, "file_storage_path", storage_root) + missing_path = storage_root / "missing" / "ghost.txt" + file = File( + id=uuid4(), + filename="ghost.txt", + file=cast("FileType", SimpleNamespace(path=str(missing_path))), + parent_type=MediaParentType.PRODUCT, + parent_id=1, + ) + + assert storage_item_exists(file) is False + assert FileReadWithinParent.model_validate(file).file_url is None diff --git a/backend/tests/unit/file_storage/test_routers.py b/backend/tests/unit/file_storage/test_routers.py new file mode 100644 index 00000000..5e874799 --- /dev/null +++ b/backend/tests/unit/file_storage/test_routers.py @@ -0,0 +1,112 @@ +"""Unit tests for file storage routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.file_storage.routers import get_resized_image + +if TYPE_CHECKING: + from pathlib import Path + +# Constants to avoid magic values +IMAGE_WIDTH = 200 +IMAGE_HEIGHT = 150 + + +def _make_request() -> MagicMock: + return MagicMock() + + +def _make_db_image(path: str | None = None) -> MagicMock: + db_image = MagicMock() + if path is not None: + db_image.file = MagicMock() + db_image.file.path = path + else: + db_image.file = None + return db_image + + +class TestGetResizedImage: + """Tests for the GET /images/{image_id}/resized endpoint handler.""" + + async def test_returns_webp_response_when_file_exists(self, tmp_path: Path) -> None: + """Test that a valid image returns a WebP response.""" + image_file = tmp_path / "test.jpg" + image_file.write_bytes(b"fake image bytes") + + db_image = _make_db_image(str(image_file)) + request = _make_request() + image_id = uuid4() + fake_session = AsyncMock() + + with ( + patch("app.api.file_storage.routers.get_image", return_value=db_image), + patch("app.api.file_storage.routers.AsyncPath") as mock_path_cls, + patch("app.api.file_storage.routers.to_thread.run_sync", return_value=b"resized_bytes"), + ): + mock_path_cls.return_value.exists = AsyncMock(return_value=True) + response = await get_resized_image(request, image_id, fake_session, width=IMAGE_WIDTH, height=IMAGE_HEIGHT) + + assert response.body == b"resized_bytes" + assert response.media_type == "image/webp" + assert "Cache-Control" in response.headers + + async def test_raises_404_when_file_record_has_no_path(self) -> None: + """Test that 404 is raised when image has no file path.""" + db_image = MagicMock() + db_image.file.path = None + request = _make_request() + fake_session = AsyncMock() + + with ( + patch("app.api.file_storage.routers.get_image", return_value=db_image), + pytest.raises(HTTPException) as exc_info, + ): + await get_resized_image(request, uuid4(), fake_session) + + assert exc_info.value.status_code == 404 + + async def test_raises_404_when_file_not_on_disk(self) -> None: + """Test that 404 is raised when file path doesn't exist on disk.""" + db_image = _make_db_image("/nonexistent/path/image.jpg") + request = _make_request() + fake_session = AsyncMock() + + with ( + patch("app.api.file_storage.routers.get_image", return_value=db_image), + patch("app.api.file_storage.routers.AsyncPath") as mock_path_cls, + ): + mock_path_cls.return_value.exists = AsyncMock(return_value=False) + with pytest.raises(HTTPException) as exc_info: + await get_resized_image(request, uuid4(), fake_session) + + assert exc_info.value.status_code == 404 + assert "disk" in exc_info.value.detail.lower() + + async def test_raises_500_on_unexpected_error(self, tmp_path: Path) -> None: + """Test that unexpected errors during resize result in 500.""" + image_file = tmp_path / "test.jpg" + image_file.write_bytes(b"fake image bytes") + + db_image = _make_db_image(str(image_file)) + request = _make_request() + fake_session = AsyncMock() + + with ( + patch("app.api.file_storage.routers.get_image", return_value=db_image), + patch("app.api.file_storage.routers.AsyncPath") as mock_path_cls, + patch("app.api.file_storage.routers.to_thread.run_sync", side_effect=RuntimeError("resize failed")), + ): + mock_path_cls.return_value.exists = AsyncMock(return_value=True) + with pytest.raises(HTTPException) as exc_info: + await get_resized_image(request, uuid4(), fake_session) + + assert exc_info.value.status_code == 500 + assert "resizing" in exc_info.value.detail.lower() diff --git a/backend/tests/unit/file_storage/test_s3_storage.py b/backend/tests/unit/file_storage/test_s3_storage.py new file mode 100644 index 00000000..7f4a7e7d --- /dev/null +++ b/backend/tests/unit/file_storage/test_s3_storage.py @@ -0,0 +1,259 @@ +"""Test S3-compatible storage backend.""" +# spell-checker: ignore AKIAIOSFODNN7EXAMPLE + +import importlib +import io +import sys +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError +from app.api.file_storage.models.storage_s3 import S3Storage +from app.core.config import CoreSettings, StorageBackend + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +class MockClientError(Exception): + """Mock ClientError that behaves like botocore.exceptions.ClientError.""" + + def __init__(self, error_response: dict, _operation_name: str | None = None) -> None: + self.response = error_response + super().__init__() + + +@pytest.fixture +def mock_boto3(mocker: MockerFixture) -> MagicMock: + """Fixture that provides mocked boto3 and botocore modules in sys.modules for lazy imports.""" + mock_boto3_module = MagicMock() + mock_botocore = MagicMock() + mock_exceptions = MagicMock() + mock_exceptions.ClientError = MockClientError + mock_botocore.exceptions = mock_exceptions + mocker.patch.dict( + sys.modules, + { + "boto3": mock_boto3_module, + "botocore": mock_botocore, + "botocore.exceptions": mock_exceptions, + }, + ) + return mock_boto3_module + + +def _make_client_error(code: str, operation: str = "Operation") -> MockClientError: + """Create a MockClientError with the given error code. + + Since botocore is optional, we create a mock that mimics ClientError's structure. + """ + return MockClientError( + {"Error": {"Code": code, "Message": f"Error: {code}"}}, + operation, + ) + + +class TestS3StoragePathConstruction: + """Test URL/path construction for S3 objects.""" + + def test_get_path_default_aws_url(self) -> None: + """Test default AWS S3 URL format: https://bucket.s3.region.amazonaws.com/prefix/key.""" + storage = S3Storage(bucket="my-bucket", prefix="images", region="eu-west-1") + path = storage.get_path("photo.jpg") + assert path == "https://my-bucket.s3.eu-west-1.amazonaws.com/images/photo.jpg" + + def test_get_path_with_base_url(self) -> None: + """Test custom base URL overrides AWS path.""" + storage = S3Storage( + bucket="my-bucket", + prefix="images", + base_url="https://cdn.example.com/assets", + ) + path = storage.get_path("photo.jpg") + assert path == "https://cdn.example.com/assets/images/photo.jpg" + + def test_get_path_with_base_url_trailing_slash(self) -> None: + """Test base URL with trailing slash is normalized.""" + storage = S3Storage( + bucket="my-bucket", + prefix="images", + base_url="https://cdn.example.com/assets/", # trailing slash + ) + path = storage.get_path("photo.jpg") + assert path == "https://cdn.example.com/assets/images/photo.jpg" + + def test_get_path_with_custom_endpoint(self) -> None: + """Test S3-compatible endpoint (e.g. MinIO) URL format.""" + storage = S3Storage( + bucket="my-bucket", + prefix="images", + endpoint_url="http://localhost:9000", + ) + path = storage.get_path("photo.jpg") + assert path == "http://localhost:9000/my-bucket/images/photo.jpg" + + def test_get_path_empty_prefix(self) -> None: + """Test URL construction with empty prefix.""" + storage = S3Storage(bucket="my-bucket", prefix="") + path = storage.get_path("photo.jpg") + assert path == "https://my-bucket.s3.us-east-1.amazonaws.com/photo.jpg" + + def test_get_name_sanitizes_filename(self) -> None: + """Test that get_name normalizes filenames like filesystem backend.""" + storage = S3Storage(bucket="my-bucket", prefix="files") + # Paths with directory separators should be stripped to just the basename + assert storage.get_name("documents/report.pdf") == "report.pdf" + # Special characters should be removed + assert storage.get_name("hello world!@#.txt") == "hello_world.txt" + + +class TestS3StorageSyncOperations: + """Test synchronous storage operations with mocked boto3.""" + + def test_write_uploads_to_s3(self, mock_boto3: MagicMock) -> None: + """Test write() calls upload_fileobj with correct bucket and key.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + + storage = S3Storage(bucket="my-bucket", prefix="files") + data = b"test content" + result = storage.write(io.BytesIO(data), "document.txt") + + assert result == "document.txt" + mock_client.upload_fileobj.assert_called_once() + # Verify upload was called with correct bucket and key. + assert mock_client.upload_fileobj.call_args.kwargs == { + "Bucket": "my-bucket", + "Key": "files/document.txt", + } + + def test_open_returns_file_contents(self, mock_boto3: MagicMock) -> None: + """Test open() returns BytesIO with object contents.""" + mock_body = MagicMock() + mock_body.read.return_value = b"file contents" + mock_client = MagicMock() + mock_client.get_object.return_value = {"Body": mock_body} + mock_boto3.client.return_value = mock_client + + storage = S3Storage(bucket="my-bucket", prefix="files") + result = storage.open("document.txt") + + assert isinstance(result, io.BytesIO) + assert result.getvalue() == b"file contents" + mock_client.get_object.assert_called_once_with(Bucket="my-bucket", Key="files/document.txt") + + def test_open_raises_on_missing_file(self, mock_boto3: MagicMock) -> None: + """Test open() raises FastAPIStorageFileNotFoundError for missing objects.""" + mock_client = MagicMock() + error = _make_client_error("NoSuchKey", "GetObject") + mock_client.get_object.side_effect = error + mock_boto3.client.return_value = mock_client + + storage = S3Storage(bucket="my-bucket", prefix="files") + + with pytest.raises(FastAPIStorageFileNotFoundError): + storage.open("missing.txt") + + def test_boto3_import_error_on_lazy_use(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test helpful error message when boto3 is not installed.""" + # Ensure boto3 is not available for import + monkeypatch.delitem(sys.modules, "boto3", raising=False) + monkeypatch.setattr( + "app.api.file_storage.models.storage_s3.import_module", + lambda name: ( + (_ for _ in ()).throw(ImportError(f"No module named '{name}'")) + if name == "boto3" + else importlib.import_module(name) + ), + ) + + storage = S3Storage(bucket="my-bucket", prefix="files") + + with pytest.raises(ImportError, match="boto3 is required for S3 storage") as exc_info: + storage.get_size("file.txt") + assert "uv sync --group s3" in str(exc_info.value) + + +class TestS3StorageAsyncOperations: + """Test asynchronous upload operations.""" + + async def test_write_upload_uploads_to_s3(self, mock_boto3: MagicMock) -> None: + """Test write_upload() streams file to S3 in thread pool.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + + # Mock the upload_fileobj to track it was called + upload_called = [] + + def mock_upload(_fileobj: object, **kwargs: object) -> None: + upload_called.append((str(kwargs["Bucket"]), str(kwargs["Key"]))) + + mock_client.upload_fileobj = mock_upload + + # Create a mock UploadFile + mock_file = MagicMock() + mock_file.file = io.BytesIO(b"test data") + mock_file.seek = AsyncMock() + mock_file.read = AsyncMock(return_value=b"test data") + mock_file.close = AsyncMock() + + storage = S3Storage(bucket="my-bucket", prefix="images") + result = await storage.write_upload(mock_file, "photo.jpg") + + assert result == "photo.jpg" + assert upload_called == [("my-bucket", "images/photo.jpg")] + mock_file.seek.assert_called_once_with(0) + mock_file.close.assert_called_once() + + async def test_write_image_upload_validates_then_uploads( + self, mock_boto3: MagicMock, mocker: MockerFixture + ) -> None: + """Test write_image_upload() validates image before upload.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + mock_validate = mocker.patch("app.api.file_storage.models.storage_s3.validate_image_file") + + mock_file = MagicMock() + mock_file.file = io.BytesIO(b"fake image data") + mock_file.seek = AsyncMock() + mock_file.read = AsyncMock(return_value=b"fake image data") + mock_file.close = AsyncMock() + + storage = S3Storage(bucket="my-bucket", prefix="images") + result = await storage.write_image_upload(mock_file, "photo.jpg") + + assert result == "photo.jpg" + # Validation should be called on the file object + mock_validate.assert_called_once() + mock_file.close.assert_called_once() + + +class TestConfigValidation: + """Test S3 configuration validation.""" + + def test_s3_backend_requires_bucket(self) -> None: + """Test that storage_backend='s3' requires s3_bucket to be set.""" + with pytest.raises(ValueError, match="S3_BUCKET must be set"): + CoreSettings( + storage_backend=StorageBackend.S3, + s3_bucket="", # Empty bucket + ) + + def test_s3_backend_with_bucket_valid(self) -> None: + """Test that storage_backend='s3' with bucket configured is valid.""" + settings = CoreSettings( + storage_backend=StorageBackend.S3, + s3_bucket="my-bucket", + ) + assert settings.storage_backend == StorageBackend.S3 + assert settings.s3_bucket == "my-bucket" + + def test_filesystem_backend_ignores_s3_config(self) -> None: + """Test that filesystem backend doesn't require S3 config.""" + settings = CoreSettings( + storage_backend=StorageBackend.FILESYSTEM, + s3_bucket="", # Empty S3 config is OK for filesystem + ) + assert settings.storage_backend == StorageBackend.FILESYSTEM diff --git a/backend/tests/unit/file_storage/test_storage_utils.py b/backend/tests/unit/file_storage/test_storage_utils.py new file mode 100644 index 00000000..bfe7c099 --- /dev/null +++ b/backend/tests/unit/file_storage/test_storage_utils.py @@ -0,0 +1,68 @@ +"""Behavior-focused tests for file-storage utility helpers.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import UploadFile + +from app.api.file_storage.crud.support_paths import delete_image_from_storage +from app.api.file_storage.crud.support_uploads import process_uploadfile_name, sanitize_filename + +TEST_SAN_RAW = "test file.txt" +TEST_SAN_CLEAN = "test-file.txt" +ARC_TAR_GZ = "archive.tar.gz" +MY_DOC_PDF = "my-document.pdf" +MY_DOC_RAW = "my document.pdf" +FAKE_IMAGE_PATH = "/fake/path/test.png" + + +class TestFileStorageCrudUtils: + """Test utility functions for file storage.""" + + def test_sanitize_filename(self) -> None: + """Test filename sanitization.""" + assert sanitize_filename(TEST_SAN_RAW) == TEST_SAN_CLEAN + assert sanitize_filename(ARC_TAR_GZ) == ARC_TAR_GZ + + long_name = "a" * 50 + ".pdf" + sanitized = sanitize_filename(long_name, max_length=10) + assert sanitized.endswith(".pdf") + assert len(sanitized) <= 15 + + def test_process_uploadfile_name_success(self) -> None: + """Test UploadFile name processing.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = MY_DOC_RAW + + file, file_id, original = process_uploadfile_name(mock_file) + + assert original == MY_DOC_PDF + assert file_id is not None + assert file.filename == f"{file_id.hex}_{MY_DOC_PDF}" + + def test_process_uploadfile_name_empty(self) -> None: + """Test UploadFile name processing with empty filename.""" + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = None + + with pytest.raises(ValueError, match="File name is empty"): + process_uploadfile_name(mock_file) + + async def test_delete_image_from_storage_removes_thumbnails_and_original(self) -> None: + """Image storage cleanup removes generated thumbnails before the original.""" + image_path = Path(FAKE_IMAGE_PATH) + + with ( + patch("app.api.file_storage.crud.support_paths.to_thread.run_sync", new=AsyncMock()) as mock_run_sync, + patch( + "app.api.file_storage.crud.support_paths.delete_file_from_storage", + new=AsyncMock(), + ) as mock_delete_file, + ): + await delete_image_from_storage(image_path) + + mock_run_sync.assert_awaited_once() + mock_delete_file.assert_awaited_once_with(image_path) diff --git a/backend/tests/unit/newsletter/__init__.py b/backend/tests/unit/newsletter/__init__.py new file mode 100644 index 00000000..2c686ed8 --- /dev/null +++ b/backend/tests/unit/newsletter/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the newsletter module.""" diff --git a/backend/tests/unit/newsletter/test_emails.py b/backend/tests/unit/newsletter/test_emails.py new file mode 100644 index 00000000..82aeeb2c --- /dev/null +++ b/backend/tests/unit/newsletter/test_emails.py @@ -0,0 +1,88 @@ +"""Unit tests for newsletter email utilities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from app.api.newsletter.utils.emails import ( + send_newsletter, + send_newsletter_subscription_email, + send_newsletter_unsubscription_request_email, +) + +TEST_EMAIL = "user@example.com" +TEST_TOKEN = "test-token-abc123" +TEST_SUBJECT = "Test Newsletter" +TEST_CONTENT = "Hello from the newsletter!" + + +class TestNewsletterEmails: + """Tests for newsletter email sending utilities.""" + + async def test_send_newsletter_subscription_email(self) -> None: + """Test that subscription email is sent with correct template and subject.""" + with ( + patch("app.api.newsletter.utils.emails.generate_token_link", return_value="http://confirm.link"), + patch("app.api.newsletter.utils.emails.send_email_with_template") as mock_send, + ): + await send_newsletter_subscription_email(TEST_EMAIL, TEST_TOKEN) + + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to_email"] == TEST_EMAIL + assert "Confirm" in call_kwargs["subject"] + assert call_kwargs["template_name"] == "newsletter_subscription.html" + assert call_kwargs["template_body"]["confirmation_link"] == "http://confirm.link" + + async def test_send_newsletter_subscription_email_with_background_tasks(self) -> None: + """Test that background_tasks is passed through to send_email_with_template.""" + background_tasks = AsyncMock() + with ( + patch("app.api.newsletter.utils.emails.generate_token_link", return_value="http://link"), + patch("app.api.newsletter.utils.emails.send_email_with_template") as mock_send, + ): + await send_newsletter_subscription_email(TEST_EMAIL, TEST_TOKEN, background_tasks=background_tasks) + + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["background_tasks"] == background_tasks + + async def test_send_newsletter(self) -> None: + """Test that newsletter is sent with unsubscribe link in body.""" + with ( + patch("app.api.newsletter.utils.emails.create_jwt_token", return_value=TEST_TOKEN) as mock_jwt, + patch( + "app.api.newsletter.utils.emails.generate_token_link", return_value="http://unsubscribe.link" + ) as mock_link, + patch("app.api.newsletter.utils.emails.send_email_with_template") as mock_send, + ): + await send_newsletter(TEST_EMAIL, TEST_SUBJECT, TEST_CONTENT) + + mock_jwt.assert_called_once() + mock_link.assert_called_once_with( + TEST_TOKEN, "newsletter/unsubscribe", base_url=mock_link.call_args.kwargs.get("base_url") + ) + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to_email"] == TEST_EMAIL + assert call_kwargs["subject"] == TEST_SUBJECT + assert call_kwargs["template_name"] == "newsletter.html" + assert call_kwargs["template_body"]["content"] == TEST_CONTENT + assert "unsubscribe_link" in call_kwargs["template_body"] + + async def test_send_newsletter_unsubscription_request_email(self) -> None: + """Test that unsubscription request email is sent with correct template.""" + with ( + patch("app.api.newsletter.utils.emails.generate_token_link", return_value="http://unsub.link") as mock_link, + patch("app.api.newsletter.utils.emails.send_email_with_template") as mock_send, + ): + await send_newsletter_unsubscription_request_email(TEST_EMAIL, TEST_TOKEN) + + mock_link.assert_called_once_with( + TEST_TOKEN, "newsletter/unsubscribe", base_url=mock_link.call_args.kwargs.get("base_url") + ) + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to_email"] == TEST_EMAIL + assert "Unsubscribe" in call_kwargs["subject"] + assert call_kwargs["template_name"] == "newsletter_unsubscribe.html" + assert "unsubscribe_link" in call_kwargs["template_body"] diff --git a/backend/tests/unit/newsletter/test_tokens.py b/backend/tests/unit/newsletter/test_tokens.py new file mode 100644 index 00000000..17664be1 --- /dev/null +++ b/backend/tests/unit/newsletter/test_tokens.py @@ -0,0 +1,87 @@ +"""Unit tests for newsletter tokens.""" +# spell-checker: ignore usefixtures + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from app.api.newsletter.utils.tokens import JWTType, create_jwt_token, verify_jwt_token + +if TYPE_CHECKING: + from collections.abc import Generator + +# Constants for magic values +TEST_EMAIL = "test@example.com" +TEST_SECRET = "test_secret" +INVALID_TOKEN = "invalid.token.string" +TTL_3600 = 3600 +TTL_7200 = 7200 + + +@pytest.fixture +def mock_settings() -> Generator[MagicMock]: + """Mock settings for newsletter token tests.""" + with patch("app.api.newsletter.utils.tokens.settings") as mocked_settings: + mocked_settings.newsletter_secret = MagicMock() + mocked_settings.newsletter_secret.get_secret_value.return_value = TEST_SECRET + mocked_settings.verification_token_ttl_seconds = TTL_3600 + mocked_settings.newsletter_unsubscription_token_ttl_seconds = TTL_7200 + yield mocked_settings + + +@pytest.mark.usefixtures("mock_settings") +def test_create_and_verify_token() -> None: + """Both token types round-trip correctly: created token verifies to the original email.""" + for token_type in (JWTType.NEWSLETTER_CONFIRMATION, JWTType.NEWSLETTER_UNSUBSCRIBE): + token = create_jwt_token(TEST_EMAIL, token_type) + assert verify_jwt_token(token, token_type) == TEST_EMAIL + + +@pytest.mark.usefixtures("mock_settings") +def test_verify_invalid_token() -> None: + """Test verification of an invalid token.""" + verified_email = verify_jwt_token(INVALID_TOKEN, JWTType.NEWSLETTER_CONFIRMATION) + assert verified_email is None + + +@pytest.mark.usefixtures("mock_settings") +def test_verify_wrong_token_type() -> None: + """Test verification of a token with the wrong type.""" + test_token = create_jwt_token(TEST_EMAIL, JWTType.NEWSLETTER_CONFIRMATION) + + # Try to verify as unsubscribe token + verified_email = verify_jwt_token(test_token, JWTType.NEWSLETTER_UNSUBSCRIBE) + assert verified_email is None + + +@pytest.mark.usefixtures("mock_settings") +def test_token_expiration() -> None: + """Test token expiration logic.""" + # Mock datetime to control time + fixed_now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=UTC) + + with patch("app.api.newsletter.utils.tokens.datetime") as mock_datetime: + mock_datetime.now.return_value = fixed_now + + test_token = create_jwt_token(TEST_EMAIL, JWTType.NEWSLETTER_CONFIRMATION) + assert test_token is not None + + # Verify immediately (should work) + # We move time slightly forward but within TTL (3600s) + mock_datetime.now.return_value = fixed_now + timedelta(seconds=1) + + # Create a token that is already expired + with patch("app.api.newsletter.utils.tokens.datetime") as mock_datetime_create: + # Set "now" to 2 hours ago + past_time = datetime.now(UTC) - timedelta(hours=2) + mock_datetime_create.now.return_value = past_time + + # This will create a token with exp = past_time + 3600s (still 1 hour ago) + expired_token = create_jwt_token(TEST_EMAIL, JWTType.NEWSLETTER_CONFIRMATION) + + # Verify now (should fail) + assert verify_jwt_token(expired_token, JWTType.NEWSLETTER_CONFIRMATION) is None diff --git a/backend/tests/unit/plugins/__init__.py b/backend/tests/unit/plugins/__init__.py new file mode 100644 index 00000000..4de506a6 --- /dev/null +++ b/backend/tests/unit/plugins/__init__.py @@ -0,0 +1 @@ +"""Unit tests for plugins.""" diff --git a/backend/tests/unit/plugins/rpi_cam/__init__.py b/backend/tests/unit/plugins/rpi_cam/__init__.py new file mode 100644 index 00000000..3ba983b4 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/__init__.py @@ -0,0 +1 @@ +"""Unit tests for RPi Cam plugin services.""" diff --git a/backend/tests/unit/plugins/rpi_cam/conftest.py b/backend/tests/unit/plugins/rpi_cam/conftest.py new file mode 100644 index 00000000..dbb3a675 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/conftest.py @@ -0,0 +1,114 @@ +"""Local fixtures for split RPi Cam unit tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.schemas import RelayPublicKeyJWK +from app.api.plugins.rpi_cam.services import YouTubeService +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from app.api.auth.models import User + + +@dataclass +class OAuthAccountStub: + """Typed OAuth account stub for service tests.""" + + access_token: str + refresh_token: str | None + expires_at: float | None + + +class GoogleOAuthClientStub: + """Typed Google OAuth client stub for service tests.""" + + def __init__(self) -> None: + self.refresh_token = AsyncMock() + + +class HTTPClientStub: + """Typed HTTP client stub for service tests.""" + + def __init__(self) -> None: + self.request = AsyncMock() + + +@pytest.fixture +def mock_google_oauth_client() -> GoogleOAuthClientStub: + """Return a mock Google OAuth client.""" + return GoogleOAuthClientStub() + + +@pytest.fixture +def mock_http_client() -> HTTPClientStub: + """Return a mock shared HTTP client.""" + return HTTPClientStub() + + +@pytest.fixture +def mock_oauth_account() -> OAuthAccountStub: + """Return a mock OAuth account.""" + return OAuthAccountStub( + access_token="fake_access_token", + refresh_token="fake_refresh_token", + expires_at=(datetime.now(UTC) + timedelta(hours=1)).timestamp(), + ) + + +@pytest.fixture +def youtube_service( + mock_oauth_account: OAuthAccountStub, + mock_google_oauth_client: GoogleOAuthClientStub, + mock_session: AsyncMock, + mock_http_client: HTTPClientStub, +) -> YouTubeService: + """Build a YouTubeService with stubbed dependencies.""" + return YouTubeService( + cast("Any", mock_oauth_account), + cast("Any", mock_google_oauth_client), + cast("Any", mock_session), + cast("Any", mock_http_client), + ) + + +@pytest.fixture +def mock_user() -> User: + """Return a mock user for stream-router tests.""" + user = UserFactory.build( + id=uuid4(), + email="test@example.com", + is_active=True, + is_verified=True, + hashed_password="hashed_password", + ) + assert user.id is not None + return user + + +@pytest.fixture +def mock_camera(mock_user: User) -> Camera: + """Return a mock camera for stream-router tests.""" + assert mock_user.id is not None + return Camera( + id=uuid4(), + name="Test Camera", + description="Test Camera", + relay_public_key_jwk=RelayPublicKeyJWK( + kty="EC", + crv="P-256", + x="x", + y="y", + kid="test-key-id", + ).model_dump(), + relay_key_id="test-key-id", + owner_id=mock_user.id, + ) diff --git a/backend/tests/unit/plugins/rpi_cam/service_test_support.py b/backend/tests/unit/plugins/rpi_cam/service_test_support.py new file mode 100644 index 00000000..acc1b2a2 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/service_test_support.py @@ -0,0 +1,102 @@ +"""Shared support code for split RPi Cam service tests.""" +# spell-checker: ignore excinfo + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.plugins.rpi_cam.services import YouTubeService + +FAKE_ACCESS_TOKEN = "fake_access_token" +FAKE_REFRESH_TOKEN = "fake_refresh_token" +NEW_FAKE_ACCESS_TOKEN = "new_fake_access_token" +FAKE_STREAM_NAME = "fake_stream_name" +FAKE_BROADCAST_ID = "fake_broadcast_id" +FAKE_STREAM_ID = "fake_stream_id" +TEST_STREAM_TITLE = "Test Stream" +CAPTURE_URL = "/fake_image.jpg" +CAPTURE_TIME = "2023-01-01T00:00:00Z" +IMG_BYTES = b"fake image bytes" + + +@dataclass +class OAuthAccountStub: + """Typed OAuth account stub for service tests.""" + + access_token: str + refresh_token: str | None + expires_at: float | None + + +class GoogleOAuthClientStub: + """Typed Google OAuth client stub for service tests.""" + + def __init__(self) -> None: + self.refresh_token = AsyncMock() + + +class SessionStub: + """Typed database session stub for service tests.""" + + def __init__(self) -> None: + self.add = MagicMock() + self.commit = AsyncMock() + self.delete = AsyncMock() + self.refresh = AsyncMock() + self.get = AsyncMock(return_value=None) + + +class HTTPClientStub: + """Typed HTTP client stub for service tests.""" + + def __init__(self) -> None: + self.request = AsyncMock() + + +@pytest.fixture +def mock_session() -> SessionStub: + """Return a mock database session.""" + return SessionStub() + + +@pytest.fixture +def mock_google_oauth_client() -> GoogleOAuthClientStub: + """Return a mock Google OAuth client.""" + return GoogleOAuthClientStub() + + +@pytest.fixture +def mock_http_client() -> HTTPClientStub: + """Return a mock shared HTTP client.""" + return HTTPClientStub() + + +@pytest.fixture +def mock_oauth_account() -> OAuthAccountStub: + """Return a mock OAuth account.""" + return OAuthAccountStub( + access_token=FAKE_ACCESS_TOKEN, + refresh_token=FAKE_REFRESH_TOKEN, + expires_at=(datetime.now(UTC) + timedelta(hours=1)).timestamp(), + ) + + +@pytest.fixture +def youtube_service( + mock_oauth_account: OAuthAccountStub, + mock_google_oauth_client: GoogleOAuthClientStub, + mock_session: SessionStub, + mock_http_client: HTTPClientStub, +) -> YouTubeService: + """Return a YouTubeService instance with typed stub dependencies.""" + return YouTubeService( + cast("Any", mock_oauth_account), + cast("Any", mock_google_oauth_client), + cast("Any", mock_session), + cast("Any", mock_http_client), + ) diff --git a/backend/tests/unit/plugins/rpi_cam/stream_router_test_support.py b/backend/tests/unit/plugins/rpi_cam/stream_router_test_support.py new file mode 100644 index 00000000..9dd95f14 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/stream_router_test_support.py @@ -0,0 +1,87 @@ +"""Shared support code for split RPi Cam stream-router tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from app.api.auth.models import User +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.schemas import RelayPublicKeyJWK +from app.api.plugins.rpi_cam.services import YoutubeStreamConfigWithID +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from uuid import UUID + +TEST_EMAIL = "test@example.com" +TEST_HASHED_PASSWORD = "hashed_password" +TEST_CAMERA_NAME = "Test Camera" +TEST_CAMERA_DESC = "Test Camera" +TEST_STREAM_URL = "http://stream.url" +YOUTUBE_STREAM_URL = "http://youtube.stream" +FAKE_ACCESS_TOKEN = "test" +FAKE_ACCOUNT_ID = "123" +FAKE_ACCOUNT_EMAIL = "test@test.com" +FAKE_STREAM_KEY = "key" +FAKE_BROADCAST_KEY = "bcast" +FAKE_STREAM_ID = "stream" +HTTP_OK = 200 +HTTP_NO_CONTENT = 204 + + +def require_uuid(value: UUID | None) -> UUID: + """Narrow optional UUID values produced by Pydantic models.""" + assert value is not None + return value + + +def build_user() -> User: + """Build a user for stream router tests.""" + user = UserFactory.build( + id=uuid4(), + email=TEST_EMAIL, + is_active=True, + is_verified=True, + hashed_password=TEST_HASHED_PASSWORD, + ) + assert user.id is not None + return user + + +@pytest.fixture +def mock_user() -> User: + """Return a mock user for testing.""" + return build_user() + + +@pytest.fixture +def mock_camera(mock_user: User) -> Camera: + """Return a mock camera for testing.""" + owner_id = require_uuid(mock_user.id) + return Camera( + id=uuid4(), + name=TEST_CAMERA_NAME, + description=TEST_CAMERA_DESC, + relay_public_key_jwk=RelayPublicKeyJWK( + kty="EC", + crv="P-256", + x="x", + y="y", + kid="test-key-id", + ).model_dump(), + relay_key_id="test-key-id", + owner_id=owner_id, + ) + + +def build_stream_config() -> YoutubeStreamConfigWithID: + """Return a consistent fake YouTube stream configuration.""" + return YoutubeStreamConfigWithID( + stream_key=SecretStr(FAKE_STREAM_KEY), + broadcast_key=SecretStr(FAKE_BROADCAST_KEY), + stream_id=FAKE_STREAM_ID, + ) diff --git a/backend/tests/unit/plugins/rpi_cam/test_camera_crud.py b/backend/tests/unit/plugins/rpi_cam/test_camera_crud.py new file mode 100644 index 00000000..c0d2b409 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_camera_crud.py @@ -0,0 +1,73 @@ +"""Unit tests for Raspberry Pi camera CRUD routes.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from fastapi import BackgroundTasks + +from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus, CameraStatus +from app.api.plugins.rpi_cam.routers.camera_crud import _notify_camera_unpair, delete_user_camera + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +async def test_delete_user_camera_schedules_unpair_notification() -> None: + """Deleting a camera should commit first and queue the unpair notification in the background.""" + camera_id = uuid4() + camera = cast("Camera", SimpleNamespace(id=camera_id)) + session = AsyncMock() + background_tasks = MagicMock(spec=BackgroundTasks) + redis = cast("Redis | None", object()) + + with patch("app.api.plugins.rpi_cam.routers.camera_crud._notify_camera_unpair") as mock_notify: + await delete_user_camera( + background_tasks=background_tasks, + db=session, + camera=camera, + redis=redis, + ) + + session.delete.assert_awaited_once_with(camera) + session.commit.assert_awaited_once() + assert background_tasks.add_task.call_args_list[0].args[0] is mock_notify + assert background_tasks.add_task.call_args_list[0].args[1:] == (camera_id, redis) + mock_notify.assert_not_called() + + +async def test_notify_camera_unpair_skips_relay_when_camera_is_offline() -> None: + """Offline cameras should not wait on relay timeout during delete cleanup.""" + camera_id = uuid4() + redis = cast("Redis | None", object()) + + with ( + patch( + "app.api.plugins.rpi_cam.routers.camera_crud.fetch_camera_status", + new=AsyncMock(return_value=CameraStatus(connection=CameraConnectionStatus.OFFLINE)), + ), + patch("app.api.plugins.rpi_cam.routers.camera_crud.relay_via_websocket", new=AsyncMock()) as relay_mock, + ): + await _notify_camera_unpair(camera_id, redis) + + relay_mock.assert_not_awaited() + + +async def test_notify_camera_unpair_relays_when_camera_is_online() -> None: + """Online cameras should still receive the best-effort unpair command.""" + camera_id = uuid4() + redis = cast("Redis | None", object()) + + with ( + patch( + "app.api.plugins.rpi_cam.routers.camera_crud.fetch_camera_status", + new=AsyncMock(return_value=CameraStatus(connection=CameraConnectionStatus.ONLINE)), + ), + patch("app.api.plugins.rpi_cam.routers.camera_crud.relay_via_websocket", new=AsyncMock()) as relay_mock, + ): + await _notify_camera_unpair(camera_id, redis) + + relay_mock.assert_awaited_once_with(camera_id, "DELETE", "/pairing", redis=redis) diff --git a/backend/tests/unit/plugins/rpi_cam/test_camera_interaction_utils.py b/backend/tests/unit/plugins/rpi_cam/test_camera_interaction_utils.py new file mode 100644 index 00000000..ca0fd3e5 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_camera_interaction_utils.py @@ -0,0 +1,153 @@ +"""Unit tests for camera interaction utilities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus, CameraCredentialStatus, CameraStatus +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( + HttpMethod, + fetch_from_camera_url, + get_user_owned_camera, +) + + +def build_camera() -> Camera: + """Build a minimal WebSocket-relayed camera for testing.""" + return Camera( + id=uuid4(), + name="Test Camera", + relay_public_key_jwk={"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}, + relay_key_id="test-key-id", + relay_credential_status=CameraCredentialStatus.ACTIVE, + owner_id=uuid4(), + ) + + +async def test_fetch_from_camera_url_delegates_to_relay(monkeypatch: pytest.MonkeyPatch) -> None: + """fetch_from_camera_url should delegate entirely to relay_via_websocket.""" + camera = build_camera() + mock_relay = AsyncMock(return_value=AsyncMock(status_code=200)) + monkeypatch.setattr( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.relay_via_websocket", + mock_relay, + ) + + await fetch_from_camera_url(camera, endpoint="/camera", method=HttpMethod.GET) + + mock_relay.assert_awaited_once_with( + camera.id, + "GET", + "/camera", + body=None, + error_msg=None, + expect_binary=False, + redis=None, + ) + + +async def test_fetch_from_camera_url_passes_body_and_flags(monkeypatch: pytest.MonkeyPatch) -> None: + """fetch_from_camera_url should forward body, error_msg, and expect_binary.""" + camera = build_camera() + mock_relay = AsyncMock(return_value=AsyncMock(status_code=200)) + monkeypatch.setattr( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.relay_via_websocket", + mock_relay, + ) + + await fetch_from_camera_url( + camera, + endpoint="/captures", + method=HttpMethod.POST, + error_msg="Failed", + body={"key": "val"}, + expect_binary=True, + ) + + mock_relay.assert_awaited_once_with( + camera.id, + "POST", + "/captures", + body={"key": "val"}, + error_msg="Failed", + expect_binary=True, + redis=None, + ) + + +async def test_get_user_owned_camera_returns_camera_when_online() -> None: + """Should return the camera when it is online.""" + camera = build_camera() + session = AsyncMock() + user_id = uuid4() + redis = AsyncMock() + get_status_mock = AsyncMock(return_value=CameraStatus(connection=CameraConnectionStatus.ONLINE)) + + with ( + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_user_owned_object", + new=AsyncMock(return_value=camera), + ), + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_camera_status", + new=get_status_mock, + ), + ): + result = await get_user_owned_camera(session, camera.id, user_id, redis) + + assert result is camera + get_status_mock.assert_awaited_once_with(redis, camera.id) + + +async def test_get_user_owned_camera_raises_503_when_offline() -> None: + """Should raise HTTP 503 when the camera is offline.""" + camera = build_camera() + session = AsyncMock() + user_id = uuid4() + redis = AsyncMock() + get_status_mock = AsyncMock(return_value=CameraStatus(connection=CameraConnectionStatus.OFFLINE)) + + with ( + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_user_owned_object", + new=AsyncMock(return_value=camera), + ), + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_camera_status", + new=get_status_mock, + ), + pytest.raises(HTTPException) as exc_info, + ): + await get_user_owned_camera(session, camera.id, user_id, redis) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Camera is offline" + get_status_mock.assert_awaited_once_with(redis, camera.id) + + +async def test_get_user_owned_camera_raises_401_when_unauthorized() -> None: + """Should raise HTTP 401 when the camera returns unauthorized status.""" + camera = build_camera() + session = AsyncMock() + user_id = uuid4() + redis = AsyncMock() + get_status_mock = AsyncMock(return_value=CameraStatus(connection=CameraConnectionStatus.UNAUTHORIZED)) + + with ( + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_user_owned_object", + new=AsyncMock(return_value=camera), + ), + patch( + "app.api.plugins.rpi_cam.routers.camera_interaction.utils.get_camera_status", + new=get_status_mock, + ), + pytest.raises(HTTPException) as exc_info, + ): + await get_user_owned_camera(session, camera.id, user_id, redis) + + assert exc_info.value.status_code == 401 diff --git a/backend/tests/unit/plugins/rpi_cam/test_config.py b/backend/tests/unit/plugins/rpi_cam/test_config.py new file mode 100644 index 00000000..776c16a5 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_config.py @@ -0,0 +1,23 @@ +"""Unit tests for the Raspberry Pi Camera plugin configuration.""" + +from app.api.plugins.rpi_cam.config import RPiCamSettings + + +class TestRPiCamSettingsDefaults: + """RPiCamSettings should produce safe defaults when no env file is present.""" + + def test_plugin_secret_accepts_empty_value(self) -> None: + """Plugin secret can be explicitly set to empty string (safe for dev).""" + settings = RPiCamSettings(rpi_cam_plugin_secret="") + assert settings.rpi_cam_plugin_secret == "" + + +class TestRPiCamSettingsOverrides: + """RPiCamSettings should accept constructor-level overrides.""" + + def test_plugin_secret_can_be_set(self) -> None: + """A Fernet key supplied via constructor is stored correctly.""" + # Valid 32-byte URL-safe base64 Fernet key (test-only, not used for real encryption) + fernet_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + settings = RPiCamSettings(rpi_cam_plugin_secret=fernet_key) + assert settings.rpi_cam_plugin_secret == fernet_key diff --git a/backend/tests/unit/plugins/rpi_cam/test_cross_worker_relay.py b/backend/tests/unit/plugins/rpi_cam/test_cross_worker_relay.py new file mode 100644 index 00000000..b1e97253 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_cross_worker_relay.py @@ -0,0 +1,353 @@ +"""Unit tests for the cross-worker relay Redis bridge. + +The relay serialises relay commands between Uvicorn workers via Redis lists. +These tests exercise the serialization, deadline, timeout, and binary-payload +contract without standing up a real Redis (a mock Redis is sufficient to +validate the module's own logic — the Redis driver itself is not under test). +""" +# spell-checker: ignore blpop, rpush + +# ruff: noqa: SLF001 — private helpers (_resp_ttl_seconds, _execute_and_respond, …) are the subject under test + +from __future__ import annotations + +import asyncio +import base64 +import json +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.api.plugins.rpi_cam.websocket import cross_worker_relay as cwr + + +@pytest.fixture(autouse=True) +def _clear_blocking_redis() -> None: + """Reset the module-level blocking-Redis singleton between tests.""" + cwr.set_blocking_redis(None) + + +def _mock_redis() -> MagicMock: + redis = MagicMock() + redis.rpush = AsyncMock(return_value=1) + redis.ltrim = AsyncMock(return_value=True) + redis.expire = AsyncMock(return_value=True) + redis.blpop = AsyncMock() + return redis + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +class TestRespTtl: + """Cover the response-key TTL bounds helper.""" + + def test_floor(self) -> None: + """Below-floor timeouts use the configured minimum TTL.""" + assert cwr._resp_ttl_seconds(1.0) == cwr._RESP_TTL_MIN_SECONDS + + def test_above_floor_uses_timeout_plus_margin(self) -> None: + """Above the floor, TTL follows the request timeout plus a small margin.""" + assert cwr._resp_ttl_seconds(200) == 210 + + +class TestBlockingRedisSingleton: + """Cover the blocking-Redis singleton registration.""" + + def test_round_trip(self) -> None: + """set_blocking_redis / get_blocking_redis round-trip the registered client.""" + assert cwr.get_blocking_redis() is None + sentinel = MagicMock(name="redis") + cwr.set_blocking_redis(sentinel) + assert cwr.get_blocking_redis() is sentinel + + +# ── relay_cross_worker ─────────────────────────────────────────────────────── + + +class TestRelayCrossWorker: + """Cover the requesting-worker side of the relay: command push + response wait.""" + + async def test_happy_path_roundtrip(self) -> None: + """A valid response with binary payload round-trips cleanly back to the caller.""" + redis = _mock_redis() + binary = b"\x00\x01\x02\x03" + response = { + "status": 200, + "data": {"ok": True}, + "binary_b64": base64.b64encode(binary).decode(), + } + redis.blpop.return_value = ("key", json.dumps(response)) + + json_resp, got_binary = await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/hls/segment", + params=None, + body=None, + headers=None, + timeout_s=30, + ) + + assert json_resp == {"status": 200, "data": {"ok": True}} + assert got_binary == binary + redis.rpush.assert_awaited_once() + redis.ltrim.assert_awaited_once() + + async def test_command_payload_shape(self) -> None: + """The pushed command must carry the fields the listener reads.""" + redis = _mock_redis() + redis.blpop.return_value = ("key", json.dumps({"status": 200, "data": {}})) + camera_id = uuid4() + + await cwr.relay_cross_worker( + redis, + camera_id, + "POST", + "/capture", + params={"q": "1"}, + body={"name": "x"}, + headers={"X-Test": "1"}, + timeout_s=10, + ) + + cmd_key, raw_cmd = redis.rpush.await_args.args + assert cmd_key == f"rpi_cam:relay_cmd:{camera_id}" + cmd = json.loads(raw_cmd) + assert cmd["method"] == "POST" + assert cmd["path"] == "/capture" + assert cmd["params"] == {"q": "1"} + assert cmd["body"] == {"name": "x"} + assert cmd["headers"] == {"X-Test": "1"} + assert cmd["timeout_s"] == 10 + assert "msg_id" in cmd + assert "deadline" in cmd + + async def test_timeout_raises_runtime_error(self) -> None: + """If BLPOP never returns within the deadline, the caller sees RuntimeError (→ HTTP 503).""" + redis = _mock_redis() + + async def _hang(*_a: object, **_kw: object) -> None: + await asyncio.sleep(10) + + redis.blpop.side_effect = _hang + + with pytest.raises(RuntimeError, match="timed out"): + await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/x", + None, + None, + None, + timeout_s=0.05, + ) + + async def test_blpop_none_raises(self) -> None: + """A None result from BLPOP is treated as a timeout.""" + redis = _mock_redis() + redis.blpop.return_value = None + with pytest.raises(RuntimeError, match="timed out"): + await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/x", + None, + None, + None, + timeout_s=1, + ) + + async def test_malformed_response_raises(self) -> None: + """Corrupt response JSON on the wire is reported as a relay failure.""" + redis = _mock_redis() + redis.blpop.return_value = ("key", "{not-json") + with pytest.raises(RuntimeError, match="malformed response"): + await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/x", + None, + None, + None, + timeout_s=1, + ) + + async def test_error_field_propagates(self) -> None: + """An ``error`` field in the response surfaces as a RuntimeError carrying the remote message.""" + redis = _mock_redis() + redis.blpop.return_value = ("key", json.dumps({"error": "camera gone"})) + with pytest.raises(RuntimeError, match="camera gone"): + await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/x", + None, + None, + None, + timeout_s=1, + ) + + async def test_bad_base64_raises(self) -> None: + """Binary payloads with invalid base64 are rejected (don't silently corrupt data).""" + redis = _mock_redis() + redis.blpop.return_value = ( + "key", + json.dumps({"status": 200, "data": {}, "binary_b64": "!!!not-base64!!!"}), + ) + with pytest.raises(RuntimeError, match="binary payload"): + await cwr.relay_cross_worker( + redis, + uuid4(), + "GET", + "/x", + None, + None, + None, + timeout_s=1, + ) + + +# ── _execute_and_respond ───────────────────────────────────────────────────── + + +class TestExecuteAndRespond: + """Cover the camera-owning-worker side: execute one relayed command and push the result.""" + + async def test_success_pushes_response_and_expire(self) -> None: + """On success the JSON response and any binary payload are pushed to the per-msg response list.""" + redis = _mock_redis() + manager = MagicMock() + binary = b"payload" + manager.send_command = AsyncMock(return_value=({"status": 200, "data": {"k": 1}}, binary)) + cmd = {"msg_id": "m1", "method": "GET", "path": "/p", "timeout_s": 30} + + await cwr._execute_and_respond(redis, uuid4(), manager, cmd, "m1") + + redis.rpush.assert_awaited_once() + redis.expire.assert_awaited_once() + resp_key, raw = redis.rpush.await_args.args + assert resp_key == "rpi_cam:relay_resp:m1" + assert json.loads(raw) == { + "status": 200, + "data": {"k": 1}, + "binary_b64": base64.b64encode(binary).decode(), + } + + async def test_camera_disconnected_writes_error(self) -> None: + """RuntimeError from send_command (camera gone) is serialised as an ``error`` payload.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock(side_effect=RuntimeError("socket closed")) + cmd = {"msg_id": "m2", "method": "GET", "path": "/p", "timeout_s": 5} + + await cwr._execute_and_respond(redis, uuid4(), manager, cmd, "m2") + + raw = redis.rpush.await_args.args[1] + assert json.loads(raw) == {"error": "socket closed"} + + async def test_unexpected_exception_writes_internal_error(self) -> None: + """Non-RuntimeError failures are wrapped as ``Internal relay error`` so callers still unblock.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock(side_effect=ValueError("boom")) + cmd = {"msg_id": "m3", "method": "GET", "path": "/p", "timeout_s": 5} + + await cwr._execute_and_respond(redis, uuid4(), manager, cmd, "m3") + + raw = redis.rpush.await_args.args[1] + assert json.loads(raw) == {"error": "Internal relay error: boom"} + + +# ── run_relay_listener ─────────────────────────────────────────────────────── + + +class TestRunRelayListener: + """Cover the listener's pre-processing: filter bad / stale commands before executing them.""" + + async def test_skips_expired_command(self) -> None: + """Commands whose deadline is in the past must be dropped before dispatch.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock() + + expired_cmd = {"msg_id": "m", "deadline": 1.0, "method": "GET", "path": "/"} + responses: list[object] = [("k", json.dumps(expired_cmd))] + + async def _blpop(*_a: object, **_kw: object) -> object: + if responses: + return responses.pop(0) + raise asyncio.CancelledError + + redis.blpop.side_effect = _blpop + + await cwr.run_relay_listener(redis, uuid4(), manager) + + manager.send_command.assert_not_called() + + async def test_skips_malformed_json(self) -> None: + """A malformed JSON command should be caught and skipped, not crash the listener.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock() + responses: list[object] = [("k", "{not-json")] + + async def _blpop(*_a: object, **_kw: object) -> object: + if responses: + return responses.pop(0) + raise asyncio.CancelledError + + redis.blpop.side_effect = _blpop + await cwr.run_relay_listener(redis, uuid4(), manager) + manager.send_command.assert_not_called() + + async def test_skips_missing_msg_id(self) -> None: + """Commands without a msg_id cannot be replied to, so they must be skipped.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock() + responses: list[object] = [("k", json.dumps({"method": "GET", "path": "/"}))] + + async def _blpop(*_a: object, **_kw: object) -> object: + if responses: + return responses.pop(0) + raise asyncio.CancelledError + + redis.blpop.side_effect = _blpop + await cwr.run_relay_listener(redis, uuid4(), manager) + manager.send_command.assert_not_called() + + async def test_dispatches_valid_command(self) -> None: + """A well-formed command with a future deadline should be dispatched to the manager.""" + redis = _mock_redis() + manager = MagicMock() + manager.send_command = AsyncMock(return_value=({"status": 200, "data": {}}, None)) + + cmd = { + "msg_id": "m1", + "method": "GET", + "path": "/hls", + "params": None, + "body": None, + "headers": {}, + "deadline": 0, # 0 means "no deadline" per module convention + "timeout_s": 30, + } + responses: list[object] = [("k", json.dumps(cmd))] + + async def _blpop(*_a: object, **_kw: object) -> object: + if responses: + return responses.pop(0) + raise asyncio.CancelledError + + redis.blpop.side_effect = _blpop + await cwr.run_relay_listener(redis, uuid4(), manager) + + manager.send_command.assert_awaited_once() + assert any(call.args[0] == "rpi_cam:relay_resp:m1" for call in redis.rpush.await_args_list) diff --git a/backend/tests/unit/plugins/rpi_cam/test_dependencies.py b/backend/tests/unit/plugins/rpi_cam/test_dependencies.py new file mode 100644 index 00000000..9397c8ff --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_dependencies.py @@ -0,0 +1,161 @@ +"""Unit tests for RPi Cam router dependencies.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, call, patch + +import pytest + +from app.api.auth.exceptions import UserHasNoOrgError, UserIsNotMemberError +from app.api.auth.models import OrganizationRole +from app.api.plugins.rpi_cam.dependencies import get_camera_transfer_owner_id +from app.api.plugins.rpi_cam.exceptions import InvalidCameraOwnershipTransferError +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.schemas import CameraUpdate +from tests.factories.models import UserFactory + + +def build_camera(*, owner_id: uuid.UUID) -> Camera: + """Build a camera instance for dependency tests.""" + return Camera( + name="Camera", + owner_id=owner_id, + relay_public_key_jwk={"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}, + relay_key_id="test-key-id", + ) + + +async def test_get_camera_transfer_owner_id_returns_none_when_owner_unchanged() -> None: + """No extra lookup is needed if the request does not include owner_id.""" + session = AsyncMock() + camera = build_camera(owner_id=uuid.uuid4()) + camera_in = CameraUpdate(name="Updated") + + with patch("app.api.plugins.rpi_cam.dependencies.require_model", new=AsyncMock()) as mock_get_model: + result = await get_camera_transfer_owner_id(camera_in, camera, session) + + assert result is None + mock_get_model.assert_not_awaited() + + +async def test_get_camera_transfer_owner_id_allows_same_org_transfer() -> None: + """Ownership transfer is allowed within the same organization.""" + session = AsyncMock() + org_id = uuid.uuid4() + current_owner = UserFactory.build( + id=uuid.uuid4(), + email="owner@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=org_id, + organization_role=OrganizationRole.OWNER, + ) + target_owner = UserFactory.build( + id=uuid.uuid4(), + email="target@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=org_id, + organization_role=OrganizationRole.MEMBER, + ) + camera = build_camera(owner_id=current_owner.id) + camera_in = CameraUpdate.model_validate({"owner_id": target_owner.id}) + + with patch( + "app.api.plugins.rpi_cam.dependencies.require_model", + new=AsyncMock(side_effect=[current_owner, target_owner]), + ) as mock_get_model: + result = await get_camera_transfer_owner_id(camera_in, camera, session) + + assert result == target_owner.id + assert mock_get_model.await_args_list == [ + call(session, type(current_owner), current_owner.id), + call(session, type(target_owner), target_owner.id), + ] + + +async def test_get_camera_transfer_owner_id_rejects_null_owner_transfer() -> None: + """Explicit null ownership changes are rejected.""" + session = AsyncMock() + camera = build_camera(owner_id=uuid.uuid4()) + camera_in = CameraUpdate.model_validate({"owner_id": None}) + + with pytest.raises(InvalidCameraOwnershipTransferError, match="owner_id must reference an existing user"): + await get_camera_transfer_owner_id(camera_in, camera, session) + + +async def test_get_camera_transfer_owner_id_rejects_transfer_to_other_org() -> None: + """Ownership transfer must stay within the same organization.""" + session = AsyncMock() + current_owner = UserFactory.build( + id=uuid.uuid4(), + email="owner@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=uuid.uuid4(), + organization_role=OrganizationRole.OWNER, + ) + target_owner = UserFactory.build( + id=uuid.uuid4(), + email="other@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=uuid.uuid4(), + organization_role=OrganizationRole.MEMBER, + ) + camera = build_camera(owner_id=current_owner.id) + camera_in = CameraUpdate.model_validate({"owner_id": target_owner.id}) + + with ( + patch( + "app.api.plugins.rpi_cam.dependencies.require_model", + new=AsyncMock(side_effect=[current_owner, target_owner]), + ), + pytest.raises(UserIsNotMemberError), + ): + await get_camera_transfer_owner_id(camera_in, camera, session) + + +async def test_get_camera_transfer_owner_id_rejects_owner_without_org() -> None: + """Ownership transfer is rejected if the current owner has no organization.""" + session = AsyncMock() + current_owner = UserFactory.build( + id=uuid.uuid4(), + email="owner@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=None, + organization_role=None, + ) + target_owner = UserFactory.build( + id=uuid.uuid4(), + email="target@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + organization_id=uuid.uuid4(), + organization_role=OrganizationRole.MEMBER, + ) + camera = build_camera(owner_id=current_owner.id) + camera_in = CameraUpdate.model_validate({"owner_id": target_owner.id}) + + with ( + patch( + "app.api.plugins.rpi_cam.dependencies.require_model", + new=AsyncMock(side_effect=[current_owner, target_owner]), + ), + pytest.raises(UserHasNoOrgError), + ): + await get_camera_transfer_owner_id(camera_in, camera, session) diff --git a/backend/tests/unit/plugins/rpi_cam/test_device_assertion.py b/backend/tests/unit/plugins/rpi_cam/test_device_assertion.py new file mode 100644 index 00000000..9ec939e8 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_device_assertion.py @@ -0,0 +1,229 @@ +"""Unit tests for the device-assertion module's missing branches. + +The happy/unhappy paths of :func:`verify_device_assertion` are covered in +``test_websocket_router.py``; this module fills the remaining gaps: +missing ``jti``, bearer extraction, and the ``_authenticated_camera`` FastAPI +dependency. +""" +# ruff: noqa: SLF001 — private members are the subject under test + +from __future__ import annotations + +import base64 +import secrets +import time +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from fastapi import HTTPException, status + +from app.api.plugins.rpi_cam import device_assertion as da + + +def _make_keypair() -> tuple[ec.EllipticCurvePrivateKey, dict]: + private_key = ec.generate_private_key(ec.SECP256R1()) + pub = private_key.public_key().public_numbers() + + def _b64(n: int) -> str: + return base64.urlsafe_b64encode(n.to_bytes(32, "big")).rstrip(b"=").decode() + + return private_key, {"kty": "EC", "crv": "P-256", "x": _b64(pub.x), "y": _b64(pub.y)} + + +def _make_camera(key_id: str = "kid-1", *, active: bool = True) -> tuple[MagicMock, ec.EllipticCurvePrivateKey]: + """Build a camera mock + its signing key. The key is returned separately to avoid poking private attrs.""" + private_key, jwk = _make_keypair() + camera = MagicMock() + camera.id = uuid4() + camera.relay_key_id = key_id + camera.relay_public_key_jwk = jwk + camera.credential_is_active = active + return camera, private_key + + +def _sign( + camera: MagicMock, + private_key: ec.EllipticCurvePrivateKey, + *, + jti: str | None = None, + exp_offset: int = 120, + omit_jti: bool = False, +) -> str: + now = int(time.time()) + payload: dict = { + "iss": f"camera:{camera.id}", + "sub": f"camera:{camera.id}", + "aud": da.ASSERTION_AUDIENCE, + "iat": now, + "nbf": now, + "exp": now + exp_offset, + } + if not omit_jti: + payload["jti"] = jti if jti is not None else secrets.token_urlsafe(24) + return jwt.encode( + payload, + private_key, + algorithm="ES256", + headers={"kid": camera.relay_key_id}, + ) + + +# ── verify_device_assertion: missing jti ───────────────────────────────────── + + +class TestVerifyMissingJti: + """Cover the explicit jti-string validation that runs after PyJWT's decode.""" + + async def test_empty_jti_rejected(self) -> None: + """An empty jti claim should be rejected before Redis is consulted.""" + camera, private_key = _make_camera() + redis = AsyncMock() + assertion = _sign(camera, private_key, jti="") + with pytest.raises(jwt.InvalidTokenError): + await da.verify_device_assertion(assertion, camera, redis) + redis.set.assert_not_called() + + +# ── TTL helper ─────────────────────────────────────────────────────────────── + + +class TestAssertionReplayTtl: + """Cover the replay-window TTL bounds helper.""" + + def test_clamps_to_max(self) -> None: + """TTLs far in the future are clamped to the configured maximum.""" + now = int(time.time()) + huge_exp = now + 10 * da.MAX_ASSERTION_TTL_SECONDS + assert da._assertion_replay_ttl({"exp": huge_exp}) == da.MAX_ASSERTION_TTL_SECONDS + + def test_floor_of_one(self) -> None: + """Already-expired tokens still use a TTL >= 1 so SET NX succeeds.""" + now = int(time.time()) + assert da._assertion_replay_ttl({"exp": now - 100}) == 1 + + +# ── _extract_bearer ────────────────────────────────────────────────────────── + + +class TestExtractBearer: + """Cover bearer-token extraction from the Authorization header.""" + + async def test_extracts_token(self) -> None: + """A valid Bearer header yields the raw token.""" + request = MagicMock() + request.headers = {"Authorization": "Bearer abc.def.ghi"} + assert await da._extract_bearer(request) == "abc.def.ghi" + + async def test_missing_header_raises_401(self) -> None: + """Requests without an Authorization header get a 401 + WWW-Authenticate challenge.""" + request = MagicMock() + request.headers = {} + with pytest.raises(HTTPException) as exc_info: + await da._extract_bearer(request) + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert exc_info.value.headers == {"WWW-Authenticate": "Bearer"} + + async def test_bearer_prefix_only_raises(self) -> None: + """A Bearer header with no token body is treated the same as a missing header.""" + request = MagicMock() + request.headers = {"Authorization": "Bearer "} + with pytest.raises(HTTPException): + await da._extract_bearer(request) + + +# ── _authenticated_camera dependency ───────────────────────────────────────── + + +def _request_with_auth(assertion: str = "placeholder") -> MagicMock: + request = MagicMock() + request.headers = {"Authorization": f"Bearer {assertion}"} + return request + + +class TestAuthenticatedCameraDep: + """Cover the FastAPI dependency that resolves and authenticates a camera per request.""" + + async def test_unknown_camera_returns_401(self) -> None: + """If the camera row doesn't exist, the dep returns 401 (don't leak existence).""" + session = MagicMock() + session.get = AsyncMock(return_value=None) + request = _request_with_auth() + + with pytest.raises(HTTPException) as exc_info: + await da._authenticated_camera(request, uuid4(), session) + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_inactive_credential_returns_401(self) -> None: + """A camera whose credential has been deactivated must not authenticate.""" + camera, _ = _make_camera(active=False) + session = MagicMock() + session.get = AsyncMock(return_value=camera) + request = _request_with_auth() + + with pytest.raises(HTTPException) as exc_info: + await da._authenticated_camera(request, camera.id, session) + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_redis_unavailable_returns_503(self) -> None: + """Without Redis we cannot enforce replay protection, so we fail closed with 503.""" + camera, _ = _make_camera() + session = MagicMock() + session.get = AsyncMock(return_value=camera) + request = _request_with_auth() + + with ( + patch.object(da, "get_connection_redis", return_value=None), + pytest.raises(HTTPException) as exc_info, + ): + await da._authenticated_camera(request, camera.id, session) + assert exc_info.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + async def test_invalid_assertion_returns_401(self) -> None: + """A malformed JWT in the Authorization header is rejected with 401.""" + camera, _ = _make_camera() + session = MagicMock() + session.get = AsyncMock(return_value=camera) + redis = AsyncMock() + request = _request_with_auth("not-a-jwt") + + with ( + patch.object(da, "get_connection_redis", return_value=redis), + pytest.raises(HTTPException) as exc_info, + ): + await da._authenticated_camera(request, camera.id, session) + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_valid_assertion_returns_camera(self) -> None: + """A valid, unseen assertion authenticates and returns the camera row.""" + camera, private_key = _make_camera() + session = MagicMock() + session.get = AsyncMock(return_value=camera) + redis = AsyncMock() + redis.set = AsyncMock(return_value=True) + assertion = _sign(camera, private_key) + request = _request_with_auth(assertion) + + with patch.object(da, "get_connection_redis", return_value=redis): + result = await da._authenticated_camera(request, camera.id, session) + assert result is camera + redis.set.assert_awaited_once() + + async def test_replayed_assertion_returns_401(self) -> None: + """Re-use of a previously-seen jti is rejected as replay and surfaces as 401.""" + camera, private_key = _make_camera() + session = MagicMock() + session.get = AsyncMock(return_value=camera) + redis = AsyncMock() + redis.set = AsyncMock(return_value=None) # nx failed → replay + assertion = _sign(camera, private_key) + request = _request_with_auth(assertion) + + with ( + patch.object(da, "get_connection_redis", return_value=redis), + pytest.raises(HTTPException) as exc_info, + ): + await da._authenticated_camera(request, camera.id, session) + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/backend/tests/unit/plugins/rpi_cam/test_encryption.py b/backend/tests/unit/plugins/rpi_cam/test_encryption.py new file mode 100644 index 00000000..dbec3a28 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_encryption.py @@ -0,0 +1,102 @@ +"""Unit tests for the RPi Cam Fernet encryption helpers.""" +# spell-checker: ignore usefixtures + +from __future__ import annotations + +import pytest +from cryptography.fernet import Fernet, InvalidToken + +from app.api.plugins.rpi_cam.utils import encryption + +VALID_KEY = Fernet.generate_key().decode() + + +@pytest.fixture +def _configured_secret(monkeypatch: pytest.MonkeyPatch) -> str: + """Set a valid Fernet secret on the plugin settings singleton.""" + monkeypatch.setattr(encryption.settings, "rpi_cam_plugin_secret", VALID_KEY) + return VALID_KEY + + +@pytest.mark.usefixtures("_configured_secret") +class TestStringRoundTrip: + """Encrypting and decrypting a string should return the original string.""" + + def test_encrypt_decrypt_returns_original(self) -> None: + """A string encrypted and then decrypted should yield the original string.""" + plaintext = "CAM_super-secret-api-key" + ciphertext = encryption.encrypt_str(plaintext) + assert ciphertext != plaintext + assert encryption.decrypt_str(ciphertext) == plaintext + + def test_encrypt_is_non_deterministic(self) -> None: + """Fernet embeds a random IV; two encryptions of the same plaintext must differ.""" + assert encryption.encrypt_str("same") != encryption.encrypt_str("same") + + +@pytest.mark.usefixtures("_configured_secret") +class TestDictRoundTrip: + """Encrypting and decrypting a dict should preserve structure and content.""" + + def test_encrypt_decrypt_preserves_structure(self) -> None: + """A dict encrypted and then decrypted should yield the original dict, even when nested.""" + payload = {"kid": "abc", "nested": {"n": 1}, "list": [1, 2, 3]} + ciphertext = encryption.encrypt_dict(payload) + assert encryption.decrypt_dict(ciphertext) == payload + + def test_decrypt_dict_raises_on_tampered_token(self) -> None: + """A tampered token (one whose MAC doesn't verify) should raise a controlled RuntimeError.""" + ciphertext = encryption.encrypt_dict({"k": "v"}) + # Flip a character in the middle to corrupt the MAC. + tampered = ciphertext[:-4] + ("A" if ciphertext[-1] != "A" else "B") + ciphertext[-3:] + with pytest.raises(RuntimeError, match="Failed to decrypt"): + encryption.decrypt_dict(tampered) + + def test_decrypt_dict_raises_on_garbage_input(self) -> None: + """A non-Fernet token surfaces as a controlled RuntimeError, not a cryptography exception.""" + with pytest.raises(RuntimeError, match="Failed to decrypt"): + encryption.decrypt_dict("not-a-fernet-token") + + +class TestSecretConfiguration: + """Tests for handling of the Fernet secret configuration.""" + + def test_missing_secret_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing secret is a config error that must be fixed by the operator; fail loudly.""" + monkeypatch.setattr(encryption.settings, "rpi_cam_plugin_secret", "") + with pytest.raises(RuntimeError, match="not configured"): + encryption.encrypt_str("x") + + def test_invalid_secret_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The Fernet key must be a url-safe base64-encoded 32-byte value; anything else is a config error.""" + monkeypatch.setattr(encryption.settings, "rpi_cam_plugin_secret", "not-a-valid-fernet-key") + with pytest.raises(RuntimeError, match="url-safe base64 Fernet key"): + encryption.encrypt_str("x") + + def test_ciphertext_from_one_key_fails_under_another(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Rotating keys without re-encrypting must surface as a decrypt failure, not silent mis-data.""" + monkeypatch.setattr(encryption.settings, "rpi_cam_plugin_secret", VALID_KEY) + ciphertext = encryption.encrypt_str("secret") + + other_key = Fernet.generate_key().decode() + monkeypatch.setattr(encryption.settings, "rpi_cam_plugin_secret", other_key) + with pytest.raises(InvalidToken): + encryption.decrypt_str(ciphertext) + + +class TestApiKeyGeneration: + """Tests for API key generation helper.""" + + def test_default_prefix(self) -> None: + """Generated keys use the default camera prefix.""" + key = encryption.generate_api_key() + assert key.startswith("CAM_") + assert len(key) > len("CAM_") + 20 + + def test_custom_prefix(self) -> None: + """Generated keys allow a custom prefix.""" + assert encryption.generate_api_key(prefix="RELAY").startswith("RELAY_") + + def test_keys_are_unique(self) -> None: + """Two generated keys should not match.""" + assert encryption.generate_api_key() != encryption.generate_api_key() diff --git a/backend/tests/unit/plugins/rpi_cam/test_models.py b/backend/tests/unit/plugins/rpi_cam/test_models.py new file mode 100644 index 00000000..1fb1c29a --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_models.py @@ -0,0 +1,74 @@ +"""Unit tests for RPi Cam plugin models.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus, CameraCredentialStatus + +HTTP_OK = 200 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 +HTTP_INTERNAL_ERROR = 500 +HTTP_SERVICE_UNAVAILABLE = 503 + +TEST_CAMERA_NAME = "Test Camera" +FETCHED_VAL = "fetched" +CACHED_VAL = "cached" +PUBLIC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "y": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "kid": "key-12345", +} + + +def build_camera() -> Camera: + """Build a camera for model tests.""" + return Camera( + id=uuid4(), + name=TEST_CAMERA_NAME, + description="A test camera", + relay_public_key_jwk=PUBLIC_JWK, + relay_key_id="key-12345", + relay_credential_status=CameraCredentialStatus.ACTIVE, + owner_id=uuid4(), + ) + + +class TestCameraConnectionStatus: + """Test suite for CameraConnectionStatus enum utilities.""" + + def test_to_http_error(self) -> None: + """Test conversion of connection status to HTTP error tuples.""" + assert CameraConnectionStatus.ONLINE.to_http_error() == (HTTP_OK, "Camera is online") + assert CameraConnectionStatus.OFFLINE.to_http_error() == (HTTP_SERVICE_UNAVAILABLE, "Camera is offline") + assert CameraConnectionStatus.UNAUTHORIZED.to_http_error() == ( + HTTP_UNAUTHORIZED, + "Unauthorized access to camera", + ) + assert CameraConnectionStatus.FORBIDDEN.to_http_error() == (HTTP_FORBIDDEN, "Forbidden access to camera") + assert CameraConnectionStatus.ERROR.to_http_error() == (HTTP_INTERNAL_ERROR, "Camera access error") + + +class TestCameraModel: + """Test suite for the Camera model functionality.""" + + @pytest.fixture + def camera(self) -> Camera: + """Return a camera instance for testing.""" + return build_camera() + + def test_hash_and_str(self, camera: Camera) -> None: + """Test string representation and hashing of the camera model.""" + assert hash(camera) == hash(camera.id) + assert str(camera) == f"{TEST_CAMERA_NAME} (id: {camera.id})" + + def test_credential_is_active(self, camera: Camera) -> None: + """Test credential status helper.""" + assert camera.credential_is_active is True + camera.relay_credential_status = CameraCredentialStatus.REVOKED + assert camera.credential_is_active is False diff --git a/backend/tests/unit/plugins/rpi_cam/test_pairing_router.py b/backend/tests/unit/plugins/rpi_cam/test_pairing_router.py new file mode 100644 index 00000000..f66fd703 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_pairing_router.py @@ -0,0 +1,153 @@ +"""Unit tests for the RPi camera pairing router.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +from fakeredis.aioredis import FakeRedis +from starlette.requests import Request + +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.routers.pairing import claim_pairing_code, poll_pairing_status, register_pairing_code +from app.api.plugins.rpi_cam.schemas import RelayPublicKeyJWK +from app.api.plugins.rpi_cam.schemas.pairing import PairingClaimRequest, PairingRegisterRequest +from tests.factories.models import UserFactory + +PUBLIC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "y": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "kid": "key-12345", +} +KEY_ID = "key-12345" + + +def build_camera() -> Camera: + """Build a camera database model stub.""" + return Camera( + id=uuid4(), + name="Test Camera", + relay_public_key_jwk=PUBLIC_JWK, + relay_key_id=KEY_ID, + owner_id=uuid4(), + ) + + +def build_request() -> Request: + """Build a minimal Starlette request for router tests.""" + return Request({"type": "http", "method": "POST", "path": "/", "headers": [], "query_string": b""}) + + +async def test_register_pairing_code_sanitizes_code_in_log() -> None: + """Register logging should neutralize line breaks in the pairing code.""" + body = PairingRegisterRequest.model_construct( + code="ABCD\n12", + rpi_fingerprint="fingerprint", + public_key_jwk=RelayPublicKeyJWK(**PUBLIC_JWK), + key_id=KEY_ID, + ) + redis_client = await _make_fake_redis() + + with patch("app.api.plugins.rpi_cam.routers.pairing.logger") as mock_logger: + response = await register_pairing_code( + request=build_request(), + body=body, + redis=redis_client, + ) + + assert response.code == body.code + mock_logger.info.assert_called_once_with("Pairing code %s registered.", "ABCD 12") + stored = await redis_client.get("rpi_cam:pairing:ABCD\n12") + assert stored is not None + payload = json.loads(stored) + assert payload["public_key_jwk"] == PUBLIC_JWK + assert payload["key_id"] == KEY_ID + + +async def test_claim_pairing_code_sanitizes_code_in_log() -> None: + """Claim logging should neutralize line breaks in the pairing code.""" + session = AsyncMock() + current_user = UserFactory.build( + id=uuid4(), + email="owner@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + camera = build_camera() + body = PairingClaimRequest(code="ABCD12", camera_name="Camera", description="Description") + redis_client = await _make_fake_redis() + await redis_client.set( + "rpi_cam:pairing:ABCD12", + json.dumps( + { + "status": "waiting", + "rpi_fingerprint": "fingerprint", + "public_key_jwk": PUBLIC_JWK, + "key_id": KEY_ID, + } + ), + ) + + with ( + patch("app.api.plugins.rpi_cam.routers.pairing.crud.create_camera", new=AsyncMock(return_value=camera)), + patch("app.api.plugins.rpi_cam.routers.pairing.logger") as mock_logger, + ): + response = await claim_pairing_code( + request=build_request(), + body=body, + session=session, + current_user=current_user, + redis=redis_client, + ) + + assert response.id == camera.id + assert mock_logger.info.call_args.args[1] == "ABCD12" + stored = await redis_client.get("rpi_cam:pairing:ABCD12") + assert stored is not None + payload = json.loads(stored) + assert payload["auth_scheme"] == "device_assertion" + assert payload["key_id"] == KEY_ID + assert "api_key" not in payload + + +async def test_poll_pairing_status_sanitizes_code_in_log() -> None: + """Polling logs should neutralize line breaks in the pairing code.""" + body_code = "ABCD\n12" + redis_client = await _make_fake_redis() + await redis_client.set( + "rpi_cam:pairing:ABCD\n12", + json.dumps( + { + "status": "paired", + "camera_id": "1", + "ws_url": "3", + "auth_scheme": "device_assertion", + "key_id": KEY_ID, + "rpi_fingerprint": "fingerprint", + } + ), + ) + + with patch("app.api.plugins.rpi_cam.routers.pairing.logger") as mock_logger: + response = await poll_pairing_status( + request=build_request(), + redis=redis_client, + code=body_code, + fingerprint="fingerprint", + ) + + assert response.status == "paired" + assert response.auth_scheme == "device_assertion" + assert response.key_id == KEY_ID + mock_logger.info.assert_called_once_with("Pairing credentials retrieved for code %s.", "ABCD 12") + assert await redis_client.get("rpi_cam:pairing:ABCD\n12") is None + + +async def _make_fake_redis() -> FakeRedis: + """Build a fake Redis client for unit tests.""" + return FakeRedis(decode_responses=True, version=7) diff --git a/backend/tests/unit/plugins/rpi_cam/test_router_surface.py b/backend/tests/unit/plugins/rpi_cam/test_router_surface.py new file mode 100644 index 00000000..4ac88330 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_router_surface.py @@ -0,0 +1,13 @@ +"""Router surface tests for the RPi camera plugin.""" + +from fastapi.routing import APIRoute + +from app.api.plugins.rpi_cam.routers.camera_interaction import router + + +def test_camera_interaction_router_does_not_expose_open_close_routes() -> None: + """Legacy open/close proxy routes should no longer be exposed.""" + paths = {route.path for route in router.routes if isinstance(route, APIRoute)} + assert "/plugins/rpi-cam/cameras/{camera_id}/open" not in paths + assert "/plugins/rpi-cam/cameras/{camera_id}/close" not in paths + assert "/plugins/rpi-cam/cameras/{camera_id}/snapshot" not in paths diff --git a/backend/tests/unit/plugins/rpi_cam/test_routers_device_uploads.py b/backend/tests/unit/plugins/rpi_cam/test_routers_device_uploads.py new file mode 100644 index 00000000..b2e3a95a --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_routers_device_uploads.py @@ -0,0 +1,58 @@ +"""Tests for Pi-initiated upload routes.""" + +from __future__ import annotations + +from io import BytesIO +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast +from uuid import uuid4 + +import pytest +from fastapi import HTTPException, UploadFile + +from app.api.plugins.rpi_cam.routers.camera_interaction.images import receive_preview_thumbnail_upload +from app.core.config import settings + +if TYPE_CHECKING: + from pathlib import Path + + from app.api.plugins.rpi_cam.models import Camera + + +class TestReceivePreviewThumbnailUpload: + """Tests for cached preview-thumbnail uploads from the Pi.""" + + async def test_persists_deterministic_preview_thumbnail_and_returns_url( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A device upload should overwrite the deterministic preview-thumbnail cache file.""" + camera_id = uuid4() + monkeypatch.setattr(settings, "image_storage_path", tmp_path) + camera = cast("Camera", SimpleNamespace(id=camera_id, name="Bench Cam")) + upload = UploadFile(filename="preview.jpg", file=BytesIO(b"preview-bytes")) + + ack = await receive_preview_thumbnail_upload(camera_id=camera_id, camera=camera, file=upload) + + path = tmp_path / "rpi-cam-preview" / f"{camera_id}.jpg" + assert path.read_bytes() == b"preview-bytes" + expected_mtime = int(path.stat().st_mtime) + assert ack.preview_thumbnail_url == f"/uploads/images/rpi-cam-preview/{camera_id}.jpg?v={expected_mtime}" + + async def test_rejects_empty_preview_thumbnail_upload( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Empty uploads should fail with a client error instead of writing a bogus cache file.""" + camera_id = uuid4() + monkeypatch.setattr(settings, "image_storage_path", tmp_path) + camera = cast("Camera", SimpleNamespace(id=camera_id, name="Bench Cam")) + upload = UploadFile(filename="preview.jpg", file=BytesIO(b"")) + + with pytest.raises(HTTPException) as exc_info: + await receive_preview_thumbnail_upload(camera_id=camera_id, camera=camera, file=upload) + + assert exc_info.value.status_code == 400 + assert "empty" in str(exc_info.value.detail).lower() diff --git a/backend/tests/unit/plugins/rpi_cam/test_routers_hls.py b/backend/tests/unit/plugins/rpi_cam/test_routers_hls.py new file mode 100644 index 00000000..35d7e021 --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_routers_hls.py @@ -0,0 +1,246 @@ +"""Unit tests for the RPi Cam LL-HLS proxy router.""" +# spell-checker: ignore ftypmp, EXTM +# ruff: noqa: SLF001 # Private member behaviour is tested here, so we want to allow it. + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.auth.models import User +from app.api.plugins.rpi_cam.constants import HttpMethod +from app.api.plugins.rpi_cam.models import Camera +from app.api.plugins.rpi_cam.routers.camera_interaction import hls as hls_mod +from app.api.plugins.rpi_cam.routers.camera_interaction.hls import proxy_hls +from app.api.plugins.rpi_cam.websocket.protocol import RelayResponse +from tests.factories.models import UserFactory + +if TYPE_CHECKING: + from uuid import UUID + +TEST_EMAIL = "test@example.com" +TEST_HASHED_PASSWORD = "hashed_password" +TEST_CAMERA_NAME = "Test Camera" +TEST_CAMERA_DESC = "A test camera" + + +def require_uuid(value: UUID | None) -> UUID: + """Narrow optional UUID values produced by Pydantic models.""" + assert value is not None + return value + + +@pytest.fixture +def mock_user() -> User: + """Return a mock user for testing.""" + user = UserFactory.build( + id=uuid4(), + email=TEST_EMAIL, + is_active=True, + is_verified=True, + hashed_password=TEST_HASHED_PASSWORD, + ) + assert user.id is not None + return user + + +@pytest.fixture +def mock_camera(mock_user: User) -> Camera: + """Return a mock camera for testing.""" + owner_id = require_uuid(mock_user.id) + return Camera( + id=uuid4(), + name=TEST_CAMERA_NAME, + description=TEST_CAMERA_DESC, + relay_public_key_jwk={"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}, + relay_key_id="test-key-id", + owner_id=owner_id, + ) + + +class TestProxyHls: + """HLS proxy forwards the path verbatim through the relay and returns bytes.""" + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_playlist_request_forwarded_and_returned_as_hls_text( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """``.m3u8`` requests come back with the HLS manifest content type.""" + mock_get_cam.return_value = mock_camera + playlist = b"#EXTM3U\n#EXT-X-VERSION:9\n" + mock_camera_request = AsyncMock(return_value=RelayResponse(status_code=200, _content=playlist)) + mock_build_camera_request.return_value = mock_camera_request + + result = await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/index.m3u8", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert result.body == playlist + assert result.media_type == "application/vnd.apple.mpegurl" + mock_camera_request.assert_awaited_once() + assert mock_camera_request.await_args is not None + kwargs = mock_camera_request.await_args.kwargs + assert kwargs["endpoint"] == "/preview/hls/cam-preview/index.m3u8" + assert kwargs["method"] == HttpMethod.GET + assert kwargs["expect_binary"] is True + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_segment_request_returns_video_mp4( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """``.mp4`` segments come back with ``video/mp4`` content-type.""" + mock_get_cam.return_value = mock_camera + segment = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00" + mock_camera_request = AsyncMock(return_value=RelayResponse(status_code=200, _content=segment)) + mock_build_camera_request.return_value = mock_camera_request + + result = await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/segment0.mp4", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert result.body == segment + assert result.media_type == "video/mp4" + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_unknown_extension_falls_back_to_octet_stream( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """Unknown file extensions get a generic binary content type.""" + mock_get_cam.return_value = mock_camera + mock_camera_request = AsyncMock(return_value=RelayResponse(status_code=200, _content=b"raw")) + mock_build_camera_request.return_value = mock_camera_request + + result = await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/part0.m4s", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert result.media_type == "application/octet-stream" + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.asyncio.sleep", new_callable=AsyncMock) + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_manifest_retries_404_with_exponential_backoff( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_sleep: AsyncMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """Manifest 404s should retry with the configured exponential schedule.""" + mock_get_cam.return_value = mock_camera + playlist = b"#EXTM3U\n" + mock_camera_request = AsyncMock( + side_effect=[ + HTTPException(status_code=404, detail="not ready"), + HTTPException(status_code=404, detail="still warming"), + RelayResponse(status_code=200, _content=playlist), + ] + ) + mock_build_camera_request.return_value = mock_camera_request + + result = await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/index.m3u8", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert result.body == playlist + assert mock_camera_request.await_count == 3 + assert mock_sleep.await_args_list == [ + ((hls_mod._MANIFEST_RETRY_BACKOFF_S[0],), {}), + ((hls_mod._MANIFEST_RETRY_BACKOFF_S[1],), {}), + ] + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.asyncio.sleep", new_callable=AsyncMock) + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_manifest_raises_last_404_after_retry_budget_exhausted( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_sleep: AsyncMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """Manifest retries should stop after the configured backoff budget.""" + mock_get_cam.return_value = mock_camera + last_exc = HTTPException(status_code=404, detail="still not ready") + mock_camera_request = AsyncMock(side_effect=[last_exc] * (len(hls_mod._MANIFEST_RETRY_BACKOFF_S) + 1)) + mock_build_camera_request.return_value = mock_camera_request + + with pytest.raises(HTTPException) as exc_info: + await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/index.m3u8", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "still not ready" + assert mock_camera_request.await_count == len(hls_mod._MANIFEST_RETRY_BACKOFF_S) + 1 + assert mock_sleep.await_count == len(hls_mod._MANIFEST_RETRY_BACKOFF_S) + + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.asyncio.sleep", new_callable=AsyncMock) + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.get_user_owned_camera") + @patch("app.api.plugins.rpi_cam.routers.camera_interaction.hls.build_camera_request") + async def test_segment_404_is_not_retried( + self, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_sleep: AsyncMock, + mock_camera: Camera, + mock_user: User, + ) -> None: + """Segments should fail immediately; only manifests get retries.""" + mock_get_cam.return_value = mock_camera + mock_camera_request = AsyncMock(side_effect=HTTPException(status_code=404, detail="missing segment")) + mock_build_camera_request.return_value = mock_camera_request + + with pytest.raises(HTTPException) as exc_info: + await proxy_hls( + require_uuid(mock_camera.id), + "cam-preview/segment0.mp4", + AsyncMock(), + mock_user, + AsyncMock(), + ) + + assert exc_info.value.status_code == 404 + mock_camera_request.assert_awaited_once() + mock_sleep.assert_not_awaited() diff --git a/backend/tests/unit/plugins/rpi_cam/test_routers_streams_recording.py b/backend/tests/unit/plugins/rpi_cam/test_routers_streams_recording.py new file mode 100644 index 00000000..75c71edf --- /dev/null +++ b/backend/tests/unit/plugins/rpi_cam/test_routers_streams_recording.py @@ -0,0 +1,363 @@ +"""Unit tests for recording-focused RPi Cam stream router behavior.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from httpx import Response + +from app.api.auth.models import OAuthAccount +from app.api.common.exceptions import ServiceUnavailableError +from app.api.file_storage.models import Video +from app.api.plugins.rpi_cam.constants import PLUGIN_STREAM_ENDPOINT +from app.api.plugins.rpi_cam.routers.camera_interaction.streams import ( + YouTubePrivacyStatus, + get_recording_monitor_stream, + start_recording, + stop_recording, +) +from app.api.plugins.rpi_cam.schemas.youtube import YouTubeMonitorStreamResponse +from tests.unit.plugins.rpi_cam.stream_router_test_support import ( + FAKE_ACCESS_TOKEN, + FAKE_ACCOUNT_EMAIL, + FAKE_ACCOUNT_ID, + FAKE_BROADCAST_KEY, + HTTP_NO_CONTENT, + HTTP_OK, + YOUTUBE_STREAM_URL, + build_stream_config, + build_user, + require_uuid, +) + +if TYPE_CHECKING: + from app.api.plugins.rpi_cam.models import Camera + + +def build_oauth_account() -> OAuthAccount: + """Return a typed OAuth account for stream-router tests.""" + return OAuthAccount( + id=uuid4(), + user_id=uuid4(), + oauth_name="google", + access_token=FAKE_ACCESS_TOKEN, + expires_at=None, + refresh_token=None, + account_id=FAKE_ACCOUNT_ID, + account_email=FAKE_ACCOUNT_EMAIL, + ) + + +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.get_user_owned_camera") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.build_camera_request") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.YouTubeService") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.require_model") +async def test_start_recording( + mock_db_check: MagicMock, + mock_yt_service_class: MagicMock, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, +) -> None: + """Starting recording should create a YouTube session and cache it.""" + del mock_db_check + mock_get_cam.return_value = mock_camera + + session_mock = AsyncMock() + oauth_account = build_oauth_account() + session_mock.scalar.return_value = oauth_account + session_mock.get.return_value = None + session_mock.add = MagicMock() + + mock_yt_service = AsyncMock() + mock_yt_service.setup_livestream.return_value = build_stream_config() + mock_yt_service.validate_stream_status.return_value = True + mock_yt_service_class.return_value = mock_yt_service + mock_camera_request = AsyncMock( + return_value=Response( + HTTP_OK, + json={ + "url": YOUTUBE_STREAM_URL, + "mode": "youtube", + "provider": "youtube", + "started_at": "2026-02-26T10:00:00Z", + "metadata": {"camera_properties": {}, "capture_metadata": {}}, + }, + ) + ) + mock_build_camera_request.return_value = mock_camera_request + + redis_mock = AsyncMock() + redis_mock.get.return_value = None + user_mock = build_user() + http_client = AsyncMock() + camera_id = require_uuid(mock_camera.id) + + result = await start_recording( + camera_id=camera_id, + session=session_mock, + http_client=http_client, + redis=redis_mock, + current_user=user_mock, + product_id=1, + title="Test", + description="Test Desc", + privacy_status=YouTubePrivacyStatus.PRIVATE, + ) + + assert str(result.url) == f"{YOUTUBE_STREAM_URL}/" + assert mock_camera_request.await_args_list[0].kwargs["endpoint"] == PLUGIN_STREAM_ENDPOINT + redis_mock.set.assert_awaited_once() + cache_key, payload = redis_mock.set.await_args.args[:2] + assert cache_key == f"rpi_cam:youtube_recording:{camera_id}" + cached_session = json.loads(payload) + assert cached_session["product_id"] == 1 + + +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.get_user_owned_camera") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.build_camera_request") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.YouTubeService") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.create_video", new_callable=AsyncMock) +async def test_stop_recording( + mock_create_video: AsyncMock, + mock_yt_service_class: MagicMock, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, +) -> None: + """Stopping recording should end YouTube and persist the video row.""" + mock_get_cam.return_value = mock_camera + redis_mock = AsyncMock() + redis_mock.get.return_value = json.dumps( + { + "product_id": 1, + "title": "Test", + "description": "Test Desc", + "stream_url": f"{YOUTUBE_STREAM_URL}/", + "broadcast_key": FAKE_BROADCAST_KEY, + "video_metadata": {"camera_properties": {}, "capture_metadata": {}}, + } + ) + mock_yt_service = AsyncMock() + mock_yt_service.end_livestream.return_value = None + mock_yt_service_class.return_value = mock_yt_service + mock_build_camera_request.return_value = AsyncMock(return_value=Response(HTTP_NO_CONTENT)) + mock_create_video.return_value = Video( + id=1, + url=YOUTUBE_STREAM_URL, + title="Test", + description="Test Desc", + product_id=1, + video_metadata={"camera_properties": {}, "capture_metadata": {}}, + ) + + session_mock = AsyncMock() + session_mock.scalar.return_value = build_oauth_account() + user_mock = build_user() + camera_id = require_uuid(mock_camera.id) + http_client = AsyncMock() + + result = await stop_recording( + camera_id=camera_id, + session=session_mock, + http_client=http_client, + redis=redis_mock, + current_user=user_mock, + ) + + assert str(result.url) == YOUTUBE_STREAM_URL + assert result.product_id == 1 + mock_yt_service.end_livestream.assert_awaited_once_with(FAKE_BROADCAST_KEY) + redis_mock.delete.assert_awaited_once_with(f"rpi_cam:youtube_recording:{camera_id}") + + +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.get_user_owned_camera") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.build_camera_request") +@patch("app.api.plugins.rpi_cam.routers.camera_interaction.streams.YouTubeService") +async def test_get_recording_monitor_stream( + mock_yt_service_class: MagicMock, + mock_build_camera_request: MagicMock, + mock_get_cam: MagicMock, + mock_camera: Camera, +) -> None: + """Fetching the monitor stream should delegate to YouTubeService.""" + mock_get_cam.return_value = mock_camera + session_mock = AsyncMock() + session_mock.scalar.return_value = build_oauth_account() + + monitor_stream = YouTubeMonitorStreamResponse( + enableMonitorStream=True, + broadcastStreamDelayMs=0, + embedHtml="