Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Binary
/wizard
/reporting-wizard

# Evidence storage
evidence/*
!evidence/.gitkeep

# Database
*.db
*.db-wal
*.db-shm

# Environment
.env
.env.*

# IDE
.idea/
.vscode/
*.swp
*~

# OS
.DS_Store
Thumbs.db
116 changes: 116 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# AGENTS.md -- AI Coding Agent Guidelines

## Project Overview

This is a Go web application that automates filing abuse reports with hosting
providers. It uses chi for routing, html/template for SSR, SQLite via
modernc.org/sqlite (no CGO), and htmx for progressive enhancement. Templates
and static files are embedded into the binary.

## Code Style

- Standard `gofmt` formatting. Run `go vet ./...` before committing.
- Error handling: always check errors. Use `log.Printf("ERROR: ...")` for
operational errors. Return errors to callers with `fmt.Errorf("context: %w", err)`.
- SQL: always use parameterized queries (`?` placeholders). Never concatenate
user input into SQL strings.
- Templates: use `html/template` (not `text/template`). All user-facing data
is auto-escaped.
- Imports: stdlib first, then third-party, then internal packages (goimports
ordering).

## Architecture Rules

- **internal/** packages are not importable outside this module. All application
logic lives here.
- **model/** contains only data types and constants. No business logic, no
imports beyond stdlib.
- **store/** defines the `Store` interface and the SQLite implementation. All
database access goes through this interface.
- **server/** contains HTTP handlers, middleware, and routing. Handlers call
store methods and render templates.
- **admin/** contains admin-specific handlers. It receives dependencies via
constructor injection (store, templates, etc.).
- **infra/** handles infrastructure discovery (DNS, ASN, RDAP, BGP). It makes
external network calls and should always use context timeouts.
- **report/** handles X-ARF generation and email composition/sending.
- **escalation/** is a background worker. It runs in its own goroutine.
- **snapshot/** handles URL text extraction.
- **gdrive/** handles Google Drive URL parsing and metadata verification.

## Database

- SQLite with WAL mode and `busy_timeout(5000)`.
- Migrations are embedded SQL files in `internal/store/migrations/`. They run
automatically on startup in filename order.
- New migrations: create `NNN_description.sql` with the next sequence number.
Use `ALTER TABLE ... ADD COLUMN` for additive changes. SQLite does not support
`DROP COLUMN` or `ALTER COLUMN`.
- All times stored as `TEXT` in `datetime('now')` / RFC3339-like format using
the `timeFormat` constant in `sqlite.go`.
- Boolean fields stored as `INTEGER` (0/1).

## Testing

- Run `go test ./...` to execute all tests.
- Test files live alongside the code they test (e.g., `gdrive_test.go`).
- Mock the `Store` interface for handler/engine tests (see
`escalation/escalation_test.go` for an example mock store).
- External network calls (DNS, RDAP, etc.) should be mockable via interfaces.

## Security Considerations

- All POST endpoints are CSRF-protected. Templates must include
`<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">`.
- Rate limiting is applied globally. Report-specific rate limits exist per-user.
- Never serve user-controlled content inline. Use `Content-Disposition: attachment`.
- Never trust `Content-Type` headers alone; validate magic bytes for file uploads.
- The session secret (`WIZARD_SESSION_SECRET`) must be cryptographically random
and at least 32 characters in production.
- Google OAuth tokens are stored on the user record for Drive API access. These
are sensitive and should be treated as credentials.

## Evidence Model

Evidence is URL-based. Users provide links to files in their own cloud storage.
The application does NOT store evidence files locally. The `evidence/` directory
is vestigial and will be removed.

For Google Drive URLs, the app extracts file IDs, calls the Drive API for
metadata verification, and stores results on the Evidence record (`DriveFileID`,
`DriveFileName`, `DriveMimeType`, `DriveSize`, `DriveVerified`).

## Legal/Duty-of-Care

This application has important legal and ethical constraints:

- **Retaliation warnings**: Users must be warned that hosting providers will
forward reports to site operators, who may retaliate.
- **NCII identity**: NCII reports must come from the affected person or their
authorized representative.
- **Not DMCA**: Copyright reports are ToS-based, not DMCA takedown notices.
- **CSAM**: Must direct users to NCMEC CyberTipline and IC3. The application
must not be used to handle CSAM.
- **Report content**: Outgoing emails are sent under the organization's identity.
The individual reporter's email is never included in outgoing reports.

## Common Tasks

### Adding a new violation type
1. Add the constant to `model/models.go`
2. Add the mapping in `report/xarf.go` (`violationMapping`)
3. Add the label in `report/email.go` (`violationLabel`)
4. Add the `<option>` in `templates/wizard/step3_evidence.html`
5. Add any type-specific disclaimer (see NCII/copyvio patterns in step3)

### Adding a new migration
1. Create `internal/store/migrations/NNN_description.sql`
2. Update model structs in `model/models.go`
3. Update SQL queries in `store/sqlite.go`
4. Update the `Store` interface in `store/store.go`
5. Update mock stores in test files (e.g., `escalation_test.go`)

### Adding a new template
1. Create the `.html` file in `templates/`
2. Use `{{ template "layout" . }}` and `{{ define "content" }}...{{ end }}`
3. Pass data via `s.render(w, r, "name.html", map[string]interface{}{...})`
191 changes: 191 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@

Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

1. Definitions.

"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.

"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.

"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.

"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.

"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.

"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.

"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).

"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.

"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.

3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.

4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:

(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and

(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and

(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and

(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.

You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.

5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.

6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.

7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.

8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.

9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.

END OF TERMS AND CONDITIONS

Copyright 2026 End Harassment

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
173 changes: 173 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# End Harassment Reporting Wizard

A Go web application that automates filing abuse reports with hosting providers
on behalf of individuals targeted by online harassment, hate speech, doxxing,
non-consensual intimate imagery (NCII), and copyright/likeness violations.

Reports are generated in [X-ARF v4](https://x-arf.org/) format and sent via
email to the abuse contacts discovered through DNS, ASN, RDAP, and BGP lookups.

**This tool files Terms of Service abuse reports. It does not file DMCA takedown
notices or law enforcement reports.** See the in-app disclaimers for details.

## License

Apache 2.0. See [LICENSE](LICENSE).

## Architecture

```
cmd/wizard/main.go Entry point, config, server startup
internal/
server/ HTTP handlers, middleware, routing (chi)
store/ SQLite persistence (modernc.org/sqlite)
migrations/ Embedded SQL migrations
model/ Domain types (User, Report, Evidence, etc.)
infra/ Infrastructure discovery (DNS, ASN, RDAP, BGP)
report/ X-ARF generation, email composition, SendGrid
escalation/ Background engine for report escalation
snapshot/ URL text snapshotting (plain HTTP / Tor)
gdrive/ Google Drive URL parsing and metadata verification
admin/ Admin handlers (dashboard, approval queue)
templates/ html/template files (embedded)
static/ CSS, htmx (embedded)
```

The application uses server-side rendering with `html/template` and
[htmx](https://htmx.org/) for progressive enhancement. Templates and static
assets are embedded into the binary via `//go:embed`.

## Prerequisites

- Go 1.25+ (uses `modernc.org/sqlite`, no CGO required)
- A Google OAuth 2.0 client ID (for authentication and optional Drive verification)
- A SendGrid API key (for sending abuse report emails)
- Optionally: a GitHub OAuth app for GitHub login

## Configuration

All configuration is via environment variables (with flag overrides for some):

| Variable | Required | Default | Description |
|---|---|---|---|
| `WIZARD_LISTEN` | No | `:8080` | HTTP listen address |
| `WIZARD_DB_PATH` | No | `./wizard.db` | SQLite database file path |
| `WIZARD_BASE_URL` | Yes | `http://localhost:8080` | Public base URL (used for OAuth callbacks) |
| `WIZARD_SESSION_SECRET` | **Yes** | -- | Secret key for CSRF tokens (min 32 chars, must be set in production) |
| `WIZARD_GOOGLE_CLIENT_ID` | Yes | -- | Google OAuth client ID |
| `WIZARD_GOOGLE_SECRET` | Yes | -- | Google OAuth client secret |
| `WIZARD_GITHUB_CLIENT_ID` | No | -- | GitHub OAuth client ID |
| `WIZARD_GITHUB_SECRET` | No | -- | GitHub OAuth client secret |
| `WIZARD_SENDGRID_KEY` | Yes | -- | SendGrid API key for outgoing emails |
| `WIZARD_FROM_EMAIL` | No | `reports@endharassment.net` | Sender email address |
| `WIZARD_FROM_NAME` | No | `End Harassment` | Sender display name |

### Google OAuth Setup

1. Create a project in the [Google Cloud Console](https://console.cloud.google.com/).
2. Enable the **Google Drive API**.
3. Configure the OAuth consent screen. Add the scopes: `openid`, `email`,
`profile`, `https://www.googleapis.com/auth/drive.metadata.readonly`.
4. Create an OAuth 2.0 Client ID (Web application type).
5. Add `{WIZARD_BASE_URL}/auth/google/callback` as an authorized redirect URI.
6. Set `WIZARD_GOOGLE_CLIENT_ID` and `WIZARD_GOOGLE_SECRET`.

The `drive.metadata.readonly` scope allows the app to verify that Google Drive
evidence links are accessible and to pull file metadata (name, type, size). It
does **not** allow reading file contents.

## Building and Running

```bash
go build -o wizard ./cmd/wizard
export WIZARD_SESSION_SECRET="$(openssl rand -hex 32)"
export WIZARD_GOOGLE_CLIENT_ID="..."
export WIZARD_GOOGLE_SECRET="..."
export WIZARD_SENDGRID_KEY="..."
export WIZARD_BASE_URL="https://your-domain.example.com"
./wizard
```

The SQLite database and migrations are created automatically on first run.

## Development

```bash
go build ./... # compile
go vet ./... # static analysis
go test ./... # run tests
```

## Report Workflow

1. **Reporter enters URLs** of abusive content (all must be on the same domain).
2. **Infrastructure discovery** runs automatically: DNS resolution, IP-to-ASN
mapping (Team Cymru), RDAP abuse contact lookup, BGP upstream discovery,
Cloudflare detection.
3. **Reporter provides evidence** by pasting links to files in their own cloud
storage (Google Drive recommended for automatic metadata verification).
Describes the violation and selects a category.
4. **Reporter reviews** the generated report, including an email preview and
X-ARF attachment. URL text snapshots are captured automatically.
5. **Admin reviews and approves** the report. Approved reports are sent to the
hosting provider's abuse contact via SendGrid.
6. **Escalation engine** monitors sent reports. If no response is received
within the configured period (default 14 days), reports are automatically
escalated to upstream providers.

## Evidence Handling

Users upload evidence to their own cloud storage accounts (Google Drive,
Dropbox, iCloud, etc.) and provide share links. The application does **not**
store evidence files.

For Google Drive links, the application:
- Extracts the file ID from the URL
- Verifies the file exists via the Drive API (using the reporter's OAuth token)
- Pulls metadata: file name, MIME type, size, creation date
- Displays a "Verified" badge in the UI

For non-Drive links, URLs are stored as-is and displayed without verification.

## Important Disclaimers

The application prominently warns users that:

- **Hosting providers will forward abuse reports to the site operator.** There
is no expectation of privacy for abuse reports.
- **Site operators may retaliate** by publicly posting complaints and
encouraging further harassment.
- **NCII reports** must be filed by the person depicted or their authorized
representative.
- **DMCA takedown notices** are a separate legal process that this tool does
not perform. Copyright reports are ToS-based only.
- **CSAM** must be reported to [NCMEC CyberTipline](https://report.cybertip.org/)
and [IC3](https://www.ic3.gov/), not through this tool.

## Security

See [SECURITY_REVIEW.md](SECURITY_REVIEW.md) for a detailed security audit.

Key security features:
- CSRF protection (HMAC double-submit cookie)
- Per-IP and per-user rate limiting (token bucket)
- Security headers (CSP, X-Frame-Options, etc.)
- Parameterized SQL queries (no string concatenation)
- `html/template` auto-escaping (XSS prevention)
- `crypto/rand` for all token generation
- Admin approval required before any email is sent
- Audit logging for all admin actions

## Pre-Launch Checklist

Before deploying to production:

- [ ] Set a strong `WIZARD_SESSION_SECRET` (the app will refuse to start with the default)
- [ ] Configure SPF/DKIM/DMARC for your sending domain
- [ ] Use a dedicated SendGrid IP and warm it gradually
- [ ] Retain legal counsel re: CSAM reporting obligations (18 USC 2258A) and GDPR
- [ ] Create a Terms of Service with reporter attestation language
- [ ] Create a Privacy Policy with data retention schedule
- [ ] Set `WIZARD_BASE_URL` to your HTTPS domain (enables Secure cookie flag)
- [ ] Review and customize the `ReporterOrg` fields in the email config
- [ ] Set up monitoring/alerting for the application
388 changes: 388 additions & 0 deletions SECURITY_REVIEW.md

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions cmd/wizard/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"context"
"flag"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

wizard "github.com/endharassment/reporting-wizard"
"github.com/endharassment/reporting-wizard/internal/escalation"
"github.com/endharassment/reporting-wizard/internal/infra"
"github.com/endharassment/reporting-wizard/internal/server"
"github.com/endharassment/reporting-wizard/internal/snapshot"
"github.com/endharassment/reporting-wizard/internal/store"
)

func main() {
listenAddr := flag.String("listen", envOr("WIZARD_LISTEN", ":8080"), "HTTP listen address")
dbPath := flag.String("db", envOr("WIZARD_DB_PATH", "./wizard.db"), "SQLite database path")
flag.Parse()

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

db, err := store.NewSQLiteStore(ctx, *dbPath)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()

tmplFS, err := fs.Sub(wizard.TemplatesFS, "templates")
if err != nil {
log.Fatalf("Failed to create templates sub-FS: %v", err)
}
stFS, err := fs.Sub(wizard.StaticFS, "static")
if err != nil {
log.Fatalf("Failed to create static sub-FS: %v", err)
}

baseURL := envOr("WIZARD_BASE_URL", "http://localhost:8080")
sessionSecret := os.Getenv("WIZARD_SESSION_SECRET")
if sessionSecret == "" || sessionSecret == "change-me-in-production" {
if strings.HasPrefix(baseURL, "https://") {
log.Fatal("WIZARD_SESSION_SECRET must be set to a strong random value in production (try: openssl rand -hex 32)")
}
// Allow an insecure default for local development only.
log.Println("WARNING: using insecure default session secret -- set WIZARD_SESSION_SECRET for production")
sessionSecret = "insecure-dev-only-session-secret-do-not-use"
}

cfg := server.Config{
ListenAddr: *listenAddr,
DBPath: *dbPath,
SendGridKey: os.Getenv("WIZARD_SENDGRID_KEY"),
FromEmail: envOr("WIZARD_FROM_EMAIL", "reports@endharassment.net"),
FromName: envOr("WIZARD_FROM_NAME", "End Harassment"),
BaseURL: baseURL,
GoogleClientID: os.Getenv("WIZARD_GOOGLE_CLIENT_ID"),
GoogleSecret: os.Getenv("WIZARD_GOOGLE_SECRET"),
GitHubClientID: os.Getenv("WIZARD_GITHUB_CLIENT_ID"),
GitHubSecret: os.Getenv("WIZARD_GITHUB_SECRET"),
EscalationDays: 14,
SessionSecret: sessionSecret,
}

srv, err := server.NewServer(cfg, db, tmplFS, stFS)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
defer srv.Stop()

// Set up URL snapshotter (plain HTTP; tor-fetcher can be wired in when
// a local Tor SOCKS proxy is available).
srv.SetSnapshotter(snapshot.NewPlainHTTPSnapshotter())

// Start escalation engine.
logger := slog.Default()
abuseContactLookup := &infra.RDAPAbuseContactLookup{
RDAP: infra.NewRDAPClient(),
ASN: infra.NewASNClient(),
}
escalationEngine := escalation.NewEngine(db, abuseContactLookup, cfg.EscalationDays, logger)
go func() {
if err := escalationEngine.Run(ctx); err != nil && err != context.Canceled {
log.Printf("ERROR: escalation engine: %v", err)
}
}()
log.Println("Escalation engine started")

httpSrv := &http.Server{
Addr: *listenAddr,
Handler: srv.Handler(),
}

go func() {
log.Printf("Listening on %s", *listenAddr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
}
}()

<-ctx.Done()
log.Println("Shutting down...")

shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Shutdown error: %v", err)
}
}

func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
13 changes: 13 additions & 0 deletions embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package wizard

import "embed"

// TemplatesFS embeds the HTML templates directory.
//
//go:embed templates
var TemplatesFS embed.FS

// StaticFS embeds the static assets directory.
//
//go:embed static
var StaticFS embed.FS
32 changes: 32 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module github.com/endharassment/reporting-wizard

go 1.25.6

require (
github.com/ammario/ipisp/v2 v2.0.1
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/openrdap/rdap v0.9.1
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
modernc.org/sqlite v1.44.3
)

require (
github.com/alecthomas/kingpin/v2 v2.3.2 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
92 changes: 92 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU=
github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/ammario/ipisp/v2 v2.0.1 h1:54qm0Dwz0OjSa7ynqijgAieN6skjVeL8/2m78HP8Stc=
github.com/ammario/ipisp/v2 v2.0.1/go.mod h1:bQ6KAL5LnYYEj6olUn+Bzv/im/4Esa5oGkbv9b+uOjo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
393 changes: 393 additions & 0 deletions internal/admin/admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,393 @@
package admin

import (
"context"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"path/filepath"
"time"

"github.com/endharassment/reporting-wizard/internal/infra"
"github.com/endharassment/reporting-wizard/internal/model"
"github.com/endharassment/reporting-wizard/internal/report"
"github.com/endharassment/reporting-wizard/internal/store"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)

// UserFunc extracts the authenticated user from a context.
type UserFunc func(ctx context.Context) *model.User

// CSRFFunc extracts the CSRF token from a context.
type CSRFFunc func(ctx context.Context) string

// AdminHandler holds dependencies for admin route handlers.
type AdminHandler struct {
store store.Store
discovery *infra.Discovery
emailCfg report.EmailConfig
templates *template.Template
getUser UserFunc
getCSRF CSRFFunc
}

// NewAdminHandler creates an AdminHandler.
func NewAdminHandler(s store.Store, d *infra.Discovery, emailCfg report.EmailConfig, tmpl *template.Template, getUser UserFunc, getCSRF CSRFFunc) *AdminHandler {
return &AdminHandler{
store: s,
discovery: d,
emailCfg: emailCfg,
templates: tmpl,
getUser: getUser,
getCSRF: getCSRF,
}
}

// DashboardCounts holds the counts shown on the admin dashboard.
type DashboardCounts struct {
PendingApproval int
Sent int
Escalating int
CloudflarePending int
}

// HandleDashboard renders the admin dashboard.
func (h *AdminHandler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
pending, _ := h.store.ListEmailsByStatus(r.Context(), model.EmailPendingApproval)
sent, _ := h.store.ListEmailsByStatus(r.Context(), model.EmailSent)
escalating, _ := h.store.ListReportsByStatus(r.Context(), model.StatusEscalating)
cfPending, _ := h.store.ListReportsByStatus(r.Context(), model.StatusCloudfarePending)

counts := DashboardCounts{
PendingApproval: len(pending),
Sent: len(sent),
Escalating: len(escalating),
CloudflarePending: len(cfPending),
}

h.render(w, r, "dashboard.html", map[string]interface{}{
"Counts": counts,
})
}

// HandleQueue renders the email approval queue.
func (h *AdminHandler) HandleQueue(w http.ResponseWriter, r *http.Request) {
pending, err := h.store.ListEmailsByStatus(r.Context(), model.EmailPendingApproval)
if err != nil {
log.Printf("ERROR: list pending emails: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

h.render(w, r, "queue.html", map[string]interface{}{
"PendingEmails": pending,
})
}

// HandleReportView renders the admin report detail page.
func (h *AdminHandler) HandleReportView(w http.ResponseWriter, r *http.Request) {
reportID := chi.URLParam(r, "reportID")

rpt, err := h.store.GetReport(r.Context(), reportID)
if err != nil {
http.Error(w, "Report not found", http.StatusNotFound)
return
}

evidence, _ := h.store.ListEvidenceByReport(r.Context(), reportID)
auditLog, _ := h.store.ListAuditLogByTarget(r.Context(), reportID)

h.render(w, r, "report.html", map[string]interface{}{
"Report": rpt,
"Evidence": evidence,
"AuditLog": auditLog,
})
}

// HandleEmailPreview shows email details for review.
func (h *AdminHandler) HandleEmailPreview(w http.ResponseWriter, r *http.Request) {
emailID := chi.URLParam(r, "emailID")

email, err := h.store.GetOutgoingEmail(r.Context(), emailID)
if err != nil {
http.Error(w, "Email not found", http.StatusNotFound)
return
}

rpt, _ := h.store.GetReport(r.Context(), email.ReportID)

h.render(w, r, "queue.html", map[string]interface{}{
"Email": email,
"Report": rpt,
})
}

// HandleEmailApprove approves an email and triggers sending.
func (h *AdminHandler) HandleEmailApprove(w http.ResponseWriter, r *http.Request) {
emailID := chi.URLParam(r, "emailID")
user := h.getUser(r.Context())

email, err := h.store.GetOutgoingEmail(r.Context(), emailID)
if err != nil {
http.Error(w, "Email not found", http.StatusNotFound)
return
}

now := time.Now().UTC()
email.Status = model.EmailApproved
email.ApprovedBy = user.ID
email.ApprovedAt = &now

if err := h.store.UpdateOutgoingEmail(r.Context(), email); err != nil {
log.Printf("ERROR: approve email: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

// Attempt to send via SendGrid.
if h.emailCfg.SendGridAPIKey != "" {
sender := &report.RealSendGridSender{APIKey: h.emailCfg.SendGridAPIKey}
result, err := report.SendEmail(sender, h.emailCfg, email)
if err != nil {
log.Printf("ERROR: send email %s: %v", emailID, err)
} else {
sentAt := time.Now().UTC()
email.Status = model.EmailSent
email.SentAt = &sentAt
email.SendGridID = result.MessageID

// Set escalation timer.
escalateAt := sentAt.Add(14 * 24 * time.Hour) // default 14 days
email.EscalateAfter = &escalateAt

_ = h.store.UpdateOutgoingEmail(r.Context(), email)
}
} else {
log.Printf("INFO: SendGrid not configured; email %s approved but not sent", emailID)
}

// Create audit log entry.
h.createAuditEntry(r, user.ID, "email_approved", emailID, fmt.Sprintf("Email to %s approved", email.Recipient))

// For htmx requests, return a replacement row.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<tr id="email-%s"><td colspan="5">Approved</td></tr>`, emailID)
return
}

http.Redirect(w, r, "/admin/queue", http.StatusFound)
}

// HandleEmailReject rejects an email.
func (h *AdminHandler) HandleEmailReject(w http.ResponseWriter, r *http.Request) {
emailID := chi.URLParam(r, "emailID")
user := h.getUser(r.Context())

email, err := h.store.GetOutgoingEmail(r.Context(), emailID)
if err != nil {
http.Error(w, "Email not found", http.StatusNotFound)
return
}

notes := r.FormValue("notes")
email.Status = model.EmailRejected
email.ResponseNotes = notes

if err := h.store.UpdateOutgoingEmail(r.Context(), email); err != nil {
log.Printf("ERROR: reject email: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

h.createAuditEntry(r, user.ID, "email_rejected", emailID, fmt.Sprintf("Email to %s rejected: %s", email.Recipient, notes))

if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<tr id="email-%s"><td colspan="5">Rejected</td></tr>`, emailID)
return
}

http.Redirect(w, r, "/admin/queue", http.StatusFound)
}

// HandleReportApprove approves all pending emails for a report.
func (h *AdminHandler) HandleReportApprove(w http.ResponseWriter, r *http.Request) {
reportID := chi.URLParam(r, "reportID")
user := h.getUser(r.Context())

emails, _ := h.store.ListEmailsByReport(r.Context(), reportID)
for _, email := range emails {
if email.Status != model.EmailPendingApproval {
continue
}
now := time.Now().UTC()
email.Status = model.EmailApproved
email.ApprovedBy = user.ID
email.ApprovedAt = &now
_ = h.store.UpdateOutgoingEmail(r.Context(), email)

if h.emailCfg.SendGridAPIKey != "" {
sender := &report.RealSendGridSender{APIKey: h.emailCfg.SendGridAPIKey}
result, err := report.SendEmail(sender, h.emailCfg, email)
if err != nil {
log.Printf("ERROR: send email %s: %v", email.ID, err)
} else {
sentAt := time.Now().UTC()
email.Status = model.EmailSent
email.SentAt = &sentAt
email.SendGridID = result.MessageID
escalateAt := sentAt.Add(14 * 24 * time.Hour)
email.EscalateAfter = &escalateAt
_ = h.store.UpdateOutgoingEmail(r.Context(), email)
}
}
}

rpt, _ := h.store.GetReport(r.Context(), reportID)
if rpt != nil {
rpt.Status = model.StatusSent
rpt.UpdatedAt = time.Now().UTC()
_ = h.store.UpdateReport(r.Context(), rpt)
}

h.createAuditEntry(r, user.ID, "report_approved", reportID, "All emails approved and queued for sending")

http.Redirect(w, r, fmt.Sprintf("/admin/reports/%s", reportID), http.StatusFound)
}

// HandleReportReject rejects all pending emails for a report.
func (h *AdminHandler) HandleReportReject(w http.ResponseWriter, r *http.Request) {
reportID := chi.URLParam(r, "reportID")
user := h.getUser(r.Context())
notes := r.FormValue("notes")

emails, _ := h.store.ListEmailsByReport(r.Context(), reportID)
for _, email := range emails {
if email.Status != model.EmailPendingApproval {
continue
}
email.Status = model.EmailRejected
email.ResponseNotes = notes
_ = h.store.UpdateOutgoingEmail(r.Context(), email)
}

h.createAuditEntry(r, user.ID, "report_rejected", reportID, fmt.Sprintf("Report rejected: %s", notes))

http.Redirect(w, r, fmt.Sprintf("/admin/reports/%s", reportID), http.StatusFound)
}

// HandleSetOriginIP handles setting a Cloudflare origin IP for a report.
func (h *AdminHandler) HandleSetOriginIP(w http.ResponseWriter, r *http.Request) {
reportID := chi.URLParam(r, "reportID")
user := h.getUser(r.Context())
originIP := r.FormValue("origin_ip")

// Validate IP.
if net.ParseIP(originIP) == nil {
http.Error(w, "Invalid IP address", http.StatusBadRequest)
return
}

rpt, err := h.store.GetReport(r.Context(), reportID)
if err != nil {
http.Error(w, "Report not found", http.StatusNotFound)
return
}

rpt.CloudflareOriginIP = originIP
rpt.UpdatedAt = time.Now().UTC()
if err := h.store.UpdateReport(r.Context(), rpt); err != nil {
log.Printf("ERROR: update report origin IP: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

// Delete old infra results and re-run discovery with the origin IP.
if err := h.store.DeleteInfraResultsByReport(r.Context(), reportID); err != nil {
log.Printf("ERROR: delete old infra results: %v", err)
}

// Run discovery using the origin IP directly.
results, err := h.discovery.Run(r.Context(), rpt.Domain)
if err != nil {
log.Printf("WARN: re-discovery for %s failed: %v", rpt.Domain, err)
}

now := time.Now().UTC()
for i := range results {
results[i].ID = uuid.New().String()
results[i].ReportID = reportID
results[i].CreatedAt = now
if err := h.store.CreateInfraResult(r.Context(), &results[i]); err != nil {
log.Printf("ERROR: store infra result: %v", err)
}
}

// Update report status from cloudflare_pending to draft so the user
// can continue through the wizard.
rpt.Status = model.StatusDraft
rpt.UpdatedAt = time.Now().UTC()
_ = h.store.UpdateReport(r.Context(), rpt)

h.createAuditEntry(r, user.ID, "origin_ip_set", reportID, fmt.Sprintf("Origin IP set to %s", originIP))

http.Redirect(w, r, fmt.Sprintf("/admin/reports/%s", reportID), http.StatusFound)
}

// HandleAdminEvidenceDownload serves evidence files for admin review.
func (h *AdminHandler) HandleAdminEvidenceDownload(w http.ResponseWriter, r *http.Request) {
evidenceID := chi.URLParam(r, "evidenceID")

ev, err := h.store.GetEvidence(r.Context(), evidenceID)
if err != nil {
http.Error(w, "Evidence not found", http.StatusNotFound)
return
}

f, err := os.Open(ev.StoragePath)
if err != nil {
log.Printf("ERROR: open evidence file: %v", err)
http.Error(w, "Evidence file not found", http.StatusNotFound)
return
}
defer f.Close()

w.Header().Set("Content-Type", ev.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(ev.Filename)))
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, ev.Filename, ev.CreatedAt, f)
}

// --- Helpers ---

func (h *AdminHandler) render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["User"] = h.getUser(r.Context())
data["CSRFToken"] = h.getCSRF(r.Context())

if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("ERROR: render template %s: %v", name, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

func (h *AdminHandler) createAuditEntry(r *http.Request, userID, action, targetID, details string) {
entry := &model.AuditLogEntry{
ID: uuid.New().String(),
UserID: userID,
Action: action,
TargetID: targetID,
Details: details,
CreatedAt: time.Now().UTC(),
}
if err := h.store.CreateAuditLogEntry(r.Context(), entry); err != nil {
log.Printf("ERROR: create audit log: %v", err)
}
}
242 changes: 242 additions & 0 deletions internal/escalation/escalation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package escalation

import (
"context"
"fmt"
"log/slog"
"strings"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
"github.com/endharassment/reporting-wizard/internal/store"
"github.com/google/uuid"
)

// AbuseContactLookup resolves an abuse contact email for a given ASN.
type AbuseContactLookup interface {
LookupAbuseContactByASN(ctx context.Context, asn int) (string, error)
}

// Engine checks for emails that are due for escalation and creates
// escalation emails to upstream providers.
type Engine struct {
store store.Store
abuseContact AbuseContactLookup
escalationDays int
tickInterval time.Duration
logger *slog.Logger
}

// NewEngine creates an escalation engine.
func NewEngine(s store.Store, ac AbuseContactLookup, escalationDays int, logger *slog.Logger) *Engine {
return &Engine{
store: s,
abuseContact: ac,
escalationDays: escalationDays,
tickInterval: 1 * time.Hour,
logger: logger,
}
}

// SetTickInterval overrides the default tick interval (for testing).
func (e *Engine) SetTickInterval(d time.Duration) {
e.tickInterval = d
}

// Run starts a ticker loop that calls checkAndEscalate on each tick.
// It blocks until the context is cancelled.
func (e *Engine) Run(ctx context.Context) error {
ticker := time.NewTicker(e.tickInterval)
defer ticker.Stop()

// Run once immediately on start.
e.checkAndEscalate(ctx)

for {
select {
case <-ctx.Done():
e.logger.Info("escalation engine shutting down")
return ctx.Err()
case <-ticker.C:
e.checkAndEscalate(ctx)
}
}
}

// checkAndEscalate finds emails due for escalation and creates escalation
// emails to upstream providers.
func (e *Engine) checkAndEscalate(ctx context.Context) {
now := time.Now().UTC()

dueEmails, err := e.store.ListEmailsDueForEscalation(ctx, now)
if err != nil {
e.logger.Error("listing emails due for escalation", "error", err)
return
}

escalationsCreated := 0

for _, email := range dueEmails {
if email.ResponseNotes != "" {
continue
}

created, err := e.escalateEmail(ctx, email, now)
if err != nil {
e.logger.Error("escalating email",
"email_id", email.ID,
"report_id", email.ReportID,
"error", err,
)
continue
}
escalationsCreated += created
}

e.logger.Info("escalation check complete",
"emails_checked", len(dueEmails),
"escalations_created", escalationsCreated,
)
}

// escalateEmail processes a single email due for escalation.
// It returns the number of escalation emails created.
func (e *Engine) escalateEmail(ctx context.Context, email *model.OutgoingEmail, now time.Time) (int, error) {
report, err := e.store.GetReport(ctx, email.ReportID)
if err != nil {
return 0, fmt.Errorf("getting report %s: %w", email.ReportID, err)
}

infraResults, err := e.store.ListInfraResultsByReport(ctx, email.ReportID)
if err != nil {
return 0, fmt.Errorf("listing infra results for report %s: %w", email.ReportID, err)
}

// Collect unique upstream ASNs across all infra results.
upstreamASNs := make(map[int]bool)
for _, ir := range infraResults {
for _, asn := range ir.UpstreamASNs {
upstreamASNs[asn] = true
}
}

if len(upstreamASNs) == 0 {
e.logger.Warn("no upstream ASNs found for escalation",
"email_id", email.ID,
"report_id", email.ReportID,
)
}

created := 0
// Deduplicate abuse contacts to avoid sending multiple emails to the same address.
seenContacts := make(map[string]bool)

for asn := range upstreamASNs {
abuseContact, err := e.abuseContact.LookupAbuseContactByASN(ctx, asn)
if err != nil {
e.logger.Error("looking up abuse contact for upstream ASN",
"asn", asn,
"email_id", email.ID,
"error", err,
)
continue
}
if abuseContact == "" {
e.logger.Warn("no abuse contact found for upstream ASN",
"asn", asn,
"email_id", email.ID,
)
continue
}

if seenContacts[abuseContact] {
continue
}
seenContacts[abuseContact] = true

sentDate := ""
if email.SentAt != nil {
sentDate = email.SentAt.Format("2006-01-02")
} else {
sentDate = email.CreatedAt.Format("2006-01-02")
}

days := int(now.Sub(email.CreatedAt).Hours() / 24)

body := composeEscalationBody(report, email, asn, sentDate, days)
subject := fmt.Sprintf("Escalation: Abuse Report for %s (upstream AS%d)", report.Domain, asn)

escAfter := now.Add(time.Duration(e.escalationDays) * 24 * time.Hour)

escEmail := &model.OutgoingEmail{
ID: uuid.New().String(),
ReportID: email.ReportID,
ParentEmailID: email.ID,
Recipient: abuseContact,
RecipientOrg: fmt.Sprintf("AS%d", asn),
TargetASN: asn,
EmailType: model.EmailTypeEscalation,
XARFJson: email.XARFJson,
EmailSubject: subject,
EmailBody: body,
Status: model.EmailPendingApproval,
EscalateAfter: &escAfter,
CreatedAt: now,
}

if err := e.store.CreateOutgoingEmail(ctx, escEmail); err != nil {
e.logger.Error("creating escalation email",
"upstream_asn", asn,
"email_id", email.ID,
"error", err,
)
continue
}

e.logger.Info("created escalation email",
"escalation_id", escEmail.ID,
"upstream_asn", asn,
"recipient", abuseContact,
"parent_email_id", email.ID,
)
created++
}

// Mark the original email so it is not picked up again.
email.ResponseNotes = "escalated"
if err := e.store.UpdateOutgoingEmail(ctx, email); err != nil {
return created, fmt.Errorf("updating response_notes for email %s: %w", email.ID, err)
}

return created, nil
}

func composeEscalationBody(report *model.Report, original *model.OutgoingEmail, upstreamASN int, sentDate string, days int) string {
var b strings.Builder

b.WriteString("Dear Abuse Team,\n\n")
b.WriteString(fmt.Sprintf(
"Report %s regarding %s was filed with %s (AS%d) on %s. "+
"No action has been taken after %d days. "+
"We are escalating to you as an upstream provider.\n\n",
report.ID, report.Domain, original.Recipient, original.TargetASN, sentDate, days,
))

b.WriteString("Original report details:\n")
b.WriteString(fmt.Sprintf(" Domain: %s\n", report.Domain))
if len(report.URLs) > 0 {
b.WriteString(" URLs:\n")
for _, u := range report.URLs {
b.WriteString(fmt.Sprintf(" - %s\n", u))
}
}
b.WriteString(fmt.Sprintf(" Violation: %s\n", report.ViolationType))
b.WriteString(fmt.Sprintf(" Description: %s\n\n", report.Description))

b.WriteString("The original machine-readable X-ARF v4 report is attached to this email.\n\n")
b.WriteString("We request that you investigate this matter and take appropriate action.\n\n")
b.WriteString("Regards,\n")
b.WriteString("End Harassment Reporting Wizard\n")

return b.String()
}
456 changes: 456 additions & 0 deletions internal/escalation/escalation_test.go

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions internal/gdrive/gdrive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Package gdrive provides helpers for parsing Google Drive URLs and
// verifying file metadata using the Google Drive API.
package gdrive

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"time"

"golang.org/x/oauth2"
)

// FileMeta contains the metadata returned by the Drive API for a file.
type FileMeta struct {
ID string
Name string
MimeType string
Size int64
CreatedTime time.Time
}

// driveFileIDPatterns matches common Google Drive URL formats.
var driveFileIDPatterns = []*regexp.Regexp{
// https://drive.google.com/file/d/FILE_ID/view?usp=sharing
regexp.MustCompile(`drive\.google\.com/file/d/([a-zA-Z0-9_-]+)`),
// https://drive.google.com/open?id=FILE_ID
regexp.MustCompile(`drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)`),
// https://docs.google.com/document/d/FILE_ID/...
regexp.MustCompile(`docs\.google\.com/(?:document|spreadsheets|presentation)/d/([a-zA-Z0-9_-]+)`),
}

// IsDriveURL returns true if the URL appears to be a Google Drive URL.
func IsDriveURL(rawURL string) bool {
return ExtractFileID(rawURL) != ""
}

// ExtractFileID extracts the Google Drive file ID from a URL.
// Returns empty string if the URL is not a recognized Drive URL.
func ExtractFileID(rawURL string) string {
for _, pat := range driveFileIDPatterns {
matches := pat.FindStringSubmatch(rawURL)
if len(matches) >= 2 {
return matches[1]
}
}
// Also try the id= query parameter for general Drive URLs.
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
if parsed.Host == "drive.google.com" || parsed.Host == "www.drive.google.com" {
if id := parsed.Query().Get("id"); id != "" {
return id
}
}
return ""
}

// driveFileResponse is the JSON response from the Drive Files.get API.
type driveFileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
MimeType string `json:"mimeType"`
Size string `json:"size"`
CreatedTime string `json:"createdTime"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}

// GetFileMeta fetches metadata for a Google Drive file using the provided
// OAuth2 token. The token must have the drive.metadata.readonly scope.
// This uses the REST API directly to avoid pulling in the full Google API
// client library.
func GetFileMeta(ctx context.Context, token *oauth2.Token, fileID string) (*FileMeta, error) {
client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))

apiURL := fmt.Sprintf(
"https://www.googleapis.com/drive/v3/files/%s?fields=id,name,mimeType,size,createdTime",
url.PathEscape(fileID),
)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("drive API request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("drive API returned %d: %s", resp.StatusCode, body)
}

var driveFile driveFileResponse
if err := json.Unmarshal(body, &driveFile); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}

meta := &FileMeta{
ID: driveFile.ID,
Name: driveFile.Name,
MimeType: driveFile.MimeType,
}

if driveFile.Size != "" {
fmt.Sscanf(driveFile.Size, "%d", &meta.Size)
}
if driveFile.CreatedTime != "" {
meta.CreatedTime, _ = time.Parse(time.RFC3339, driveFile.CreatedTime)
}

return meta, nil
}

// RefreshTokenIfNeeded uses the oauth2 config to refresh the access token
// if it is expired. Returns the potentially-refreshed token.
func RefreshTokenIfNeeded(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*oauth2.Token, error) {
src := cfg.TokenSource(ctx, token)
newToken, err := src.Token()
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
return newToken, nil
}
65 changes: 65 additions & 0 deletions internal/gdrive/gdrive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package gdrive

import "testing"

func TestExtractFileID(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "file/d/ URL",
url: "https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view?usp=sharing",
want: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
},
{
name: "open?id= URL",
url: "https://drive.google.com/open?id=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
want: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
},
{
name: "Google Docs URL",
url: "https://docs.google.com/document/d/1abc-def_GHI/edit",
want: "1abc-def_GHI",
},
{
name: "Google Sheets URL",
url: "https://docs.google.com/spreadsheets/d/1abc-def_GHI/edit#gid=0",
want: "1abc-def_GHI",
},
{
name: "non-Drive URL",
url: "https://www.dropbox.com/s/abc123/file.png",
want: "",
},
{
name: "empty string",
url: "",
want: "",
},
{
name: "not a URL",
url: "not a url",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractFileID(tt.url)
if got != tt.want {
t.Errorf("ExtractFileID(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
}

func TestIsDriveURL(t *testing.T) {
if !IsDriveURL("https://drive.google.com/file/d/abc123/view") {
t.Error("expected true for Drive URL")
}
if IsDriveURL("https://www.dropbox.com/s/abc/file.png") {
t.Error("expected false for Dropbox URL")
}
}
53 changes: 53 additions & 0 deletions internal/infra/abuse_contact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package infra

import (
"context"
"fmt"
"net"
)

// RDAPAbuseContactLookup adapts the RDAP client to look up abuse contacts
// by ASN. It performs an ASN-to-IP lookup (using a representative prefix)
// and then queries RDAP for the abuse contact.
type RDAPAbuseContactLookup struct {
RDAP RDAPClient
ASN ASNClient
}

// LookupAbuseContactByASN looks up the abuse contact email for an ASN.
// Since RDAP works on IPs rather than ASNs, we construct a representative
// query using the ASN's name from a Team Cymru lookup.
func (r *RDAPAbuseContactLookup) LookupAbuseContactByASN(ctx context.Context, asn int) (string, error) {
// Use Team Cymru to get a representative IP for this ASN by looking
// up the ASN's origin prefix. We construct a DNS query for
// AS<num>.asn.cymru.com to get prefix info.
// For simplicity, we'll use a well-known approach: query the peer
// information and use any IP in the announced prefix.
// Fallback: construct a placeholder IP query to RDAP using the ASN
// number as a lookup hint.

// Try to look up via RDAP using the ASN directly first. Many RDAP
// implementations support autnum queries.
client := &defaultRDAPClient{}
_ = client

// Simple approach: Try to query RDAP for the ASN's IP prefix.
// Since we may not have a direct IP, we'll attempt a prefix-based lookup.
// For now, use a simple approach: look up a known IP for the ASN.
query := fmt.Sprintf("AS%d", asn)
_ = query

// Use net lookup to find IPs announced by this ASN.
// This is best-effort; if we can't find an IP, return empty.
ips, err := net.DefaultResolver.LookupHost(ctx, fmt.Sprintf("as%d.asn.cymru.com", asn))
if err != nil || len(ips) == 0 {
// Fallback: try RDAP autnum query directly.
return "", fmt.Errorf("could not resolve representative IP for AS%d: %w", asn, err)
}

contact, err := LookupAbuseContact(ctx, r.RDAP, ips[0])
if err != nil {
return "", fmt.Errorf("RDAP lookup for AS%d (via %s): %w", asn, ips[0], err)
}
return contact, nil
}
58 changes: 58 additions & 0 deletions internal/infra/asn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package infra

import (
"context"
"net"

"github.com/ammario/ipisp/v2"
)

// ASNClient abstracts IP-to-ASN lookups for testing.
type ASNClient interface {
LookupIP(ctx context.Context, ip net.IP) (*ipisp.Response, error)
}

// cymruClient wraps ipisp for Team Cymru DNS lookups.
type cymruClient struct{}

func (c *cymruClient) LookupIP(ctx context.Context, ip net.IP) (*ipisp.Response, error) {
return ipisp.LookupIP(ctx, ip)
}

// NewASNClient returns an ASNClient backed by Team Cymru DNS.
func NewASNClient() ASNClient {
return &cymruClient{}
}

// ASNInfo holds the result of an ASN lookup for a single IP.
type ASNInfo struct {
ASN int
ASNName string
BGPPrefix string
Country string
}

// LookupASN performs an IP-to-ASN lookup and returns the ASN info.
func LookupASN(ctx context.Context, client ASNClient, ip string) (*ASNInfo, error) {
parsed := net.ParseIP(ip)
if parsed == nil {
return nil, &net.ParseError{Type: "IP address", Text: ip}
}

resp, err := client.LookupIP(ctx, parsed)
if err != nil {
return nil, err
}

bgpPrefix := ""
if resp.Range != nil {
bgpPrefix = resp.Range.String()
}

return &ASNInfo{
ASN: int(resp.ASN),
ASNName: resp.ISPName,
BGPPrefix: bgpPrefix,
Country: resp.Country,
}, nil
}
82 changes: 82 additions & 0 deletions internal/infra/bgp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package infra

import (
"bufio"
"context"
"fmt"
"net"
"strconv"
"strings"
)

// BGPClient abstracts BGP upstream lookups for testing.
type BGPClient interface {
LookupUpstreams(ctx context.Context, asn int) ([]int, error)
}

// bgpToolsClient queries bgp.tools via TCP whois.
type bgpToolsClient struct {
addr string
}

// NewBGPClient returns a BGPClient that queries bgp.tools on port 43.
func NewBGPClient() BGPClient {
return &bgpToolsClient{addr: "bgp.tools:43"}
}

func (c *bgpToolsClient) LookupUpstreams(ctx context.Context, asn int) ([]int, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", c.addr)
if err != nil {
return nil, fmt.Errorf("bgp.tools dial: %w", err)
}
defer conn.Close()

query := fmt.Sprintf("AS%d\n", asn)
if _, err := conn.Write([]byte(query)); err != nil {
return nil, fmt.Errorf("bgp.tools write: %w", err)
}

return ParseBGPUpstreams(bufio.NewScanner(conn))
}

// ParseBGPUpstreams parses the whois-style response from bgp.tools,
// looking for upstream ASN numbers. The response format includes lines like:
//
// Upstreams: AS174, AS3356
func ParseBGPUpstreams(scanner *bufio.Scanner) ([]int, error) {
var upstreams []int
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "Upstreams:") {
continue
}
// Extract the value part after "Upstreams:"
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
continue
}
value := strings.TrimSpace(parts[1])
if value == "" || value == "None" {
return nil, nil
}
for _, tok := range strings.Split(value, ",") {
tok = strings.TrimSpace(tok)
tok = strings.TrimPrefix(tok, "AS")
tok = strings.TrimPrefix(tok, "as")
if tok == "" {
continue
}
n, err := strconv.Atoi(tok)
if err != nil {
continue
}
upstreams = append(upstreams, n)
}
break
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("bgp.tools read: %w", err)
}
return upstreams, nil
}
28 changes: 28 additions & 0 deletions internal/infra/cloudflare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package infra

import "github.com/endharassment/reporting-wizard/internal/model"

// CloudflareASN is the primary ASN for Cloudflare.
const CloudflareASN = 13335

// IsCloudflare returns true if the given ASN belongs to Cloudflare.
func IsCloudflare(asn int) bool {
return asn == CloudflareASN
}

// MarkCloudflare sets IsCloudflare on each InfraResult based on its ASN.
func MarkCloudflare(results []model.InfraResult) {
for i := range results {
results[i].IsCloudflare = IsCloudflare(results[i].ASN)
}
}

// AnyCloudflare returns true if any result has a Cloudflare ASN.
func AnyCloudflare(results []model.InfraResult) bool {
for _, r := range results {
if IsCloudflare(r.ASN) {
return true
}
}
return false
}
131 changes: 131 additions & 0 deletions internal/infra/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package infra

import (
"context"
"sync"

"github.com/endharassment/reporting-wizard/internal/model"
"golang.org/x/sync/errgroup"
)

// MaxConcurrency is the default bound on parallel lookups.
const MaxConcurrency = 8

// Discovery orchestrates the full infrastructure discovery pipeline.
type Discovery struct {
DNS DNSResolver
ASN ASNClient
RDAP RDAPClient
BGP BGPClient
}

// NewDiscovery returns a Discovery with production clients.
func NewDiscovery() *Discovery {
return &Discovery{
DNS: NewDNSResolver(),
ASN: NewASNClient(),
RDAP: NewRDAPClient(),
BGP: NewBGPClient(),
}
}

// Run executes the full discovery pipeline for a domain:
// 1. DNS lookup to get IPs
// 2. For each IP: ASN lookup, Cloudflare check, RDAP abuse contact
// 3. For each unique ASN: BGP upstream lookup
// 4. Returns complete []model.InfraResult
func (d *Discovery) Run(ctx context.Context, domain string) ([]model.InfraResult, error) {
// Step 1: DNS resolution.
results, err := LookupDomain(ctx, d.DNS, domain)
if err != nil {
return nil, err
}

// Step 2: Parallel ASN + RDAP lookups per IP.
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(MaxConcurrency)

var mu sync.Mutex

for i := range results {
i := i
ip := results[i].IP

// ASN lookup.
g.Go(func() error {
info, err := LookupASN(gctx, d.ASN, ip)
if err != nil {
return err
}
mu.Lock()
results[i].ASN = info.ASN
results[i].ASNName = info.ASNName
results[i].BGPPrefix = info.BGPPrefix
results[i].Country = info.Country
mu.Unlock()
return nil
})

// RDAP abuse contact lookup.
g.Go(func() error {
contact, err := LookupAbuseContact(gctx, d.RDAP, ip)
if err != nil {
// RDAP failures are non-fatal; we still have other data.
return nil
}
mu.Lock()
results[i].AbuseContact = contact
mu.Unlock()
return nil
})
}

if err := g.Wait(); err != nil {
return nil, err
}

// Mark Cloudflare.
MarkCloudflare(results)

// Step 3: Collect unique ASNs and look up BGP upstreams.
asnSet := make(map[int]bool)
for _, r := range results {
if r.ASN != 0 {
asnSet[r.ASN] = true
}
}

upstreamMap := make(map[int][]int)
var upMu sync.Mutex

g2, gctx2 := errgroup.WithContext(ctx)
g2.SetLimit(MaxConcurrency)

for asn := range asnSet {
asn := asn
g2.Go(func() error {
upstreams, err := d.BGP.LookupUpstreams(gctx2, asn)
if err != nil {
// BGP failures are non-fatal.
return nil
}
upMu.Lock()
upstreamMap[asn] = upstreams
upMu.Unlock()
return nil
})
}

if err := g2.Wait(); err != nil {
return nil, err
}

// Attach upstream ASNs to results.
for i := range results {
if ups, ok := upstreamMap[results[i].ASN]; ok {
results[i].UpstreamASNs = ups
}
}

return results, nil
}
75 changes: 75 additions & 0 deletions internal/infra/dns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package infra

import (
"context"
"net"

"github.com/endharassment/reporting-wizard/internal/model"
)

// DNSResolver abstracts DNS lookups for testing.
type DNSResolver interface {
LookupIP(ctx context.Context, network, host string) ([]net.IP, error)
}

// netResolver wraps net.Resolver to implement DNSResolver.
type netResolver struct {
r *net.Resolver
}

func (n *netResolver) LookupIP(ctx context.Context, network, host string) ([]net.IP, error) {
return n.r.LookupIP(ctx, network, host)
}

// NewDNSResolver returns a DNSResolver backed by the system resolver.
func NewDNSResolver() DNSResolver {
return &netResolver{r: net.DefaultResolver}
}

// LookupDomain resolves A and AAAA records for a domain, returning partially
// filled InfraResults with IP and RecordType set.
func LookupDomain(ctx context.Context, resolver DNSResolver, domain string) ([]model.InfraResult, error) {
var results []model.InfraResult

ip4s, err := resolver.LookupIP(ctx, "ip4", domain)
if err != nil {
// If no A records exist, that's not necessarily fatal.
if !isNoSuchHost(err) {
return nil, err
}
}
for _, ip := range ip4s {
results = append(results, model.InfraResult{
IP: ip.String(),
RecordType: "A",
})
}

ip6s, err := resolver.LookupIP(ctx, "ip6", domain)
if err != nil {
if !isNoSuchHost(err) {
return nil, err
}
}
for _, ip := range ip6s {
results = append(results, model.InfraResult{
IP: ip.String(),
RecordType: "AAAA",
})
}

if len(results) == 0 {
return nil, &net.DNSError{
Err: "no A or AAAA records found",
Name: domain,
IsNotFound: true,
}
}

return results, nil
}

func isNoSuchHost(err error) bool {
dnsErr, ok := err.(*net.DNSError)
return ok && dnsErr.IsNotFound
}
672 changes: 672 additions & 0 deletions internal/infra/infra_test.go

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions internal/infra/rdap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package infra

import (
"context"
"fmt"
"net"
"strings"

"github.com/openrdap/rdap"
)

// RDAPClient abstracts RDAP lookups for testing.
type RDAPClient interface {
LookupIP(ctx context.Context, ip string) (*rdap.IPNetwork, error)
}

// defaultRDAPClient uses the openrdap library.
type defaultRDAPClient struct{}

func (c *defaultRDAPClient) LookupIP(ctx context.Context, ip string) (*rdap.IPNetwork, error) {
client := &rdap.Client{}
req := &rdap.Request{
Type: rdap.IPRequest,
Query: ip,
}
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
ipNet, ok := resp.Object.(*rdap.IPNetwork)
if !ok {
return nil, fmt.Errorf("rdap: unexpected response type for IP %s", ip)
}
return ipNet, nil
}

// NewRDAPClient returns an RDAPClient backed by the standard RDAP bootstrap.
func NewRDAPClient() RDAPClient {
return &defaultRDAPClient{}
}

// LookupAbuseContact queries RDAP for the abuse contact email of the given IP.
func LookupAbuseContact(ctx context.Context, client RDAPClient, ip string) (string, error) {
parsed := net.ParseIP(ip)
if parsed == nil {
return "", &net.ParseError{Type: "IP address", Text: ip}
}

ipNet, err := client.LookupIP(ctx, ip)
if err != nil {
return "", fmt.Errorf("rdap lookup for %s: %w", ip, err)
}

return extractAbuseContact(ipNet.Entities), nil
}

// extractAbuseContact walks the RDAP entity tree looking for an abuse role
// with an email in the vCard.
func extractAbuseContact(entities []rdap.Entity) string {
for _, entity := range entities {
for _, role := range entity.Roles {
if strings.EqualFold(role, "abuse") {
if email := extractEmailFromVCard(entity); email != "" {
return email
}
}
}
// Check nested entities.
if email := extractAbuseContact(entity.Entities); email != "" {
return email
}
}
return ""
}

// extractEmailFromVCard extracts an email from the vCard in an RDAP entity.
func extractEmailFromVCard(entity rdap.Entity) string {
if entity.VCard == nil {
return ""
}
// Use the VCard.Email() helper which returns the first email property value.
return entity.VCard.Email()
}
162 changes: 162 additions & 0 deletions internal/model/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package model

import "time"

// ViolationType represents the kind of abuse being reported.
type ViolationType string

const (
ViolationHarassment ViolationType = "harassment"
ViolationHateSpeech ViolationType = "hate_speech"
ViolationNCII ViolationType = "ncii"
ViolationDoxxing ViolationType = "doxxing"
ViolationCopyvio ViolationType = "copyvio"
)

// ReportStatus tracks a report through its lifecycle.
type ReportStatus string

const (
StatusDraft ReportStatus = "draft"
StatusPendingApproval ReportStatus = "pending_approval"
StatusCloudfarePending ReportStatus = "cloudflare_pending"
StatusSent ReportStatus = "sent"
StatusAwaitingResponse ReportStatus = "awaiting_response"
StatusEscalating ReportStatus = "escalating"
StatusResolved ReportStatus = "resolved"
)

// EmailStatus tracks an outgoing email through its lifecycle.
type EmailStatus string

const (
EmailPendingApproval EmailStatus = "pending_approval"
EmailApproved EmailStatus = "approved"
EmailSent EmailStatus = "sent"
EmailBounced EmailStatus = "bounced"
EmailRejected EmailStatus = "rejected"
)

// EmailType classifies outgoing emails.
type EmailType string

const (
EmailTypeInitialReport EmailType = "initial_report"
EmailTypeEscalation EmailType = "escalation"
EmailTypeCloudflare EmailType = "cloudflare"
)

// User represents a reporter or admin.
type User struct {
ID string
Email string
Name string
IsAdmin bool
GoogleAccessToken string
GoogleRefreshToken string
GoogleTokenExpiry time.Time
CreatedAt time.Time
}

// Session represents an authenticated session.
type Session struct {
ID string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
}

// Report represents an abuse report targeting a single domain.
type Report struct {
ID string
UserID string
Domain string
URLs []string // stored as JSON in DB
ViolationType ViolationType
Description string
Status ReportStatus
CloudflareOriginIP string // set by admin when Cloudflare reveals origin
CreatedAt time.Time
UpdatedAt time.Time
}

// InfraResult represents discovered infrastructure for a report's domain.
type InfraResult struct {
ID string
ReportID string
IP string
RecordType string // "A" or "AAAA"
ASN int
ASNName string
BGPPrefix string
Country string
AbuseContact string
IsCloudflare bool
UpstreamASNs []int // stored as JSON in DB
CreatedAt time.Time
}

// Evidence represents a piece of evidence for a report. Evidence can be either
// a URL pointing to user-hosted content (e.g., Google Drive, Dropbox) or
// a locally stored file (legacy).
type Evidence struct {
ID string
ReportID string
Filename string // empty for URL-only evidence
ContentType string // empty for URL-only evidence
StoragePath string // empty for URL-only evidence
SHA256 string // empty for URL-only evidence
SizeBytes int64 // 0 for URL-only evidence
EvidenceURL string // URL to cloud-hosted evidence (primary method)
Description string
// Google Drive metadata (populated when evidence URL is a Drive link).
DriveFileID string
DriveFileName string
DriveMimeType string
DriveSize int64
DriveVerified bool
CreatedAt time.Time
}

// URLSnapshot represents a text-only crawl of a reported URL.
type URLSnapshot struct {
ID string
ReportID string
URL string
TextContent string
FetchedAt time.Time
Error string
CreatedAt time.Time
}

// OutgoingEmail represents an email queued for sending.
type OutgoingEmail struct {
ID string
ReportID string
ParentEmailID string // links to prior report in escalation chain
Recipient string
RecipientOrg string
TargetASN int
EmailType EmailType
XARFJson string
EmailSubject string
EmailBody string
Status EmailStatus
ApprovedBy string
ApprovedAt *time.Time
SentAt *time.Time
SendGridID string
EscalateAfter *time.Time
ResponseNotes string
CreatedAt time.Time
}

// AuditLogEntry records an admin action.
type AuditLogEntry struct {
ID string
UserID string
Action string
TargetID string
Details string
CreatedAt time.Time
}
193 changes: 193 additions & 0 deletions internal/report/email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package report

import (
"encoding/base64"
"fmt"
"strings"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)

// EmailConfig holds settings for composing and sending emails.
type EmailConfig struct {
XARF XARFConfig
FromAddress string
FromName string
// SandboxMode when true prevents actual email delivery via SendGrid.
SandboxMode bool
// SendGridAPIKey is the API key for SendGrid.
SendGridAPIKey string
}

// ComposeEmail builds a model.OutgoingEmail from a report, its infrastructure
// results, and evidence. It generates both a human-readable body and an X-ARF
// JSON attachment.
func ComposeEmail(cfg EmailConfig, report *model.Report, infraResults []*model.InfraResult, evidence []*model.Evidence, evidenceContent map[string]string) (*model.OutgoingEmail, error) {
xarfJSON, err := GenerateXARF(cfg.XARF, report, infraResults, evidence, evidenceContent)
if err != nil {
return nil, fmt.Errorf("generating X-ARF: %w", err)
}

// Pick the first abuse contact; caller should create one email per target.
recipient := ""
recipientOrg := ""
targetASN := 0
if len(infraResults) > 0 {
recipient = infraResults[0].AbuseContact
recipientOrg = infraResults[0].ASNName
targetASN = infraResults[0].ASN
}

subject := fmt.Sprintf("Abuse Report: %s violation on %s", violationLabel(report.ViolationType), report.Domain)

body := composeBody(cfg, report, infraResults, evidence)

return &model.OutgoingEmail{
ReportID: report.ID,
Recipient: recipient,
RecipientOrg: recipientOrg,
TargetASN: targetASN,
EmailType: model.EmailTypeInitialReport,
XARFJson: string(xarfJSON),
EmailSubject: subject,
EmailBody: body,
Status: model.EmailPendingApproval,
CreatedAt: time.Now().UTC(),
}, nil
}

func composeBody(cfg EmailConfig, report *model.Report, infraResults []*model.InfraResult, evidence []*model.Evidence) string {
var b strings.Builder

b.WriteString("Dear Abuse Team,\n\n")
b.WriteString(fmt.Sprintf("We are writing on behalf of an affected individual to report a %s violation hosted on the domain %s, and to request that you take action under your acceptable use policy.\n\n", violationLabel(report.ViolationType), report.Domain))

// Add context-specific disclaimers.
switch report.ViolationType {
case model.ViolationNCII:
b.WriteString("This report is filed on behalf of the person depicted in the non-consensual intimate imagery, or their authorized representative.\n\n")
case model.ViolationCopyvio:
b.WriteString("NOTE: This is a Terms of Service abuse report, not a DMCA takedown notice. We are requesting that you review this content under your acceptable use policy.\n\n")
}

b.WriteString("Reported URLs:\n")
for _, u := range report.URLs {
b.WriteString(fmt.Sprintf(" - %s\n", u))
}
b.WriteString("\n")

b.WriteString("Description:\n")
b.WriteString(report.Description)
b.WriteString("\n\n")

if len(infraResults) > 0 {
b.WriteString("Infrastructure Details:\n")
for _, ir := range infraResults {
b.WriteString(fmt.Sprintf(" - IP: %s (AS%d %s, %s)\n", ir.IP, ir.ASN, ir.ASNName, ir.Country))
}
b.WriteString("\n")
}

if len(evidence) > 0 {
b.WriteString("Evidence:\n")
for _, e := range evidence {
if e.EvidenceURL != "" {
b.WriteString(fmt.Sprintf(" - %s\n", e.EvidenceURL))
} else {
b.WriteString(fmt.Sprintf(" - %s (%s, SHA-256: %s)\n", e.Filename, e.ContentType, e.SHA256))
}
}
b.WriteString("\n")
}

b.WriteString("A machine-readable X-ARF v4 report is attached to this email as a JSON file.\n\n")
b.WriteString("We request that you investigate this matter and take appropriate action in accordance with your acceptable use policy.\n\n")
b.WriteString("Regards,\n")
b.WriteString(fmt.Sprintf("%s\n", cfg.XARF.ReporterOrg))
b.WriteString(fmt.Sprintf("%s <%s>\n", cfg.XARF.ReporterContactName, cfg.XARF.ReporterContactEmail))

return b.String()
}

func violationLabel(vt model.ViolationType) string {
switch vt {
case model.ViolationHarassment:
return "harassment"
case model.ViolationHateSpeech:
return "hate speech"
case model.ViolationNCII:
return "non-consensual intimate imagery (NCII)"
case model.ViolationDoxxing:
return "doxxing"
case model.ViolationCopyvio:
return "copyright infringement"
default:
return string(vt)
}
}

// SendGridSender is the interface for sending emails via SendGrid.
// This abstraction allows for easy mocking in tests.
type SendGridSender interface {
Send(email *mail.SGMailV3) (*SendResult, error)
}

// SendResult contains the result of sending an email.
type SendResult struct {
StatusCode int
MessageID string
}

// RealSendGridSender sends emails via the SendGrid API.
type RealSendGridSender struct {
APIKey string
}

// Send dispatches an email through the SendGrid API.
func (s *RealSendGridSender) Send(email *mail.SGMailV3) (*SendResult, error) {
client := sendgrid.NewSendClient(s.APIKey)
resp, err := client.Send(email)
if err != nil {
return nil, fmt.Errorf("sendgrid send: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("sendgrid returned status %d: %s", resp.StatusCode, resp.Body)
}
messageID := ""
if ids, ok := resp.Headers["X-Message-Id"]; ok && len(ids) > 0 {
messageID = ids[0]
}
return &SendResult{
StatusCode: resp.StatusCode,
MessageID: messageID,
}, nil
}

// SendEmail sends an OutgoingEmail via the provided SendGridSender.
// When sandboxMode is true, the SendGrid sandbox mail setting is enabled,
// which validates the request without delivering the message.
func SendEmail(sender SendGridSender, cfg EmailConfig, outgoing *model.OutgoingEmail) (*SendResult, error) {
from := mail.NewEmail(cfg.FromName, cfg.FromAddress)
to := mail.NewEmail(outgoing.RecipientOrg, outgoing.Recipient)

message := mail.NewSingleEmail(from, outgoing.EmailSubject, to, outgoing.EmailBody, "")

// Attach X-ARF JSON report.
attachment := mail.NewAttachment()
attachment.SetContent(base64.StdEncoding.EncodeToString([]byte(outgoing.XARFJson)))
attachment.SetType("application/json")
attachment.SetFilename("xarf-report.json")
attachment.SetDisposition("attachment")
message.AddAttachment(attachment)

if cfg.SandboxMode {
settings := mail.NewMailSettings()
settings.SetSandboxMode(mail.NewSetting(true))
message.SetMailSettings(settings)
}

return sender.Send(message)
}
125 changes: 125 additions & 0 deletions internal/report/evidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package report

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
"github.com/google/uuid"
)

const maxEvidenceFileSize int64 = 20 << 20 // 20 MiB

// allowedContentTypes lists MIME types accepted for evidence uploads.
var allowedContentTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/tiff": true,
"image/bmp": true,
"application/pdf": true,
"text/plain": true,
"video/mp4": true,
}

var (
ErrFileTooLarge = errors.New("evidence file exceeds maximum size of 20MB")
ErrDisallowedType = errors.New("content type not allowed for evidence upload")
ErrContentTypeEmpty = errors.New("content type must not be empty")
ErrFilenameEmpty = errors.New("filename must not be empty")
ErrReportIDEmpty = errors.New("report ID must not be empty")
ErrEvidenceDirMissing = errors.New("evidence directory does not exist")
)

// isAllowedContentType checks whether a MIME type is permitted. It accepts
// any image/* subtype in addition to the explicit allowlist.
func isAllowedContentType(ct string) bool {
ct = strings.TrimSpace(strings.ToLower(ct))
// Strip parameters (e.g. "text/plain; charset=utf-8")
if idx := strings.Index(ct, ";"); idx != -1 {
ct = strings.TrimSpace(ct[:idx])
}
if allowedContentTypes[ct] {
return true
}
if strings.HasPrefix(ct, "image/") {
return true
}
return false
}

// HandleUpload processes an evidence file upload. It streams the reader to
// disk under evidenceDir, computing a SHA-256 hash as it goes, and returns
// a populated model.Evidence struct.
func HandleUpload(ctx context.Context, evidenceDir string, reportID string, filename string, contentType string, r io.Reader) (*model.Evidence, error) {
if reportID == "" {
return nil, ErrReportIDEmpty
}
if filename == "" {
return nil, ErrFilenameEmpty
}
if contentType == "" {
return nil, ErrContentTypeEmpty
}
if !isAllowedContentType(contentType) {
return nil, fmt.Errorf("%w: %s", ErrDisallowedType, contentType)
}

// Verify evidence directory exists.
info, err := os.Stat(evidenceDir)
if err != nil || !info.IsDir() {
return nil, ErrEvidenceDirMissing
}

// Create report-specific subdirectory.
reportDir := filepath.Join(evidenceDir, reportID)
if err := os.MkdirAll(reportDir, 0o750); err != nil {
return nil, fmt.Errorf("creating report evidence directory: %w", err)
}

evidenceID := uuid.New().String()
storagePath := filepath.Join(reportDir, evidenceID)

f, err := os.Create(storagePath)
if err != nil {
return nil, fmt.Errorf("creating evidence file: %w", err)
}
defer f.Close()

hasher := sha256.New()
limitedReader := io.LimitReader(r, maxEvidenceFileSize+1)
written, err := io.Copy(f, io.TeeReader(limitedReader, hasher))
if err != nil {
os.Remove(storagePath)
return nil, fmt.Errorf("writing evidence file: %w", err)
}
if written > maxEvidenceFileSize {
os.Remove(storagePath)
return nil, ErrFileTooLarge
}

if err := f.Close(); err != nil {
os.Remove(storagePath)
return nil, fmt.Errorf("closing evidence file: %w", err)
}

return &model.Evidence{
ID: evidenceID,
ReportID: reportID,
Filename: filename,
ContentType: contentType,
StoragePath: storagePath,
SHA256: hex.EncodeToString(hasher.Sum(nil)),
SizeBytes: written,
CreatedAt: time.Now().UTC(),
}, nil
}
537 changes: 537 additions & 0 deletions internal/report/report_test.go

Large diffs are not rendered by default.

242 changes: 242 additions & 0 deletions internal/report/safety.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package report

import (
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"unicode"

"github.com/endharassment/reporting-wizard/internal/model"
)

// Per-report total evidence size limit.
const MaxTotalEvidenceBytesPerReport int64 = 100 << 20 // 100 MiB

// Per-file size limit (mirrors evidence.go).
const MaxEvidenceFileSize int64 = 20 << 20 // 20 MiB

// SafeAllowedContentTypes is the strict allowlist of MIME types accepted for
// evidence uploads. Unlike the permissive list in evidence.go, this does NOT
// accept arbitrary image/* subtypes (which would include SVG with embedded JS).
var SafeAllowedContentTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"application/pdf": true,
"text/plain": true,
}

// magicHeaders maps content types to their file magic byte signatures.
var magicHeaders = map[string][]byte{
"image/jpeg": {0xFF, 0xD8, 0xFF},
"image/png": {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
"image/gif": {0x47, 0x49, 0x46, 0x38}, // GIF8
"image/webp": {0x52, 0x49, 0x46, 0x46}, // RIFF (WebP container)
"application/pdf": {0x25, 0x50, 0x44, 0x46}, // %PDF
}

var (
ErrContentTypeNotAllowed = errors.New("content type not in safety allowlist")
ErrMagicBytesMismatch = errors.New("file content does not match declared content type")
ErrTotalSizeExceeded = errors.New("total evidence size exceeds per-report limit")
ErrFilenameDangerous = errors.New("filename contains dangerous characters")
)

// ValidateContentType checks whether a MIME type is in the strict safety
// allowlist. It strips parameters (e.g., charset) before checking.
func ValidateContentType(contentType string) error {
ct := strings.TrimSpace(strings.ToLower(contentType))
if idx := strings.Index(ct, ";"); idx != -1 {
ct = strings.TrimSpace(ct[:idx])
}
if !SafeAllowedContentTypes[ct] {
return fmt.Errorf("%w: %s", ErrContentTypeNotAllowed, contentType)
}
return nil
}

// ValidateFileMagic reads the first bytes of a file and verifies they match
// the expected magic bytes for the declared content type. For text/plain,
// it checks that the content is valid UTF-8 text without embedded null bytes.
// The reader is wrapped so the peeked bytes are still available.
func ValidateFileMagic(r io.Reader, declaredType string) (io.Reader, error) {
ct := normalizeContentType(declaredType)

if ct == "text/plain" {
return validateTextContent(r)
}

expected, ok := magicHeaders[ct]
if !ok {
// No magic header to check; allow (content type was already validated).
return r, nil
}

header := make([]byte, len(expected))
n, err := io.ReadFull(r, header)
if err != nil {
return nil, fmt.Errorf("reading file header: %w", err)
}

for i := 0; i < n; i++ {
if header[i] != expected[i] {
return nil, fmt.Errorf("%w: expected %s", ErrMagicBytesMismatch, ct)
}
}

// Return a reader that replays the header bytes then continues with the
// rest of the original reader.
return io.MultiReader(strings.NewReader(string(header[:n])), r), nil
}

// validateTextContent peeks at the first chunk of a text file and verifies
// it contains no null bytes (which would indicate a binary file disguised
// as text).
func validateTextContent(r io.Reader) (io.Reader, error) {
buf := make([]byte, 512)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return nil, fmt.Errorf("reading text content: %w", err)
}
for _, b := range buf[:n] {
if b == 0 {
return nil, fmt.Errorf("%w: null bytes in text/plain file", ErrMagicBytesMismatch)
}
}
return io.MultiReader(strings.NewReader(string(buf[:n])), r), nil
}

// ValidateTotalEvidenceSize checks whether adding a new file of the given
// size would exceed the per-report total evidence limit. existingBytes is the
// sum of all existing evidence file sizes for the report.
func ValidateTotalEvidenceSize(existingBytes, newFileBytes int64) error {
if existingBytes+newFileBytes > MaxTotalEvidenceBytesPerReport {
return fmt.Errorf("%w: current %d + new %d > limit %d",
ErrTotalSizeExceeded, existingBytes, newFileBytes, MaxTotalEvidenceBytesPerReport)
}
return nil
}

// SanitizeFilename removes directory components, null bytes, and control
// characters from a user-provided filename, returning a safe version.
func SanitizeFilename(filename string) (string, error) {
// Normalize Windows-style backslash separators to forward slash so that
// filepath.Base strips them on Linux.
filename = strings.ReplaceAll(filename, "\\", "/")

// Take only the base name (strip any directory components).
name := filepath.Base(filename)

// filepath.Base returns "." for empty input.
if name == "." || name == "" {
return "", fmt.Errorf("%w: empty after sanitization", ErrFilenameDangerous)
}

// Remove null bytes and control characters.
var sb strings.Builder
for _, r := range name {
if r == 0 || unicode.IsControl(r) {
continue
}
sb.WriteRune(r)
}
name = sb.String()

// Reject if empty after cleaning.
if name == "" || name == "." || name == ".." {
return "", fmt.Errorf("%w: invalid after sanitization", ErrFilenameDangerous)
}

// Truncate excessively long filenames.
if len(name) > 255 {
ext := filepath.Ext(name)
base := name[:255-len(ext)]
name = base + ext
}

return name, nil
}

// DetectContentType uses http.DetectContentType on the first 512 bytes to
// determine the actual MIME type of a file. Returns the detected type and a
// reader that replays the sniffed bytes followed by the rest of the content.
func DetectContentType(r io.Reader) (string, io.Reader, error) {
buf := make([]byte, 512)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return "", nil, fmt.Errorf("sniffing content type: %w", err)
}
detected := http.DetectContentType(buf[:n])
combined := io.MultiReader(strings.NewReader(string(buf[:n])), r)
return detected, combined, nil
}

// HashChecker defines the interface for checking evidence file hashes against
// known-bad content databases (e.g., NCMEC/PhotoDNA). Implementations should
// return true if the hash matches a known-bad entry.
type HashChecker interface {
// CheckSHA256 checks a SHA-256 hash against the known-bad database.
// Returns (isMatch, error).
CheckSHA256(hash string) (bool, error)
}

// NoOpHashChecker is a placeholder that always returns no match. Replace this
// with a real implementation when integrating with PhotoDNA or a hash-sharing
// service.
type NoOpHashChecker struct{}

// CheckSHA256 always returns false (no match). This is a placeholder.
func (n *NoOpHashChecker) CheckSHA256(_ string) (bool, error) {
return false, nil
}

// EvidenceMetadata provides a view of an evidence file's metadata without
// exposing the file content. This is used for admin review workflows where
// viewing content may not be desired or safe.
type EvidenceMetadata struct {
ID string
ReportID string
Filename string
ContentType string
SHA256 string
SizeBytes int64
Description string
CreatedAt string
}

// MetadataFromEvidence extracts metadata from a model.Evidence without loading
// or exposing file content. This allows admins to review file details without
// being exposed to potentially harmful content.
func MetadataFromEvidence(ev *model.Evidence) EvidenceMetadata {
return EvidenceMetadata{
ID: ev.ID,
ReportID: ev.ReportID,
Filename: ev.Filename,
ContentType: ev.ContentType,
SHA256: ev.SHA256,
SizeBytes: ev.SizeBytes,
Description: ev.Description,
CreatedAt: ev.CreatedAt.Format("2006-01-02 15:04:05 UTC"),
}
}

// MetadataFromEvidenceList converts a slice of evidence to metadata-only views.
func MetadataFromEvidenceList(evs []*model.Evidence) []EvidenceMetadata {
metas := make([]EvidenceMetadata, len(evs))
for i, ev := range evs {
metas[i] = MetadataFromEvidence(ev)
}
return metas
}

func normalizeContentType(ct string) string {
ct = strings.TrimSpace(strings.ToLower(ct))
if idx := strings.Index(ct, ";"); idx != -1 {
ct = strings.TrimSpace(ct[:idx])
}
return ct
}
233 changes: 233 additions & 0 deletions internal/report/safety_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package report

import (
"strings"
"testing"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
)

func TestValidateContentType(t *testing.T) {
tests := []struct {
name string
contentType string
wantErr bool
}{
{"jpeg allowed", "image/jpeg", false},
{"png allowed", "image/png", false},
{"gif allowed", "image/gif", false},
{"webp allowed", "image/webp", false},
{"pdf allowed", "application/pdf", false},
{"text allowed", "text/plain", false},
{"text with charset", "text/plain; charset=utf-8", false},
{"svg rejected", "image/svg+xml", true},
{"tiff rejected", "image/tiff", true},
{"bmp rejected", "image/bmp", true},
{"video rejected", "video/mp4", true},
{"html rejected", "text/html", true},
{"executable rejected", "application/octet-stream", true},
{"empty rejected", "", true},
{"uppercase normalized", "IMAGE/JPEG", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateContentType(tt.contentType)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateContentType(%q) error = %v, wantErr %v", tt.contentType, err, tt.wantErr)
}
})
}
}

func TestValidateFileMagic(t *testing.T) {
tests := []struct {
name string
content string
contentType string
wantErr bool
}{
{
name: "valid jpeg",
content: "\xFF\xD8\xFF\xE0rest of jpeg data",
contentType: "image/jpeg",
wantErr: false,
},
{
name: "invalid jpeg magic",
content: "\x89PNG\r\n\x1a\nthis is actually png",
contentType: "image/jpeg",
wantErr: true,
},
{
name: "valid png",
content: "\x89PNG\r\n\x1a\nrest of png data",
contentType: "image/png",
wantErr: false,
},
{
name: "valid gif",
content: "GIF89a rest of gif data",
contentType: "image/gif",
wantErr: false,
},
{
name: "valid pdf",
content: "%PDF-1.4 rest of pdf data",
contentType: "application/pdf",
wantErr: false,
},
{
name: "valid text",
content: "This is plain text content",
contentType: "text/plain",
wantErr: false,
},
{
name: "text with null bytes rejected",
content: "text with \x00 null byte",
contentType: "text/plain",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := strings.NewReader(tt.content)
_, err := ValidateFileMagic(r, tt.contentType)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFileMagic() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestValidateTotalEvidenceSize(t *testing.T) {
tests := []struct {
name string
existing int64
newFile int64
wantErr bool
}{
{"within limit", 50 << 20, 30 << 20, false},
{"exactly at limit", 80 << 20, 20 << 20, false},
{"exceeds limit", 90 << 20, 20 << 20, true},
{"zero existing", 0, 20 << 20, false},
{"zero new", 50 << 20, 0, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTotalEvidenceSize(tt.existing, tt.newFile)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateTotalEvidenceSize(%d, %d) error = %v, wantErr %v",
tt.existing, tt.newFile, err, tt.wantErr)
}
})
}
}

func TestSanitizeFilename(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{"simple name", "photo.jpg", "photo.jpg", false},
{"strips directory", "/etc/passwd", "passwd", false},
{"strips relative path", "../../../etc/passwd", "passwd", false},
{"strips windows path", `C:\Users\evil\file.txt`, "file.txt", false},
{"removes null bytes", "file\x00name.jpg", "filename.jpg", false},
{"removes control chars", "file\x01\x02name.jpg", "filename.jpg", false},
{"empty string rejected", "", "", true},
{"dot rejected", ".", "", true},
{"dotdot rejected", "..", "", true},
{"preserves spaces", "my file (1).jpg", "my file (1).jpg", false},
{"preserves unicode", "photo_\u00e9vidence.png", "photo_\u00e9vidence.png", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SanitizeFilename(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("SanitizeFilename(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("SanitizeFilename(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

func TestSanitizeFilenameLongName(t *testing.T) {
long := strings.Repeat("a", 300) + ".jpg"
got, err := SanitizeFilename(long)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) > 255 {
t.Errorf("filename length %d exceeds 255", len(got))
}
if !strings.HasSuffix(got, ".jpg") {
t.Errorf("expected .jpg extension, got %q", got)
}
}

func TestMetadataFromEvidence(t *testing.T) {
ev := &model.Evidence{
ID: "ev-123",
ReportID: "rpt-456",
Filename: "screenshot.png",
ContentType: "image/png",
SHA256: "abcdef1234567890",
SizeBytes: 12345,
Description: "Screenshot of harassment",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
}

meta := MetadataFromEvidence(ev)

if meta.ID != ev.ID {
t.Errorf("ID = %q, want %q", meta.ID, ev.ID)
}
if meta.ReportID != ev.ReportID {
t.Errorf("ReportID = %q, want %q", meta.ReportID, ev.ReportID)
}
if meta.Filename != ev.Filename {
t.Errorf("Filename = %q, want %q", meta.Filename, ev.Filename)
}
if meta.SHA256 != ev.SHA256 {
t.Errorf("SHA256 = %q, want %q", meta.SHA256, ev.SHA256)
}
if meta.SizeBytes != ev.SizeBytes {
t.Errorf("SizeBytes = %d, want %d", meta.SizeBytes, ev.SizeBytes)
}
}

func TestMetadataFromEvidenceList(t *testing.T) {
evs := []*model.Evidence{
{ID: "ev-1", Filename: "a.png", CreatedAt: time.Now()},
{ID: "ev-2", Filename: "b.jpg", CreatedAt: time.Now()},
}
metas := MetadataFromEvidenceList(evs)
if len(metas) != 2 {
t.Fatalf("got %d metadata entries, want 2", len(metas))
}
if metas[0].ID != "ev-1" || metas[1].ID != "ev-2" {
t.Error("metadata IDs don't match input evidence")
}
}

func TestNoOpHashChecker(t *testing.T) {
checker := &NoOpHashChecker{}
match, err := checker.CheckSHA256("abcdef1234567890")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if match {
t.Error("NoOpHashChecker should always return false")
}
}
123 changes: 123 additions & 0 deletions internal/report/xarf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package report

import (
"encoding/json"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
)

// XARFReport represents an X-ARF v4 abuse report.
type XARFReport struct {
Version string `json:"Version"`
ReporterInfo XARFReporter `json:"ReporterInfo"`
Report XARFReportBody `json:"Report"`
Evidence []XARFEvidence `json:"Evidence"`
}

// XARFReporter identifies the organization filing the report.
type XARFReporter struct {
ReporterOrg string `json:"ReporterOrg"`
ReporterOrgDomain string `json:"ReporterOrgDomain"`
ReporterContactEmail string `json:"ReporterContactEmail"`
ReporterContactName string `json:"ReporterContactName"`
}

// XARFReportBody contains the report details.
type XARFReportBody struct {
ReportClass string `json:"ReportClass"`
ReportType string `json:"ReportType"`
Date string `json:"Date"`
SourceIP string `json:"SourceIp"`
SourcePort int `json:"SourcePort"`
Domain string `json:"Domain"`
URLs []string `json:"URLs"`
Description string `json:"Description"`
}

// XARFEvidence represents a piece of evidence in X-ARF format.
type XARFEvidence struct {
Description string `json:"Description"`
ContentType string `json:"ContentType,omitempty"`
SHA256 string `json:"SHA256,omitempty"`
URL string `json:"URL,omitempty"`
Content string `json:"Content,omitempty"`
}

// XARFConfig holds the reporter identity used in X-ARF reports.
type XARFConfig struct {
ReporterOrg string
ReporterOrgDomain string
ReporterContactEmail string
ReporterContactName string
}

// maxInlineEvidenceBytes is the maximum size of evidence content to include
// inline (base64-encoded) in the X-ARF JSON. Larger files are referenced
// by hash only.
const maxInlineEvidenceBytes int64 = 1 << 20 // 1 MiB

// violationMapping maps ViolationType to (ReportClass, ReportType).
var violationMapping = map[model.ViolationType][2]string{
model.ViolationHarassment: {"content", "illegal_content"},
model.ViolationHateSpeech: {"content", "illegal_content"},
model.ViolationNCII: {"content", "illegal_content"},
model.ViolationDoxxing: {"content", "illegal_content"},
model.ViolationCopyvio: {"copyright", "copyright_infringement"},
}

// GenerateXARF creates an X-ARF v4 JSON report from a model.Report, its
// infrastructure results, and associated evidence.
func GenerateXARF(cfg XARFConfig, report *model.Report, infraResults []*model.InfraResult, evidence []*model.Evidence, evidenceContent map[string]string) ([]byte, error) {
mapping, ok := violationMapping[report.ViolationType]
if !ok {
mapping = [2]string{"content", "illegal_content"}
}

sourceIP := ""
if len(infraResults) > 0 {
sourceIP = infraResults[0].IP
}

xarfEvidence := make([]XARFEvidence, 0, len(evidence))
for _, e := range evidence {
xe := XARFEvidence{
Description: e.Description,
}
if e.EvidenceURL != "" {
xe.URL = e.EvidenceURL
} else {
xe.ContentType = e.ContentType
xe.SHA256 = e.SHA256
if e.SizeBytes <= maxInlineEvidenceBytes {
if content, found := evidenceContent[e.ID]; found {
xe.Content = content
}
}
}
xarfEvidence = append(xarfEvidence, xe)
}

xarf := XARFReport{
Version: "4",
ReporterInfo: XARFReporter{
ReporterOrg: cfg.ReporterOrg,
ReporterOrgDomain: cfg.ReporterOrgDomain,
ReporterContactEmail: cfg.ReporterContactEmail,
ReporterContactName: cfg.ReporterContactName,
},
Report: XARFReportBody{
ReportClass: mapping[0],
ReportType: mapping[1],
Date: report.CreatedAt.UTC().Format(time.RFC3339),
SourceIP: sourceIP,
SourcePort: 0,
Domain: report.Domain,
URLs: report.URLs,
Description: report.Description,
},
Evidence: xarfEvidence,
}

return json.MarshalIndent(xarf, "", " ")
}
412 changes: 412 additions & 0 deletions internal/server/auth.go

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions internal/server/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package server

import (
"context"

"github.com/endharassment/reporting-wizard/internal/model"
)

type contextKey int

const (
ctxKeyUser contextKey = iota
ctxKeyCSRFToken
ctxKeyRequestID
)

func withUser(ctx context.Context, user *model.User) context.Context {
return context.WithValue(ctx, ctxKeyUser, user)
}

// UserFromContext returns the authenticated user from the context, or nil.
func UserFromContext(ctx context.Context) *model.User {
u, _ := ctx.Value(ctxKeyUser).(*model.User)
return u
}

func withCSRFToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, ctxKeyCSRFToken, token)
}
257 changes: 257 additions & 0 deletions internal/server/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package server

import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"strings"
"sync/atomic"
"time"
)

// RequestIDFromContext returns the request ID from the context, if present.
func RequestIDFromContext(ctx context.Context) string {
if id, ok := ctx.Value(ctxKeyRequestID).(string); ok {
return id
}
return ""
}

// --- Request ID Middleware ---

var requestCounter atomic.Uint64

// RequestIDMiddleware assigns a unique request ID to each request and adds it
// to the response headers and request context.
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
seq := requestCounter.Add(1)
id = fmt.Sprintf("%d-%d", time.Now().UnixMilli(), seq)
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// --- Logging Middleware ---

// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
written int64
}

func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.written += int64(n)
return n, err
}

// LoggingMiddleware logs each request with structured fields including method,
// path, status code, duration, and request ID.
func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
duration := time.Since(start)

logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", duration.Milliseconds(),
"bytes", rw.written,
"remote_addr", r.RemoteAddr,
"request_id", RequestIDFromContext(r.Context()),
)
})
}
}

// --- Recovery Middleware ---

// RecoveryMiddleware recovers from panics in downstream handlers, logs the
// stack trace, and returns a 500 Internal Server Error.
func RecoveryMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
logger.Error("panic recovered",
"error", fmt.Sprintf("%v", rec),
"stack", string(stack),
"method", r.Method,
"path", r.URL.Path,
"request_id", RequestIDFromContext(r.Context()),
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}

// --- Security Headers Middleware ---

// SecurityHeadersMiddleware sets security-related HTTP headers on all responses.
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}

// --- CSRF Middleware ---

const (
csrfCookieName = "_csrf"
csrfFieldName = "csrf_token"
csrfHeaderName = "X-CSRF-Token"
csrfTokenBytes = 32
)

// CSRFMiddleware provides CSRF protection using the double-submit cookie
// pattern with HMAC verification.
func CSRFMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
token, err := generateCSRFToken(secret)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
})
ctx := withCSRFToken(r.Context(), token)
next.ServeHTTP(w, r.WithContext(ctx))

case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
cookie, err := r.Cookie(csrfCookieName)
if err != nil {
http.Error(w, "Forbidden: missing CSRF cookie", http.StatusForbidden)
return
}

submitted := r.FormValue(csrfFieldName)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" {
http.Error(w, "Forbidden: missing CSRF token", http.StatusForbidden)
return
}

if !validateCSRFToken(secret, cookie.Value, submitted) {
http.Error(w, "Forbidden: invalid CSRF token", http.StatusForbidden)
return
}

token, err := generateCSRFToken(secret)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
})
ctx := withCSRFToken(r.Context(), token)
next.ServeHTTP(w, r.WithContext(ctx))

default:
next.ServeHTTP(w, r)
}
})
}
}

// CSRFTokenFromContext returns the CSRF token from the context, if present.
func CSRFTokenFromContext(ctx context.Context) string {
t, _ := ctx.Value(ctxKeyCSRFToken).(string)
return t
}

// generateCSRFToken creates a random token and signs it with HMAC.
func generateCSRFToken(secret []byte) (string, error) {
randomBytes := make([]byte, csrfTokenBytes)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("generating CSRF random bytes: %w", err)
}

encoded := base64.RawURLEncoding.EncodeToString(randomBytes)
mac := hmac.New(sha256.New, secret)
mac.Write(randomBytes)
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))

return encoded + "." + sig, nil
}

// validateCSRFToken checks that two CSRF tokens are valid and equal.
func validateCSRFToken(secret []byte, cookieToken, submittedToken string) bool {
cookieParts := strings.SplitN(cookieToken, ".", 2)
submittedParts := strings.SplitN(submittedToken, ".", 2)
if len(cookieParts) != 2 || len(submittedParts) != 2 {
return false
}

cookieRandom, err := base64.RawURLEncoding.DecodeString(cookieParts[0])
if err != nil {
return false
}
cookieSig, err := base64.RawURLEncoding.DecodeString(cookieParts[1])
if err != nil {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(cookieRandom)
expectedSig := mac.Sum(nil)
if !hmac.Equal(cookieSig, expectedSig) {
return false
}

return hmac.Equal([]byte(cookieToken), []byte(submittedToken))
}

// generateRandomHex creates a random hex-encoded string (for OAuth state, etc.)
func generateRandomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
326 changes: 326 additions & 0 deletions internal/server/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
package server

import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

func TestRequestIDMiddleware(t *testing.T) {
tests := []struct {
name string
existingID string
wantCustom bool
}{
{
name: "generates ID when none provided",
existingID: "",
wantCustom: false,
},
{
name: "preserves existing ID",
existingID: "custom-id-123",
wantCustom: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ctxID string
handler := RequestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxID = RequestIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.existingID != "" {
req.Header.Set("X-Request-ID", tt.existingID)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

respID := rec.Header().Get("X-Request-ID")
if respID == "" {
t.Error("expected X-Request-ID header in response")
}
if tt.wantCustom && respID != tt.existingID {
t.Errorf("got %q, want %q", respID, tt.existingID)
}
if ctxID != respID {
t.Errorf("context ID %q != response header ID %q", ctxID, respID)
}
})
}
}

func TestLoggingMiddleware(t *testing.T) {
logger := slog.Default()
handler := LoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello"))
}))

req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
}

func TestRecoveryMiddleware(t *testing.T) {
logger := slog.Default()
handler := RecoveryMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("test panic")
}))

req := httptest.NewRequest(http.MethodGet, "/panic", nil)
rec := httptest.NewRecorder()

// Should not panic.
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusInternalServerError {
t.Errorf("got status %d, want %d", rec.Code, http.StatusInternalServerError)
}
}

func TestSecurityHeadersMiddleware(t *testing.T) {
handler := SecurityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

expectedHeaders := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-XSS-Protection": "0",
}

for header, expected := range expectedHeaders {
got := rec.Header().Get(header)
if got != expected {
t.Errorf("header %s = %q, want %q", header, got, expected)
}
}

csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("expected Content-Security-Policy header")
}
if !strings.Contains(csp, "default-src 'self'") {
t.Errorf("CSP missing default-src 'self': %s", csp)
}

pp := rec.Header().Get("Permissions-Policy")
if pp == "" {
t.Error("expected Permissions-Policy header")
}
}

func TestCSRFMiddleware(t *testing.T) {
secret := []byte("test-secret-key-for-csrf-tokens!")

t.Run("GET sets cookie and context token", func(t *testing.T) {
var ctxToken string
handler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxToken = CSRFTokenFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "/form", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d", rec.Code, http.StatusOK)
}
if ctxToken == "" {
t.Error("expected CSRF token in context")
}

cookies := rec.Result().Cookies()
var found bool
for _, c := range cookies {
if c.Name == csrfCookieName {
found = true
if c.Value != ctxToken {
t.Errorf("cookie value %q != context token %q", c.Value, ctxToken)
}
}
}
if !found {
t.Error("expected CSRF cookie to be set")
}
})

t.Run("POST without cookie returns 403", func(t *testing.T) {
handler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodPost, "/submit", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusForbidden {
t.Errorf("got status %d, want %d", rec.Code, http.StatusForbidden)
}
})

t.Run("POST with valid token succeeds", func(t *testing.T) {
// First, get a token via GET.
var token string
getHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token = CSRFTokenFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))

getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getRec := httptest.NewRecorder()
getHandler.ServeHTTP(getRec, getReq)

// Now POST with the token.
postHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

form := url.Values{}
form.Set(csrfFieldName, token)
postReq := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postReq.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
postRec := httptest.NewRecorder()
postHandler.ServeHTTP(postRec, postReq)

if postRec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", postRec.Code, http.StatusOK)
}
})

t.Run("POST with mismatched token returns 403", func(t *testing.T) {
// Get a valid token.
var token string
getHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token = CSRFTokenFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getRec := httptest.NewRecorder()
getHandler.ServeHTTP(getRec, getReq)

// Get a different token.
var token2 string
getReq2 := httptest.NewRequest(http.MethodGet, "/form", nil)
getRec2 := httptest.NewRecorder()
getHandler2 := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token2 = CSRFTokenFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
getHandler2.ServeHTTP(getRec2, getReq2)

// POST with cookie from first token but form value from second.
postHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

form := url.Values{}
form.Set(csrfFieldName, token2)
postReq := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postReq.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
postRec := httptest.NewRecorder()
postHandler.ServeHTTP(postRec, postReq)

if postRec.Code != http.StatusForbidden {
t.Errorf("got status %d, want %d", postRec.Code, http.StatusForbidden)
}
})

t.Run("POST with header token succeeds", func(t *testing.T) {
var token string
getHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token = CSRFTokenFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getRec := httptest.NewRecorder()
getHandler.ServeHTTP(getRec, getReq)

postHandler := CSRFMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
postReq := httptest.NewRequest(http.MethodPost, "/api/submit", nil)
postReq.Header.Set(csrfHeaderName, token)
postReq.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
postRec := httptest.NewRecorder()
postHandler.ServeHTTP(postRec, postReq)

if postRec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", postRec.Code, http.StatusOK)
}
})
}

func TestCSRFTokenGeneration(t *testing.T) {
secret := []byte("test-secret-key-for-csrf-tokens!")

token1, err := generateCSRFToken(secret)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
token2, err := generateCSRFToken(secret)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if token1 == token2 {
t.Error("expected unique tokens, got identical")
}

// Token should contain a dot separator.
if !strings.Contains(token1, ".") {
t.Errorf("token missing dot separator: %s", token1)
}
}

func TestCSRFTokenValidation(t *testing.T) {
secret := []byte("test-secret-key-for-csrf-tokens!")

token, err := generateCSRFToken(secret)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

tests := []struct {
name string
cookie string
submitted string
want bool
}{
{"matching tokens", token, token, true},
{"mismatched tokens", token, "wrong.token", false},
{"empty cookie", "", token, false},
{"empty submitted", token, "", false},
{"tampered signature", token, strings.Split(token, ".")[0] + ".tampered", false},
{"no dot in cookie", "nodot", token, false},
{"no dot in submitted", token, "nodot", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := validateCSRFToken(secret, tt.cookie, tt.submitted)
if got != tt.want {
t.Errorf("validateCSRFToken() = %v, want %v", got, tt.want)
}
})
}
}
225 changes: 225 additions & 0 deletions internal/server/ratelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package server

import (
"net/http"
"sync"
"time"
)

// RateLimiterConfig holds configuration for rate limiting.
type RateLimiterConfig struct {
// Per-IP limits for general requests.
GeneralRequestsPerMin int
// Per-IP limits for report submission endpoints.
ReportRequestsPerMin int
// Per-user report submission limits.
UserReportsPerHour int
UserReportsPerDay int
// CleanupInterval is how often stale buckets are purged.
CleanupInterval time.Duration
}

// DefaultRateLimiterConfig returns sensible defaults.
func DefaultRateLimiterConfig() RateLimiterConfig {
return RateLimiterConfig{
GeneralRequestsPerMin: 60,
ReportRequestsPerMin: 10,
UserReportsPerHour: 5,
UserReportsPerDay: 20,
CleanupInterval: 5 * time.Minute,
}
}

// tokenBucket implements a simple token bucket rate limiter.
type tokenBucket struct {
tokens float64
maxTokens float64
refillRate float64 // tokens per second
lastRefill time.Time
}

func newTokenBucket(maxTokens float64, refillRate float64) *tokenBucket {
return &tokenBucket{
tokens: maxTokens,
maxTokens: maxTokens,
refillRate: refillRate,
lastRefill: time.Now(),
}
}

func (b *tokenBucket) allow() bool {
now := time.Now()
elapsed := now.Sub(b.lastRefill).Seconds()
b.tokens += elapsed * b.refillRate
if b.tokens > b.maxTokens {
b.tokens = b.maxTokens
}
b.lastRefill = now

if b.tokens >= 1 {
b.tokens--
return true
}
return false
}

func (b *tokenBucket) stale(ttl time.Duration) bool {
return time.Since(b.lastRefill) > ttl
}

// RateLimiter provides per-IP and per-user rate limiting.
type RateLimiter struct {
config RateLimiterConfig

ipBuckets sync.Map // map[string]*tokenBucket (keyed by IP)
userBuckets sync.Map // map[string]*userRateState (keyed by userID)

mu sync.Mutex
stopCh chan struct{}
}

// userRateState tracks per-user rate limits using separate hourly and daily
// token buckets.
type userRateState struct {
hourly *tokenBucket
daily *tokenBucket
}

// NewRateLimiter creates a new RateLimiter and starts a background cleanup
// goroutine. Call Stop() to release resources.
func NewRateLimiter(config RateLimiterConfig) *RateLimiter {
rl := &RateLimiter{
config: config,
stopCh: make(chan struct{}),
}
go rl.cleanup()
return rl
}

// Stop halts the background cleanup goroutine.
func (rl *RateLimiter) Stop() {
close(rl.stopCh)
}

func (rl *RateLimiter) cleanup() {
interval := rl.config.CleanupInterval
if interval <= 0 {
interval = 5 * time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-rl.stopCh:
return
case <-ticker.C:
ttl := 10 * time.Minute
rl.ipBuckets.Range(func(key, value any) bool {
if b, ok := value.(*tokenBucket); ok && b.stale(ttl) {
rl.ipBuckets.Delete(key)
}
return true
})
rl.userBuckets.Range(func(key, value any) bool {
if s, ok := value.(*userRateState); ok && s.hourly.stale(ttl) && s.daily.stale(ttl) {
rl.userBuckets.Delete(key)
}
return true
})
}
}
}

// AllowIP checks whether a request from the given IP is allowed under the
// general per-IP rate limit. Returns true if allowed.
func (rl *RateLimiter) AllowIP(ip string, perMinLimit int) bool {
rate := float64(perMinLimit) / 60.0
maxTokens := float64(perMinLimit)

val, _ := rl.ipBuckets.LoadOrStore(ip, newTokenBucket(maxTokens, rate))
bucket := val.(*tokenBucket)

rl.mu.Lock()
defer rl.mu.Unlock()
return bucket.allow()
}

// AllowUserReport checks whether a user is allowed to submit a report under
// per-user hourly and daily limits. Returns true if allowed.
func (rl *RateLimiter) AllowUserReport(userID string) bool {
hourlyRate := float64(rl.config.UserReportsPerHour) / 3600.0
dailyRate := float64(rl.config.UserReportsPerDay) / 86400.0

val, _ := rl.userBuckets.LoadOrStore(userID, &userRateState{
hourly: newTokenBucket(float64(rl.config.UserReportsPerHour), hourlyRate),
daily: newTokenBucket(float64(rl.config.UserReportsPerDay), dailyRate),
})
state := val.(*userRateState)

rl.mu.Lock()
defer rl.mu.Unlock()
if !state.hourly.allow() {
return false
}
if !state.daily.allow() {
return false
}
return true
}

// IPRateLimitMiddleware returns middleware that enforces per-IP rate limits
// on all requests. It returns 429 Too Many Requests when the limit is exceeded.
func IPRateLimitMiddleware(rl *RateLimiter, perMinLimit int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractIP(r)
if !rl.AllowIP(ip, perMinLimit) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

// ReportRateLimitMiddleware returns middleware that enforces stricter per-IP
// rate limits on report submission endpoints.
func ReportRateLimitMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractIP(r)
if !rl.AllowIP(ip, rl.config.ReportRequestsPerMin) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

// extractIP returns the client IP from the request, preferring
// X-Forwarded-For if behind a trusted reverse proxy. In production,
// this should be configured to only trust known proxy IPs.
func extractIP(r *http.Request) string {
// In production behind a reverse proxy, use the first entry in
// X-Forwarded-For from a trusted proxy. For now, fall back to RemoteAddr.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the leftmost (client) IP. In production, validate this
// against trusted proxy list.
for i := range xff {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}

// Strip port from RemoteAddr.
addr := r.RemoteAddr
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
186 changes: 186 additions & 0 deletions internal/server/ratelimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package server

import (
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestTokenBucketAllow(t *testing.T) {
tests := []struct {
name string
max float64
rate float64
calls int
wantAllow int
}{
{
name: "allows up to max tokens",
max: 3,
rate: 1,
calls: 5,
wantAllow: 3,
},
{
name: "single token",
max: 1,
rate: 1,
calls: 2,
wantAllow: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTokenBucket(tt.max, tt.rate)
allowed := 0
for range tt.calls {
if b.allow() {
allowed++
}
}
if allowed != tt.wantAllow {
t.Errorf("got %d allowed, want %d", allowed, tt.wantAllow)
}
})
}
}

func TestTokenBucketRefill(t *testing.T) {
b := newTokenBucket(2, 1000) // 1000 tokens/sec refill
// Drain the bucket.
b.allow()
b.allow()
if b.allow() {
t.Fatal("expected bucket to be empty")
}

// Wait for refill.
time.Sleep(10 * time.Millisecond)
if !b.allow() {
t.Error("expected bucket to have refilled after sleep")
}
}

func TestRateLimiterAllowIP(t *testing.T) {
cfg := DefaultRateLimiterConfig()
cfg.CleanupInterval = time.Hour // don't interfere with test
rl := NewRateLimiter(cfg)
defer rl.Stop()

ip := "192.0.2.1"
limit := 5
allowed := 0
for range 10 {
if rl.AllowIP(ip, limit) {
allowed++
}
}
if allowed != limit {
t.Errorf("got %d allowed, want %d", allowed, limit)
}

// Different IP should have its own bucket.
if !rl.AllowIP("192.0.2.2", limit) {
t.Error("different IP should be allowed")
}
}

func TestRateLimiterAllowUserReport(t *testing.T) {
cfg := DefaultRateLimiterConfig()
cfg.UserReportsPerHour = 3
cfg.UserReportsPerDay = 5
cfg.CleanupInterval = time.Hour
rl := NewRateLimiter(cfg)
defer rl.Stop()

userID := "user-1"
allowed := 0
for range 10 {
if rl.AllowUserReport(userID) {
allowed++
}
}
// Should be limited by the hourly cap (3).
if allowed != cfg.UserReportsPerHour {
t.Errorf("got %d allowed, want %d (hourly limit)", allowed, cfg.UserReportsPerHour)
}
}

func TestIPRateLimitMiddleware(t *testing.T) {
cfg := DefaultRateLimiterConfig()
cfg.CleanupInterval = time.Hour
rl := NewRateLimiter(cfg)
defer rl.Stop()

handler := IPRateLimitMiddleware(rl, 3)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

okCount := 0
limitedCount := 0
for range 10 {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
okCount++
} else if rec.Code == http.StatusTooManyRequests {
limitedCount++
}
}
if okCount != 3 {
t.Errorf("got %d OK responses, want 3", okCount)
}
if limitedCount != 7 {
t.Errorf("got %d rate-limited responses, want 7", limitedCount)
}
}

func TestExtractIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
xff string
want string
}{
{
name: "remote addr with port",
remoteAddr: "192.0.2.1:12345",
want: "192.0.2.1",
},
{
name: "remote addr without port",
remoteAddr: "192.0.2.1",
want: "192.0.2.1",
},
{
name: "xff single",
remoteAddr: "127.0.0.1:80",
xff: "203.0.113.50",
want: "203.0.113.50",
},
{
name: "xff multiple",
remoteAddr: "127.0.0.1:80",
xff: "203.0.113.50, 70.41.3.18, 150.172.238.178",
want: "203.0.113.50",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
req.Header.Set("X-Forwarded-For", tt.xff)
}
got := extractIP(req)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
53 changes: 53 additions & 0 deletions internal/server/reports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package server

import (
"log"
"net/http"

"github.com/go-chi/chi/v5"
)

// HandleReportsList renders the current user's reports.
func (s *Server) HandleReportsList(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())

reports, err := s.store.ListReportsByUser(r.Context(), user.ID)
if err != nil {
log.Printf("ERROR: list reports: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

s.render(w, r, "list.html", map[string]interface{}{
"Reports": reports,
})
}

// HandleReportDetail renders a single report with all its associated data.
func (s *Server) HandleReportDetail(w http.ResponseWriter, r *http.Request) {
reportID := chi.URLParam(r, "reportID")
user := UserFromContext(r.Context())

rpt, err := s.store.GetReport(r.Context(), reportID)
if err != nil {
http.Error(w, "Report not found", http.StatusNotFound)
return
}
if rpt.UserID != user.ID && !user.IsAdmin {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

evidence, _ := s.store.ListEvidenceByReport(r.Context(), reportID)
emails, _ := s.store.ListEmailsByReport(r.Context(), reportID)
auditLog, _ := s.store.ListAuditLogByTarget(r.Context(), reportID)
snapshots, _ := s.store.ListURLSnapshotsByReport(r.Context(), reportID)

s.render(w, r, "detail.html", map[string]interface{}{
"Report": rpt,
"Evidence": evidence,
"Emails": emails,
"Timeline": auditLog,
"Snapshots": snapshots,
})
}
215 changes: 215 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package server

import (
"context"
"html/template"
"io/fs"
"log"
"log/slog"
"net/http"
"strings"

"github.com/endharassment/reporting-wizard/internal/admin"
"github.com/endharassment/reporting-wizard/internal/infra"
"github.com/endharassment/reporting-wizard/internal/report"
"github.com/endharassment/reporting-wizard/internal/store"
"github.com/go-chi/chi/v5"
)

// Config holds server configuration.
type Config struct {
ListenAddr string
DBPath string
SendGridKey string
FromEmail string
FromName string
BaseURL string
GoogleClientID string
GoogleSecret string
GitHubClientID string
GitHubSecret string
EscalationDays int
SessionSecret string
}

// Snapshotter defines the interface for crawling and snapshotting URLs.
type Snapshotter interface {
// Snapshot fetches a URL and returns its text-only content.
Snapshot(ctx context.Context, targetURL string) (string, error)
}

// Server is the main HTTP server for the reporting wizard.
type Server struct {
config Config
store store.Store
templates *template.Template
discovery *infra.Discovery
emailCfg report.EmailConfig
rl *RateLimiter
router chi.Router
staticFS fs.FS
snapshotter Snapshotter
}

// NewServer creates a new Server from the given config, store, and filesystem assets.
func NewServer(cfg Config, s store.Store, templatesFS fs.FS, staticFS fs.FS) (*Server, error) {
funcMap := template.FuncMap{
"string": func(v interface{}) string {
switch val := v.(type) {
case string:
return val
case fmt_Stringer:
return val.String()
default:
return ""
}
},
}

tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "*.html", "**/*.html")
if err != nil {
return nil, err
}

emailCfg := report.EmailConfig{
XARF: report.XARFConfig{
ReporterOrg: "End Harassment",
ReporterOrgDomain: extractDomain(cfg.BaseURL),
ReporterContactEmail: cfg.FromEmail,
ReporterContactName: cfg.FromName,
},
FromAddress: cfg.FromEmail,
FromName: cfg.FromName,
SendGridAPIKey: cfg.SendGridKey,
}

srv := &Server{
config: cfg,
store: s,
templates: tmpl,
discovery: infra.NewDiscovery(),
emailCfg: emailCfg,
rl: NewRateLimiter(DefaultRateLimiterConfig()),
staticFS: staticFS,
}

srv.router = srv.routes()
return srv, nil
}

type fmt_Stringer interface {
String() string
}

func extractDomain(baseURL string) string {
s := strings.TrimPrefix(baseURL, "https://")
s = strings.TrimPrefix(s, "http://")
if idx := strings.Index(s, "/"); idx != -1 {
s = s[:idx]
}
if idx := strings.Index(s, ":"); idx != -1 {
s = s[:idx]
}
return s
}

func (s *Server) routes() chi.Router {
r := chi.NewRouter()

logger := slog.Default()
r.Use(RequestIDMiddleware)
r.Use(LoggingMiddleware(logger))
r.Use(RecoveryMiddleware(logger))
r.Use(SecurityHeadersMiddleware)
r.Use(IPRateLimitMiddleware(s.rl, s.rl.config.GeneralRequestsPerMin))
r.Use(CSRFMiddleware([]byte(s.config.SessionSecret)))
r.Use(s.SessionMiddleware)

// Static files.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS))))

// Public routes.
r.Get("/", s.HandleIndex)
r.Get("/auth/login", s.HandleLogin)
r.Get("/auth/google", s.HandleGoogleLogin)
r.Get("/auth/google/callback", s.HandleGoogleCallback)
r.Get("/auth/github", s.HandleGitHubLogin)
r.Get("/auth/github/callback", s.HandleGitHubCallback)

// Authenticated routes.
r.Group(func(r chi.Router) {
r.Use(RequireAuth)
r.Post("/auth/logout", s.HandleLogout)

// Wizard.
r.Get("/wizard/step1", s.HandleWizardStep1)
r.Post("/wizard/step1", s.HandleWizardStep1Submit)
r.Get("/wizard/step2/{reportID}", s.HandleWizardStep2)
r.Post("/wizard/step2/{reportID}/cloudflare-ack", s.HandleCloudflareAck)
r.Get("/wizard/step3/{reportID}", s.HandleWizardStep3)
r.Post("/wizard/step3/{reportID}", s.HandleWizardStep3Submit)
r.Get("/wizard/step4/{reportID}", s.HandleWizardStep4)
r.Post("/wizard/step4/{reportID}/submit", s.HandleWizardStep4Submit)

// Reports.
r.Get("/reports", s.HandleReportsList)
r.Get("/reports/{reportID}", s.HandleReportDetail)
})

// Admin routes.
r.Group(func(r chi.Router) {
r.Use(RequireAuth)
r.Use(RequireAdmin)

ah := admin.NewAdminHandler(s.store, s.discovery, s.emailCfg, s.templates,
UserFromContext,
func(ctx context.Context) string { return CSRFTokenFromContext(ctx) },
)

r.Get("/admin", ah.HandleDashboard)
r.Get("/admin/queue", ah.HandleQueue)
r.Get("/admin/reports/{reportID}", ah.HandleReportView)
r.Post("/admin/reports/{reportID}/origin-ip", ah.HandleSetOriginIP)
r.Post("/admin/reports/{reportID}/approve", ah.HandleReportApprove)
r.Post("/admin/reports/{reportID}/reject", ah.HandleReportReject)
r.Get("/admin/emails/{emailID}", ah.HandleEmailPreview)
r.Post("/admin/emails/{emailID}/approve", ah.HandleEmailApprove)
r.Post("/admin/emails/{emailID}/reject", ah.HandleEmailReject)
})

return r
}

// Handler returns the HTTP handler for the server.
func (s *Server) Handler() http.Handler {
return s.router
}

// SetSnapshotter configures the URL snapshotter for text-only URL crawling.
func (s *Server) SetSnapshotter(snap Snapshotter) {
s.snapshotter = snap
}

// Stop cleans up server resources.
func (s *Server) Stop() {
s.rl.Stop()
}

// HandleIndex renders the home page.
func (s *Server) HandleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "index.html", nil)
}

// render executes a template with common data.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["User"] = UserFromContext(r.Context())
data["CSRFToken"] = CSRFTokenFromContext(r.Context())

if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("ERROR: render template %s: %v", name, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
41 changes: 41 additions & 0 deletions internal/server/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package server

import (
"context"
"log"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
"github.com/google/uuid"
)

// snapshotURLs performs best-effort text-only snapshots of the given URLs
// and stores the results in the database. This runs asynchronously so the
// user doesn't wait for potentially slow Tor fetches.
func (s *Server) snapshotURLs(reportID string, urls []string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

for _, u := range urls {
now := time.Now().UTC()
snap := &model.URLSnapshot{
ID: uuid.New().String(),
ReportID: reportID,
URL: u,
FetchedAt: now,
CreatedAt: now,
}

text, err := s.snapshotter.Snapshot(ctx, u)
if err != nil {
log.Printf("WARN: snapshot %s: %v", u, err)
snap.Error = err.Error()
} else {
snap.TextContent = text
}

if err := s.store.CreateURLSnapshot(ctx, snap); err != nil {
log.Printf("ERROR: store snapshot for %s: %v", u, err)
}
}
}
489 changes: 489 additions & 0 deletions internal/server/wizard.go

Large diffs are not rendered by default.

215 changes: 215 additions & 0 deletions internal/snapshot/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Package snapshot provides URL text snapshotting for evidentiary purposes.
// It uses endharassment/tor-fetcher to crawl URLs (including .onion sites
// with PoW challenges) and extracts text-only content.
package snapshot

import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode"
)

// maxBodyBytes is the maximum response body size we'll read.
const maxBodyBytes = 1 << 20 // 1 MiB

// Fetcher abstracts the HTTP fetch operation so we can use tor-fetcher
// or a regular HTTP client.
type Fetcher interface {
Fetch(target, referer string) (*http.Response, error)
}

// TorSnapshotter uses a Fetcher (typically tor-fetcher's TorClient) to
// snapshot URLs and extract text content.
type TorSnapshotter struct {
fetcher Fetcher
timeout time.Duration
}

// NewTorSnapshotter creates a snapshotter backed by the given Fetcher.
func NewTorSnapshotter(f Fetcher) *TorSnapshotter {
return &TorSnapshotter{
fetcher: f,
timeout: 30 * time.Second,
}
}

// Snapshot fetches a URL and extracts text-only content from the HTML.
func (s *TorSnapshotter) Snapshot(_ context.Context, targetURL string) (string, error) {
resp, err := s.fetcher.Fetch(targetURL, "")
if err != nil {
return "", fmt.Errorf("fetching %s: %w", targetURL, err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return "", fmt.Errorf("reading response body: %w", err)
}

text := StripHTML(string(body))
// Truncate to a reasonable size for storage.
if len(text) > 50000 {
text = text[:50000] + "\n[truncated]"
}

return text, nil
}

// PlainHTTPSnapshotter uses a standard net/http client for non-onion URLs.
type PlainHTTPSnapshotter struct {
client *http.Client
}

// NewPlainHTTPSnapshotter creates a snapshotter using a plain HTTP client.
func NewPlainHTTPSnapshotter() *PlainHTTPSnapshotter {
return &PlainHTTPSnapshotter{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}

// Snapshot fetches a URL and extracts text-only content.
func (s *PlainHTTPSnapshotter) Snapshot(ctx context.Context, targetURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "EndHarassment-ReportingWizard/1.0 (abuse report evidence snapshot)")

resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("fetching %s: %w", targetURL, err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return "", fmt.Errorf("reading response body: %w", err)
}

text := StripHTML(string(body))
if len(text) > 50000 {
text = text[:50000] + "\n[truncated]"
}

return text, nil
}

// StripHTML removes HTML tags and extracts visible text content.
// This is a simple implementation that handles common cases.
func StripHTML(s string) string {
var b strings.Builder
inTag := false
inScript := false
inStyle := false
prevSpace := false

lower := strings.ToLower(s)

for i := 0; i < len(s); i++ {
c := s[i]

// Check for script/style opening tags.
if c == '<' {
rest := strings.ToLower(s[i:])
if strings.HasPrefix(rest, "<script") {
inScript = true
} else if strings.HasPrefix(rest, "<style") {
inStyle = true
}
_ = lower // avoid unused warning
inTag = true
continue
}

if c == '>' {
// Check for script/style closing tags.
// Look back for </script> or </style>.
tagContent := ""
for j := i; j >= 0 && j >= i-20; j-- {
if s[j] == '<' {
tagContent = strings.ToLower(s[j : i+1])
break
}
}
if strings.HasPrefix(tagContent, "</script") {
inScript = false
} else if strings.HasPrefix(tagContent, "</style") {
inStyle = false
}
inTag = false
continue
}

if inTag || inScript || inStyle {
continue
}

// Handle HTML entities.
if c == '&' {
entity := ""
for j := i; j < len(s) && j < i+10; j++ {
entity += string(s[j])
if s[j] == ';' {
break
}
}
switch strings.ToLower(entity) {
case "&amp;":
b.WriteByte('&')
i += len(entity) - 1
prevSpace = false
continue
case "&lt;":
b.WriteByte('<')
i += len(entity) - 1
prevSpace = false
continue
case "&gt;":
b.WriteByte('>')
i += len(entity) - 1
prevSpace = false
continue
case "&quot;":
b.WriteByte('"')
i += len(entity) - 1
prevSpace = false
continue
case "&nbsp;":
b.WriteByte(' ')
i += len(entity) - 1
prevSpace = true
continue
}
}

// Collapse whitespace.
if unicode.IsSpace(rune(c)) {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}

b.WriteByte(c)
prevSpace = false
}

// Clean up: trim and collapse blank lines.
lines := strings.Split(b.String(), "\n")
var cleaned []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}

return strings.Join(cleaned, "\n")
}
6 changes: 6 additions & 0 deletions internal/store/migrations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package store

import "embed"

//go:embed migrations/*.sql
var migrationsFS embed.FS
111 changes: 111 additions & 0 deletions internal/store/migrations/001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
-- Users (reporters and admins)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Sessions for auth
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Magic link tokens
CREATE TABLE IF NOT EXISTS magic_links (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- A report targets a single domain
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
domain TEXT NOT NULL,
urls TEXT NOT NULL,
violation_type TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
cloudflare_origin_ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Infrastructure discovered for a report's domain
CREATE TABLE IF NOT EXISTS infra_results (
id TEXT PRIMARY KEY,
report_id TEXT NOT NULL REFERENCES reports(id),
ip TEXT NOT NULL,
record_type TEXT NOT NULL,
asn INTEGER,
asn_name TEXT NOT NULL DEFAULT '',
bgp_prefix TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
abuse_contact TEXT NOT NULL DEFAULT '',
is_cloudflare INTEGER NOT NULL DEFAULT 0,
upstream_asns TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Evidence files attached to a report
CREATE TABLE IF NOT EXISTS evidence (
id TEXT PRIMARY KEY,
report_id TEXT NOT NULL REFERENCES reports(id),
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
storage_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Every outgoing email (initial reports + escalations)
CREATE TABLE IF NOT EXISTS outgoing_emails (
id TEXT PRIMARY KEY,
report_id TEXT NOT NULL REFERENCES reports(id),
parent_email_id TEXT REFERENCES outgoing_emails(id),
recipient TEXT NOT NULL,
recipient_org TEXT NOT NULL DEFAULT '',
target_asn INTEGER,
email_type TEXT NOT NULL,
xarf_json TEXT NOT NULL,
email_subject TEXT NOT NULL,
email_body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_approval',
approved_by TEXT REFERENCES users(id),
approved_at TEXT,
sent_at TEXT,
sendgrid_id TEXT,
escalate_after TEXT,
response_notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Audit log for admin actions
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_id TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_reports_user_id ON reports(user_id);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
CREATE INDEX IF NOT EXISTS idx_infra_results_report_id ON infra_results(report_id);
CREATE INDEX IF NOT EXISTS idx_evidence_report_id ON evidence(report_id);
CREATE INDEX IF NOT EXISTS idx_outgoing_emails_report_id ON outgoing_emails(report_id);
CREATE INDEX IF NOT EXISTS idx_outgoing_emails_status ON outgoing_emails(status);
CREATE INDEX IF NOT EXISTS idx_outgoing_emails_escalate ON outgoing_emails(status, escalate_after);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
16 changes: 16 additions & 0 deletions internal/store/migrations/002_evidence_urls_and_snapshots.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Add evidence_url column for cloud-hosted evidence links.
-- Make storage_path/sha256/size_bytes optional (they were required for file uploads).
ALTER TABLE evidence ADD COLUMN evidence_url TEXT NOT NULL DEFAULT '';

-- URL snapshots: text-only crawl of reported URLs for evidentiary purposes.
CREATE TABLE IF NOT EXISTS url_snapshots (
id TEXT PRIMARY KEY,
report_id TEXT NOT NULL REFERENCES reports(id),
url TEXT NOT NULL,
text_content TEXT NOT NULL DEFAULT '',
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_url_snapshots_report_id ON url_snapshots(report_id);
11 changes: 11 additions & 0 deletions internal/store/migrations/003_google_drive_metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Store Google OAuth tokens on users for Drive API access.
ALTER TABLE users ADD COLUMN google_access_token TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN google_refresh_token TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN google_token_expiry TEXT NOT NULL DEFAULT '';

-- Store Google Drive metadata on evidence records.
ALTER TABLE evidence ADD COLUMN drive_file_id TEXT NOT NULL DEFAULT '';
ALTER TABLE evidence ADD COLUMN drive_file_name TEXT NOT NULL DEFAULT '';
ALTER TABLE evidence ADD COLUMN drive_mime_type TEXT NOT NULL DEFAULT '';
ALTER TABLE evidence ADD COLUMN drive_size INTEGER NOT NULL DEFAULT 0;
ALTER TABLE evidence ADD COLUMN drive_verified INTEGER NOT NULL DEFAULT 0;
600 changes: 600 additions & 0 deletions internal/store/sqlite.go

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package store

import (
"context"
"time"

"github.com/endharassment/reporting-wizard/internal/model"
)

// Store defines the persistence interface for the reporting wizard.
type Store interface {
// Users
CreateUser(ctx context.Context, user *model.User) error
GetUser(ctx context.Context, id string) (*model.User, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
UpdateUser(ctx context.Context, user *model.User) error

// Sessions
CreateSession(ctx context.Context, session *model.Session) error
GetSession(ctx context.Context, id string) (*model.Session, error)
DeleteSession(ctx context.Context, id string) error
DeleteExpiredSessions(ctx context.Context) error

// Reports
CreateReport(ctx context.Context, report *model.Report) error
GetReport(ctx context.Context, id string) (*model.Report, error)
UpdateReport(ctx context.Context, report *model.Report) error
ListReportsByUser(ctx context.Context, userID string) ([]*model.Report, error)
ListReportsByStatus(ctx context.Context, status model.ReportStatus) ([]*model.Report, error)

// Infrastructure Results
CreateInfraResult(ctx context.Context, result *model.InfraResult) error
ListInfraResultsByReport(ctx context.Context, reportID string) ([]*model.InfraResult, error)
DeleteInfraResultsByReport(ctx context.Context, reportID string) error

// Evidence
CreateEvidence(ctx context.Context, evidence *model.Evidence) error
UpdateEvidence(ctx context.Context, evidence *model.Evidence) error
GetEvidence(ctx context.Context, id string) (*model.Evidence, error)
ListEvidenceByReport(ctx context.Context, reportID string) ([]*model.Evidence, error)

// URL Snapshots
CreateURLSnapshot(ctx context.Context, snapshot *model.URLSnapshot) error
ListURLSnapshotsByReport(ctx context.Context, reportID string) ([]*model.URLSnapshot, error)

// Outgoing Emails
CreateOutgoingEmail(ctx context.Context, email *model.OutgoingEmail) error
GetOutgoingEmail(ctx context.Context, id string) (*model.OutgoingEmail, error)
UpdateOutgoingEmail(ctx context.Context, email *model.OutgoingEmail) error
ListEmailsByReport(ctx context.Context, reportID string) ([]*model.OutgoingEmail, error)
ListEmailsByStatus(ctx context.Context, status model.EmailStatus) ([]*model.OutgoingEmail, error)
ListEmailsDueForEscalation(ctx context.Context, now time.Time) ([]*model.OutgoingEmail, error)

// Audit Log
CreateAuditLogEntry(ctx context.Context, entry *model.AuditLogEntry) error
ListAuditLogByTarget(ctx context.Context, targetID string) ([]*model.AuditLogEntry, error)
}
1 change: 1 addition & 0 deletions static/htmx.min.js

Large diffs are not rendered by default.

792 changes: 792 additions & 0 deletions static/style.css

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions templates/admin/dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{{ template "layout" . }}
{{ define "content" }}
<section>
<h1>Admin Dashboard</h1>

<div class="dashboard-cards">
<div class="card">
<h2>{{ .Counts.PendingApproval }}</h2>
<p>Pending Approval</p>
<a href="/admin/queue">Review Queue</a>
</div>
<div class="card">
<h2>{{ .Counts.Sent }}</h2>
<p>Sent (Awaiting Response)</p>
</div>
<div class="card">
<h2>{{ .Counts.Escalating }}</h2>
<p>Escalating</p>
</div>
<div class="card">
<h2>{{ .Counts.CloudflarePending }}</h2>
<p>Cloudflare Pending</p>
<a href="/admin/cloudflare-pending">View</a>
</div>
</div>
</section>
{{ end }}
46 changes: 46 additions & 0 deletions templates/admin/queue.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{{ template "layout" . }}
{{ define "content" }}
<section>
<h1>Approval Queue</h1>

{{ if .PendingEmails }}
<div class="table-responsive">
<table>
<thead>
<tr>
<th scope="col">Report Domain</th>
<th scope="col">Recipient</th>
<th scope="col">Type</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{{ range .PendingEmails }}
<tr id="email-{{ .ID }}">
<td><a href="/admin/reports/{{ .ReportID }}">{{ .RecipientOrg }}</a></td>
<td>{{ .Recipient }}</td>
<td>{{ .EmailType }}</td>
<td><time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006" }}</time></td>
<td class="action-buttons">
<button class="btn btn-primary btn-sm"
hx-post="/admin/emails/{{ .ID }}/approve"
hx-target="#email-{{ .ID }}"
hx-swap="outerHTML"
hx-confirm="Approve this email for sending?">Approve</button>
<button class="btn btn-danger btn-sm"
hx-post="/admin/emails/{{ .ID }}/reject"
hx-target="#email-{{ .ID }}"
hx-swap="outerHTML"
hx-confirm="Reject this email?">Reject</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<p>No emails pending approval.</p>
{{ end }}
</section>
{{ end }}
126 changes: 126 additions & 0 deletions templates/admin/report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
{{ template "layout" . }}
{{ define "content" }}
<section>
<h1>Admin: Report {{ .Report.Domain }}</h1>
<p><span class="badge badge-{{ .Report.Status }}">{{ .Report.Status }}</span></p>

<section class="detail-section">
<h2>URLs</h2>
<ul>
{{ range .Report.URLs }}
<li><code>{{ . }}</code></li>
{{ end }}
</ul>
</section>

<section class="detail-section">
<h2>Violation</h2>
<dl>
<dt>Type</dt>
<dd>{{ .Report.ViolationType }}</dd>
<dt>Description</dt>
<dd>{{ .Report.Description }}</dd>
</dl>
</section>

<section class="detail-section">
<h2>Evidence</h2>
<p class="content-warning">Evidence links may lead to disturbing content. Exercise caution when reviewing.</p>
{{ if .Evidence }}
<ul class="evidence-list">
{{ range .Evidence }}
<li class="evidence-item">
{{ if .EvidenceURL }}
<a href="{{ .EvidenceURL }}" target="_blank" rel="noopener noreferrer">{{ .EvidenceURL }}</a>
{{ if .DriveVerified }}
<span class="badge badge-verified" title="Verified via Google Drive API">Verified</span>
<br><span class="evidence-meta">{{ .DriveFileName }} &middot; {{ .DriveMimeType }}{{ if .DriveSize }} &middot; {{ .DriveSize }} bytes{{ end }}</span>
{{ else if .DriveFileID }}
<span class="badge badge-unverified" title="Google Drive link not verified — user may not have granted Drive scope">Unverified</span>
{{ end }}
{{ if and .Description (not .DriveVerified) }}<span class="evidence-meta">{{ .Description }}</span>{{ end }}
{{ else }}
<span class="evidence-filename">{{ .Filename }}</span>
<span class="evidence-meta">{{ .ContentType }} &middot; {{ .SizeBytes }} bytes &middot; SHA-256: <code class="hash">{{ .SHA256 }}</code></span>
{{ end }}
</li>
{{ end }}
</ul>
{{ else }}
<p>No evidence provided.</p>
{{ end }}
</section>

{{ if eq (string .Report.Status) "cloudflare_pending" }}
<section class="detail-section">
<h2>Cloudflare Origin IP</h2>
<p>This report is awaiting the origin IP from Cloudflare. Enter it below when received.</p>
<form method="post" action="/admin/reports/{{ .Report.ID }}/origin-ip">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="form-group">
<label for="origin_ip">Origin IP Address</label>
<input type="text" id="origin_ip" name="origin_ip" required
placeholder="203.0.113.1"
pattern="[0-9a-fA-F.:]+">
</div>
<button type="submit" class="btn btn-primary">Save Origin IP</button>
</form>
</section>
{{ end }}

<section class="detail-section">
<h2>Actions</h2>
<div class="form-actions">
<form method="post" action="/admin/reports/{{ .Report.ID }}/approve" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<button type="submit" class="btn btn-primary"
onclick="return confirm('Approve this report and queue emails for sending?')">Approve Report</button>
</form>
<form method="post" action="/admin/reports/{{ .Report.ID }}/reject" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="form-group">
<label for="reject_notes">Rejection Notes</label>
<textarea id="reject_notes" name="notes" rows="3" placeholder="Reason for rejection..."></textarea>
</div>
<button type="submit" class="btn btn-danger"
onclick="return confirm('Reject this report?')">Reject Report</button>
</form>
</div>
</section>

{{ if .AuditLog }}
<section class="detail-section">
<h2>Audit Log</h2>
<ol class="timeline">
{{ range .AuditLog }}
<li class="timeline-entry">
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006 3:04 PM" }}</time>
<span class="timeline-action">{{ .Action }}</span>
{{ if .Details }}<span class="timeline-details">{{ .Details }}</span>{{ end }}
</li>
{{ end }}
</ol>
</section>
{{ end }}

<div class="form-actions">
<a href="/admin/queue" class="btn btn-secondary">Back to Queue</a>
</div>
</section>

<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.evidence-preview.blurred').forEach(function(el) {
el.addEventListener('click', function() {
this.classList.remove('blurred');
});
el.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.classList.remove('blurred');
}
});
});
});
</script>
{{ end }}
12 changes: 12 additions & 0 deletions templates/auth/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="auth-page">
<h1>Sign In</h1>
<p>Sign in with your Google account to file or manage abuse reports.</p>

<div class="oauth-buttons">
<a href="/auth/google" class="btn btn-primary btn-block">Sign in with Google</a>
<a href="/auth/github" class="btn btn-secondary btn-block">Sign in with GitHub</a>
</div>
</section>
{{ end }}
56 changes: 56 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="hero">
<h1>Report Abuse. Protect People.</h1>
<p>The End Harassment Reporting Wizard automates the process of filing abuse reports against hate sites and harassment campaigns with their hosting providers.</p>
<a href="/wizard/step1" class="btn btn-primary btn-lg">File a Report</a>
</section>

<section class="alert alert-warning" role="alert">
<h2>Understand the Risks Before Filing</h2>
<p>Hosting providers <strong>will forward your abuse report to the site operator</strong>. This is standard industry practice. The site operator will see the full report text, including your description of the violation and any evidence links you provide.</p>
<p>Operators of abusive sites are known to <strong>retaliate against complainants</strong> by publicly posting copies of abuse complaints and using them to direct further harassment at the reporter.</p>
<p><strong>There is no expectation of privacy for abuse reports.</strong> Assume that anything you include in your report will be seen by the people you are reporting.</p>
<p>Your personal email address is <em>not</em> included in outgoing reports &mdash; reports are sent on behalf of our organization. However, you should avoid including personally identifying details in your description or evidence unless you are comfortable with the site operator seeing them.</p>
</section>

<section class="alert alert-info" role="alert">
<h2>How We File Reports</h2>
<p>We file <strong>Terms of Service (ToS) abuse reports</strong> with hosting providers <strong>on your behalf and with your permission</strong>. Reports are sent under our organization's name, not yours.</p>
<p><strong>For NCII (non-consensual intimate images):</strong> You must be the person depicted in the content, or their authorized representative. We act on your behalf to request removal &mdash; the hosting provider needs to know the report comes from the affected person (or their agent), not an unrelated third party.</p>
<p><strong>For copyright violations:</strong> We file ToS-based abuse reports requesting removal of infringing content. <strong>We do not file DMCA takedown notices.</strong> A DMCA notice is a legal instrument that must be signed by the copyright holder or their authorized agent under penalty of perjury. If you need to file a DMCA takedown, you or your attorney must do so directly. Our reports ask the hosting provider to enforce their own acceptable use policies.</p>
</section>

<section class="alert alert-danger" role="alert">
<h2>Important: Illegal Content</h2>
<p>This tool files abuse reports with <strong>hosting providers</strong> regarding Terms of Service violations such as harassment, hate speech, doxxing, NCII, and copyright infringement.</p>
<p><strong>This tool is not appropriate for reporting illegal content that requires law enforcement action.</strong> In particular:</p>
<ul>
<li><strong>Child Sexual Abuse Material (CSAM):</strong> Report immediately to the <a href="https://report.cybertip.org/" target="_blank" rel="noopener noreferrer">NCMEC CyberTipline</a>. Do <em>not</em> upload, download, screenshot, or redistribute CSAM under any circumstances.</li>
<li><strong>Imminent threats of violence:</strong> Contact your local law enforcement and/or the <a href="https://www.ic3.gov/" target="_blank" rel="noopener noreferrer">FBI's Internet Crime Complaint Center (IC3)</a>.</li>
<li><strong>Other federal cybercrimes:</strong> File a report at <a href="https://www.ic3.gov/" target="_blank" rel="noopener noreferrer">IC3.gov</a>.</li>
</ul>
</section>

<section class="process-overview">
<h2>How It Works</h2>
<ol class="steps-overview">
<li>
<strong>Enter URLs</strong>
<p>Provide the URLs of the abusive content you want to report.</p>
</li>
<li>
<strong>Discover Infrastructure</strong>
<p>We automatically identify the hosting providers, IP addresses, and abuse contacts responsible for serving the content.</p>
</li>
<li>
<strong>Provide Evidence</strong>
<p>Link to screenshots or other evidence documenting the abuse, and classify the violation type.</p>
</li>
<li>
<strong>Review &amp; Submit</strong>
<p>Review the generated abuse report and submit it for admin approval. Once approved, it is sent to the appropriate abuse contacts.</p>
</li>
</ol>
</section>
{{ end }}
57 changes: 57 additions & 0 deletions templates/layout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .CSRFToken }}">
<title>{{ if .Title }}{{ .Title }} - {{ end }}End Harassment Reporting Wizard</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:configRequest', function(event) {
var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
event.detail.headers['X-CSRF-Token'] = token;
});
});
</script>
</head>
<body>
<header>
<nav aria-label="Main navigation">
<div class="nav-inner">
<a href="/" class="nav-brand">End Harassment</a>
<ul class="nav-links">
<li><a href="/">Home</a></li>
{{ if .User }}
<li><a href="/reports">My Reports</a></li>
{{ if .User.IsAdmin }}
<li><a href="/admin">Admin</a></li>
{{ end }}
<li><a href="/auth/logout">Logout</a></li>
{{ else }}
<li><a href="/auth/login">Login</a></li>
{{ end }}
</ul>
</div>
</nav>
</header>

<main>
{{ if .Flash }}
<div class="flash flash-{{ .Flash.Type }}" role="alert">
{{ .Flash.Message }}
</div>
{{ end }}

{{ template "content" . }}
</main>

<footer>
<p>End Harassment Reporting Wizard</p>
<p class="footer-legal">To report CSAM: <a href="https://report.cybertip.org/">NCMEC CyberTipline</a> | Federal cybercrimes: <a href="https://www.ic3.gov/">IC3.gov</a></p>
</footer>
</body>
</html>
{{ end }}
121 changes: 121 additions & 0 deletions templates/reports/detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{{ template "layout" . }}
{{ define "content" }}
<section>
<h1>Report: {{ .Report.Domain }}</h1>
<p><span class="badge badge-{{ .Report.Status }}">{{ .Report.Status }}</span></p>

<section class="detail-section">
<h2>URLs</h2>
<ul>
{{ range .Report.URLs }}
<li><code>{{ . }}</code></li>
{{ end }}
</ul>
</section>

<section class="detail-section">
<h2>Violation</h2>
<dl>
<dt>Type</dt>
<dd>{{ .Report.ViolationType }}</dd>
<dt>Description</dt>
<dd>{{ .Report.Description }}</dd>
</dl>
</section>

<section class="detail-section">
<h2>Evidence</h2>
{{ if .Evidence }}
<ul class="evidence-list">
{{ range .Evidence }}
<li>
{{ if .EvidenceURL }}
<a href="{{ .EvidenceURL }}" target="_blank" rel="noopener noreferrer">{{ .EvidenceURL }}</a>
{{ if .DriveVerified }}
<span class="badge badge-verified" title="Verified via Google Drive API">Verified</span>
<br><span class="evidence-meta">{{ .DriveFileName }} &middot; {{ .DriveMimeType }}{{ if .DriveSize }} &middot; {{ .DriveSize }} bytes{{ end }}</span>
{{ else if .DriveFileID }}
<span class="badge badge-unverified" title="Google Drive link detected but metadata could not be verified">Unverified</span>
{{ end }}
{{ else }}
<span class="evidence-filename">{{ .Filename }}</span>
<span class="evidence-meta">{{ .ContentType }} &middot; SHA-256: <code class="hash">{{ .SHA256 }}</code></span>
{{ end }}
</li>
{{ end }}
</ul>
{{ else }}
<p>No evidence provided.</p>
{{ end }}
</section>

{{ if .Snapshots }}
<section class="detail-section">
<h2>URL Text Snapshots</h2>
{{ range .Snapshots }}
<details>
<summary>{{ .URL }}{{ if .Error }} <span class="form-error">(error)</span>{{ end }}</summary>
{{ if .Error }}
<p class="form-error">{{ .Error }}</p>
{{ else }}
<pre class="snapshot-text">{{ .TextContent }}</pre>
{{ end }}
</details>
{{ end }}
</section>
{{ end }}

{{ if .Timeline }}
<section class="detail-section">
<h2>Status Timeline</h2>
<ol class="timeline">
{{ range .Timeline }}
<li class="timeline-entry">
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006 3:04 PM" }}</time>
<span class="timeline-action">{{ .Action }}</span>
{{ if .Details }}<span class="timeline-details">{{ .Details }}</span>{{ end }}
</li>
{{ end }}
</ol>
</section>
{{ end }}

{{ if .Emails }}
<section class="detail-section">
<h2>Outgoing Emails</h2>
<div class="table-responsive">
<table>
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Type</th>
<th scope="col">Status</th>
<th scope="col">Sent</th>
</tr>
</thead>
<tbody>
{{ range .Emails }}
<tr>
<td>{{ .Recipient }} ({{ .RecipientOrg }})</td>
<td>{{ .EmailType }}</td>
<td><span class="badge badge-{{ .Status }}">{{ .Status }}</span></td>
<td>
{{ if .SentAt }}
<time datetime="{{ .SentAt.Format "2006-01-02T15:04:05Z" }}">{{ .SentAt.Format "Jan 2, 2006 3:04 PM" }}</time>
{{ else }}
--
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</section>
{{ end }}

<div class="form-actions">
<a href="/reports" class="btn btn-secondary">Back to Reports</a>
</div>
</section>
{{ end }}
35 changes: 35 additions & 0 deletions templates/reports/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{{ template "layout" . }}
{{ define "content" }}
<section>
<h1>My Reports</h1>

{{ if .Reports }}
<div class="table-responsive">
<table>
<thead>
<tr>
<th scope="col">Domain</th>
<th scope="col">Violation</th>
<th scope="col">Status</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{{ range .Reports }}
<tr>
<td>{{ .Domain }}</td>
<td>{{ .ViolationType }}</td>
<td><span class="badge badge-{{ .Status }}">{{ .Status }}</span></td>
<td><time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ .CreatedAt.Format "Jan 2, 2006" }}</time></td>
<td><a href="/reports/{{ .ID }}">View</a></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<p>You have not filed any reports yet. <a href="/wizard/step1">File a report</a> to get started.</p>
{{ end }}
</section>
{{ end }}
41 changes: 41 additions & 0 deletions templates/wizard/step1_urls.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="wizard">
<div class="step-indicator" aria-label="Step 1 of 4">
<span class="step active" aria-current="step">1. URLs</span>
<span class="step">2. Infrastructure</span>
<span class="step">3. Evidence</span>
<span class="step">4. Review</span>
</div>

<h1>Step 1: Enter URLs</h1>
<p>Enter the URLs of the abusive content, one per line. All URLs must be from the same domain.</p>

<div class="alert alert-danger" role="alert">
<strong>Stop:</strong> If the content involves child sexual abuse material (CSAM), do <em>not</em> use this tool. Report it immediately to the <a href="https://report.cybertip.org/" target="_blank" rel="noopener noreferrer">NCMEC CyberTipline</a>. For other federal cybercrimes or imminent threats, report to <a href="https://www.ic3.gov/" target="_blank" rel="noopener noreferrer">IC3.gov</a>.
</div>

<div class="alert alert-warning" role="alert">
<strong>Retaliation risk:</strong> Hosting providers <strong>will forward</strong> your abuse report to the site operator. Operators of abusive sites may retaliate by publicly posting your complaint and encouraging further harassment. There is <strong>no expectation of privacy</strong> for abuse reports. Do not include personally identifying information in your description or evidence unless you accept this risk.
</div>

<form method="post" action="/wizard/step1">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">

<div class="form-group">
<label for="urls">URLs (one per line)</label>
<textarea id="urls" name="urls" rows="8" required
placeholder="https://example.com/page1&#10;https://example.com/page2"
aria-describedby="urls-help">{{ .FormValues.URLs }}</textarea>
<small id="urls-help" class="form-help">All URLs must belong to the same domain.</small>
{{ if .Errors.URLs }}
<span class="form-error" role="alert">{{ .Errors.URLs }}</span>
{{ end }}
</div>

<div class="form-actions">
<button type="submit" class="btn btn-primary">Look Up Infrastructure</button>
</div>
</form>
</section>
{{ end }}
40 changes: 40 additions & 0 deletions templates/wizard/step2_cloudflare.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="wizard">
<div class="step-indicator" aria-label="Step 2 of 4">
<span class="step completed">1. URLs</span>
<span class="step active" aria-current="step">2. Infrastructure</span>
<span class="step">3. Evidence</span>
<span class="step">4. Review</span>
</div>

<h1>Step 2: Cloudflare Detected</h1>

<div class="alert alert-warning" role="alert">
<strong>This site is behind Cloudflare (AS13335).</strong>
<p>Cloudflare is a CDN/proxy that hides the actual hosting provider. To identify the origin server, you must first file a report with Cloudflare and request that they reveal the origin IP.</p>
</div>

<h2>What to do</h2>
<ol>
<li>
Visit <a href="https://abuse.cloudflare.com/threat" target="_blank" rel="noopener noreferrer">Cloudflare's abuse reporting form</a> and file a report for <strong>{{ .Report.Domain }}</strong>.
</li>
<li>
Cloudflare will review the report and, if they take action, may reveal the origin server IP address.
</li>
<li>
Once you receive a response from Cloudflare, an admin will enter the origin IP so we can identify the actual hosting provider and send an abuse report to them.
</li>
</ol>

<form method="post" action="/wizard/step2/{{ .Report.ID }}/cloudflare-ack">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">

<div class="form-actions">
<button type="submit" class="btn btn-primary">I've Filed with Cloudflare</button>
<a href="/reports/{{ .Report.ID }}" class="btn btn-secondary">Save and Continue Later</a>
</div>
</form>
</section>
{{ end }}
55 changes: 55 additions & 0 deletions templates/wizard/step2_infra.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="wizard">
<div class="step-indicator" aria-label="Step 2 of 4">
<span class="step completed">1. URLs</span>
<span class="step active" aria-current="step">2. Infrastructure</span>
<span class="step">3. Evidence</span>
<span class="step">4. Review</span>
</div>

<h1>Step 2: Infrastructure Results</h1>
<p>We discovered the following infrastructure for <strong>{{ .Report.Domain }}</strong>:</p>

<div class="table-responsive">
<table>
<caption>DNS and hosting information</caption>
<thead>
<tr>
<th scope="col">IP Address</th>
<th scope="col">Record</th>
<th scope="col">ASN</th>
<th scope="col">Host</th>
<th scope="col">Country</th>
<th scope="col">Abuse Contact</th>
</tr>
</thead>
<tbody>
{{ range .InfraResults }}
<tr>
<td>{{ .IP }}</td>
<td>{{ .RecordType }}</td>
<td>AS{{ .ASN }}</td>
<td>{{ .ASNName }}</td>
<td>{{ .Country }}</td>
<td>{{ .AbuseContact }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>

{{ if .UpstreamASNs }}
<h2>Upstream Transit Providers</h2>
<ul class="upstream-list">
{{ range .UpstreamASNs }}
<li>AS{{ . }}</li>
{{ end }}
</ul>
{{ end }}

<div class="form-actions">
<a href="/wizard/step3/{{ .Report.ID }}" class="btn btn-primary">Continue to Evidence</a>
</div>
</section>
{{ end }}
106 changes: 106 additions & 0 deletions templates/wizard/step3_evidence.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="wizard">
<div class="step-indicator" aria-label="Step 3 of 4">
<span class="step completed">1. URLs</span>
<span class="step completed">2. Infrastructure</span>
<span class="step active" aria-current="step">3. Evidence</span>
<span class="step">4. Review</span>
</div>

<h1>Step 3: Provide Evidence</h1>
<p>Describe the violation and link to evidence you have uploaded to your own cloud storage (Google Drive, Dropbox, iCloud, etc.).</p>

<form method="post" action="/wizard/step3/{{ .Report.ID }}">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">

<div class="form-group">
<label for="violation_type">Violation Type</label>
<select id="violation_type" name="violation_type" required>
<option value="">-- Select a violation type --</option>
<option value="harassment"{{ if eq (string .Report.ViolationType) "harassment" }} selected{{ end }}>Harassment</option>
<option value="hate_speech"{{ if eq (string .Report.ViolationType) "hate_speech" }} selected{{ end }}>Hate Speech</option>
<option value="ncii"{{ if eq (string .Report.ViolationType) "ncii" }} selected{{ end }}>Non-Consensual Intimate Images (NCII)</option>
<option value="doxxing"{{ if eq (string .Report.ViolationType) "doxxing" }} selected{{ end }}>Doxxing</option>
<option value="copyvio"{{ if eq (string .Report.ViolationType) "copyvio" }} selected{{ end }}>Copyright / Likeness Violation</option>
</select>
{{ if .Errors.ViolationType }}
<span class="form-error" role="alert">{{ .Errors.ViolationType }}</span>
{{ end }}
</div>

<div id="ncii-warning" class="alert alert-warning" role="alert" style="display: none;">
<strong>Important:</strong> NCII reports <strong>must</strong> be filed by the person depicted in the content, or by their authorized legal representative. We file this report on your behalf &mdash; the hosting provider needs to know the complaint originates from the affected individual, not an unrelated third party. By selecting this category, you are confirming that you are the affected person or are authorized to act on their behalf.
<br><br><strong>If the content involves minors, this constitutes CSAM.</strong> Do <em>not</em> upload, screenshot, or redistribute it. Report it immediately to the <a href="https://report.cybertip.org/" target="_blank" rel="noopener noreferrer">NCMEC CyberTipline</a> and <a href="https://www.ic3.gov/" target="_blank" rel="noopener noreferrer">IC3</a>.
</div>

<div id="copyvio-warning" class="alert alert-warning" role="alert" style="display: none;">
<strong>Not a DMCA notice:</strong> We file <strong>Terms of Service abuse reports</strong> asking the hosting provider to enforce their acceptable use policies. We do <em>not</em> file DMCA takedown notices, which are a legal instrument that must be signed under penalty of perjury by the copyright holder or their authorized agent. If you need to send a DMCA takedown, you or your attorney must do so directly with the hosting provider.
</div>

<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="5" required
placeholder="Describe the abusive content and its impact..."
aria-describedby="desc-help">{{ .Report.Description }}</textarea>
<small id="desc-help" class="form-help">Provide context about the abuse. This will be included in the report sent to hosting providers.</small>
{{ if .Errors.Description }}
<span class="form-error" role="alert">{{ .Errors.Description }}</span>
{{ end }}
</div>

<div class="form-group">
<label for="evidence_urls">Evidence Links (one per line)</label>
<textarea id="evidence_urls" name="evidence_urls" rows="4"
placeholder="https://drive.google.com/file/d/.../view?usp=sharing&#10;https://www.dropbox.com/s/.../screenshot.png?dl=0"
aria-describedby="evidence-help">{{ .EvidenceURLs }}</textarea>
<small id="evidence-help" class="form-help">Upload your screenshots and evidence files to your own cloud storage and paste the share links here. <strong>Google Drive links are recommended</strong> &mdash; if you signed in with Google, we can automatically verify that the files exist and pull metadata (name, type, size). For other services (Dropbox, iCloud, OneDrive), make sure the links are set to "anyone with the link can view."</small>
{{ if .Errors.EvidenceURLs }}
<span class="form-error" role="alert">{{ .Errors.EvidenceURLs }}</span>
{{ end }}
</div>

{{ if .Evidence }}
<h2>Previously Added Evidence</h2>
<ul class="evidence-list">
{{ range .Evidence }}
<li>
{{ if .EvidenceURL }}
<a href="{{ .EvidenceURL }}" target="_blank" rel="noopener noreferrer" class="evidence-url">{{ .EvidenceURL }}</a>
{{ if .DriveVerified }}
<span class="badge badge-verified" title="Verified via Google Drive API">Verified</span>
<span class="evidence-meta">{{ .DriveFileName }} &middot; {{ .DriveMimeType }}{{ if .DriveSize }} &middot; {{ .DriveSize }} bytes{{ end }}</span>
{{ else if .DriveFileID }}
<span class="badge badge-unverified" title="Google Drive link detected but not verified">Unverified</span>
{{ end }}
{{ else }}
<span class="evidence-filename">{{ .Filename }}</span>
{{ end }}
{{ if and .Description (not .DriveVerified) }}
<span class="evidence-meta">{{ .Description }}</span>
{{ end }}
</li>
{{ end }}
</ul>
{{ end }}

<div class="form-actions">
<button type="submit" class="btn btn-primary">Continue to Review</button>
</div>
</form>
</section>

<script>
document.addEventListener('DOMContentLoaded', function() {
var select = document.getElementById('violation_type');
var nciiWarning = document.getElementById('ncii-warning');
var copyvioWarning = document.getElementById('copyvio-warning');
function checkViolationType() {
nciiWarning.style.display = select.value === 'ncii' ? 'block' : 'none';
copyvioWarning.style.display = select.value === 'copyvio' ? 'block' : 'none';
}
select.addEventListener('change', checkViolationType);
checkViolationType();
});
</script>
{{ end }}
129 changes: 129 additions & 0 deletions templates/wizard/step4_review.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
{{ template "layout" . }}
{{ define "content" }}
<section class="wizard">
<div class="step-indicator" aria-label="Step 4 of 4">
<span class="step completed">1. URLs</span>
<span class="step completed">2. Infrastructure</span>
<span class="step completed">3. Evidence</span>
<span class="step active" aria-current="step">4. Review</span>
</div>

<h1>Step 4: Review &amp; Submit</h1>
<p>Please review the details of your report before submitting.</p>

<section class="review-section">
<h2>Domain &amp; URLs</h2>
<dl>
<dt>Domain</dt>
<dd>{{ .Report.Domain }}</dd>
<dt>URLs</dt>
<dd>
<ul>
{{ range .Report.URLs }}
<li><code>{{ . }}</code></li>
{{ end }}
</ul>
</dd>
</dl>
</section>

<section class="review-section">
<h2>Violation</h2>
<dl>
<dt>Type</dt>
<dd>{{ .Report.ViolationType }}</dd>
<dt>Description</dt>
<dd>{{ .Report.Description }}</dd>
</dl>
</section>

<section class="review-section">
<h2>Evidence</h2>
{{ if .Evidence }}
<ul class="evidence-list">
{{ range .Evidence }}
<li>
{{ if .EvidenceURL }}
<a href="{{ .EvidenceURL }}" target="_blank" rel="noopener noreferrer">{{ .EvidenceURL }}</a>
{{ if .DriveVerified }}
<span class="badge badge-verified" title="Verified via Google Drive API">Verified</span>
<br><span class="evidence-meta">{{ .DriveFileName }} &middot; {{ .DriveMimeType }}{{ if .DriveSize }} &middot; {{ .DriveSize }} bytes{{ end }}</span>
{{ else if .DriveFileID }}
<span class="badge badge-unverified" title="Google Drive link detected but metadata could not be verified">Unverified</span>
{{ end }}
{{ else }}
<span>{{ .Filename }} ({{ .ContentType }}, SHA-256: <code class="hash">{{ .SHA256 }}</code>)</span>
{{ end }}
</li>
{{ end }}
</ul>
{{ else }}
<p>No evidence provided.</p>
{{ end }}
</section>

{{ if .Snapshots }}
<section class="review-section">
<h2>URL Text Snapshots</h2>
<p class="form-help">We attempted to capture text-only snapshots of the reported URLs for evidentiary purposes.</p>
{{ range .Snapshots }}
<details>
<summary>
{{ .URL }}
{{ if .Error }}<span class="form-error">(fetch error)</span>{{ end }}
</summary>
{{ if .Error }}
<p class="form-error">Error: {{ .Error }}</p>
{{ else }}
<pre class="snapshot-text">{{ .TextContent }}</pre>
{{ end }}
</details>
{{ end }}
</section>
{{ end }}

<section class="review-section">
<h2>Abuse Contacts</h2>
<ul>
{{ range .AbuseContacts }}
<li>{{ . }}</li>
{{ end }}
</ul>
</section>

{{ if .EmailPreview }}
<section class="review-section">
<h2>Email Preview</h2>
<div class="email-preview">
<dl>
<dt>To</dt>
<dd>{{ .EmailPreview.Recipient }}</dd>
<dt>Subject</dt>
<dd>{{ .EmailPreview.Subject }}</dd>
</dl>
<pre class="email-body">{{ .EmailPreview.Body }}</pre>
</div>
</section>
{{ end }}

<div class="alert alert-warning" role="alert">
<strong>Important &mdash; please read before submitting:</strong>
<p>By submitting this report, you understand that:</p>
<ul>
<li>The hosting provider <strong>will forward</strong> this report &mdash; including your description, violation type, and evidence links &mdash; to the site operator.</li>
<li>Operators of abusive sites are known to <strong>retaliate</strong> by publicly posting abuse complaints and using them to direct further harassment at the complainant.</li>
<li>There is <strong>no expectation of privacy</strong> for abuse reports. Assume everything in this report will be seen by the people you are reporting.</li>
</ul>
<p>Your personal email address is not included in the outgoing report. Reports are sent on behalf of our organization.</p>
</div>

<form method="post" action="/wizard/step4/{{ .Report.ID }}/submit">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<p class="review-note">Your report will be reviewed by an admin before being sent to abuse contacts.</p>
<div class="form-actions">
<a href="/wizard/step3/{{ .Report.ID }}" class="btn btn-secondary">Back to Evidence</a>
<button type="submit" class="btn btn-primary">Submit for Admin Review</button>
</div>
</form>
</section>
{{ end }}